chaowen.← Back to archive

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.

Linux deep dive · Shared memory and capability boundaries

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:

NeedSeal or protocolImportant limit
Never change the byte contents againF_SEAL_WRITEAdding it fails with EBUSY while a writable shared mapping exists.
Stop new writers but let a producer finish through an old mappingF_SEAL_FUTURE_WRITEAn existing writable shared mapping can still mutate the bytes.
Prevent truncationF_SEAL_SHRINKBy itself it does not prevent growth.
Prevent extensionF_SEAL_GROWBy itself it does not prevent shrinking or content writes inside the file.
Prevent changes to the seal setF_SEAL_SEALIt must be added last; after it, even more restrictive seals cannot be added.
Core invariant: seals belong to the shared inode, not to one descriptor. They only accumulate. F_SEAL_FUTURE_WRITE is a write-admission rule for future operations, not a snapshot operation.
broker process sandbox process │ │ │ memfd_create("artifact", MFD_ALLOW_SEALING) │ │ ftruncate(fd, size) │ │ mmap(fd, MAP_SHARED | PROT_WRITE) │ │ populate + verify bytes │ │ │ ├─ F_ADD_SEALS(F_SEAL_SHRINK | F_SEAL_GROW | │ │ F_SEAL_WRITE) │ │ │ ├─ sendmsg(..., SCM_RIGHTS, fd) ──────────────────▶│ │ ├─ mmap(PROT_READ, MAP_SHARED) │ └─ read/verify/use F_SEAL_WRITE requires the broker's writable shared mapping to be gone. F_SEAL_FUTURE_WRITE permits that old mapping to remain writable.

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 flagMeaningDesign consequence
MFD_CLOEXECSet close-on-exec atomically.Use it unless deliberate descriptor inheritance is part of the protocol.
MFD_ALLOW_SEALINGStart with an empty seal set and permit F_ADD_SEALS.Without it, the initial F_SEAL_SEAL prevents adding any other seal.
MFD_HUGETLBUse hugetlbfs-backed memory instead of ordinary shmem.Requires configured huge pages and has different allocation/accounting constraints.
MFD_EXEC / MFD_NOEXEC_SEALModern 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.

memfd_create(name, flags) mm/memfd.c │ ├─ sanitize_flags() and alloc_name("memfd:" + name) ├─ memfd_alloc_file() │ ├─ shmem_file_setup() ordinary memfd │ └─ hugetlb_file_setup() MFD_HUGETLB ├─ initialize anonymous inode security and seal state └─ install file in caller's fd table ftruncate(fd, size) → shmem inode size mmap(fd, ...) → VMA references file mapping page fault → shmem allocates or finds a backing folio fcntl(F_ADD_SEALS, mask) │ ├─ memfd_add_seals(): validate, lock inode, reject F_SEAL_SEAL ├─ F_SEAL_WRITE: mapping_deny_writable() + wait for pins └─ inode_seals |= mask write()/fallocate() → shmem write paths check inode seals → -EPERM mmap(PROT_WRITE|MAP_SHARED) → memfd_check_seals_mmap() → -EPERM

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 sealF_SEAL_WRITEF_SEAL_FUTURE_WRITE
Add seal while an old writable shared mapping existsFails with EBUSY.Succeeds.
Write through an old writable shared mappingNot possible to add the seal until that mapping is gone.Still allowed.
write() through any fdFails with EPERM.Fails with EPERM.
Create a new MAP_SHARED | PROT_WRITE mappingFails with EPERM.Fails with EPERM.
Remove the sealImpossible; 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

TechniqueStrengthCost or risk
Named temporary fileWorks across many tools and can be reopened by path.Needs directory policy, cleanup, permissions, and race-resistant open logic.
memfd + mmapFast 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 memoryStandardized named shared-memory object.Has a namespace-visible name and separate lifecycle/unlink protocol.
Pipe or socketNatural streaming and backpressure.Copies or moves a byte stream; does not expose a random-access shared buffer.
memfd + sealsLets 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

Debugging method

  1. Log the creation flags, fd number, file size, and F_GET_SEALS result as a symbolic mask. A decimal value alone hides whether the bug is write, resize, or seal-set policy.
  2. Inspect readlink /proc/<pid>/fd/<fd> to confirm the object is a memfd, and inspect /proc/<pid>/maps or /proc/<pid>/smaps for unexpected rw-s mappings.
  3. Trace memfd_create, fcntl, ftruncate, mmap, mprotect, write, and sendmsg with strace. An EBUSY from F_ADD_SEALS(F_SEAL_WRITE) points to a live writable shared mapping or relevant pin.
  4. 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.
  5. When behavior differs across hosts, check the kernel version and headers. F_SEAL_FUTURE_WRITE requires 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

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