Linux memory files / file sealing
Linux memfd seals: freezing shared bytes without a pathname
A process often needs to hand another process a large buffer without creating a temporary filename, and then make that buffer structurally or logically immutable. Linux combines an anonymous shmem-backed file with monotonic inode seals to solve that handoff—but only if you understand the difference between stopping all writes and stopping future writers.
2026-08-17
The concrete problem
Suppose a broker verifies a 200 MB artifact and passes it to a sandboxed worker. A pathname is awkward: it needs cleanup, permissions, a shared filesystem, and a race-free “which file did I open?” protocol. A normal shared-memory mapping is fast, but it has no file-descriptor-shaped handle that can be passed over a Unix socket and no built-in way to say “the size and contents are now frozen.”
memfd_create() creates a file-like object backed by anonymous volatile memory. The descriptor can be sent with SCM_RIGHTS, mapped with mmap(), truncated like a file, and inspected through /proc/<pid>/fd. F_ADD_SEALS then adds irreversible restrictions to the underlying inode:
| Need | Seal or protocol | Important limit |
|---|---|---|
| Never change the byte contents again | F_SEAL_WRITE | Adding it fails with EBUSY while a writable shared mapping exists. |
| Stop new writers but let a producer finish through an old mapping | F_SEAL_FUTURE_WRITE | An existing writable shared mapping can still mutate the bytes. |
| Prevent truncation | F_SEAL_SHRINK | By itself it does not prevent growth. |
| Prevent extension | F_SEAL_GROW | By itself it does not prevent shrinking or content writes inside the file. |
| Prevent changes to the seal set | F_SEAL_SEAL | It must be added last; after it, even more restrictive seals cannot be added. |
F_SEAL_FUTURE_WRITE is a write-admission rule for future operations, not a snapshot operation.The API contract
The Linux-specific syscall is exposed by glibc as memfd_create(const char *name, unsigned int flags). The name is not a filesystem path: Linux prefixes it with memfd: only for diagnostics such as /proc/self/fd/7. The object starts at size zero, is opened read-write, and disappears after the last reference is closed.
| Creation flag | Meaning | Design consequence |
|---|---|---|
MFD_CLOEXEC | Set close-on-exec atomically. | Use it unless deliberate descriptor inheritance is part of the protocol. |
MFD_ALLOW_SEALING | Start with an empty seal set and permit F_ADD_SEALS. | Without it, the initial F_SEAL_SEAL prevents adding any other seal. |
MFD_HUGETLB | Use hugetlbfs-backed memory instead of ordinary shmem. | Requires configured huge pages and has different allocation/accounting constraints. |
MFD_EXEC / MFD_NOEXEC_SEAL | Modern kernels let creation explicitly choose executable policy. | Relevant to code-loading use cases; separate this from data immutability. |
Sealing uses fcntl(fd, F_ADD_SEALS, mask); inspection uses fcntl(fd, F_GET_SEALS). The fd supplied to F_ADD_SEALS must be writable. A duplicate descriptor, a descriptor inherited through fork(), or a descriptor passed with SCM_RIGHTS still points to the same inode and therefore sees the same seal set.
F_SEAL_WRITE blocks write(), relevant fallocate() operations, and new shared writable mappings. It does not itself block file-size changes, so a read-only artifact normally uses F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK. F_SEAL_FUTURE_WRITE blocks future write() calls and future shared writable mappings, but leaves a shared writable mapping created before the seal usable.
Kernel data and control flow
On the ordinary path, a memfd is a special shmem file. It has no directory entry, but it still uses the file, inode, address-space, page-cache, VMA, and descriptor machinery that make file-backed sharing composable.
The seal bits live in filesystem-private inode state. In the current kernel, struct shmem_inode_info contains an unsigned seals field. The helper memfd_file_seals_ptr() selects that field for shmem, or the corresponding hugetlbfs inode field for MFD_HUGETLB. This is why seals affect every alias of the inode, not just the file descriptor used to add them.
The interesting asymmetry happens in memfd_add_seals(). For F_SEAL_WRITE, the kernel first calls mapping_deny_writable(); if a writable shared VMA is still present, that operation cannot complete and the syscall returns EBUSY. It also waits for certain outstanding page pins. Once the mapping is denied, the requested bits are ORed into the existing set under the inode lock.
For F_SEAL_FUTURE_WRITE, the kernel does not revoke old writable VMAs. Instead, memfd_check_seals_mmap() rejects a new shared writable VMA and removes future write permission from a read-only mapping's VM_MAYWRITE state. The old VMA is the deliberate escape hatch that makes a producer/consumer handoff possible without copying.
Ordinary writes reach shmem's write path. Current shmem_write_begin() checks for F_SEAL_WRITE or F_SEAL_FUTURE_WRITE before allocating a writable folio and returns -EPERM. Size-changing operations are checked separately against F_SEAL_GROW and F_SEAL_SHRINK. The seal is therefore enforced at more than one boundary: fd writes, allocation/truncation, and VMA creation.
The two write seals, precisely
| Operation after the seal | F_SEAL_WRITE | F_SEAL_FUTURE_WRITE |
|---|---|---|
| Add seal while an old writable shared mapping exists | Fails with EBUSY. | Succeeds. |
| Write through an old writable shared mapping | Not possible to add the seal until that mapping is gone. | Still allowed. |
write() through any fd | Fails with EPERM. | Fails with EPERM. |
Create a new MAP_SHARED | PROT_WRITE mapping | Fails with EPERM. | Fails with EPERM. |
| Remove the seal | Impossible; seals are monotonic. | |
This distinction matters in a zero-copy handoff. If the producer fills a buffer through an existing mapping and then sends a descriptor, F_SEAL_FUTURE_WRITE can stop the recipient—or a leaked duplicate fd—from obtaining another write path while the producer finishes. It does not prove that the recipient sees a frozen buffer. To make that claim, stop using the old writable mapping and add F_SEAL_WRITE.
Memory visibility is a separate issue. The seal controls which kernel operations are admitted; it is not a C or C++ memory fence for data already being written through a mapping. Publish a completion flag with C11 atomics, a process-shared mutex/condition variable, or another explicit protocol before the consumer reads the buffer.
Tradeoffs and production shapes
| Technique | Strength | Cost or risk |
|---|---|---|
| Named temporary file | Works across many tools and can be reopened by path. | Needs directory policy, cleanup, permissions, and race-resistant open logic. |
memfd + mmap | Fast shared bytes with an fd-shaped capability and no pathname. | Linux-specific; lifetime is tied to descriptor references and memory pressure still matters. |
| POSIX shared memory | Standardized named shared-memory object. | Has a namespace-visible name and separate lifecycle/unlink protocol. |
| Pipe or socket | Natural streaming and backpressure. | Copies or moves a byte stream; does not expose a random-access shared buffer. |
memfd + seals | Lets a broker hand off a buffer with monotonic policy enforcement. | Seals are not ACLs, not a snapshot, and not a substitute for synchronization. |
Real systems use this shape for large blobs passed between privilege or sandbox boundaries, shared memory regions registered with virtualization or device APIs, and in-memory artifacts that should be executable or readable only under an explicit descriptor-passing policy. The design is especially useful when a supervisor wants to verify bytes once, pass the fd, and avoid a second copy.
Huge-page memfds are a different allocation choice, not a stronger seal. They can reduce translation overhead for large shared regions, but require preconfigured huge pages and may fail under memory or capability limits. Ordinary memfds are the better first implementation; optimize page size only after measuring TLB and fault behavior.
Failure modes and security implications
- Forgot
MFD_ALLOW_SEALING: the object starts withF_SEAL_SEAL;F_ADD_SEALSfails withEPERM. This is a creation-time choice, not something a later fd operation can repair. - Expecting a snapshot from
F_SEAL_FUTURE_WRITE: a pre-existing shared writable mapping can still change bytes. UseF_SEAL_WRITEafter unmapping every writable shared VMA when immutability is the security property. - Sealing only size:
F_SEAL_GROW | F_SEAL_SHRINKprotects the extent, not the contents. A holder of an unsealed write path can still overwrite bytes in range. - Adding
F_SEAL_SEALtoo early: it locks an incomplete policy forever. Query withF_GET_SEALSand add the final mask only after all required restrictions are present. - Leaked descriptors: the fd is a capability. Before sealing, a leaked writable fd can mutate the object; after a future-write seal, a leaked old mapping can still mutate it. Set
MFD_CLOEXEC, close copies afterfork(), and audit Unix-socket fd passing. - Confusing memfd with encryption: the bytes are not secret merely because there is no pathname. They are ordinary kernel-managed memory subject to the process, ptrace, dump, swap, and host security policy.
- Executable content: if a memfd is used for code loading, handle the modern
MFD_EXEC/MFD_NOEXEC_SEALandF_SEAL_EXECpolicy explicitly. Data sealing alone is not an executable-memory policy. - Resource exhaustion: an attacker who can ask a broker to create and populate memfds can consume file descriptors and memory. Apply fd, address-space, tmpfs, cgroup, and request-size limits.
Debugging method
- Log the creation flags, fd number, file size, and
F_GET_SEALSresult as a symbolic mask. A decimal value alone hides whether the bug is write, resize, or seal-set policy. - Inspect
readlink /proc/<pid>/fd/<fd>to confirm the object is a memfd, and inspect/proc/<pid>/mapsor/proc/<pid>/smapsfor unexpectedrw-smappings. - Trace
memfd_create,fcntl,ftruncate,mmap,mprotect,write, andsendmsgwithstrace. AnEBUSYfromF_ADD_SEALS(F_SEAL_WRITE)points to a live writable shared mapping or relevant pin. - If the consumer sees stale or partial bytes, debug publication and memory ordering separately from seals. A successful seal does not wait for an application-level “producer finished” flag.
- When behavior differs across hosts, check the kernel version and headers.
F_SEAL_FUTURE_WRITErequires Linux 5.1 or newer; newer no-exec flags require newer kernels and policy support.
# Descriptor and mapping inspection readlink /proc/$PID/fd/$FD grep -E 'rw-s|r--s' /proc/$PID/maps # Syscall-level evidence strace -f -tt -e trace=memfd_create,fcntl,ftruncate,mmap,mprotect,write,sendmsg ./memfd-seal-lab future
Compact reproducible lab
Goal
Compare both write seals while an existing shared writable mapping is alive. The default run demonstrates why F_SEAL_WRITE first returns EBUSY; the future run demonstrates that F_SEAL_FUTURE_WRITE succeeds but leaves the old mapping writable.
Prerequisites
Linux 5.1 or newer, a C compiler, and glibc or headers defining F_SEAL_FUTURE_WRITE. Compile the following as Linux code; it intentionally uses the raw memfd_create syscall so the creation step is visible.
Exact commands
cc -O2 -Wall -Wextra -o /tmp/memfd-seal-lab -x c - <<'C'
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <linux/memfd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
static void die(const char *what) { perror(what); exit(EXIT_FAILURE); }
static void show(const char *what, long rc) {
printf("%s: rc=%ld", what, rc);
if (rc < 0) printf(" errno=%d (%s)", errno, strerror(errno));
putchar('\n');
}
int main(int argc, char **argv) {
int future = argc > 1 && strcmp(argv[1], "future") == 0;
int fd = syscall(SYS_memfd_create, "seal-lab",
MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd < 0) die("memfd_create");
if (ftruncate(fd, 4096) < 0) die("ftruncate");
char *old = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (old == MAP_FAILED) die("mmap old");
memcpy(old, "old-map", 8);
int seal = future ? F_SEAL_FUTURE_WRITE : F_SEAL_WRITE;
errno = 0;
long rc = fcntl(fd, F_ADD_SEALS, seal);
show(future ? "add F_SEAL_FUTURE_WRITE" : "add F_SEAL_WRITE", rc);
if (!future) {
if (rc == 0) die("unexpected F_SEAL_WRITE success");
if (munmap(old, 4096) < 0) die("munmap old");
errno = 0;
rc = fcntl(fd, F_ADD_SEALS,
F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK);
show("add F_SEAL_WRITE after unmap", rc);
} else if (rc == 0) {
memcpy(old, "existing-map", 13);
char buf[32] = {0};
ssize_t n = pread(fd, buf, sizeof buf - 1, 0);
if (n < 0) die("pread");
printf("existing shared map remains writable: %s\n", buf);
errno = 0;
n = write(fd, "fd-write", 8);
show("write after future-write seal", n);
errno = 0;
void *new_map = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (new_map == MAP_FAILED)
printf("new writable shared mmap: failed errno=%d (%s)\n",
errno, strerror(errno));
else {
printf("new writable shared mmap: unexpectedly succeeded\n");
munmap(new_map, 4096);
}
}
if (!future && rc == 0) {
errno = 0;
ssize_t n = write(fd, "fd-write", 8);
show("write after write seal", n);
errno = 0;
void *new_map = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (new_map == MAP_FAILED)
printf("new writable shared mmap: failed errno=%d (%s)\n",
errno, strerror(errno));
else {
printf("new writable shared mmap: unexpectedly succeeded\n");
munmap(new_map, 4096);
}
}
if (future) munmap(old, 4096);
close(fd);
return 0;
}
C
/tmp/memfd-seal-lab
/tmp/memfd-seal-lab future
Expected observation
add F_SEAL_WRITE: rc=-1 errno=16 (Device or resource busy) add F_SEAL_WRITE after unmap: rc=0 write after write seal: rc=-1 errno=1 (Operation not permitted) new writable shared mmap: failed errno=1 (Operation not permitted) add F_SEAL_FUTURE_WRITE: rc=0 existing shared map remains writable: existing-map write after future-write seal: rc=-1 errno=1 (Operation not permitted) new writable shared mmap: failed errno=1 (Operation not permitted)
The exact wording of strerror() can vary, but the error numbers are the point: EBUSY while adding F_SEAL_WRITE with a live writable shared VMA, then EPERM for later fd writes and new writable mappings. The future-write run proves the old mapping is the exceptional remaining write path.
Variations
- Remove
MFD_ALLOW_SEALINGfrom the creation flags. The initial seal set containsF_SEAL_SEAL, so the firstF_ADD_SEALSshould fail withEPERM. - After the producer has unmapped its writable view, add
F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL. Then callF_GET_SEALS; the returned mask should contain all four bits, and a later attempt to add any bit should fail withEPERM. - On a non-Linux host, native compilation is expected to fail because
linux/memfd.h,SYS_memfd_create, and the seal ABI are Linux interfaces. Run the same source inside a Linux VM or container instead.
Run status for this note: the authoring host is macOS, so the Linux program was not run natively. A disposable Linux-container fallback was attempted, but the required debian:bookworm-slim image was unavailable in the local image cache and could not be obtained during the run. The output above is the documented expected result; verify it on a Linux host or a working Linux container runtime.
Primary sources
- Linux
memfd_create(2)manual — object lifetime, flags, inheritance, and errors. F_GET_SEALS(2const)manual — normative descriptions of every seal, includingEBUSYandF_SEAL_FUTURE_WRITE.- Linux kernel
mm/memfd.c—memfd_create,memfd_add_seals, mapping checks, and seal storage selection. - Linux kernel
mm/shmem.c— shmem write, truncate, and fallocate paths that enforce seals. struct shmem_inode_infoand UAPI seal definitions — the inode field and bit values.mmap(2)manual — shared mappings and protection semantics relevant to the write-seal boundary.