memfd F_SEAL_EXEC implicitly sets F_SEAL_WRITE without revoking pre-existing writable mappings
| field | value |
|---|---|
| researcher | _SiCk <sick@afflicted.sh> |
| discovered | 2026-05-04 |
| targets confirmed | Linux kernel 6.12.74 (Ubuntu Resolute 26.04 LTS, kernel package 7.0.0-15.15-generic) · Linux kernel 6.6.87.2-microsoft-standard-WSL2 |
| class | incomplete revoke on seal application; F_SEAL_WRITE can be set on a memfd while writable mappings remain live, breaking the documented content-immutability contract of F_GET_SEALS / F_SEAL_WRITE |
| severity | medium-high. defeats the F_SEAL_WRITE immutability contract. any privileged consumer that handshakes on F_GET_SEALS for content immutability of a SCM_RIGHTS-handed memfd can be fed mutable bytes by an unprivileged sender. operational impact depends on the specific consumer's defense in depth (see scope and limits below). |
| CVE | requesting a kernel CVE assignment via cve@kernel.org for the mm/memfd.c half. |
tldr
fcntl(memfd_fd, F_ADD_SEALS, F_SEAL_EXEC) quietly sets
F_SEAL_WRITE in the seal state without ever calling
mapping_deny_writable() or
memfd_wait_for_pins(). pre-existing
PROT_WRITE | MAP_SHARED mappings stay live. after that
F_GET_SEALS reports F_SEAL_WRITE set, yet writes
through the original mmap still land in the page cache and show up to fresh
PROT_READ readers. the seal is a lie.
Documentation/userspace-api/seals.rst says
F_SEAL_WRITE means "the file content cannot be
modified". on this path the content can still be modified through any
pre-existing writable mapping while F_GET_SEALS reports the
documented-immutable state. the doc and the kernel disagree, and the kernel
wins the argument every time someone trusts F_GET_SEALS.
the bug. mm/memfd.c
the bug showed up while i was reading memfd_add_seals() on a
Resolute box. the relevant block on Linux 6.12.74 reads:
if ((seals & F_SEAL_WRITE) && !(*file_seals & F_SEAL_WRITE)) {
error = mapping_deny_writable(file->f_mapping);
if (error)
goto unlock;
error = memfd_wait_for_pins(file->f_mapping);
if (error) {
mapping_allow_writable(file->f_mapping);
goto unlock;
}
}
/* SEAL_EXEC implies SEAL_WRITE, making W^X from the start. */
if (seals & F_SEAL_EXEC && inode->i_mode & 0111)
seals |= F_SEAL_SHRINK | F_SEAL_GROW |
F_SEAL_WRITE | F_SEAL_FUTURE_WRITE;
*file_seals |= seals;
the first if-test looks at the caller-supplied seals mask. the caller passed
only F_SEAL_EXEC, so the test is false and the deny-writable /
wait-for-pins gate never runs. then the second if-test fires: the caller asked
for F_SEAL_EXEC, the inode carries executable mode bits, and the
kernel ORs F_SEAL_WRITE | F_SEAL_FUTURE_WRITE into the local seals
variable. *file_seals then picks up all of those bits, implicit
F_SEAL_WRITE included. the gate that was supposed to guard that bit
already ran and decided it had nothing to do.
so the F_SEAL_WRITE bit lands in the seals state without
mapping_deny_writable() or memfd_wait_for_pins() ever
being called. pre-existing writable mappings stay live and writes through them
keep updating the page cache, and a fresh PROT_READ mapping sees
the post-seal content. nothing got frozen.
reproducer
memfd-seal-exec-poc.c. 147 lines, libc only, self-verifying.
$ gcc -O2 -o memfd-seal-exec-poc memfd-seal-exec-poc.c
$ ./memfd-seal-exec-poc
[*] pre-seal contents: "BEFORE-SEAL"
[*] direct F_ADD_SEALS F_SEAL_WRITE blocked: Device or resource busy (expected EBUSY)
[*] after F_ADD_SEALS EXEC: seals=0x3e [SHRINK GROW WRITE FUTURE_WRITE EXEC ]
[*] post-seal via fresh RO map: "AFTER-SEAL!"
[*] new PROT_WRITE mmap correctly blocked: Operation not permitted
[+] CONFIRMED.
what the run actually shows:
- an honest
F_ADD_SEALS F_SEAL_WRITEgets rejected with-EBUSYbecause the writable mapping exists.mapping_deny_writable()works fine when the gate actually runs. F_ADD_SEALS F_SEAL_EXECis accepted.F_GET_SEALSafterwards returns0x3e, which carriesF_SEAL_WRITE(0x08). the kernel injected that bit on its own.- the writable mapping made before the seal is still writable after it. bytes written through it show up in a freshly mmap'd
PROT_READ MAP_SHAREDview, so they reached the page cache and not just a private copy. - a new
PROT_WRITEmmap is blocked.F_SEAL_FUTURE_WRITEgets enforced for new mappings, just not for the one that already existed.
exit 0 means bug confirmed. exit 2 means the kernel doesn't exhibit it (fix already merged, maybe). exit 3 means content mismatch.
what the primitive provides
an unprivileged process holds a memfd it created. after F_ADD_SEALS F_SEAL_EXEC
it keeps a live writable mapping, can rewrite the bytes whenever it wants, and can hand
the same fd to any consumer that expects an F_SEAL_WRITE-immutable buffer, which will confirm via F_GET_SEALS that F_SEAL_WRITE is set.
the sender's mapping stays writable for as long as they hold it.
any privileged consumer of sealed memfds that handshakes on
F_GET_SEALS for content immutability is being lied to about the
real mapping topology of the file. whatever the consumer builds on top of those
bytes, signing, hashing, parse-then-trust, copy-once-into-an-iovec, is
weaker than it looks, because the sender keeps a TOCTOU window between any two
reads the consumer makes.
scope and limits
the obvious privileged consumer of sealed memfds via SCM_RIGHTS on a Resolute
desktop is systemd-journald, so i ran the chain end-to-end against journald
259.5 on Resolute 26.04 LTS. the kernel TOCTOU primitive works: parse-vs-dispatch
divergence in record contents, 2710 of 2710 forge attempts changed the buffer
between journald's parse pass and its dispatch pass. then systemd 259.5's own
hardening killed the part that would have made it useful, user-supplied
underscored-prefix fields get refused with "Received file outside of allowed
directories, refusing." in journald's log. the forged trusted-prefix fields
never show up in journalctl output on current Resolute, and i'm not
going to pretend otherwise about journald attribution forgery on this target.
the kernel bug stands on its own, separate from journald's hardening. it
applies to every consumer that trusts F_SEAL_WRITE for content
immutability. i haven't verified any other consumer case-by-case yet, so i make
no specific claim about snap, chromium IPC, or anything else here. the
follow-up writeup of the journald chain is
here.
affected systems
kernel half (mm/memfd.c F_SEAL_EXEC implicit F_SEAL_WRITE without revoke) confirmed against:
Linux 6.12.74 (Ubuntu Resolute 26.04 LTS, 7.0.0-15.15-generic
and 7.0.0-kasan-kcov #15.15.kasan, x86_64)
Linux 6.6.87.2-microsoft-standard-WSL2 (Windows 11 host, x86_64)
the same code ordering exists in linux-mainline as of writing and in the active stable trees (6.12.x, 6.6.x, 6.1.x, 5.15.x). i confirmed that by reading source, not by running every tree.
any kernel that does the F_SEAL_EXEC implicit-F_SEAL_WRITE OR-inject after the deny-writable gate is affected. that ordering hasn't changed from the F_SEAL_EXEC introduction (~5.10) up to current.
suggested fix
move the F_SEAL_EXEC implicit-write OR-inject ABOVE the deny-writable gate so the gate runs against the post-inject seals value:
/* compute the final seals mask first, including any implicit
* additions, before testing the gate */
if (seals & F_SEAL_EXEC && inode->i_mode & 0111)
seals |= F_SEAL_SHRINK | F_SEAL_GROW |
F_SEAL_WRITE | F_SEAL_FUTURE_WRITE;
if ((seals & F_SEAL_WRITE) && !(*file_seals & F_SEAL_WRITE)) {
error = mapping_deny_writable(file->f_mapping);
if (error)
goto unlock;
error = memfd_wait_for_pins(file->f_mapping);
if (error) {
mapping_allow_writable(file->f_mapping);
goto unlock;
}
}
*file_seals |= seals;
that keeps the W^X-from-the-start semantic of F_SEAL_EXEC and
makes the seal honest. callers that pass F_SEAL_EXEC against a
memfd with active writable mappings get -EBUSY, same as the
existing F_SEAL_WRITE path already does.
other option: drop the implicit OR-inject entirely.
F_SEAL_EXEC's documented semantic is "no future executable
mappings". tying it to F_SEAL_WRITE was a convenience for
memfd-protect. callers who want both can ask for both.
history check (lore + git)
- F_SEAL_EXEC introduced in
7d51b95cad7c, May 2022, Linux 5.18. - Lorenzo Stoakes' Nov-Dec 2024 seal cleanups (
fa00b8e,8ec396d) restructured nearby code without touching this gate. - no public CVE.
- no published PoC.
- no upstream fix commit.
- no lore thread on the deny-writable / OR-inject ordering.
downloads
- memfd-seal-exec-poc.c: 147-line libc-only self-verifying reproducer.
gcc -O2 -o memfd-seal-exec-poc memfd-seal-exec-poc.c. exit 0 = bug confirmed. - lab-output.txt: captured run on resolute-exp (Ubuntu Resolute 26.04 LTS, 7.0.0-15-generic), uid 1000.
- journald_forge.c: chain harness for the journald TOCTOU companion post.
SHA-256:
62a3b55bf4a3f0d13577d984bfd3a1c98c5fb3fbfe9788affd2143cd9070cc7f memfd-seal-exec-poc.c
6afa7dcf0f2dc69c46cecf4f14e4fa077f5c096134ec785801d4501ebbdeb23e lab-output.txt
69e0bb91802aac1e2508888d1a44665dd09d1481f881af767db02ddc7b5c7966 journald_forge.c
credit
_SiCk <sick@afflicted.sh> (https://afflicted.sh)
no warranty. no support. no apologies.
. _SiCk · afflicted.sh