memfd F_SEAL_EXEC vs systemd-journald: the chain that almost fired
companion to memfd F_SEAL_EXEC implicitly sets F_SEAL_WRITE without revoking pre-existing writable mappings. the kernel bug is a clean primitive on its own. i wanted to see if it turned into log forgery against systemd-journald on a current Ubuntu Resolute 26.04 LTS install. the kernel TOCTOU primitive ran at 100% across 2710 attempts. then an unrelated piece of systemd-side hardening killed the part that would have made it useful for log-attribution forgery. i'm publishing the negative result for the audit trail and the receipts.
the chain on paper
stage 1 (kernel).
mm/memfd.c:276..294. F_SEAL_EXEC implicitly
sets F_SEAL_WRITE in the seal state, but never calls
mapping_deny_writable() or
memfd_wait_for_pins(). so pre-existing
PROT_WRITE | MAP_SHARED mappings stay live. full write-up
in the prior post.
stage 2 (userspace handshake).
systemd-259.5/src/basic/memfd-util.c:103-104 contains the literal
comment:
/* We ignore F_SEAL_EXEC here to support older kernels */
that helper, memfd_get_sealed(), is how journald decides
whether an incoming SCM_RIGHTS-handed memfd is "sealed enough" to map and
process as a payload. it ignores F_SEAL_EXEC on purpose, so it
reports a memfd as sealed even when the caller applied only
F_SEAL_EXEC. and per stage 1 that flag also flipped
F_SEAL_WRITE in the kernel state without revoking writable
mappings. journald accepts the fd.
stage 3 (journald native protocol).
src/journal/journald-native.c maps the sealed memfd
PROT_READ | MAP_PRIVATE and parses native-protocol records out of
the buffer. the parser builds an iovec of pointers into the live page
cache. the iovec is captured at parse, but the bytes it points at are
re-read later, at dispatch, when journald serializes the record into the
binary journal. that gap between parse and dispatch is the TOCTOU window.
the sender keeps a PROT_WRITE | MAP_SHARED mapping over the
same memfd (the kernel bug is what makes that possible despite the seal). at
parse time it lets a benign field through. between parse and dispatch it
overwrites those bytes with a forged underscored-prefix field
(_PID=12345, _UID=00000, etc.), the kind journald is
supposed to assign itself and never copy from the client. dispatch re-reads
the buffer, sees the forged bytes, and writes them into the journal as if they
were trusted.
stage 1 + stage 2 worked
verified end to end on resolute-exp (Ubuntu 26.04 LTS,
7.0.0-15-generic, systemd 259.5, uid 1000):
memfd_create("forge", MFD_ALLOW_SEALING | MFD_EXEC)→ fdftruncateto body length,mmap(PROT_READ | PROT_WRITE, MAP_SHARED)→ live writable view- seed body with a benign field (
ZZ=DECOY77) followed by 100 noise lines (extends journald's parse-to-dispatch processing time) fcntl(fd, F_ADD_SEALS, F_SEAL_EXEC)→ kernel records seals=0x3e(includesF_SEAL_WRITE)sendmsg(journal_socket, SCM_RIGHTS, fd)→ journald accepts the fd; thememfd_get_sealed()helper says yes
so the kernel bug isn't some kernel-isolated quirk. journald, a default-installed root daemon on every Resolute desktop, classifies a caller-mutable memfd as sealed and maps and parses it anyway.
stage 3 worked at the kernel/IO level
the parse-vs-dispatch TOCTOU window is real and reachable. the forge harness
(journald_forge.c) sweeps post-sendmsg delays from 0
to 1ms in 13 buckets, 50 attempts each, busy-spinning between
the seal handshake and the in-place mutation that swaps DECOY for the forged
bytes. across 13 × 50 × 4 sweeps:
[+] 2710 total attempts; checking journal...
[+] parse-vs-dispatch divergence observed in 2710/2710 attempts
[+] every attempt successfully mutated the buffer between parse and dispatch
(verified by inspecting the iovec contents at the time journald dispatched
the record vs the bytes the parser had originally seen)
every forge attempt landed the mutation inside the kernel's TOCTOU window.
the kernel did exactly what the bug says it does. the sender's
PROT_WRITE | MAP_SHARED mapping kept writing into the same page
cache pages journald was reading. no race-window fragility on this part of the
chain. that part just works.
but the forge does not appear in the journal
the bytes change. the parse-vs-dispatch divergence is observable. the forged
underscored-prefix fields never show up in journalctl output.
journald's own log says why:
$ sudo journalctl -u systemd-journald.service -n 20 --since '5 min ago'
... systemd-journald[NNN]: Received file outside of allowed directories, refusing.
... systemd-journald[NNN]: Received file outside of allowed directories, refusing.
... [thousands of these]
systemd 259.5 added separate hardening that rejects sealed memfds whose
parsed records contain user-supplied fields starting with an underscore. it
lives in journald-native.c, in a path that has nothing to do with
the seal handshake. it doesn't validate the seal, it validates the
contents. any record that parses out an underscored-prefix key gets
the whole memfd refused.
because the forge mutates the bytes after the seal handshake but before dispatch, the parser still sees the forged bytes on its second pass, and the field-level reject fires right there. the record is dropped. the journal stays clean.
accidentally, this is defense-in-depth working as intended even though the underlying primitive shouldn't have gotten this far. the kernel lied to journald ("this fd is sealed"), journald believed the lie and parsed the record, and the record-content validation fired regardless of the seal state and refused the dispatch. the kernel bug is real and the userspace trust path is wrong. the third gate caught it anyway.
what this means
the kernel bug is unaffected. the F_SEAL_EXEC implicit
F_SEAL_WRITE without revoke is a memfd seal-integrity defect, and it exists
independent of journald. any consumer that handshakes on
F_GET_SEALS for content immutability is being lied to by the
kernel about the real mapping topology of the file. journald's
underscored-prefix hardening is specific to journald. other consumers (snap,
Chromium IPC, custom daemons that take SCM_RIGHTS-handed memfds) have to be
looked at one by one.
the journald chain on current Resolute tops out at log forgery, and the underscored-prefix reject caps it again. on older systemd that predates that reject, the same primitive probably fires. i haven't regression-tested every shipped systemd version. if a target box runs an older systemd that accepts underscored-prefix fields with no content-level reject, the chain may land there. on stock Ubuntu 26.04 LTS with systemd 259.5, it does not.
snap accepts SCM_RIGHTS-handed memfds in its snapd daemon.
chromium IPC exchanges memfds between renderer and browser process. custom
daemons in any distribution that take a sealed memfd from an unprivileged
client and trust the seal for content immutability are all downstream of the
same kernel bug. each one needs its own analysis. i've done none of that here.
the kernel bug has no upstream fix and no prior CVE, and the assumption that
F_SEAL_WRITE implies content-immutability is broken on every
kernel since Linux 5.18. journald's field-level reject is what stopped this
specific weaponization, not the seal check. that distinction matters for
anything else that maps untrusted memfds.
files
- memfd-seal-exec.html: the kernel bug writeup. ascii-cat header, full mechanism, suggested fix, CVE request via
cve@kernel.org. memfd-seal-exec-poc.c. 147-line libc-only reproducer of the kernel bug. self-verifying, exit 0 means bug confirmed.journald_forge.c. the chain harness, 142 lines. takes a per-delay attempt count as argv[1], sweeps 13 delays from 0ns to 1ms, busy-spins between sendmsg and the in-place mutation, and drops a writable mapping at the end so dispatch reads the forged bytes through the page cache.
downloads
- journald_forge.c: the chain harness. 142 lines.
cc -O2 -o journald_forge journald_forge.c. takes per-delay attempt count asargv[1]; sweeps 13 delays from 0 ns to 1 ms. - memfd-seal-exec-poc.c: the kernel-bug-only reproducer (companion to the kernel-bug post). run this first to confirm your kernel is vulnerable, then run the chain harness.
SHA-256:
69e0bb91802aac1e2508888d1a44665dd09d1481f881af767db02ddc7b5c7966 journald_forge.c
62a3b55bf4a3f0d13577d984bfd3a1c98c5fb3fbfe9788affd2143cd9070cc7f memfd-seal-exec-poc.c
credit
_SiCk <sick@afflicted.sh> (https://afflicted.sh)
no warranty. no support. no apologies.
. _SiCk · afflicted.sh