_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

memfd F_SEAL_EXEC implicitly sets F_SEAL_WRITE without revoking pre-existing writable mappings

2026-05-04 · linuxkernelmemfdsealsmm
fieldvalue
researcher_SiCk <sick@afflicted.sh>
discovered2026-05-04
targets confirmedLinux 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
classincomplete 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
severitymedium-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).
CVErequesting 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:

  1. an honest F_ADD_SEALS F_SEAL_WRITE gets rejected with -EBUSY because the writable mapping exists. mapping_deny_writable() works fine when the gate actually runs.
  2. F_ADD_SEALS F_SEAL_EXEC is accepted.
  3. F_GET_SEALS afterwards returns 0x3e, which carries F_SEAL_WRITE (0x08). the kernel injected that bit on its own.
  4. 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_SHARED view, so they reached the page cache and not just a private copy.
  5. a new PROT_WRITE mmap is blocked. F_SEAL_FUTURE_WRITE gets 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)

downloads

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