* [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect
@ 2026-07-04 23:18 Cong Wang
2026-07-04 23:18 ` [PATCH v5 1/7] mm: add __do_mmap() and vm_mmap_remote()/vm_munmap_remote() Cong Wang
` (6 more replies)
0 siblings, 7 replies; 8+ messages in thread
From: Cong Wang @ 2026-07-04 23:18 UTC (permalink / raw)
To: Andy Lutomirski
Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner,
Andrew Morton, linux-mm, Cong Wang
From: Cong Wang <cwang@multikernel.io>
The seccomp user-notification SECCOMP_USER_NOTIF_FLAG_CONTINUE response
carries an inherent TOCTOU: once the supervisor decides to let a syscall
continue, the target (or a CLONE_VM peer) can rewrite the memory behind a
pointer argument before the kernel reads it. This is documented in the
UAPI header and is why the notifier "cannot be used to implement a
security policy" today.
The cooperative way around this is for the target to map a shared memfd
and mseal() it during a trusted setup window, so the supervisor can hand
the kernel an immutable buffer. That window does not exist for the common
fork()+execve() sandbox model, where the supervisor wants to confine an
uncooperative (or legacy) binary it did not write.
This series lets the supervisor close the TOCTOU without any target-side
cooperation:
- The kernel installs a sealed, read-only, MAP_SHARED mapping of a
supervisor-owned memfd directly into the trapped task's mm
(SECCOMP_IOCTL_NOTIF_PIN_INSTALL). The mapping is VM_SEALED at
creation, so neither the target nor a CLONE_VM peer can unmap,
remap, mprotect or MAP_FIXED-stomp it. The backing memfd must be
write-sealed (F_SEAL_WRITE / F_SEAL_FUTURE_WRITE), so its bytes
cannot be rewritten through any other reference either; the
supervisor stages the argument data through its own pre-seal mapping.
- The supervisor then resumes the syscall with selected argument
registers rewritten (SECCOMP_IOCTL_NOTIF_SEND_REDIRECT), the pointer
ones aimed into a pin. Each pointer substitution is validated so the
whole access [ptr, ptr+len) lies inside a sealed, read-only pin of
the supervisor's memfd that still lives in the target's current mm;
original registers are restored at syscall exit for ABI compliance.
Because the data the kernel acts on lives in an immutable pin, the
target can no longer win the race. execve() is handled as a first-class
case: its pathname is copied from the pin before the old mm is torn
down, and the register-restore is skipped once the program image has
been replaced (detected via self_exec_id).
A redirected syscall is re-validated against the outer filters in the
target's filter chain, so an inner notifier cannot use a redirect to
smuggle a syscall past a policy an outer filter enforces (e.g. redirect
to a blocked unshare()); see patch 5.
sandlock [1], a non-cooperative seccomp sandbox supervisor, will use
this to enforce argument-level policy on uncooperative targets.
[1] https://github.com/multikernel/sandlock
Patch 1 adds the mm plumbing: __do_mmap(), a variant of do_mmap() that
targets a caller-supplied mm (do_mmap() stays a current->mm wrapper, so
no existing caller changes), and vm_mmap_remote()/vm_munmap_remote(),
high-level helpers for installing and removing the sealed pin. Patch 2
adds PIN_INSTALL, patch 3 adds the __NR_seccomp_* syscall aliases, patch
4 adds SEND_REDIRECT, patch 5 adds the outer-filter re-validation, patch
6 documents the ABI, and patch 7 adds selftests.
Changes since v4:
- Fixed READ_IMPLIES_EXEC and VM warning in patch 1
- Fixed TRACE re-validation case in patch 5
- New patch 3: split the __NR_seccomp_* syscall aliases (arch-
overridable, covering rt_sigreturn and the clone/fork family) into
their own patch; x86 maps the compat (ia32) numbers.
- SEND_REDIRECT now also refuses the clone/fork family (clone, clone3,
fork, vfork) with -EOPNOTSUPP, not just rt_sigreturn: rewriting a
task-creating syscall's registers has no use case and is unsafe.
- Renamed vm_mmap_seal_remote() to vm_mmap_remote() and added the
vm_munmap_remote() counterpart (patch 1).
- vm_mmap_remote() now bounds the mapping within the target mm's own
address space with an overflow-safe task_size check, including the
caller-supplied fixed-address path.
- Compat: for SECCOMP_ARCH_COMPAT targets, redirected pointer arguments
are truncated to 32 bits before the pin range is validated.
- Selftests: two new tests -- redirect_denied_syscalls (rt_sigreturn
and the clone/fork family are all rejected with -EOPNOTSUPP) and
redirect_revalidate_chain (the re-validation walks the entire outer
filter stack, not just the nearest filter); plus error-path hardening
so a broken kernel fails or skips rather than hanging.
Changes since v3:
- Split the single seccomp patch into PIN_INSTALL (patch 2) and
SEND_REDIRECT (patch 4) for reviewability.
- New patch 5: re-validate a redirected syscall against the outer
filters in the stack, closing the bypass Andy described (an inner
notifier redirecting to a syscall an outer filter blocks, e.g.
unshare()).
- Signals: the argument restore now runs before signal/restart
processing. It is queued as task_work with TWA_RESUME -- not the
TWA_SIGNAL discussed on-list, which makes signal_pending() true for
the whole redirected syscall and livelocks an interruptible one.
TWA_RESUME still runs the restore at the top of get_signal(), before
the signal frame is built and before any -ERESTART* rewind; on a
restart the syscall re-traps seccomp and the supervisor is notified
again. rt_sigreturn is refused (-EOPNOTSUPP).
- At most one redirect-capable notifier may exist in a filter chain
(-EBUSY); ordinary notifiers are unconstrained. Syscalls with
complex signal/restart behaviour (nanosleep, futex(FUTEX_WAIT), ...)
are out of scope and should not have their arguments redirected.
- PIN_INSTALL: target_addr == 0 lets the kernel pick a free address in
the target mm (avoids a racy userspace /proc/<pid>/maps scan), and a
new offset field lets one memfd back several disjoint pins.
- New patch 6: Documentation/userspace-api/seccomp_filter.rst.
- Selftests expanded: install into a fresh post-execve mm, stateless
churn, outer-filter re-validation, ABI/versioning, and a
signal-ordering regression test.
Changes since v2:
v3 was a redesign that dropped the v2 SECCOMP_IOCTL_NOTIF_INJECT
approach (an in-kernel reimplementation of a syscall whitelist) in
favour of redirecting the real syscall into a sealed pin, as suggested
by Andy.
Changes since v1:
v2 was a redesign that dropped the v1 SECCOMP_IOCTL_NOTIF_PIN_ARGS
approach.
All pinned-memfd and redirect selftests pass (seccomp_bpf: 119/119).
Cong Wang (7):
mm: add __do_mmap() and vm_mmap_remote()/vm_munmap_remote()
seccomp: introduce SECCOMP_IOCTL_NOTIF_PIN_INSTALL
seccomp: add __NR_seccomp_* aliases for rt_sigreturn and clone/fork
seccomp: add kernel-installed pinned-memfd redirect
seccomp: re-validate a redirected syscall against outer filters
docs/seccomp: document pinned-memfd redirect ioctls
selftests/seccomp: cover non-cooperative pinned-memfd install
.../userspace-api/seccomp_filter.rst | 109 ++
arch/x86/include/asm/seccomp.h | 6 +
include/asm-generic/seccomp.h | 45 +
include/linux/mm.h | 5 +
include/linux/seccomp.h | 12 +-
include/uapi/linux/seccomp.h | 87 ++
kernel/seccomp.c | 511 ++++++-
mm/internal.h | 8 +
mm/mmap.c | 87 +-
mm/nommu.c | 22 +-
mm/util.c | 97 ++
mm/vma.c | 35 +-
mm/vma.h | 6 +-
tools/testing/selftests/seccomp/seccomp_bpf.c | 1251 +++++++++++++++++
14 files changed, 2231 insertions(+), 50 deletions(-)
base-commit: 87320be9f0d24fce67631b7eef919f0b79c3e45c
--
2.43.0
^ permalink raw reply [flat|nested] 8+ messages in thread
* [PATCH v5 1/7] mm: add __do_mmap() and vm_mmap_remote()/vm_munmap_remote()
2026-07-04 23:18 [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect Cong Wang
@ 2026-07-04 23:18 ` Cong Wang
2026-07-04 23:18 ` [PATCH v5 2/7] seccomp: introduce SECCOMP_IOCTL_NOTIF_PIN_INSTALL Cong Wang
` (5 subsequent siblings)
6 siblings, 0 replies; 8+ messages in thread
From: Cong Wang @ 2026-07-04 23:18 UTC (permalink / raw)
To: Andy Lutomirski
Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner,
Andrew Morton, linux-mm, Cong Wang
From: Cong Wang <cwang@multikernel.io>
Add __do_mmap(), a variant of do_mmap() that installs the mapping into
a caller-supplied mm rather than current->mm. Now do_mmap() becomes a
wrapper that passes current->mm; it also keeps the READ_IMPLIES_EXEC
personality handling, which is a property of the current task and must
not be applied when a supervisor installs into another task's mm.
mmap_region()/__mmap_region() gain an mm argument so the target mm
flows down to where the VMA is inserted. __do_mmap() is only mm-internal,
declared in mm/internal.h.
On top of that, add vm_mmap_remote() and vm_munmap_remote() in mm/util.c:
high-level entry points that install / remove a mapping in a
caller-specified mm without target-side cooperation. The intended
user is seccomp unotify syscall redirect.
vm_mmap_remote() resolves the placement against @mm and pins it with
MAP_FIXED, so __do_mmap()'s get_unmapped_area() path. The remote area
search (mm_get_unmapped_area_remote()) bounds the address by the
target's mm->task_size, so a 64-bit supervisor cannot place a
mapping outside a 32-bit target's address space. MAP_FIXED_NOREPLACE
keeps its -EEXIST semantics for callers that pass a fixed address.
LSM hooks (security_mmap_file, fsnotify_mmap_perm) run against current,
the supervisor installing the mapping, not the target mm's owner. This
matches the supervisor-installs-into-target mental model and parallels
pidfd_getfd()'s cross-task fd install.
Cross-task authorization is left to the caller; this primitive performs
no ptrace_may_access check.
Assisted-by: Claude:claude-opus-4.8
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
include/linux/mm.h | 5 +++
mm/internal.h | 8 ++++
mm/mmap.c | 87 ++++++++++++++++++++++++++++++++---------
mm/nommu.c | 22 ++++++++---
mm/util.c | 97 ++++++++++++++++++++++++++++++++++++++++++++++
mm/vma.c | 35 +++++++++--------
mm/vma.h | 6 +--
7 files changed, 216 insertions(+), 44 deletions(-)
diff --git a/include/linux/mm.h b/include/linux/mm.h
index 485df9c2dbdd..2cff6f4e3bf8 100644
--- a/include/linux/mm.h
+++ b/include/linux/mm.h
@@ -4152,6 +4152,10 @@ extern unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot, unsigned long flags,
vm_flags_t vm_flags, unsigned long pgoff, unsigned long *populate,
struct list_head *uf);
+unsigned long vm_mmap_remote(struct mm_struct *mm, struct file *file,
+ unsigned long addr, unsigned long len, unsigned long prot,
+ unsigned long flags, unsigned long pgoff, vm_flags_t vm_flags);
+int vm_munmap_remote(struct mm_struct *mm, unsigned long start, size_t len);
extern int do_vmi_munmap(struct vma_iterator *vmi, struct mm_struct *mm,
unsigned long start, size_t len, struct list_head *uf,
bool unlock);
@@ -4192,6 +4196,7 @@ struct vm_unmapped_area_info {
unsigned long align_mask;
unsigned long align_offset;
unsigned long start_gap;
+ struct mm_struct *mm; /* mm to search; NULL means current->mm */
};
extern unsigned long vm_unmapped_area(struct vm_unmapped_area_info *info);
diff --git a/mm/internal.h b/mm/internal.h
index 181e79f1d6a2..3d698bccc100 100644
--- a/mm/internal.h
+++ b/mm/internal.h
@@ -1436,6 +1436,14 @@ extern unsigned long __must_check vm_mmap_pgoff(struct file *, unsigned long,
unsigned long, unsigned long,
unsigned long, unsigned long);
+unsigned long __do_mmap(struct mm_struct *mm, struct file *file,
+ unsigned long addr, unsigned long len, unsigned long prot,
+ unsigned long flags, vm_flags_t vm_flags, unsigned long pgoff,
+ unsigned long *populate, struct list_head *uf);
+
+unsigned long mm_get_unmapped_area_remote(struct mm_struct *mm,
+ unsigned long len);
+
extern void set_pageblock_order(void);
unsigned long reclaim_pages(struct list_head *folio_list);
unsigned int reclaim_clean_pages_from_list(struct zone *zone,
diff --git a/mm/mmap.c b/mm/mmap.c
index 2311ae7c2ff4..81a2f64df6be 100644
--- a/mm/mmap.c
+++ b/mm/mmap.c
@@ -277,7 +277,7 @@ static inline bool file_mmap_ok(struct file *file, struct inode *inode,
}
/**
- * do_mmap() - Perform a userland memory mapping into the current process
+ * __do_mmap() - Perform a userland memory mapping into @mm's
* address space of length @len with protection bits @prot, mmap flags @flags
* (from which VMA flags will be inferred), and any additional VMA flags to
* apply @vm_flags. If this is a file-backed mapping then the file is specified
@@ -307,8 +307,11 @@ static inline bool file_mmap_ok(struct file *file, struct inode *inode,
* start of a VMA, rather only the start of a valid mapped range of length
* @len bytes, rounded down to the nearest page size.
*
- * The caller must write-lock current->mm->mmap_lock.
+ * The caller must write-lock @mm->mmap_lock. do_mmap() is the common
+ * wrapper that targets current->mm.
*
+ * @mm: The mm_struct to install the mapping into. The caller must hold a
+ * reference and write-lock its mmap_lock.
* @file: An optional struct file pointer describing the file which is to be
* mapped, if a file-backed mapping.
* @addr: If non-zero, hints at (or if @flags has MAP_FIXED set, specifies) the
@@ -333,13 +336,12 @@ static inline bool file_mmap_ok(struct file *file, struct inode *inode,
* Returns: Either an error, or the address at which the requested mapping has
* been performed.
*/
-unsigned long do_mmap(struct file *file, unsigned long addr,
- unsigned long len, unsigned long prot,
- unsigned long flags, vm_flags_t vm_flags,
- unsigned long pgoff, unsigned long *populate,
- struct list_head *uf)
+unsigned long __do_mmap(struct mm_struct *mm, struct file *file,
+ unsigned long addr, unsigned long len,
+ unsigned long prot, unsigned long flags,
+ vm_flags_t vm_flags, unsigned long pgoff,
+ unsigned long *populate, struct list_head *uf)
{
- struct mm_struct *mm = current->mm;
int pkey = 0;
*populate = 0;
@@ -349,16 +351,6 @@ unsigned long do_mmap(struct file *file, unsigned long addr,
if (!len)
return -EINVAL;
- /*
- * Does the application expect PROT_READ to imply PROT_EXEC?
- *
- * (the exception is when the underlying filesystem is noexec
- * mounted, in which case we don't add PROT_EXEC.)
- */
- if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
- if (!(file && path_noexec(&file->f_path)))
- prot |= PROT_EXEC;
-
/* force arch specific MAP_FIXED handling in get_unmapped_area */
if (flags & MAP_FIXED_NOREPLACE)
flags |= MAP_FIXED;
@@ -557,7 +549,7 @@ unsigned long do_mmap(struct file *file, unsigned long addr,
vm_flags |= VM_NORESERVE;
}
- addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);
+ addr = mmap_region(mm, file, addr, len, vm_flags, pgoff, uf);
if (!IS_ERR_VALUE(addr) &&
((vm_flags & VM_LOCKED) ||
(flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE))
@@ -565,6 +557,25 @@ unsigned long do_mmap(struct file *file, unsigned long addr,
return addr;
}
+unsigned long do_mmap(struct file *file, unsigned long addr, unsigned long len,
+ unsigned long prot, unsigned long flags,
+ vm_flags_t vm_flags, unsigned long pgoff,
+ unsigned long *populate, struct list_head *uf)
+{
+ /*
+ * Does the application expect PROT_READ to imply PROT_EXEC?
+ *
+ * (the exception is when the underlying filesystem is noexec
+ * mounted, in which case we don't add PROT_EXEC.)
+ */
+ if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
+ if (!(file && path_noexec(&file->f_path)))
+ prot |= PROT_EXEC;
+
+ return __do_mmap(current->mm, file, addr, len, prot, flags, vm_flags,
+ pgoff, populate, uf);
+}
+
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long pgoff)
@@ -809,6 +820,44 @@ unsigned long mm_get_unmapped_area_vmflags(struct file *filp, unsigned long addr
return arch_get_unmapped_area(filp, addr, len, pgoff, flags, vm_flags);
}
+/*
+ * Find a free @len-byte area in @mm, honoring @mm's mmap layout direction.
+ * Unlike the arch_get_unmapped_area() family, the search runs against @mm
+ * rather than current->mm, so a supervisor can place a mapping in a remote
+ * task's address space (see vm_mmap_remote()). The caller must hold
+ * mmap_write_lock(@mm). Returns a page-aligned address or -ENOMEM.
+ */
+unsigned long mm_get_unmapped_area_remote(struct mm_struct *mm, unsigned long len)
+{
+ struct vm_unmapped_area_info info = {
+ .length = len,
+ .mm = mm,
+ };
+ unsigned long addr;
+
+ if (mm_flags_test(MMF_TOPDOWN, mm)) {
+ info.flags = VM_UNMAPPED_AREA_TOPDOWN;
+ info.low_limit = PAGE_SIZE;
+ info.high_limit = arch_get_mmap_base(0, mm->mmap_base);
+ addr = vm_unmapped_area(&info);
+ if (!offset_in_page(addr))
+ return addr;
+ /* Topdown exhausted (e.g. huge stack rlimit); retry bottom-up. */
+ info.flags = 0;
+ info.low_limit = min_t(unsigned long, TASK_UNMAPPED_BASE,
+ PAGE_ALIGN(mm->task_size / 3));
+ info.high_limit = min_t(unsigned long,
+ arch_get_mmap_end(0, len, 0),
+ mm->task_size);
+ return vm_unmapped_area(&info);
+ }
+
+ info.low_limit = mm->mmap_base;
+ info.high_limit = min_t(unsigned long, arch_get_mmap_end(0, len, 0),
+ mm->task_size);
+ return vm_unmapped_area(&info);
+}
+
unsigned long
__get_unmapped_area(struct file *file, unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags, vm_flags_t vm_flags)
diff --git a/mm/nommu.c b/mm/nommu.c
index ed3934bc2de4..1009acbf7bd0 100644
--- a/mm/nommu.c
+++ b/mm/nommu.c
@@ -1009,7 +1009,8 @@ static int do_mmap_private(struct vm_area_struct *vma,
/*
* handle mapping creation for uClinux
*/
-unsigned long do_mmap(struct file *file,
+unsigned long __do_mmap(struct mm_struct *mm,
+ struct file *file,
unsigned long addr,
unsigned long len,
unsigned long prot,
@@ -1024,7 +1025,7 @@ unsigned long do_mmap(struct file *file,
struct rb_node *rb;
unsigned long capabilities, result;
int ret;
- VMA_ITERATOR(vmi, current->mm, 0);
+ VMA_ITERATOR(vmi, mm, 0);
*populate = 0;
@@ -1049,7 +1050,7 @@ unsigned long do_mmap(struct file *file,
if (!region)
goto error_getting_region;
- vma = vm_area_alloc(current->mm);
+ vma = vm_area_alloc(mm);
if (!vma)
goto error_getting_vma;
@@ -1190,7 +1191,7 @@ unsigned long do_mmap(struct file *file,
/* okay... we have a mapping; now we have to register it */
result = vma->vm_start;
- current->mm->total_vm += len >> PAGE_SHIFT;
+ mm->total_vm += len >> PAGE_SHIFT;
share:
BUG_ON(!vma->vm_region);
@@ -1198,8 +1199,8 @@ unsigned long do_mmap(struct file *file,
if (vma_iter_prealloc(&vmi, vma))
goto error_just_free;
- setup_vma_to_mm(vma, current->mm);
- current->mm->map_count++;
+ setup_vma_to_mm(vma, mm);
+ mm->map_count++;
/* add the VMA to the tree */
vma_iter_store_new(&vmi, vma);
@@ -1246,6 +1247,15 @@ unsigned long do_mmap(struct file *file,
return -ENOMEM;
}
+unsigned long do_mmap(struct file *file, unsigned long addr, unsigned long len,
+ unsigned long prot, unsigned long flags,
+ vm_flags_t vm_flags, unsigned long pgoff,
+ unsigned long *populate, struct list_head *uf)
+{
+ return __do_mmap(current->mm, file, addr, len, prot, flags, vm_flags, pgoff,
+ populate, uf);
+}
+
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long pgoff)
diff --git a/mm/util.c b/mm/util.c
index af2c2103f0d9..4c6f44d45541 100644
--- a/mm/util.c
+++ b/mm/util.c
@@ -588,6 +588,103 @@ unsigned long vm_mmap_pgoff(struct file *file, unsigned long addr,
return ret;
}
+/**
+ * vm_mmap_remote - install a file (or anonymous) mapping into @mm, without
+ * target-side cooperation.
+ * @mm: Target mm; caller holds a reference (e.g. get_task_mm()).
+ * @file: Backing file, or NULL for an anonymous mapping.
+ * @addr: Page-aligned address. If non-zero it is used as given (honoring
+ * MAP_FIXED/MAP_FIXED_NOREPLACE in @flags); if zero, the kernel
+ * chooses a free area in @mm and returns it.
+ * @len: Length in bytes.
+ * @prot: PROT_* protection bits.
+ * @flags: MAP_* flags.
+ * @pgoff: Page offset into @file.
+ * @vm_flags: Extra VMA flags to OR in (e.g. VM_SEALED), or 0.
+ *
+ * The general form of the remote install primitive: a supervisor places a
+ * mapping into a remote task's address space. LSM/fsnotify hooks run against
+ * %current (the installer), paralleling pidfd_getfd()'s cross-task install;
+ * cross-task authorization is the caller's responsibility The foreign mm is
+ * not mm_populate()d.
+ *
+ * Returns the mapped address on success, or a negative errno.
+ */
+unsigned long vm_mmap_remote(struct mm_struct *mm, struct file *file,
+ unsigned long addr, unsigned long len, unsigned long prot,
+ unsigned long flags, unsigned long pgoff, vm_flags_t vm_flags)
+{
+ loff_t off = (loff_t)pgoff << PAGE_SHIFT;
+ unsigned long aligned_len = PAGE_ALIGN(len);
+ unsigned long ret;
+ unsigned long populate;
+ LIST_HEAD(uf);
+
+ if (WARN_ON_ONCE(!mm))
+ return -EINVAL;
+
+ ret = security_mmap_file(file, prot, flags);
+ if (!ret)
+ ret = fsnotify_mmap_perm(file, prot, off, len);
+ if (ret)
+ return ret;
+
+ if (mmap_write_lock_killable(mm))
+ return -EINTR;
+
+ /*
+ * __do_mmap()'s get_unmapped_area() path searches current->mm and
+ * asserts current->mm's mmap_lock, we have to resolve the address
+ * against @mm ourselves and pin it with MAP_FIXED, so __do_mmap()
+ * uses it directly rather than searching.
+ */
+ if (!addr) {
+ addr = mm_get_unmapped_area_remote(mm, aligned_len);
+ if (IS_ERR_VALUE(addr)) {
+ ret = addr;
+ goto unlock;
+ }
+ }
+
+ if (aligned_len > mm->task_size || addr > mm->task_size - aligned_len) {
+ ret = -ENOMEM;
+ goto unlock;
+ }
+
+ if (!(flags & MAP_FIXED_NOREPLACE))
+ flags |= MAP_FIXED;
+
+ ret = __do_mmap(mm, file, addr, len, prot, flags, vm_flags,
+ pgoff, &populate, &uf);
+unlock:
+ mmap_write_unlock(mm);
+ userfaultfd_unmap_complete(mm, &uf);
+ return ret;
+}
+EXPORT_SYMBOL_GPL(vm_mmap_remote);
+
+/**
+ * vm_munmap_remote - unmap a range from @mm, mirroring vm_munmap() but
+ * targeting an explicit mm.
+ * @mm: Target mm; caller holds a reference (e.g. get_task_mm()).
+ * @start: Start address of the range to unmap.
+ * @len: Length in bytes.
+ *
+ * Returns 0 on success, or a negative errno.
+ */
+int vm_munmap_remote(struct mm_struct *mm, unsigned long start, size_t len)
+{
+ VMA_ITERATOR(vmi, mm, start);
+ int ret;
+
+ if (mmap_write_lock_killable(mm))
+ return -EINTR;
+ ret = do_vmi_munmap(&vmi, mm, start, len, NULL, false);
+ mmap_write_unlock(mm);
+ return ret;
+}
+EXPORT_SYMBOL_GPL(vm_munmap_remote);
+
/*
* Perform a userland memory mapping into the current process address space. See
* the comment for do_mmap() for more details on this operation in general.
diff --git a/mm/vma.c b/mm/vma.c
index 9eea2850818a..2f9159ab5123 100644
--- a/mm/vma.c
+++ b/mm/vma.c
@@ -2731,11 +2731,10 @@ static bool can_set_ksm_flags_early(struct mmap_state *map)
return false;
}
-static unsigned long __mmap_region(struct file *file, unsigned long addr,
- unsigned long len, vma_flags_t vma_flags,
+static unsigned long __mmap_region(struct mm_struct *mm, struct file *file,
+ unsigned long addr, unsigned long len, vma_flags_t vma_flags,
unsigned long pgoff, struct list_head *uf)
{
- struct mm_struct *mm = current->mm;
struct vm_area_struct *vma = NULL;
bool have_mmap_prepare = file && file->f_op->mmap_prepare;
VMA_ITERATOR(vmi, mm, addr);
@@ -2809,14 +2808,16 @@ static unsigned long __mmap_region(struct file *file, unsigned long addr,
/**
* mmap_region() - Actually perform the userland mapping of a VMA into
- * current->mm with known, aligned and overflow-checked @addr and @len, and
+ * @mm with known, aligned and overflow-checked @addr and @len, and
* correctly determined VMA flags @vm_flags and page offset @pgoff.
*
* This is an internal memory management function, and should not be used
* directly.
*
- * The caller must write-lock current->mm->mmap_lock.
+ * The caller must write-lock @mm->mmap_lock.
*
+ * @mm: The mm_struct to install the mapping into. The caller must hold a
+ * reference and write-lock its mmap_lock.
* @file: If a file-backed mapping, a pointer to the struct file describing the
* file to be mapped, otherwise NULL.
* @addr: The page-aligned address at which to perform the mapping.
@@ -2830,15 +2831,16 @@ static unsigned long __mmap_region(struct file *file, unsigned long addr,
* Returns: Either an error, or the address at which the requested mapping has
* been performed.
*/
-unsigned long mmap_region(struct file *file, unsigned long addr,
- unsigned long len, vm_flags_t vm_flags,
- unsigned long pgoff, struct list_head *uf)
+unsigned long mmap_region(struct mm_struct *mm, struct file *file,
+ unsigned long addr, unsigned long len,
+ vm_flags_t vm_flags, unsigned long pgoff,
+ struct list_head *uf)
{
unsigned long ret;
bool writable_file_mapping = false;
const vma_flags_t vma_flags = legacy_to_vma_flags(vm_flags);
- mmap_assert_write_locked(current->mm);
+ mmap_assert_write_locked(mm);
/* Check to see if MDWE is applicable. */
if (map_deny_write_exec(&vma_flags, &vma_flags))
@@ -2857,13 +2859,13 @@ unsigned long mmap_region(struct file *file, unsigned long addr,
writable_file_mapping = true;
}
- ret = __mmap_region(file, addr, len, vma_flags, pgoff, uf);
+ ret = __mmap_region(mm, file, addr, len, vma_flags, pgoff, uf);
/* Clear our write mapping regardless of error. */
if (writable_file_mapping)
mapping_unmap_writable(file->f_mapping);
- validate_mm(current->mm);
+ validate_mm(mm);
return ret;
}
@@ -2957,8 +2959,8 @@ int do_brk_flags(struct vma_iterator *vmi, struct vm_area_struct *vma,
/**
* unmapped_area() - Find an area between the low_limit and the high_limit with
- * the correct alignment and offset, all from @info. Note: current->mm is used
- * for the search.
+ * the correct alignment and offset, all from @info. Note: @info->mm (or
+ * current->mm when it is NULL) is used for the search.
*
* @info: The unmapped area information including the range [low_limit -
* high_limit), the alignment offset and mask.
@@ -2970,7 +2972,7 @@ unsigned long unmapped_area(struct vm_unmapped_area_info *info)
unsigned long length, gap;
unsigned long low_limit, high_limit;
struct vm_area_struct *tmp;
- VMA_ITERATOR(vmi, current->mm, 0);
+ VMA_ITERATOR(vmi, info->mm ? : current->mm, 0);
/* Adjust search length to account for worst case alignment overhead */
length = info->length + info->align_mask + info->start_gap;
@@ -3016,7 +3018,8 @@ unsigned long unmapped_area(struct vm_unmapped_area_info *info)
/**
* unmapped_area_topdown() - Find an area between the low_limit and the
* high_limit with the correct alignment and offset at the highest available
- * address, all from @info. Note: current->mm is used for the search.
+ * address, all from @info. Note: @info->mm (or current->mm when it is NULL)
+ * is used for the search.
*
* @info: The unmapped area information including the range [low_limit -
* high_limit), the alignment offset and mask.
@@ -3028,7 +3031,7 @@ unsigned long unmapped_area_topdown(struct vm_unmapped_area_info *info)
unsigned long length, gap, gap_end;
unsigned long low_limit, high_limit;
struct vm_area_struct *tmp;
- VMA_ITERATOR(vmi, current->mm, 0);
+ VMA_ITERATOR(vmi, info->mm ? : current->mm, 0);
/* Adjust search length to account for worst case alignment overhead */
length = info->length + info->align_mask + info->start_gap;
diff --git a/mm/vma.h b/mm/vma.h
index 8e4b61a7304c..4f5222ad2e9d 100644
--- a/mm/vma.h
+++ b/mm/vma.h
@@ -459,9 +459,9 @@ bool vma_wants_writenotify(struct vm_area_struct *vma, pgprot_t vm_page_prot);
int mm_take_all_locks(struct mm_struct *mm);
void mm_drop_all_locks(struct mm_struct *mm);
-unsigned long mmap_region(struct file *file, unsigned long addr,
- unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
- struct list_head *uf);
+unsigned long mmap_region(struct mm_struct *mm, struct file *file,
+ unsigned long addr, unsigned long len, vm_flags_t vm_flags,
+ unsigned long pgoff, struct list_head *uf);
int do_brk_flags(struct vma_iterator *vmi, struct vm_area_struct *brkvma,
unsigned long addr, unsigned long request,
--
2.43.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH v5 2/7] seccomp: introduce SECCOMP_IOCTL_NOTIF_PIN_INSTALL
2026-07-04 23:18 [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect Cong Wang
2026-07-04 23:18 ` [PATCH v5 1/7] mm: add __do_mmap() and vm_mmap_remote()/vm_munmap_remote() Cong Wang
@ 2026-07-04 23:18 ` Cong Wang
2026-07-04 23:18 ` [PATCH v5 3/7] seccomp: add __NR_seccomp_* aliases for rt_sigreturn and clone/fork Cong Wang
` (4 subsequent siblings)
6 siblings, 0 replies; 8+ messages in thread
From: Cong Wang @ 2026-07-04 23:18 UTC (permalink / raw)
To: Andy Lutomirski
Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner,
Andrew Morton, linux-mm, Cong Wang
From: Cong Wang <cwang@multikernel.io>
SECCOMP_IOCTL_NOTIF_PIN_INSTALL maps a supervisor-owned @memfd at
@target_addr in the trapped task's mm via vm_mmap_remote(),
PROT_READ, MAP_SHARED, MAP_FIXED_NOREPLACE and VM_SEALED. Because the
mapping is sealed, neither the target nor a CLONE_VM peer can munmap,
mremap, mprotect or MAP_FIXED-stomp it; its contents are immutable from
the target's side while the supervisor retains write access through its
own mapping of the same memfd. The install needs no target-side
cooperation, which is what makes the feature usable for fork+execve
sandbox wrappers (Sandlock, Firejail, Bubblewrap-style) that have no
trusted post-exec window to install their own mappings.
The pin is just a sealed VMA owned by the target's mm: it persists until
the task execve()s or exits (a sealed VMA cannot be unmapped piecemeal),
and the kernel keeps no per-pin bookkeeping. A supervisor reuses one
region across many redirects.
Assisted-by: Claude:claude-opus-4.8
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
include/linux/seccomp.h | 5 ++
include/uapi/linux/seccomp.h | 34 ++++++++++
kernel/seccomp.c | 121 +++++++++++++++++++++++++++++++++++
3 files changed, 160 insertions(+)
diff --git a/include/linux/seccomp.h b/include/linux/seccomp.h
index 9b959972bf4a..a91d1fc8a2b8 100644
--- a/include/linux/seccomp.h
+++ b/include/linux/seccomp.h
@@ -16,6 +16,11 @@
#define SECCOMP_NOTIFY_ADDFD_SIZE_VER0 24
#define SECCOMP_NOTIFY_ADDFD_SIZE_LATEST SECCOMP_NOTIFY_ADDFD_SIZE_VER0
+/* sizeof() the first published struct seccomp_notif_pin_install */
+#define SECCOMP_NOTIFY_PIN_INSTALL_SIZE_VER0 32 /* up to @size */
+#define SECCOMP_NOTIFY_PIN_INSTALL_SIZE_VER1 40 /* adds @offset */
+#define SECCOMP_NOTIFY_PIN_INSTALL_SIZE_LATEST SECCOMP_NOTIFY_PIN_INSTALL_SIZE_VER1
+
#ifdef CONFIG_SECCOMP
#include <linux/thread_info.h>
diff --git a/include/uapi/linux/seccomp.h b/include/uapi/linux/seccomp.h
index dbfc9b37fcae..d3249294788b 100644
--- a/include/uapi/linux/seccomp.h
+++ b/include/uapi/linux/seccomp.h
@@ -137,6 +137,37 @@ struct seccomp_notif_addfd {
__u32 newfd_flags;
};
+/**
+ * struct seccomp_notif_pin_install - have the kernel install a sealed
+ * MAP_SHARED mapping of @memfd into the trapped task's mm at @target_addr.
+ *
+ * The supervisor owns @memfd and the kernel installs the mapping without
+ * target-side cooperation. It is read-only and VM_SEALED, so the target and
+ * any CLONE_VM peer cannot munmap, mremap, mprotect or MAP_FIXED-stomp it.
+ * @memfd must be write-sealed (F_SEAL_WRITE or F_SEAL_FUTURE_WRITE, -EINVAL
+ * otherwise) so its bytes cannot be rewritten through any other reference to
+ * the same memfd.
+ *
+ * @id: The ID of an active seccomp notification on this listener,
+ * identifying the trapped task whose mm receives the pin.
+ * @flags: Reserved, must be 0.
+ * @memfd: Supervisor-side fd for the backing memfd. Must be write-sealed.
+ * @target_addr: Page-aligned address in the trapped task's mm to install at.
+ * If non-zero it is MAP_FIXED (no existing mapping may overlap
+ * [@target_addr, @target_addr + @size)); if zero the kernel
+ * picks a free area. The actual address is written back here.
+ * @size: Size of the pin in bytes. Must be page-aligned.
+ * @offset: Page-aligned byte offset into @memfd to map from.
+ */
+struct seccomp_notif_pin_install {
+ __u64 id;
+ __u32 flags;
+ __u32 memfd;
+ __u64 target_addr;
+ __u64 size;
+ __u64 offset;
+};
+
#define SECCOMP_IOC_MAGIC '!'
#define SECCOMP_IO(nr) _IO(SECCOMP_IOC_MAGIC, nr)
#define SECCOMP_IOR(nr, type) _IOR(SECCOMP_IOC_MAGIC, nr, type)
@@ -154,4 +185,7 @@ struct seccomp_notif_addfd {
#define SECCOMP_IOCTL_NOTIF_SET_FLAGS SECCOMP_IOW(4, __u64)
+#define SECCOMP_IOCTL_NOTIF_PIN_INSTALL SECCOMP_IOWR(5, \
+ struct seccomp_notif_pin_install)
+
#endif /* _UAPI_LINUX_SECCOMP_H */
diff --git a/kernel/seccomp.c b/kernel/seccomp.c
index 066909393c38..1c0b3bb71379 100644
--- a/kernel/seccomp.c
+++ b/kernel/seccomp.c
@@ -37,12 +37,19 @@
#ifdef CONFIG_SECCOMP_FILTER
#include <linux/file.h>
#include <linux/filter.h>
+#include <linux/memfd.h>
#include <linux/pid.h>
#include <linux/ptrace.h>
#include <linux/capability.h>
#include <linux/uaccess.h>
#include <linux/anon_inodes.h>
#include <linux/lockdep.h>
+#include <linux/mm.h>
+#include <linux/mman.h>
+#include <linux/mmap_lock.h>
+#include <linux/sched/mm.h>
+#include <linux/task_work.h>
+#include <uapi/asm-generic/mman-common.h>
/*
* When SECCOMP_IOCTL_NOTIF_ID_VALID was first introduced, it had the
@@ -1823,6 +1830,117 @@ static long seccomp_notify_addfd(struct seccomp_filter *filter,
return ret;
}
+static unsigned long seccomp_install_pin(struct task_struct *target,
+ struct file *memfd_file,
+ unsigned long target_addr, size_t size,
+ unsigned long offset)
+{
+ struct mm_struct *mm;
+ unsigned long ret;
+
+ if (!VM_SEALED)
+ return -EOPNOTSUPP;
+
+ mm = get_task_mm(target);
+ if (!mm)
+ return -ESRCH;
+
+ /*
+ * Install a sealed, read-only mapping. A fixed request (@target_addr
+ * != 0) is MAP_FIXED_NOREPLACE: an existing mapping yields -EEXIST
+ * rather than being silently clobbered. A request of 0 lets the kernel
+ * pick a free area in the target mm.
+ */
+ ret = vm_mmap_remote(mm, memfd_file, target_addr, size, PROT_READ,
+ MAP_SHARED | MAP_FIXED_NOREPLACE,
+ offset >> PAGE_SHIFT, VM_SEALED);
+ mmput(mm);
+ if (IS_ERR_VALUE(ret))
+ return ret;
+ if (target_addr && ret != target_addr)
+ return -ENOMEM;
+ return ret;
+}
+
+static long seccomp_notify_pin_install(struct seccomp_filter *filter,
+ struct seccomp_notif_pin_install __user *upin,
+ unsigned int size)
+{
+ struct seccomp_notif_pin_install pin;
+ struct seccomp_knotif *knotif;
+ struct task_struct *target;
+ struct file *memfd_file;
+ unsigned long addr;
+ int seals;
+ long ret;
+
+ BUILD_BUG_ON(sizeof(pin) < SECCOMP_NOTIFY_PIN_INSTALL_SIZE_VER0);
+ BUILD_BUG_ON(sizeof(pin) != SECCOMP_NOTIFY_PIN_INSTALL_SIZE_LATEST);
+
+ if (size < SECCOMP_NOTIFY_PIN_INSTALL_SIZE_VER0 || size >= PAGE_SIZE)
+ return -EINVAL;
+
+ ret = copy_struct_from_user(&pin, sizeof(pin), upin, size);
+ if (ret)
+ return ret;
+
+ if (pin.flags)
+ return -EINVAL;
+ if (!pin.size || !IS_ALIGNED(pin.target_addr, PAGE_SIZE) ||
+ !IS_ALIGNED(pin.size, PAGE_SIZE) || !IS_ALIGNED(pin.offset, PAGE_SIZE))
+ return -EINVAL;
+ if (pin.target_addr + pin.size < pin.target_addr)
+ return -EINVAL;
+ if (pin.offset + pin.size < pin.offset)
+ return -EINVAL;
+
+ memfd_file = fget(pin.memfd);
+ if (!memfd_file)
+ return -EBADF;
+
+ seals = memfd_get_seals(memfd_file);
+ if (seals < 0 || !(seals & (F_SEAL_WRITE | F_SEAL_FUTURE_WRITE))) {
+ ret = -EINVAL;
+ goto out_fput;
+ }
+
+ ret = mutex_lock_interruptible(&filter->notify_lock);
+ if (ret < 0)
+ goto out_fput;
+
+ knotif = find_notification(filter, pin.id);
+ if (!knotif) {
+ ret = -ENOENT;
+ goto out_unlock;
+ }
+ if (knotif->state != SECCOMP_NOTIFY_SENT) {
+ ret = -EINPROGRESS;
+ goto out_unlock;
+ }
+
+ target = knotif->task;
+ get_task_struct(target);
+ mutex_unlock(&filter->notify_lock);
+
+ addr = seccomp_install_pin(target, memfd_file, pin.target_addr,
+ pin.size, pin.offset);
+ put_task_struct(target);
+ if (IS_ERR_VALUE(addr))
+ ret = addr;
+ else if (put_user(addr, &upin->target_addr))
+ /* Pin is installed and sealed; we just can't report where. */
+ ret = -EFAULT;
+ else
+ ret = 0;
+ goto out_fput;
+
+out_unlock:
+ mutex_unlock(&filter->notify_lock);
+out_fput:
+ fput(memfd_file);
+ return ret;
+}
+
static long seccomp_notify_ioctl(struct file *file, unsigned int cmd,
unsigned long arg)
{
@@ -1847,6 +1965,9 @@ static long seccomp_notify_ioctl(struct file *file, unsigned int cmd,
switch (EA_IOCTL(cmd)) {
case EA_IOCTL(SECCOMP_IOCTL_NOTIF_ADDFD):
return seccomp_notify_addfd(filter, buf, _IOC_SIZE(cmd));
+ case EA_IOCTL(SECCOMP_IOCTL_NOTIF_PIN_INSTALL):
+ return seccomp_notify_pin_install(filter, buf,
+ _IOC_SIZE(cmd));
default:
return -EINVAL;
}
--
2.43.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH v5 3/7] seccomp: add __NR_seccomp_* aliases for rt_sigreturn and clone/fork
2026-07-04 23:18 [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect Cong Wang
2026-07-04 23:18 ` [PATCH v5 1/7] mm: add __do_mmap() and vm_mmap_remote()/vm_munmap_remote() Cong Wang
2026-07-04 23:18 ` [PATCH v5 2/7] seccomp: introduce SECCOMP_IOCTL_NOTIF_PIN_INSTALL Cong Wang
@ 2026-07-04 23:18 ` Cong Wang
2026-07-04 23:18 ` [PATCH v5 4/7] seccomp: add kernel-installed pinned-memfd redirect Cong Wang
` (3 subsequent siblings)
6 siblings, 0 replies; 8+ messages in thread
From: Cong Wang @ 2026-07-04 23:18 UTC (permalink / raw)
To: Andy Lutomirski
Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner,
Andrew Morton, linux-mm, Cong Wang
From: Cong Wang <cwang@multikernel.io>
The existing __NR_seccomp_* aliases name only the syscalls strict mode
allows (read/write/exit/sigreturn). SEND_REDIRECT needs to recognise a
few more so it can refuse to redirect them; add aliases for those,
following the same arch-overridable pattern that keeps arch-specific
numbers out of generic seccomp code:
- __NR_seccomp_rt_sigreturn{,_32}: sigreturn and rt_sigreturn are
distinct numbers on 32-bit/compat, so both must be nameable. The
generic default aliases rt_sigreturn to the plain sigreturn number,
a harmless duplicate on arches that do not distinguish the two.
- __NR_seccomp_clone{,3}{,_32}, __NR_seccomp_{,v}fork{,_32}: the
task-creation family. An arch lacking a syscall, or without a
distinct compat number, maps it to -1, which is never a valid
syscall number and so never matches.
x86 supplies the ia32 compat numbers. No functional change; the aliases
are consumed by the SEND_REDIRECT denylist in a later commit.
Assisted-by: Claude:claude-opus-4.8
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
arch/x86/include/asm/seccomp.h | 6 +++++
include/asm-generic/seccomp.h | 45 ++++++++++++++++++++++++++++++++++
2 files changed, 51 insertions(+)
diff --git a/arch/x86/include/asm/seccomp.h b/arch/x86/include/asm/seccomp.h
index 42bcd42d70d1..c95c00b8927c 100644
--- a/arch/x86/include/asm/seccomp.h
+++ b/arch/x86/include/asm/seccomp.h
@@ -6,6 +6,7 @@
#ifdef CONFIG_X86_32
#define __NR_seccomp_sigreturn __NR_sigreturn
+#define __NR_seccomp_rt_sigreturn __NR_rt_sigreturn
#endif
#ifdef CONFIG_COMPAT
@@ -14,6 +15,11 @@
#define __NR_seccomp_write_32 __NR_ia32_write
#define __NR_seccomp_exit_32 __NR_ia32_exit
#define __NR_seccomp_sigreturn_32 __NR_ia32_sigreturn
+#define __NR_seccomp_rt_sigreturn_32 __NR_ia32_rt_sigreturn
+#define __NR_seccomp_clone_32 __NR_ia32_clone
+#define __NR_seccomp_clone3_32 __NR_ia32_clone3
+#define __NR_seccomp_fork_32 __NR_ia32_fork
+#define __NR_seccomp_vfork_32 __NR_ia32_vfork
#endif
#ifdef CONFIG_X86_64
diff --git a/include/asm-generic/seccomp.h b/include/asm-generic/seccomp.h
index 6b6f42bc58f9..3c63ce888225 100644
--- a/include/asm-generic/seccomp.h
+++ b/include/asm-generic/seccomp.h
@@ -25,6 +25,51 @@
#ifndef __NR_seccomp_sigreturn
#define __NR_seccomp_sigreturn __NR_rt_sigreturn
#endif
+#if defined(CONFIG_COMPAT) && !defined(__NR_seccomp_rt_sigreturn_32)
+#define __NR_seccomp_rt_sigreturn_32 __NR_seccomp_sigreturn_32
+#endif
+#ifndef __NR_seccomp_rt_sigreturn
+#define __NR_seccomp_rt_sigreturn __NR_seccomp_sigreturn
+#endif
+
+#ifndef __NR_seccomp_clone
+#define __NR_seccomp_clone __NR_clone
+#endif
+#ifndef __NR_seccomp_clone3
+#ifdef __NR_clone3
+#define __NR_seccomp_clone3 __NR_clone3
+#else
+#define __NR_seccomp_clone3 (-1)
+#endif
+#endif
+#ifndef __NR_seccomp_fork
+#ifdef __NR_fork
+#define __NR_seccomp_fork __NR_fork
+#else
+#define __NR_seccomp_fork (-1)
+#endif
+#endif
+#ifndef __NR_seccomp_vfork
+#ifdef __NR_vfork
+#define __NR_seccomp_vfork __NR_vfork
+#else
+#define __NR_seccomp_vfork (-1)
+#endif
+#endif
+#ifdef CONFIG_COMPAT
+#ifndef __NR_seccomp_clone_32
+#define __NR_seccomp_clone_32 (-1)
+#endif
+#ifndef __NR_seccomp_clone3_32
+#define __NR_seccomp_clone3_32 (-1)
+#endif
+#ifndef __NR_seccomp_fork_32
+#define __NR_seccomp_fork_32 (-1)
+#endif
+#ifndef __NR_seccomp_vfork_32
+#define __NR_seccomp_vfork_32 (-1)
+#endif
+#endif /* CONFIG_COMPAT */
#ifdef CONFIG_COMPAT
#ifndef get_compat_mode1_syscalls
--
2.43.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH v5 4/7] seccomp: add kernel-installed pinned-memfd redirect
2026-07-04 23:18 [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect Cong Wang
` (2 preceding siblings ...)
2026-07-04 23:18 ` [PATCH v5 3/7] seccomp: add __NR_seccomp_* aliases for rt_sigreturn and clone/fork Cong Wang
@ 2026-07-04 23:18 ` Cong Wang
2026-07-04 23:18 ` [PATCH v5 5/7] seccomp: re-validate a redirected syscall against outer filters Cong Wang
` (2 subsequent siblings)
6 siblings, 0 replies; 8+ messages in thread
From: Cong Wang @ 2026-07-04 23:18 UTC (permalink / raw)
To: Andy Lutomirski
Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner,
Andrew Morton, linux-mm, Cong Wang
From: Cong Wang <cwang@multikernel.io>
Add SECCOMP_IOCTL_NOTIF_SEND_REDIRECT, which resumes a trapped syscall
(like SECCOMP_USER_NOTIF_FLAG_CONTINUE) with selected argument registers
rewritten to point into a pin installed by SECCOMP_IOCTL_NOTIF_PIN_INSTALL.
This closes the user-notification TOCTOU for fork+execve sandboxes: the
kernel acts on an immutable, supervisor-controlled sealed mapping instead
of memory a CLONE_VM peer can rewrite after the check.
The feature is gated behind SECCOMP_FILTER_FLAG_REDIRECT, declared at
listener creation (it requires SECCOMP_FILTER_FLAG_NEW_LISTENER) and
required by both ioctls. At most one redirect-capable filter may exist in
a chain (-EBUSY otherwise), so a redirect has a single, unambiguous
register fixup.
The supervisor supplies an args_mask, a ptr_mask and replacement values.
Each pointer substitution is validated by seccomp_pin_check(): the access
[args[i], args[i] + ptr_len[i]) must lie in a single VM_SEALED, read-only,
MAP_SHARED VMA still backed by the named memfd. The kernel keeps no
bookkeeping; after execve or exit the VMA is gone and validation returns
-EFAULT.
Original arg registers are saved and restored at user-mode return (via a
TWA_RESUME task_work, so a restartable syscall is not turned into a
livelock), preserving the caller-saved arg-register ABI. The restore is
skipped after a successful execve. rt_sigreturn is refused (-EOPNOTSUPP):
it restores the whole register frame and takes no arguments to substitute.
Assisted-by: Claude:claude-opus-4.8
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
include/linux/seccomp.h | 7 +-
include/uapi/linux/seccomp.h | 55 ++++++-
kernel/seccomp.c | 290 +++++++++++++++++++++++++++++++++++
3 files changed, 350 insertions(+), 2 deletions(-)
diff --git a/include/linux/seccomp.h b/include/linux/seccomp.h
index a91d1fc8a2b8..5d53f8fce508 100644
--- a/include/linux/seccomp.h
+++ b/include/linux/seccomp.h
@@ -10,7 +10,8 @@
SECCOMP_FILTER_FLAG_SPEC_ALLOW | \
SECCOMP_FILTER_FLAG_NEW_LISTENER | \
SECCOMP_FILTER_FLAG_TSYNC_ESRCH | \
- SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV)
+ SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV | \
+ SECCOMP_FILTER_FLAG_REDIRECT)
/* sizeof() the first published struct seccomp_notif_addfd */
#define SECCOMP_NOTIFY_ADDFD_SIZE_VER0 24
@@ -21,6 +22,10 @@
#define SECCOMP_NOTIFY_PIN_INSTALL_SIZE_VER1 40 /* adds @offset */
#define SECCOMP_NOTIFY_PIN_INSTALL_SIZE_LATEST SECCOMP_NOTIFY_PIN_INSTALL_SIZE_VER1
+/* sizeof() the first published struct seccomp_notif_resp_redirect */
+#define SECCOMP_NOTIFY_RESP_REDIRECT_SIZE_VER0 120
+#define SECCOMP_NOTIFY_RESP_REDIRECT_SIZE_LATEST SECCOMP_NOTIFY_RESP_REDIRECT_SIZE_VER0
+
#ifdef CONFIG_SECCOMP
#include <linux/thread_info.h>
diff --git a/include/uapi/linux/seccomp.h b/include/uapi/linux/seccomp.h
index d3249294788b..bb5875f72556 100644
--- a/include/uapi/linux/seccomp.h
+++ b/include/uapi/linux/seccomp.h
@@ -25,6 +25,12 @@
#define SECCOMP_FILTER_FLAG_TSYNC_ESRCH (1UL << 4)
/* Received notifications wait in killable state (only respond to fatal signals) */
#define SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV (1UL << 5)
+/*
+ * Declares that this listener's notifier may issue
+ * SECCOMP_IOCTL_NOTIF_PIN_INSTALL / SECCOMP_IOCTL_NOTIF_SEND_REDIRECT. At most
+ * one such filter may exist in a task's filter chain. Requires NEW_LISTENER.
+ */
+#define SECCOMP_FILTER_FLAG_REDIRECT (1UL << 6)
/*
* All BPF programs must return a 32-bit value.
@@ -139,7 +145,9 @@ struct seccomp_notif_addfd {
/**
* struct seccomp_notif_pin_install - have the kernel install a sealed
- * MAP_SHARED mapping of @memfd into the trapped task's mm at @target_addr.
+ * MAP_SHARED mapping of @memfd into the trapped task's mm at @target_addr,
+ * which SECCOMP_IOCTL_NOTIF_SEND_REDIRECT can then use as a target for
+ * substituted pointer arguments.
*
* The supervisor owns @memfd and the kernel installs the mapping without
* target-side cooperation. It is read-only and VM_SEALED, so the target and
@@ -168,6 +176,45 @@ struct seccomp_notif_pin_install {
__u64 offset;
};
+#define SECCOMP_REDIRECT_ARGS 6
+
+/**
+ * struct seccomp_notif_resp_redirect - resume the trapped syscall with
+ * substituted arg-register values, optionally pointing into an installed
+ * pinned-memfd region.
+ *
+ * Like SECCOMP_USER_NOTIF_FLAG_CONTINUE the syscall runs, but the kernel
+ * first rewrites the arg registers in @args_mask. Pointer substitutions
+ * (@ptr_mask) are validated against the trapped task's live mapping of
+ * @memfd, so a target that has exited or execve()d simply fails validation.
+ * Original registers are restored at syscall exit, skipped after a successful
+ * execve whose fresh register file must not be clobbered.
+ *
+ * @id: The ID of the seccomp notification this response consumes.
+ * @flags: SECCOMP_REDIRECT_FLAG_*. CONTINUE must be set.
+ * @args_mask: Bit i set means args[i] replaces arg register i before the
+ * syscall runs.
+ * @ptr_mask: Subset of @args_mask. Bit i set means args[i] is a pointer whose
+ * access [args[i], args[i] + ptr_len[i]) must lie inside a single
+ * VM_SEALED, read-only mapping of @memfd. Scalars (in @args_mask
+ * but not @ptr_mask) are written verbatim.
+ * @memfd: Supervisor-side fd for the backing memfd. Consulted only when
+ * @ptr_mask is non-zero.
+ * @args: Replacement values for the arg registers.
+ * @ptr_len: For each bit set in @ptr_mask, the byte length of the access at
+ * args[i]; must be non-zero and args[i] + ptr_len[i] must not
+ * overflow. Must be 0 where @ptr_mask bit i is clear.
+ */
+struct seccomp_notif_resp_redirect {
+ __u64 id;
+ __u32 flags;
+ __u32 args_mask;
+ __u32 ptr_mask;
+ __u32 memfd;
+ __u64 args[SECCOMP_REDIRECT_ARGS];
+ __u64 ptr_len[SECCOMP_REDIRECT_ARGS];
+};
+
#define SECCOMP_IOC_MAGIC '!'
#define SECCOMP_IO(nr) _IO(SECCOMP_IOC_MAGIC, nr)
#define SECCOMP_IOR(nr, type) _IOR(SECCOMP_IOC_MAGIC, nr, type)
@@ -188,4 +235,10 @@ struct seccomp_notif_pin_install {
#define SECCOMP_IOCTL_NOTIF_PIN_INSTALL SECCOMP_IOWR(5, \
struct seccomp_notif_pin_install)
+#define SECCOMP_IOCTL_NOTIF_SEND_REDIRECT SECCOMP_IOW(6, \
+ struct seccomp_notif_resp_redirect)
+
+/* Valid flags for struct seccomp_notif_resp_redirect. */
+#define SECCOMP_REDIRECT_FLAG_CONTINUE (1UL << 0)
+
#endif /* _UAPI_LINUX_SECCOMP_H */
diff --git a/kernel/seccomp.c b/kernel/seccomp.c
index 1c0b3bb71379..c5a01ae097d1 100644
--- a/kernel/seccomp.c
+++ b/kernel/seccomp.c
@@ -233,6 +233,7 @@ struct seccomp_filter {
refcount_t users;
bool log;
bool wait_killable_recv;
+ bool redirect_capable;
struct action_cache cache;
struct seccomp_filter *prev;
struct bpf_prog *prog;
@@ -953,6 +954,13 @@ static long seccomp_attach_filter(unsigned int flags,
}
}
+ if (flags & SECCOMP_FILTER_FLAG_REDIRECT) {
+ for (walker = current->seccomp.filter; walker;
+ walker = walker->prev)
+ if (walker->redirect_capable)
+ return -EBUSY;
+ }
+
/* Set log flag, if present. */
if (flags & SECCOMP_FILTER_FLAG_LOG)
filter->log = true;
@@ -961,6 +969,10 @@ static long seccomp_attach_filter(unsigned int flags,
if (flags & SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV)
filter->wait_killable_recv = true;
+ /* Set redirect-capable flag, if present. */
+ if (flags & SECCOMP_FILTER_FLAG_REDIRECT)
+ filter->redirect_capable = true;
+
/*
* If there is an existing filter, make it the prev and don't drop its
* task reference.
@@ -1941,6 +1953,277 @@ static long seccomp_notify_pin_install(struct seccomp_filter *filter,
return ret;
}
+static bool seccomp_pin_check(struct task_struct *target,
+ struct file *memfd_file, u64 ptr, u64 len)
+{
+ struct vm_area_struct *vma;
+ struct mm_struct *mm;
+ bool ok = false;
+ u64 end;
+
+ if (!len)
+ return false;
+ end = ptr + len;
+ if (end < ptr)
+ return false;
+
+ mm = get_task_mm(target);
+ if (!mm)
+ return false;
+
+ mmap_read_lock(mm);
+ vma = vma_lookup(mm, ptr);
+ /*
+ * The access must lie in a single sealed, read-only, MAP_SHARED,
+ * memfd-backed VMA. VM_SHARED is required so the bytes the kernel reads
+ * are the memfd's own pages: a MAP_PRIVATE mapping would resolve to
+ * anonymous COW copies the target could have written before sealing it
+ * read-only, defeating the guarantee.
+ */
+ if (vma && end <= vma->vm_end && (vma->vm_flags & VM_SEALED) &&
+ (vma->vm_flags & VM_SHARED) && !(vma->vm_flags & VM_WRITE) &&
+ vma->vm_file && file_inode(vma->vm_file) == file_inode(memfd_file))
+ ok = true;
+ mmap_read_unlock(mm);
+
+ mmput(mm);
+ return ok;
+}
+
+struct seccomp_redirect_restore {
+ struct callback_head twork;
+ unsigned long orig_args[SECCOMP_REDIRECT_ARGS];
+ u32 args_mask; /* bit i: arg i was substituted, restore it */
+ u64 self_exec_id; /* snapshot to detect an intervening execve */
+};
+
+/*
+ * If a syscall is redirected by more than one filter, one restore is
+ * queued per redirect, each recording the args as they stood at its own
+ * redirect. Correct restoration relies on task_work running LIFO:
+ * the first redirect captured the true original and queued first, runs
+ * last, so it wins.
+ */
+static void seccomp_redirect_restore_cb(struct callback_head *cb)
+{
+ struct seccomp_redirect_restore *r =
+ container_of(cb, struct seccomp_redirect_restore, twork);
+ unsigned long args[SECCOMP_REDIRECT_ARGS];
+ long ret, err;
+ int i;
+
+ if (READ_ONCE(current->self_exec_id) != r->self_exec_id) {
+ kfree(r);
+ return;
+ }
+
+ err = syscall_get_error(current, current_pt_regs());
+ ret = syscall_get_return_value(current, current_pt_regs());
+
+ syscall_get_arguments(current, current_pt_regs(), args);
+ for (i = 0; i < SECCOMP_REDIRECT_ARGS; i++)
+ if (r->args_mask & (1U << i))
+ args[i] = r->orig_args[i];
+ syscall_set_arguments(current, current_pt_regs(), args);
+
+ syscall_set_return_value(current, current_pt_regs(), err, ret);
+
+ kfree(r);
+}
+
+/*
+ * sigreturn/rt_sigreturn restore the entire register frame from the user
+ * signal stack; the SEND_REDIRECT register-restore (run from task_work at
+ * user-mode return) would corrupt that frame, and the syscall takes no
+ * arguments to substitute anyway. Refuse to redirect any of them, including
+ * the compat variants (legacy sigreturn and rt_sigreturn are distinct
+ * numbers). x32 rt_sigreturn carries __X32_SYSCALL_BIT and is not matched
+ * here; the deprecated x32 ABI is out of scope for redirect.
+ */
+static bool seccomp_redirect_is_sigreturn(const struct seccomp_data *sd)
+{
+#ifdef SECCOMP_ARCH_COMPAT
+ if (sd->arch == SECCOMP_ARCH_COMPAT)
+ return sd->nr == __NR_seccomp_sigreturn_32 ||
+ sd->nr == __NR_seccomp_rt_sigreturn_32;
+#endif
+ return sd->nr == __NR_seccomp_sigreturn ||
+ sd->nr == __NR_seccomp_rt_sigreturn;
+}
+
+/*
+ * clone/fork-family syscalls copy the trapped task's register frame into the
+ * new child, which gets no restore task_work of its own. A redirected task
+ * creation would leave the child in user space with the substituted (pinned)
+ * values still in its caller-saved arg registers, breaking the ABI the restore
+ * preserves for the parent. There is no use case for redirecting task creation,
+ * so refuse it. Syscalls absent on an arch resolve to -1 and never match.
+ */
+static bool seccomp_redirect_is_task_create(const struct seccomp_data *sd)
+{
+#ifdef SECCOMP_ARCH_COMPAT
+ if (sd->arch == SECCOMP_ARCH_COMPAT)
+ return sd->nr == __NR_seccomp_clone_32 ||
+ sd->nr == __NR_seccomp_clone3_32 ||
+ sd->nr == __NR_seccomp_fork_32 ||
+ sd->nr == __NR_seccomp_vfork_32;
+#endif
+ return sd->nr == __NR_seccomp_clone ||
+ sd->nr == __NR_seccomp_clone3 ||
+ sd->nr == __NR_seccomp_fork ||
+ sd->nr == __NR_seccomp_vfork;
+}
+
+static long seccomp_notify_send_redirect(struct seccomp_filter *filter,
+ struct seccomp_notif_resp_redirect __user *uresp,
+ unsigned int size)
+{
+ unsigned long args[SECCOMP_REDIRECT_ARGS];
+ struct seccomp_redirect_restore *restore;
+ struct seccomp_notif_resp_redirect resp;
+ struct file *memfd_file = NULL;
+ struct seccomp_knotif *knotif;
+ struct pt_regs *target_regs;
+ long ret;
+ int i;
+
+ BUILD_BUG_ON(sizeof(resp) < SECCOMP_NOTIFY_RESP_REDIRECT_SIZE_VER0);
+ BUILD_BUG_ON(sizeof(resp) != SECCOMP_NOTIFY_RESP_REDIRECT_SIZE_LATEST);
+
+ if (!filter->redirect_capable)
+ return -EPERM;
+
+ if (size < SECCOMP_NOTIFY_RESP_REDIRECT_SIZE_VER0 || size >= PAGE_SIZE)
+ return -EINVAL;
+
+ ret = copy_struct_from_user(&resp, sizeof(resp), uresp, size);
+ if (ret)
+ return ret;
+
+ if (!(resp.flags & SECCOMP_REDIRECT_FLAG_CONTINUE))
+ return -EINVAL;
+ if (resp.flags & ~SECCOMP_REDIRECT_FLAG_CONTINUE)
+ return -EINVAL;
+ if (resp.args_mask & ~((1U << SECCOMP_REDIRECT_ARGS) - 1))
+ return -EINVAL;
+ if (resp.ptr_mask & ~resp.args_mask)
+ return -EINVAL;
+ if (!resp.args_mask)
+ return -EINVAL;
+ for (i = 0; i < SECCOMP_REDIRECT_ARGS; i++) {
+ if (resp.ptr_mask & (1U << i)) {
+ if (!resp.ptr_len[i])
+ return -EINVAL;
+ } else if (resp.ptr_len[i]) {
+ return -EINVAL;
+ }
+ }
+ if (resp.ptr_mask) {
+ memfd_file = fget(resp.memfd);
+ if (!memfd_file)
+ return -EBADF;
+ }
+
+ restore = kzalloc_obj(*restore, GFP_KERNEL_ACCOUNT);
+ if (!restore) {
+ ret = -ENOMEM;
+ goto out_free;
+ }
+ init_task_work(&restore->twork, seccomp_redirect_restore_cb);
+
+ ret = mutex_lock_interruptible(&filter->notify_lock);
+ if (ret < 0)
+ goto out_free;
+
+ knotif = find_notification(filter, resp.id);
+ if (!knotif) {
+ ret = -ENOENT;
+ goto out_unlock_free;
+ }
+ if (knotif->state != SECCOMP_NOTIFY_SENT) {
+ ret = -EINPROGRESS;
+ goto out_unlock_free;
+ }
+
+ if (seccomp_redirect_is_sigreturn(knotif->data) ||
+ seccomp_redirect_is_task_create(knotif->data)) {
+ ret = -EOPNOTSUPP;
+ goto out_unlock_free;
+ }
+
+#ifdef SECCOMP_ARCH_COMPAT
+ if (knotif->data->arch == SECCOMP_ARCH_COMPAT) {
+ for (i = 0; i < SECCOMP_REDIRECT_ARGS; i++)
+ if (resp.ptr_mask & (1U << i))
+ resp.args[i] = (u32)resp.args[i];
+ }
+#endif
+
+ for (i = 0; i < SECCOMP_REDIRECT_ARGS; i++) {
+ if (!(resp.ptr_mask & (1U << i)))
+ continue;
+ if (!seccomp_pin_check(knotif->task, memfd_file,
+ resp.args[i], resp.ptr_len[i])) {
+ ret = -EFAULT;
+ goto out_unlock_free;
+ }
+ }
+
+ target_regs = task_pt_regs(knotif->task);
+ syscall_get_arguments(knotif->task, target_regs, args);
+
+ for (i = 0; i < SECCOMP_REDIRECT_ARGS; i++)
+ restore->orig_args[i] = args[i];
+ restore->args_mask = resp.args_mask;
+ restore->self_exec_id = READ_ONCE(knotif->task->self_exec_id);
+
+ for (i = 0; i < SECCOMP_REDIRECT_ARGS; i++)
+ if (resp.args_mask & (1U << i))
+ args[i] = resp.args[i];
+ syscall_set_arguments(knotif->task, target_regs, args);
+
+ /*
+ * Use TWA_RESUME, not TWA_SIGNAL. TWA_SIGNAL sets TIF_NOTIFY_SIGNAL,
+ * which makes signal_pending() true for the entire redirected syscall
+ * An interruptible syscall would then bail out with -ERESTARTSYS before
+ * doing any work, restart, re-trap and get redirected again, it is a
+ * livelock. TWA_RESUME does not feed signal_pending(), and the restore
+ * still runs before signal delivery: get_signal() runs task_work_run()
+ * before it dequeues a signal, so the original args are back in pt_regs
+ * before handle_signal() builds the sigframe or the -ERESTART* path
+ * rewinds for restart.
+ */
+ ret = task_work_add(knotif->task, &restore->twork, TWA_RESUME);
+ if (ret) {
+ for (i = 0; i < SECCOMP_REDIRECT_ARGS; i++)
+ args[i] = restore->orig_args[i];
+ syscall_set_arguments(knotif->task, target_regs, args);
+ goto out_unlock_free;
+ }
+
+ knotif->state = SECCOMP_NOTIFY_REPLIED;
+ knotif->error = 0;
+ knotif->val = 0;
+ knotif->flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;
+ if (filter->notif->flags & SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP)
+ complete_on_current_cpu(&knotif->ready);
+ else
+ complete(&knotif->ready);
+
+ mutex_unlock(&filter->notify_lock);
+ if (memfd_file)
+ fput(memfd_file);
+ return 0;
+
+out_unlock_free:
+ mutex_unlock(&filter->notify_lock);
+out_free:
+ if (memfd_file)
+ fput(memfd_file);
+ kfree(restore);
+ return ret;
+}
+
static long seccomp_notify_ioctl(struct file *file, unsigned int cmd,
unsigned long arg)
{
@@ -1968,6 +2251,9 @@ static long seccomp_notify_ioctl(struct file *file, unsigned int cmd,
case EA_IOCTL(SECCOMP_IOCTL_NOTIF_PIN_INSTALL):
return seccomp_notify_pin_install(filter, buf,
_IOC_SIZE(cmd));
+ case EA_IOCTL(SECCOMP_IOCTL_NOTIF_SEND_REDIRECT):
+ return seccomp_notify_send_redirect(filter, buf,
+ _IOC_SIZE(cmd));
default:
return -EINVAL;
}
@@ -2107,6 +2393,10 @@ static long seccomp_set_mode_filter(unsigned int flags,
((flags & SECCOMP_FILTER_FLAG_NEW_LISTENER) == 0))
return -EINVAL;
+ if ((flags & SECCOMP_FILTER_FLAG_REDIRECT) &&
+ ((flags & SECCOMP_FILTER_FLAG_NEW_LISTENER) == 0))
+ return -EINVAL;
+
/* Prepare the new filter before holding any locks. */
prepared = seccomp_prepare_user_filter(filter);
if (IS_ERR(prepared))
--
2.43.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH v5 5/7] seccomp: re-validate a redirected syscall against outer filters
2026-07-04 23:18 [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect Cong Wang
` (3 preceding siblings ...)
2026-07-04 23:18 ` [PATCH v5 4/7] seccomp: add kernel-installed pinned-memfd redirect Cong Wang
@ 2026-07-04 23:18 ` Cong Wang
2026-07-04 23:18 ` [PATCH v5 6/7] docs/seccomp: document pinned-memfd redirect ioctls Cong Wang
2026-07-04 23:18 ` [PATCH v5 7/7] selftests/seccomp: cover non-cooperative pinned-memfd install Cong Wang
6 siblings, 0 replies; 8+ messages in thread
From: Cong Wang @ 2026-07-04 23:18 UTC (permalink / raw)
To: Andy Lutomirski
Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner,
Andrew Morton, linux-mm, Cong Wang
From: Cong Wang <cwang@multikernel.io>
Stacked seccomp filters compose by seccomp_run_filters() taking the most
restrictive verdict over one evaluation of a single seccomp_data. That
assumes the syscall the filters voted on is the syscall that runs.
SECCOMP_IOCTL_NOTIF_SEND_REDIRECT breaks the assumption: a USER_NOTIF
filter's supervisor rewrites the argument registers and the syscall
resumes via FLAG_CONTINUE without the stack being re-consulted. So an
inner, container-installed filter can redirect a syscall into a form an
outer filter would have blocked.
Close the hole by re-evaluating after a redirect. Starting at the filter
outer to the one that notified (match->prev), seccomp_run_filters_seq()
walks toward the root, judging the substituted syscall one filter at a
time and stopping at the first that does not allow it. An outer filter may
ERRNO/KILL/TRAP/TRACE (terminal) or run its own USER_NOTIF; if that
notifier redirects again or resumes with a bare FLAG_CONTINUE, the syscall
still runs, so the walk continues from its ->prev. The walk is strictly
outward, so an inner filter is never reconsulted (no re-notify loop), and
iterative (goto, not recursion) so a deep chain cannot exhaust the kernel
stack.
Only a redirect starts the walk; the first pass and the allow-cache are
unchanged, so nothing changes for existing, non-redirect users.
Assisted-by: Claude:claude-opus-4.8
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
kernel/seccomp.c | 100 ++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 95 insertions(+), 5 deletions(-)
diff --git a/kernel/seccomp.c b/kernel/seccomp.c
index c5a01ae097d1..7e572bb34993 100644
--- a/kernel/seccomp.c
+++ b/kernel/seccomp.c
@@ -94,6 +94,13 @@ struct seccomp_knotif {
long val;
u32 flags;
+ /*
+ * Set by SEND_REDIRECT: the reply rewrote the syscall's registers,
+ * so on resume the syscall must be re-evaluated against the filters
+ * outer to the one that notified (see __seccomp_filter()).
+ */
+ bool redirect;
+
/*
* Signals when this has changed states, such as the listener
* dying, a new seccomp addfd message, or changing to REPLIED
@@ -1181,10 +1188,12 @@ static bool should_sleep_killable(struct seccomp_filter *match,
static int seccomp_do_user_notification(int this_syscall,
struct seccomp_filter *match,
- const struct seccomp_data *sd)
+ const struct seccomp_data *sd,
+ bool *redirected)
{
int err;
u32 flags = 0;
+ bool redirect = false;
long ret = 0;
struct seccomp_knotif n = {};
struct seccomp_kaddfd *addfd, *tmp;
@@ -1241,6 +1250,7 @@ static int seccomp_do_user_notification(int this_syscall,
ret = n.val;
err = n.error;
flags = n.flags;
+ redirect = n.redirect;
interrupted:
/* If there were any pending addfd calls, clear them out */
@@ -1267,19 +1277,44 @@ static int seccomp_do_user_notification(int this_syscall,
mutex_unlock(&match->notify_lock);
/* Userspace requests to continue the syscall. */
- if (flags & SECCOMP_USER_NOTIF_FLAG_CONTINUE)
+ if (flags & SECCOMP_USER_NOTIF_FLAG_CONTINUE) {
+ *redirected = redirect;
return 0;
+ }
syscall_set_return_value(current, current_pt_regs(),
err, ret);
return -1;
}
+static u32 seccomp_run_filters_seq(const struct seccomp_data *sd,
+ struct seccomp_filter **match,
+ struct seccomp_filter *f,
+ int this_syscall)
+{
+ for (; f; f = f->prev) {
+ u32 cur_ret = bpf_prog_run_pin_on_cpu(f->prog, sd);
+ u32 action = ACTION_ONLY(cur_ret);
+
+ if (action == SECCOMP_RET_ALLOW)
+ continue;
+ /* LOG does not block the syscall; record it and continue. */
+ if (action == SECCOMP_RET_LOG) {
+ seccomp_log(this_syscall, 0, action, true);
+ continue;
+ }
+ *match = f;
+ return cur_ret;
+ }
+ return SECCOMP_RET_ALLOW;
+}
+
static int __seccomp_filter(int this_syscall, const bool recheck_after_trace)
{
u32 filter_ret, action;
struct seccomp_data sd;
struct seccomp_filter *match = NULL;
+ bool in_walk = false;
int data;
/*
@@ -1291,6 +1326,8 @@ static int __seccomp_filter(int this_syscall, const bool recheck_after_trace)
populate_seccomp_data(&sd);
filter_ret = seccomp_run_filters(&sd, &match);
+
+eval:
data = filter_ret & SECCOMP_RET_DATA;
action = filter_ret & SECCOMP_RET_ACTION_FULL;
@@ -1342,6 +1379,18 @@ static int __seccomp_filter(int this_syscall, const bool recheck_after_trace)
if (this_syscall < 0)
goto skip;
+ /*
+ * During a post-redirect outward walk, TRACE is terminal: the
+ * tracer has handled the call and, like the other blocking
+ * actions, the walk consults no further filters. Do not run the
+ * full-stack recheck below: it restarts from the innermost
+ * filter and would reconsult (and re-notify) the inner filter
+ * that redirected, breaking the walk's monotonic-outward
+ * guarantee.
+ */
+ if (in_walk)
+ return 0;
+
/*
* Recheck the syscall, since it may have changed. This
* intentionally uses a NULL struct seccomp_data to force
@@ -1353,11 +1402,51 @@ static int __seccomp_filter(int this_syscall, const bool recheck_after_trace)
return 0;
- case SECCOMP_RET_USER_NOTIF:
- if (seccomp_do_user_notification(this_syscall, match, &sd))
+ case SECCOMP_RET_USER_NOTIF: {
+ struct seccomp_filter *outer;
+ bool redirected = false;
+
+ if (seccomp_do_user_notification(this_syscall, match, &sd,
+ &redirected))
goto skip;
- return 0;
+ /*
+ * Continue the outward walk only when the syscall still runs
+ * and there is an outer filter left to judge it. That holds for
+ * a redirect, and for a plain FLAG_CONTINUE (resume, no
+ * redirect) reached during the walk: either way the call must
+ * keep being offered to the remaining outer filters, or this
+ * notifier's reply would let it slip past a stricter filter
+ * further out. A redirect by the outermost filter (no
+ * match->prev) has no outer filter to re-validate against.
+ */
+ if (!match->prev || !(redirected || in_walk))
+ return 0;
+
+ if (redirected) {
+ /*
+ * The notifier rewrote the registers. Reload the
+ * seccomp_data so the outer filters judge the
+ * substituted syscall; a plain resume leaves the
+ * registers (and thus the current sd) unchanged. The
+ * walk is strictly outward, so a notifier can never
+ * re-notify on its own redirect.
+ */
+ populate_seccomp_data(&sd);
+ this_syscall = sd.nr;
+ if (this_syscall < 0)
+ return 0;
+ in_walk = true;
+ }
+
+ outer = match->prev;
+ match = NULL;
+ filter_ret = seccomp_run_filters_seq(&sd, &match, outer,
+ this_syscall);
+ if (!match)
+ return 0;
+ goto eval;
+ }
case SECCOMP_RET_LOG:
seccomp_log(this_syscall, 0, action, true);
@@ -2201,6 +2290,7 @@ static long seccomp_notify_send_redirect(struct seccomp_filter *filter,
goto out_unlock_free;
}
+ knotif->redirect = true;
knotif->state = SECCOMP_NOTIFY_REPLIED;
knotif->error = 0;
knotif->val = 0;
--
2.43.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH v5 6/7] docs/seccomp: document pinned-memfd redirect ioctls
2026-07-04 23:18 [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect Cong Wang
` (4 preceding siblings ...)
2026-07-04 23:18 ` [PATCH v5 5/7] seccomp: re-validate a redirected syscall against outer filters Cong Wang
@ 2026-07-04 23:18 ` Cong Wang
2026-07-04 23:18 ` [PATCH v5 7/7] selftests/seccomp: cover non-cooperative pinned-memfd install Cong Wang
6 siblings, 0 replies; 8+ messages in thread
From: Cong Wang @ 2026-07-04 23:18 UTC (permalink / raw)
To: Andy Lutomirski
Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner,
Andrew Morton, linux-mm, Cong Wang
From: Cong Wang <cwang@multikernel.io>
Document SECCOMP_IOCTL_NOTIF_PIN_INSTALL and
SECCOMP_IOCTL_NOTIF_SEND_REDIRECT in the userspace API guide: the
SECCOMP_FILTER_FLAG_REDIRECT opt-in and the single-redirector
restriction, the two response structures, and how the pair closes the
user-notification TOCTOU for non-cooperative fork+execve sandboxes.
Also spell out the scope the implementation deliberately enforces or
relies on: read-only input pointers only, same-syscall-number only
(rt_sigreturn and the clone/fork family are refused), the per-interruption
re-notification of restartable syscalls and the restart-block behaviour,
and the ptrace syscall-stop semantics.
Assisted-by: Claude:claude-opus-4.8
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
.../userspace-api/seccomp_filter.rst | 109 ++++++++++++++++++
1 file changed, 109 insertions(+)
diff --git a/Documentation/userspace-api/seccomp_filter.rst b/Documentation/userspace-api/seccomp_filter.rst
index cff0fa7f3175..e868a2900574 100644
--- a/Documentation/userspace-api/seccomp_filter.rst
+++ b/Documentation/userspace-api/seccomp_filter.rst
@@ -289,6 +289,115 @@ above in this document: all arguments being read from the tracee's memory
should be read into the tracer's memory before any policy decisions are made.
This allows for an atomic decision on syscall arguments.
+Non-cooperative pinned-memfd redirect
+=====================================
+
+The TOCTOU described above means ``SECCOMP_USER_NOTIF_FLAG_CONTINUE`` cannot
+enforce a policy on pointer arguments: after the supervisor inspects the
+target's memory and lets the syscall continue, the target (or a thread sharing
+its address space) can rewrite that memory before the kernel reads it. The
+cooperative workaround, the target ``mmap()`` + ``mseal()``-ing a shared
+buffer, is unavailable in the fork+execve sandbox model, where the supervisor
+confines a binary it did not write.
+
+Two ioctls let the supervisor close this race without target cooperation. The
+redirect step (below) requires a listener created with
+``SECCOMP_FILTER_FLAG_REDIRECT`` (in addition to
+``SECCOMP_FILTER_FLAG_NEW_LISTENER``). Because it rewrites another task's
+registers, at most one such listener may exist in a task's filter chain; a
+second fails with ``-EBUSY``:
+
+.. code-block:: c
+
+ fd = seccomp(SECCOMP_SET_MODE_FILTER,
+ SECCOMP_FILTER_FLAG_NEW_LISTENER | SECCOMP_FILTER_FLAG_REDIRECT,
+ &prog);
+
+``ioctl(SECCOMP_IOCTL_NOTIF_PIN_INSTALL)`` installs a sealed mapping of a
+supervisor-owned ``memfd`` directly into the trapped task's address space:
+
+.. code-block:: c
+
+ struct seccomp_notif_pin_install {
+ __u64 id;
+ __u32 flags; /* reserved, must be 0 */
+ __u32 memfd;
+ __u64 target_addr;
+ __u64 size;
+ __u64 offset; /* page-aligned offset into memfd */
+ };
+
+``id`` names an active notification (the trapped task to install into).
+``target_addr``, ``size`` and ``offset`` are page-aligned; ``offset`` selects
+where in ``memfd`` the mapping starts, so one memfd can back several pins. If
+``target_addr`` is ``0`` the kernel picks a free address and writes it back;
+otherwise an existing mapping there yields ``-EEXIST``. The pin is read-only
+and sealed, the target and its threads cannot unmap, move, reprotect or
+overwrite it, and lasts until the target ``execve()``s or exits.
+
+``memfd`` must be write-sealed (``F_SEAL_WRITE`` or ``F_SEAL_FUTURE_WRITE``)
+or the ioctl returns ``-EINVAL``; otherwise the target could rewrite the pin's
+bytes through a separate writable handle to the same memfd.
+``F_SEAL_FUTURE_WRITE`` still lets the supervisor update the contents through
+its own mapping made before the seal.
+
+``ioctl(SECCOMP_IOCTL_NOTIF_SEND_REDIRECT)`` then resumes the trapped syscall
+like ``SECCOMP_USER_NOTIF_FLAG_CONTINUE``, but with selected argument
+registers replaced:
+
+.. code-block:: c
+
+ struct seccomp_notif_resp_redirect {
+ __u64 id;
+ __u32 flags; /* SECCOMP_REDIRECT_FLAG_CONTINUE must be set */
+ __u32 args_mask; /* which arg registers to replace */
+ __u32 ptr_mask; /* which of those are pointers into a pin */
+ __u32 memfd; /* the pin's backing memfd */
+ __u64 args[6]; /* replacement values */
+ __u64 ptr_len[6]; /* validated access length for each pointer arg */
+ };
+
+Each bit in ``ptr_mask`` (a subset of ``args_mask``) marks ``args[i]`` as a
+pointer; the access ``[args[i], args[i] + ptr_len[i])`` must lie within a
+single read-only pin of ``memfd`` in the target, or the ioctl returns
+``-EFAULT``. ``ptr_len[i]`` must be non-zero for those bits and ``0``
+otherwise. Bits in ``args_mask`` but not ``ptr_mask`` are scalar replacements
+written verbatim, e.g. to set the length register that goes with a redirected
+pointer. The original registers are restored at syscall exit, so the
+substitution is invisible to the target and the TOCTOU is closed.
+
+Scope and limitations
+---------------------
+
+The redirect mechanism is deliberately narrow and is *not* a general syscall
+rewriting facility:
+
+- **Read-only input pointers only.** A pin is read-only, so only an argument
+ the syscall *reads* (a pathname, a ``sockaddr``) may be redirected into it.
+ Aiming an output or in/out argument at a pin makes the syscall fail with
+ ``-EFAULT`` when it writes back.
+
+- **Same syscall only.** A redirect replaces arguments, never the syscall
+ number. ``rt_sigreturn()`` (and its compat variant) cannot be redirected and
+ return ``-EOPNOTSUPP``.
+
+- **Signals and restarts.** The redirected syscall really runs, so it can be
+ interrupted and restarted. On a restart the original arguments are restored
+ and the syscall re-traps, so the supervisor is notified again and must answer
+ consistently. Syscalls the kernel restarts without re-trapping (e.g.
+ ``nanosleep()``, ``futex(FUTEX_WAIT)``) keep the substituted arguments --
+ safe for read-only inputs, but a reason not to redirect arguments of syscalls
+ that block or wait.
+
+- **clone()/fork().** The task-creation family (``clone``, ``clone3``,
+ ``fork``, ``vfork``) cannot be redirected and returns ``-EOPNOTSUPP``. A new
+ child inherits the substituted argument registers but not the restore, so it
+ would return to user space with the pinned values still in its registers.
+
+- **ptrace.** A tracer sees the substituted arguments at the syscall-exit stop;
+ they are restored before the task resumes, so a ``PTRACE_SETREGS`` of a
+ substituted register at that stop is overwritten.
+
Sysctls
=======
--
2.43.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH v5 7/7] selftests/seccomp: cover non-cooperative pinned-memfd install
2026-07-04 23:18 [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect Cong Wang
` (5 preceding siblings ...)
2026-07-04 23:18 ` [PATCH v5 6/7] docs/seccomp: document pinned-memfd redirect ioctls Cong Wang
@ 2026-07-04 23:18 ` Cong Wang
6 siblings, 0 replies; 8+ messages in thread
From: Cong Wang @ 2026-07-04 23:18 UTC (permalink / raw)
To: Andy Lutomirski
Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner,
Andrew Morton, linux-mm, Cong Wang
From: Cong Wang <cwang@multikernel.io>
Add coverage for SECCOMP_IOCTL_NOTIF_PIN_INSTALL and
SECCOMP_IOCTL_NOTIF_SEND_REDIRECT, where the kernel installs a sealed
PROT_READ MAP_SHARED mapping of the supervisor's memfd into the trapped
task's mm via vm_mmap_remote() and the supervisor redirects a pointer
argument into it.
The tests exercise the remote install and redirect on a bare forked
child (including the unsealed-memfd and out-of-pin rejection paths), the
full fork+execve flow with a detached SCM_RIGHTS supervisor and per-mm
rebind, stateless reuse across many short-lived targets, re-validation of
a redirect against the outer filter stack, the -EOPNOTSUPP denial of
syscalls whose register substitution is unsafe (rt_sigreturn and the
clone/fork family), and the original argument-register restore ABI
(including its ordering against signal frame setup).
Assisted-by: Claude:claude-opus-4.8
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
tools/testing/selftests/seccomp/seccomp_bpf.c | 1251 +++++++++++++++++
1 file changed, 1251 insertions(+)
diff --git a/tools/testing/selftests/seccomp/seccomp_bpf.c b/tools/testing/selftests/seccomp/seccomp_bpf.c
index 358b6c65e120..c5d915e83955 100644
--- a/tools/testing/selftests/seccomp/seccomp_bpf.c
+++ b/tools/testing/selftests/seccomp/seccomp_bpf.c
@@ -217,6 +217,10 @@ struct seccomp_metadata {
#define SECCOMP_FILTER_FLAG_NEW_LISTENER (1UL << 3)
#endif
+#ifndef SECCOMP_FILTER_FLAG_REDIRECT
+#define SECCOMP_FILTER_FLAG_REDIRECT (1UL << 6)
+#endif
+
#ifndef SECCOMP_RET_USER_NOTIF
#define SECCOMP_RET_USER_NOTIF 0x7fc00000U
@@ -295,6 +299,35 @@ struct seccomp_notif_addfd_big {
#define PTRACE_EVENTMSG_SYSCALL_EXIT 2
#endif
+#ifndef SECCOMP_IOCTL_NOTIF_PIN_INSTALL
+struct seccomp_notif_pin_install {
+ __u64 id;
+ __u32 flags;
+ __u32 memfd;
+ __u64 target_addr;
+ __u64 size;
+ __u64 offset;
+};
+#define SECCOMP_IOCTL_NOTIF_PIN_INSTALL SECCOMP_IOWR(5, \
+ struct seccomp_notif_pin_install)
+#endif
+
+#ifndef SECCOMP_IOCTL_NOTIF_SEND_REDIRECT
+#define SECCOMP_REDIRECT_FLAG_CONTINUE (1UL << 0)
+#define SECCOMP_REDIRECT_ARGS 6
+struct seccomp_notif_resp_redirect {
+ __u64 id;
+ __u32 flags;
+ __u32 args_mask;
+ __u32 ptr_mask;
+ __u32 memfd;
+ __u64 args[SECCOMP_REDIRECT_ARGS];
+ __u64 ptr_len[SECCOMP_REDIRECT_ARGS];
+};
+#define SECCOMP_IOCTL_NOTIF_SEND_REDIRECT SECCOMP_IOW(6, \
+ struct seccomp_notif_resp_redirect)
+#endif
+
#ifndef SECCOMP_USER_NOTIF_FLAG_CONTINUE
#define SECCOMP_USER_NOTIF_FLAG_CONTINUE 0x00000001
#endif
@@ -4368,6 +4401,1224 @@ TEST(user_notification_addfd_rlimit)
close(memfd);
}
+/*
+ * Create a write-sealed memfd of @size for PIN_INSTALL and map a supervisor
+ * writable view, primed with @content. F_SEAL_FUTURE_WRITE keeps this
+ * pre-seal mapping writable (so the test can still stage content) while
+ * barring any other writable reference, as PIN_INSTALL requires. Returns
+ * the memfd.
+ */
+static int make_pin_memfd(struct __test_metadata *_metadata, const char *name,
+ size_t size, char **sup_view, const char *content)
+{
+ int memfd = memfd_create(name, MFD_ALLOW_SEALING);
+
+ ASSERT_GE(memfd, 0);
+ ASSERT_EQ(0, ftruncate(memfd, size));
+ ASSERT_EQ(0, fcntl(memfd, F_ADD_SEALS, F_SEAL_SHRINK | F_SEAL_GROW));
+
+ *sup_view = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED,
+ memfd, 0);
+ ASSERT_NE(MAP_FAILED, *sup_view);
+ ASSERT_EQ(0, fcntl(memfd, F_ADD_SEALS, F_SEAL_FUTURE_WRITE));
+ memcpy(*sup_view, content, strlen(content) + 1);
+ return memfd;
+}
+
+/*
+ * Non-cooperative pinned-memfd: kernel installs a sealed PROT_READ
+ * MAP_SHARED mapping of the supervisor's memfd directly into the
+ * trapped task's mm. The target runs no mmap or mseal code itself —
+ * this exercises the same kernel path that a fork+execve sandbox
+ * supervisor would use to install a pin in the new image's fresh
+ * post-exec mm.
+ *
+ * Target child does nothing but call openat() on a bait path. The
+ * supervisor catches the trap, calls PIN_INSTALL (kernel does the
+ * mmap + seal in target's mm via vm_mmap_remote()), writes a
+ * safe path into its own memfd view, and SEND_REDIRECTs args[1]
+ * into the freshly installed pin. The child's openat resumes,
+ * reads from the sealed pin, and returns an fd to the safe path.
+ */
+TEST(user_notification_pinned_memfd_remote)
+{
+ pid_t pid;
+ long ret;
+ int status, listener, memfd, unsealed;
+ struct seccomp_notif req = {};
+ struct seccomp_notif_pin_install pin = {};
+ struct seccomp_notif_pin_install unsealed_pin = {};
+ struct seccomp_notif_resp_redirect redir = {};
+ char *sup_view;
+ const size_t PIN_SIZE = 4096;
+ const char *safe_path = "/dev/null";
+
+ ret = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ ASSERT_EQ(0, ret) {
+ TH_LOG("Kernel does not support PR_SET_NO_NEW_PRIVS!");
+ }
+
+ memfd = make_pin_memfd(_metadata, "pinned-remote", PIN_SIZE,
+ &sup_view, safe_path);
+
+ listener = user_notif_syscall(__NR_openat,
+ SECCOMP_FILTER_FLAG_NEW_LISTENER |
+ SECCOMP_FILTER_FLAG_REDIRECT);
+ ASSERT_GE(listener, 0);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ int fd;
+
+ /*
+ * Target performs no setup. Just trap on openat. Kernel
+ * (driven by the supervisor) will install the pin in this
+ * process's mm at a kernel-chosen address behind our back,
+ * and our openat will be redirected to read from there.
+ */
+ fd = syscall(__NR_openat, AT_FDCWD,
+ "/this/should/never/be/touched", O_RDONLY, 0);
+ if (fd < 0)
+ _exit(11);
+ _exit(0);
+ }
+
+ ASSERT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req));
+ EXPECT_EQ(req.data.nr, __NR_openat);
+
+ pin.id = req.id;
+ pin.memfd = memfd;
+ pin.target_addr = 0;
+ pin.size = PIN_SIZE;
+ EXPECT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_PIN_INSTALL, &pin)) {
+ if (errno == EINVAL) {
+ kill(pid, SIGKILL);
+ waitpid(pid, &status, 0);
+ SKIP(goto cleanup,
+ "Kernel does not support pinned-memfd remote install");
+ }
+ TH_LOG("PIN_INSTALL failed: errno=%d", errno);
+ }
+
+ /* The kernel wrote a non-zero, page-aligned address back to us. */
+ EXPECT_NE(0, pin.target_addr);
+ EXPECT_EQ(0, pin.target_addr & (PIN_SIZE - 1));
+
+ /* Reject: the backing memfd must be write-sealed. */
+ unsealed = memfd_create("unsealed", MFD_ALLOW_SEALING);
+ ASSERT_GE(unsealed, 0);
+ ASSERT_EQ(0, ftruncate(unsealed, PIN_SIZE));
+ unsealed_pin.id = req.id;
+ unsealed_pin.memfd = unsealed;
+ unsealed_pin.size = PIN_SIZE;
+ EXPECT_EQ(-1, ioctl(listener, SECCOMP_IOCTL_NOTIF_PIN_INSTALL,
+ &unsealed_pin));
+ EXPECT_EQ(EINVAL, errno);
+ close(unsealed);
+
+ /* Reject: redirect outside any installed pin. */
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 1;
+ redir.ptr_mask = 1U << 1;
+ redir.memfd = memfd;
+ redir.ptr_len[1] = strlen(safe_path) + 1;
+ redir.args[1] = pin.target_addr + PIN_SIZE; /* one byte past */
+ EXPECT_EQ(-1, ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT,
+ &redir));
+ EXPECT_EQ(EFAULT, errno);
+
+ /* Reject: base is inside the pin but the extent runs past its end. */
+ redir.args[1] = pin.target_addr;
+ redir.ptr_len[1] = PIN_SIZE + 1;
+ EXPECT_EQ(-1, ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT,
+ &redir));
+ EXPECT_EQ(EFAULT, errno);
+
+ /* Happy path: redirect into the kernel-installed pin. */
+ redir.args[1] = pin.target_addr;
+ redir.ptr_len[1] = strlen(safe_path) + 1;
+ EXPECT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT,
+ &redir)) {
+ /* Unblock the trapped child so waitpid() cannot hang. */
+ kill(pid, SIGKILL);
+ }
+
+ EXPECT_EQ(waitpid(pid, &status, 0), pid);
+ EXPECT_EQ(true, WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status)) {
+ TH_LOG("child exit %d (11=openat fail)", WEXITSTATUS(status));
+ }
+
+cleanup:
+ munmap(sup_view, PIN_SIZE);
+ close(memfd);
+ close(listener);
+}
+
+/*
+ * Helper for the execve test: read up to @max bytes of a NUL-terminated
+ * string from @pid's mm at @addr into @out. Returns the length read
+ * (excluding the NUL), or -1 on failure or no NUL.
+ */
+static ssize_t read_remote_string(pid_t pid, unsigned long addr,
+ char *out, size_t max)
+{
+ struct iovec local = { .iov_base = out, .iov_len = max };
+ struct iovec remote = { .iov_base = (void *)addr, .iov_len = max };
+ ssize_t n;
+ size_t i;
+
+ n = process_vm_readv(pid, &local, 1, &remote, 1, 0);
+ if (n <= 0)
+ return -1;
+ for (i = 0; i < (size_t)n; i++)
+ if (out[i] == '\0')
+ return (ssize_t)i;
+ return -1;
+}
+
+/*
+ * Send a file descriptor over a connected UNIX socket via SCM_RIGHTS.
+ * Used by the execve_scm test so the target child can hand its
+ * SECCOMP_FILTER_FLAG_NEW_LISTENER fd to the supervising parent
+ * without the parent having to inherit the seccomp filter itself.
+ */
+static int send_fd(int sock, int fd)
+{
+ char cbuf[CMSG_SPACE(sizeof(int))] = {};
+ char data = 'x';
+ struct iovec iov = { .iov_base = &data, .iov_len = 1 };
+ struct msghdr msg = {
+ .msg_iov = &iov, .msg_iovlen = 1,
+ .msg_control = cbuf, .msg_controllen = sizeof(cbuf),
+ };
+ struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
+
+ cmsg->cmsg_level = SOL_SOCKET;
+ cmsg->cmsg_type = SCM_RIGHTS;
+ cmsg->cmsg_len = CMSG_LEN(sizeof(int));
+ memcpy(CMSG_DATA(cmsg), &fd, sizeof(int));
+ return sendmsg(sock, &msg, 0) < 0 ? -1 : 0;
+}
+
+static int recv_fd(int sock)
+{
+ char cbuf[CMSG_SPACE(sizeof(int))] = {};
+ char data;
+ struct iovec iov = { .iov_base = &data, .iov_len = 1 };
+ struct msghdr msg = {
+ .msg_iov = &iov, .msg_iovlen = 1,
+ .msg_control = cbuf, .msg_controllen = sizeof(cbuf),
+ };
+ struct cmsghdr *cmsg;
+ int fd;
+
+ if (recvmsg(sock, &msg, 0) < 0)
+ return -1;
+ cmsg = CMSG_FIRSTHDR(&msg);
+ if (!cmsg || cmsg->cmsg_level != SOL_SOCKET ||
+ cmsg->cmsg_type != SCM_RIGHTS ||
+ cmsg->cmsg_len != CMSG_LEN(sizeof(int)))
+ return -1;
+ memcpy(&fd, CMSG_DATA(cmsg), sizeof(int));
+ return fd;
+}
+
+struct addr_range {
+ unsigned long start, end;
+};
+
+/*
+ * Parse /proc/<pid>/maps looking for the dynamic linker's executable
+ * mapping (glibc ld-linux-*.so, musl ld-musl-*.so, etc.). The trapped
+ * task's instruction_pointer falling in this range identifies a
+ * loader-bootstrap syscall (race-free, kernel-truth) so the supervisor
+ * can auto-allow it without inspecting argument content via the racy
+ * process_vm_readv path.
+ *
+ * Requires the supervisor not to be subject to the seccomp filter
+ * itself -- fopen() internally calls openat(). The execve_scm test
+ * structure (child installs filter, sends listener fd to parent via
+ * SCM_RIGHTS) satisfies that.
+ *
+ * Returns 0 on success with @out populated, -1 if not found.
+ */
+static int find_loader_text_range(pid_t pid, struct addr_range *out)
+{
+ char maps_path[64];
+ char line[512];
+ FILE *f;
+ int found = 0;
+
+ snprintf(maps_path, sizeof(maps_path), "/proc/%d/maps", pid);
+ f = fopen(maps_path, "r");
+ if (!f)
+ return -1;
+
+ while (fgets(line, sizeof(line), f)) {
+ unsigned long start, end;
+ char perms[8];
+ char *path;
+
+ if (sscanf(line, "%lx-%lx %7s", &start, &end, perms) != 3)
+ continue;
+ if (!strchr(perms, 'x'))
+ continue;
+ path = strchr(line, '/');
+ if (!path)
+ continue;
+ /*
+ * Match common dynamic-linker basenames: ld-linux-*.so
+ * (glibc), ld-musl-*.so (musl), ld-*.so (older glibc).
+ */
+ if (strstr(path, "/ld-") || strstr(path, "/ld.so")) {
+ out->start = start;
+ out->end = end;
+ found = 1;
+ break;
+ }
+ }
+ fclose(f);
+ return found ? 0 : -1;
+}
+
+/*
+ * Non-cooperative pinned-memfd across a real execve, using the proper
+ * supervisor-isolation pattern: the child (target) installs the seccomp
+ * filter on itself and sends its listener fd to the parent (supervisor)
+ * via SCM_RIGHTS over a socketpair. The parent therefore does not carry
+ * the seccomp filter and can freely call openat() -- which is what makes
+ * the race-free, kernel-truth loader detection (req.data.instruction_pointer
+ * + /proc/<pid>/maps) actually usable.
+ *
+ * Phase 1: child does a pre-execve openat; the supervisor PIN_INSTALLs and
+ * SEND_REDIRECTs. Phase 2: child execve's, so the pre-execve pin VMA dies
+ * with the old mm. Phase 3: in the fresh post-execve mm the supervisor
+ * PIN_INSTALLs again (idempotent replace of the stale bookkeeping) and
+ * SEND_REDIRECTs, proving the full redirect mechanism survives an mm
+ * replacement, not just the install side.
+ */
+TEST(user_notification_pinned_memfd_execve_scm)
+{
+ pid_t pid;
+ int status, listener, memfd, sv[2];
+ struct seccomp_notif req = {};
+ struct seccomp_notif_pin_install pin = {};
+ struct seccomp_notif_resp_redirect redir = {};
+ struct seccomp_notif_resp cont_resp = {};
+ char *sup_view;
+ const size_t PIN_SIZE = 4096;
+ const char *safe_path = "/dev/null";
+ const char *bait = "/seccomp_pinned_memfd_test_bait_scm";
+ bool post_exec_install_ok = false;
+ bool post_exec_redirect_done = false;
+ bool loader_known = false;
+ bool loader_check_attempted = false;
+ struct addr_range loader_range = {};
+ int phase = 0;
+ int trap_count = 0;
+ const int trap_limit = 200;
+
+ if (access("/bin/cat", X_OK) != 0)
+ SKIP(return, "/bin/cat not present");
+
+ memfd = make_pin_memfd(_metadata, "pin-execve-scm", PIN_SIZE,
+ &sup_view, safe_path);
+
+ ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_SEQPACKET, 0, sv));
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct sock_filter filter[] = {
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
+ offsetof(struct seccomp_data, nr)),
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat,
+ 0, 1),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
+ };
+ struct sock_fprog prog = {
+ .len = (unsigned short)ARRAY_SIZE(filter),
+ .filter = filter,
+ };
+ int my_listener;
+ int fd;
+
+ close(sv[0]);
+ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0))
+ _exit(20);
+ my_listener = seccomp(SECCOMP_SET_MODE_FILTER,
+ SECCOMP_FILTER_FLAG_NEW_LISTENER |
+ SECCOMP_FILTER_FLAG_REDIRECT,
+ &prog);
+ if (my_listener < 0)
+ _exit(21);
+ if (send_fd(sv[1], my_listener) < 0)
+ _exit(22);
+ close(my_listener);
+ close(sv[1]);
+
+ /* Pre-execve trap. */
+ fd = syscall(__NR_openat, AT_FDCWD,
+ "/this/should/never/be/touched", O_RDONLY, 0);
+ if (fd < 0)
+ _exit(11);
+
+ execl("/bin/cat", "cat", bait, (char *)NULL);
+ _exit(12);
+ }
+
+ close(sv[1]);
+ listener = recv_fd(sv[0]);
+ close(sv[0]);
+ ASSERT_GE(listener, 0);
+
+ /*
+ * Parent has the listener fd and does NOT have the seccomp
+ * filter. fopen(/proc/<pid>/maps) below works without
+ * deadlocking on the parent's own openat.
+ */
+ for (;;) {
+ struct pollfd pfd = { .fd = listener, .events = POLLIN };
+ int pret = poll(&pfd, 1, 500);
+ pid_t reaped;
+ bool ip_in_loader;
+
+ if (pret < 0)
+ break;
+ if (pret == 0 || !(pfd.revents & POLLIN)) {
+ reaped = waitpid(pid, &status, WNOHANG);
+ if (reaped == pid)
+ break;
+ if (pfd.revents & (POLLHUP | POLLERR))
+ break;
+ continue;
+ }
+
+ memset(&req, 0, sizeof(req));
+ if (ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req) < 0) {
+ TH_LOG("NOTIF_RECV failed: errno=%d", errno);
+ break;
+ }
+ if (++trap_count > trap_limit) {
+ TH_LOG("trap_limit (%d) exceeded", trap_limit);
+ break;
+ }
+
+ if (phase == 0) {
+ pin.id = req.id;
+ pin.memfd = memfd;
+ pin.target_addr = 0;
+ pin.size = PIN_SIZE;
+ if (ioctl(listener,
+ SECCOMP_IOCTL_NOTIF_PIN_INSTALL,
+ &pin) != 0) {
+ TH_LOG("pre-exec PIN_INSTALL failed: errno=%d",
+ errno);
+ if (errno == EINVAL)
+ SKIP(goto cleanup_scm,
+ "Kernel lacks pinned-memfd remote");
+ goto cleanup_scm;
+ }
+
+ memset(&redir, 0, sizeof(redir));
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 1;
+ redir.ptr_mask = 1U << 1;
+ redir.memfd = memfd;
+ redir.ptr_len[1] = strlen(safe_path) + 1;
+ redir.args[1] = pin.target_addr;
+ if (ioctl(listener,
+ SECCOMP_IOCTL_NOTIF_SEND_REDIRECT,
+ &redir) != 0) {
+ TH_LOG("pre-exec SEND_REDIRECT failed: errno=%d",
+ errno);
+ goto cleanup_scm;
+ }
+ phase = 1;
+ continue;
+ }
+
+ /*
+ * Post-execve. Lazily resolve the loader range. The
+ * supervisor's own openat (fopen on /proc/<pid>/maps)
+ * doesn't trap because the filter lives on the child,
+ * not on us.
+ */
+ if (!loader_known && !loader_check_attempted) {
+ if (find_loader_text_range(req.pid,
+ &loader_range) == 0)
+ loader_known = true;
+ loader_check_attempted = true;
+ }
+
+ ip_in_loader = loader_known &&
+ req.data.instruction_pointer >= loader_range.start &&
+ req.data.instruction_pointer < loader_range.end;
+
+ if (ip_in_loader) {
+ memset(&cont_resp, 0, sizeof(cont_resp));
+ cont_resp.id = req.id;
+ cont_resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;
+ ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &cont_resp);
+ continue;
+ }
+
+ /* Program code: inspect the path to identify the bait. */
+ {
+ char path[PATH_MAX];
+ ssize_t n;
+
+ n = read_remote_string(req.pid, req.data.args[1],
+ path, sizeof(path));
+ if (n < 0 || strcmp(path, bait) != 0) {
+ memset(&cont_resp, 0, sizeof(cont_resp));
+ cont_resp.id = req.id;
+ cont_resp.flags =
+ SECCOMP_USER_NOTIF_FLAG_CONTINUE;
+ ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND,
+ &cont_resp);
+ continue;
+ }
+
+ pin.id = req.id;
+ pin.memfd = memfd;
+ pin.target_addr = 0;
+ pin.size = PIN_SIZE;
+ if (ioctl(listener,
+ SECCOMP_IOCTL_NOTIF_PIN_INSTALL,
+ &pin) == 0) {
+ post_exec_install_ok = true;
+ } else {
+ TH_LOG("post-exec PIN_INSTALL failed: errno=%d",
+ errno);
+ memset(&cont_resp, 0, sizeof(cont_resp));
+ cont_resp.id = req.id;
+ cont_resp.flags =
+ SECCOMP_USER_NOTIF_FLAG_CONTINUE;
+ ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND,
+ &cont_resp);
+ continue;
+ }
+
+ memset(&redir, 0, sizeof(redir));
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 1;
+ redir.ptr_mask = 1U << 1;
+ redir.memfd = memfd;
+ redir.ptr_len[1] = strlen(safe_path) + 1;
+ redir.args[1] = pin.target_addr;
+ if (ioctl(listener,
+ SECCOMP_IOCTL_NOTIF_SEND_REDIRECT,
+ &redir) == 0) {
+ post_exec_redirect_done = true;
+ } else {
+ TH_LOG("post-exec SEND_REDIRECT failed: errno=%d",
+ errno);
+ memset(&cont_resp, 0, sizeof(cont_resp));
+ cont_resp.id = req.id;
+ cont_resp.flags =
+ SECCOMP_USER_NOTIF_FLAG_CONTINUE;
+ ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND,
+ &cont_resp);
+ }
+ }
+ }
+
+ if (waitpid(pid, &status, WNOHANG) == 0) {
+ kill(pid, SIGKILL);
+ waitpid(pid, &status, 0);
+ }
+ EXPECT_EQ(true, loader_known) {
+ TH_LOG("find_loader_text_range never resolved");
+ }
+ EXPECT_EQ(true, post_exec_install_ok);
+ EXPECT_EQ(true, post_exec_redirect_done);
+ EXPECT_EQ(true, WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+cleanup_scm:
+ if (waitpid(pid, &status, WNOHANG) == 0) {
+ kill(pid, SIGKILL);
+ waitpid(pid, &status, 0);
+ }
+ munmap(sup_view, PIN_SIZE);
+ close(memfd);
+ close(listener);
+}
+
+/*
+ * Stateless redirect validation must hold up across many short-lived
+ * targets over one listener, and must not accumulate per-target state.
+ *
+ * PIN_INSTALL records nothing: the installed VM_SEALED VMA is the only
+ * record, and SEND_REDIRECT re-validates the pointer against the live
+ * mapping (sealed, read-only, backed by the supervisor's memfd inode).
+ * So a supervisor servicing a long churn of targets keeps working with
+ * no bookkeeping to leak. Each iteration lets the kernel choose the pin
+ * address in the fresh target mm; every install/redirect must succeed, and
+ * kmemleak/KASAN over the loop confirms nothing accumulates.
+ */
+TEST(user_notification_pinned_memfd_churn)
+{
+ const size_t PIN_SIZE = 4096;
+ const char *safe_path = "/dev/null";
+ const int iters = 16;
+ int listener, memfd, i;
+ char *sup_view;
+ long ret;
+
+ ret = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ ASSERT_EQ(0, ret) {
+ TH_LOG("Kernel does not support PR_SET_NO_NEW_PRIVS!");
+ }
+
+ memfd = make_pin_memfd(_metadata, "pinned-reap", PIN_SIZE,
+ &sup_view, safe_path);
+
+ listener = user_notif_syscall(__NR_openat,
+ SECCOMP_FILTER_FLAG_NEW_LISTENER |
+ SECCOMP_FILTER_FLAG_REDIRECT);
+ ASSERT_GE(listener, 0);
+
+ for (i = 0; i < iters; i++) {
+ struct seccomp_notif req = {};
+ struct seccomp_notif_pin_install pin = {};
+ struct seccomp_notif_resp_redirect redir = {};
+ int status;
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+ if (pid == 0) {
+ int fd = syscall(__NR_openat, AT_FDCWD,
+ "/never/touched", O_RDONLY, 0);
+ _exit(fd < 0 ? 11 : 0);
+ }
+
+ ASSERT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req));
+ EXPECT_EQ(req.data.nr, __NR_openat);
+
+ pin.id = req.id;
+ pin.memfd = memfd;
+ pin.target_addr = 0;
+ pin.size = PIN_SIZE;
+ EXPECT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_PIN_INSTALL,
+ &pin)) {
+ if (errno == EINVAL) {
+ kill(pid, SIGKILL);
+ waitpid(pid, &status, 0);
+ SKIP(goto cleanup,
+ "Kernel lacks pinned-memfd remote install");
+ }
+ TH_LOG("iter %d PIN_INSTALL failed: errno=%d", i, errno);
+ }
+
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 1;
+ redir.ptr_mask = 1U << 1;
+ redir.memfd = memfd;
+ redir.ptr_len[1] = strlen(safe_path) + 1;
+ redir.args[1] = pin.target_addr;
+ EXPECT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT,
+ &redir)) {
+ kill(pid, SIGKILL);
+ }
+
+ EXPECT_EQ(waitpid(pid, &status, 0), pid);
+ EXPECT_EQ(true, WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status)) {
+ TH_LOG("iter %d child exit %d (11=openat fail)",
+ i, WEXITSTATUS(status));
+ }
+ /*
+ * Target is dead now; its pin (this iter's mm, at the
+ * kernel-chosen address) is stale. The next iteration's
+ * PIN_INSTALL walk must reap it rather than leak the range +
+ * mm + memfd reference.
+ */
+ }
+
+cleanup:
+ munmap(sup_view, PIN_SIZE);
+ close(memfd);
+ close(listener);
+}
+
+#ifdef __NR_socket
+/*
+ * A redirect must not let an inner (more recently installed) filter's
+ * notifier smuggle a syscall past an outer filter. Two filters are
+ * stacked on the target:
+ *
+ * outer (installed first): socket(AF_INET, ...) -> RET_ERRNO(EACCES),
+ * everything else ALLOW.
+ * inner (installed second): socket -> RET_USER_NOTIF.
+ *
+ * The child calls socket(AF_UNIX, ...), which the outer filter allows, so
+ * the inner notifier wins and fires. The supervisor SEND_REDIRECTs arg0
+ * to AF_INET. The kernel must then re-run the outer filter against the
+ * rewritten registers and block it with EACCES; without the outer-suffix
+ * re-validation the inner filter would have bypassed the outer policy.
+ */
+TEST(user_notification_redirect_outer_refilter)
+{
+ struct sock_filter outer_filter[] = {
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
+ offsetof(struct seccomp_data, nr)),
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_socket, 0, 3),
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, syscall_arg(0)),
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AF_INET, 0, 1),
+ BPF_STMT(BPF_RET | BPF_K,
+ SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA)),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
+ };
+ struct sock_fprog outer_prog = {
+ .len = (unsigned short)ARRAY_SIZE(outer_filter),
+ .filter = outer_filter,
+ };
+ struct seccomp_notif req = {};
+ struct seccomp_notif_resp_redirect redir = {};
+ int status, listener;
+ pid_t pid;
+ long ret;
+
+ ret = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ ASSERT_EQ(0, ret) {
+ TH_LOG("Kernel does not support PR_SET_NO_NEW_PRIVS!");
+ }
+
+ /* Outer filter first => it becomes the outer/root of the stack. */
+ ASSERT_EQ(0, seccomp(SECCOMP_SET_MODE_FILTER, 0, &outer_prog));
+
+ /* Inner USER_NOTIF filter second (innermost); returns the listener. */
+ listener = user_notif_syscall(__NR_socket,
+ SECCOMP_FILTER_FLAG_NEW_LISTENER |
+ SECCOMP_FILTER_FLAG_REDIRECT);
+ ASSERT_GE(listener, 0);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ int fd = syscall(__NR_socket, AF_UNIX, SOCK_STREAM, 0);
+
+ if (fd >= 0)
+ _exit(12);
+ if (errno != EACCES)
+ _exit(13);
+ _exit(0);
+ }
+
+ ASSERT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req));
+ EXPECT_EQ(req.data.nr, __NR_socket);
+ EXPECT_EQ(req.data.args[0], AF_UNIX);
+
+ /* Scalar redirect of arg0 (no pin needed): AF_UNIX -> AF_INET. */
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 0;
+ redir.args[0] = AF_INET;
+ ret = ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT, &redir);
+ if (ret < 0 && errno == EINVAL) {
+ kill(pid, SIGKILL);
+ waitpid(pid, &status, 0);
+ close(listener);
+ SKIP(return, "Kernel lacks SECCOMP_IOCTL_NOTIF_SEND_REDIRECT");
+ }
+ EXPECT_EQ(0, ret);
+ if (ret)
+ kill(pid, SIGKILL);
+
+ EXPECT_EQ(waitpid(pid, &status, 0), pid);
+ EXPECT_EQ(true, WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status)) {
+ switch (WEXITSTATUS(status)) {
+ case 12:
+ TH_LOG("child exit 12: redirect bypassed the outer filter");
+ break;
+ case 13:
+ TH_LOG("child exit 13: socket failed with unexpected errno");
+ break;
+ default:
+ TH_LOG("child exit %d (unexpected)", WEXITSTATUS(status));
+ }
+ }
+
+ close(listener);
+}
+#endif /* __NR_socket */
+
+/*
+ * SEND_REDIRECT refuses syscalls whose register substitution would be unsafe
+ * and returns -EOPNOTSUPP: sigreturn (its frame restore fights the redirect
+ * restore) and the clone/fork family (a new child inherits the substituted
+ * registers with no restore). The trapped call is then answered with a plain
+ * error, so it never actually runs.
+ *
+ * The target installs the filter and hands the listener to the supervisor over
+ * a socketpair: a filter that traps fork/clone cannot be installed before the
+ * supervisor itself forks the target.
+ */
+TEST(user_notification_redirect_denied_syscalls)
+{
+ static const int denied[] = {
+ __NR_rt_sigreturn,
+ __NR_clone,
+ __NR_clone3,
+#ifdef __NR_fork
+ __NR_fork,
+#endif
+#ifdef __NR_vfork
+ __NR_vfork,
+#endif
+ };
+ unsigned int i;
+ long ret;
+
+ for (i = 0; i < ARRAY_SIZE(denied); i++) {
+ struct seccomp_notif req = {};
+ struct seccomp_notif_resp resp = {};
+ struct seccomp_notif_resp_redirect redir = {};
+ int sk[2], listener, status;
+ pid_t pid;
+
+ if (denied[i] < 0)
+ continue;
+
+ ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sk));
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+ if (pid == 0) {
+ close(sk[0]);
+ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0))
+ _exit(1);
+ listener = user_notif_syscall(
+ denied[i],
+ SECCOMP_FILTER_FLAG_NEW_LISTENER |
+ SECCOMP_FILTER_FLAG_REDIRECT);
+ if (listener < 0)
+ _exit(2);
+ if (send_fd(sk[1], listener))
+ _exit(3);
+ syscall(denied[i], 0, 0, 0, 0, 0, 0);
+ _exit(0);
+ }
+
+ close(sk[1]);
+ listener = recv_fd(sk[0]);
+ if (listener < 0) {
+ waitpid(pid, &status, 0);
+ close(sk[0]);
+ SKIP(return, "SECCOMP_FILTER_FLAG_REDIRECT unsupported");
+ }
+
+ ASSERT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req));
+ EXPECT_EQ(req.data.nr, denied[i]);
+
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 0;
+ redir.args[0] = 0;
+ errno = 0;
+ ret = ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT, &redir);
+ EXPECT_EQ(-1, ret);
+ EXPECT_EQ(EOPNOTSUPP, errno) {
+ TH_LOG("nr %d: SEND_REDIRECT errno %d, want EOPNOTSUPP",
+ denied[i], errno);
+ }
+
+ resp.id = req.id;
+ resp.error = -EPERM;
+ EXPECT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp));
+
+ EXPECT_EQ(waitpid(pid, &status, 0), pid);
+ EXPECT_EQ(true, WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status)) {
+ TH_LOG("nr %d: child exit %d", denied[i],
+ WEXITSTATUS(status));
+ }
+ close(listener);
+ close(sk[0]);
+ }
+}
+
+#ifdef __NR_socket
+/*
+ * Re-validation walks *every* filter outer to the notifier, not just the
+ * nearest. Stack (outer -> inner):
+ *
+ * outer (1st): socket(AF_INET) -> RET_ERRNO(EACCES), else ALLOW
+ * middle (2nd): ALLOW everything
+ * inner (3rd): socket -> RET_USER_NOTIF (the redirector)
+ *
+ * The target calls socket(AF_UNIX); the inner notifier fires and the
+ * supervisor SEND_REDIRECTs arg0 to AF_INET. The outward walk must pass
+ * through the permissive middle filter and still reach the outer filter,
+ * which blocks the substituted call with EACCES. If the walk stopped at the
+ * first outer filter (middle, ALLOW), the redirect would slip past the outer
+ * policy. This exercises the iterative multi-filter walk that the single-outer
+ * outer_refilter test does not.
+ */
+TEST(user_notification_redirect_revalidate_chain)
+{
+ struct sock_filter outer_filter[] = {
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
+ offsetof(struct seccomp_data, nr)),
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_socket, 0, 3),
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, syscall_arg(0)),
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AF_INET, 0, 1),
+ BPF_STMT(BPF_RET | BPF_K,
+ SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA)),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
+ };
+ struct sock_fprog outer_prog = {
+ .len = (unsigned short)ARRAY_SIZE(outer_filter),
+ .filter = outer_filter,
+ };
+ struct sock_filter allow_filter[] = {
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
+ };
+ struct sock_fprog allow_prog = {
+ .len = (unsigned short)ARRAY_SIZE(allow_filter),
+ .filter = allow_filter,
+ };
+ struct seccomp_notif req = {};
+ struct seccomp_notif_resp_redirect redir = {};
+ int status, listener;
+ pid_t pid;
+ long ret;
+
+ ret = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ ASSERT_EQ(0, ret) {
+ TH_LOG("Kernel does not support PR_SET_NO_NEW_PRIVS!");
+ }
+
+ /* outer (root) -> middle (permissive) -> inner (notifier). */
+ ASSERT_EQ(0, seccomp(SECCOMP_SET_MODE_FILTER, 0, &outer_prog));
+ ASSERT_EQ(0, seccomp(SECCOMP_SET_MODE_FILTER, 0, &allow_prog));
+ listener = user_notif_syscall(__NR_socket,
+ SECCOMP_FILTER_FLAG_NEW_LISTENER |
+ SECCOMP_FILTER_FLAG_REDIRECT);
+ ASSERT_GE(listener, 0);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ int fd = syscall(__NR_socket, AF_UNIX, SOCK_STREAM, 0);
+
+ if (fd >= 0)
+ _exit(12);
+ if (errno != EACCES)
+ _exit(13);
+ _exit(0);
+ }
+
+ ASSERT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req));
+ EXPECT_EQ(req.data.nr, __NR_socket);
+ EXPECT_EQ(req.data.args[0], AF_UNIX);
+
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 0;
+ redir.args[0] = AF_INET;
+ ret = ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT, &redir);
+ if (ret < 0 && errno == EINVAL) {
+ kill(pid, SIGKILL);
+ waitpid(pid, &status, 0);
+ close(listener);
+ SKIP(return, "Kernel lacks SECCOMP_IOCTL_NOTIF_SEND_REDIRECT");
+ }
+ EXPECT_EQ(0, ret);
+ if (ret)
+ kill(pid, SIGKILL);
+
+ EXPECT_EQ(waitpid(pid, &status, 0), pid);
+ EXPECT_EQ(true, WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status)) {
+ switch (WEXITSTATUS(status)) {
+ case 12:
+ TH_LOG("exit 12: walk stopped at middle; outer bypassed");
+ break;
+ case 13:
+ TH_LOG("exit 13: socket failed with unexpected errno");
+ break;
+ default:
+ TH_LOG("child exit %d (unexpected)", WEXITSTATUS(status));
+ }
+ }
+
+ close(listener);
+}
+#endif /* __NR_socket */
+
+#ifdef __x86_64__
+/*
+ * Load-bearing ABI check: after SEND_REDIRECT, the trapped task's
+ * redirected arg register must be restored to its original value
+ * before user-mode code resumes. The kernel's restore mechanism
+ * (task_work_add(TWA_RESUME) -> seccomp_redirect_restore_cb) is
+ * what guarantees this; without a test the property is just an
+ * assertion. Bypass libc's syscall() wrapper (which caller-saves
+ * arg values and would mask a restore bug) and capture the actual
+ * arg register immediately after the SYSCALL instruction.
+ *
+ * The child issues openat with RSI = sentinel_path. The supervisor
+ * SEND_REDIRECTs args[1] (RSI) to point into the pin. The kernel:
+ * - saves the original RSI into the knotif
+ * - writes the pin address into RSI via syscall_set_arguments()
+ * - runs the syscall (kernel reads path from the pin)
+ * - on syscall_exit_to_user_mode, fires task_work which calls
+ * syscall_set_arguments() again with the saved original
+ * - returns to user mode
+ *
+ * If task_work fires correctly, the child observes RSI == sentinel.
+ * If broken, RSI holds the pin address (the redirected value the
+ * kernel left in pt_regs).
+ */
+TEST(user_notification_pinned_memfd_abi)
+{
+ pid_t pid;
+ long ret;
+ int status, listener, memfd;
+ struct seccomp_notif req = {};
+ struct seccomp_notif_pin_install pin = {};
+ struct seccomp_notif_resp_redirect redir = {};
+ char *sup_view;
+ const size_t PIN_SIZE = 4096;
+ const char *safe_path = "/dev/null";
+ /*
+ * The "sentinel" is a real string the child can also pass as
+ * the openat path. Its address is captured pre-syscall as RSI;
+ * post-syscall RSI must equal the same address.
+ */
+ static const char sentinel_path[] = "/seccomp_abi_sentinel";
+
+ ret = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ ASSERT_EQ(0, ret) {
+ TH_LOG("Kernel does not support PR_SET_NO_NEW_PRIVS!");
+ }
+
+ memfd = make_pin_memfd(_metadata, "pin-abi", PIN_SIZE,
+ &sup_view, safe_path);
+
+ listener = user_notif_syscall(__NR_openat,
+ SECCOMP_FILTER_FLAG_NEW_LISTENER |
+ SECCOMP_FILTER_FLAG_REDIRECT);
+ ASSERT_GE(listener, 0);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ register long r10_val asm("r10") = 0;
+ unsigned long rsi_after;
+ long fd;
+
+ asm volatile(
+ "syscall\n\t"
+ "mov %%rsi, %[after]"
+ : "=a"(fd), [after] "=&r"(rsi_after)
+ : "0"((long)__NR_openat),
+ "D"((long)AT_FDCWD),
+ "S"((unsigned long)sentinel_path),
+ "d"((long)O_RDONLY),
+ "r"(r10_val)
+ : "rcx", "r11", "memory"
+ );
+
+ if (fd < 0)
+ _exit(11);
+ /*
+ * Load-bearing check: RSI immediately post-SYSCALL must
+ * still be the sentinel pointer the child passed in. The
+ * kernel's REDIRECT-then-restore mechanism is the only
+ * thing that guarantees this; a broken restore would leave
+ * the pin address in RSI.
+ */
+ if (rsi_after != (unsigned long)sentinel_path)
+ _exit(12);
+ _exit(0);
+ }
+
+ ASSERT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req));
+ EXPECT_EQ(req.data.nr, __NR_openat);
+ EXPECT_EQ(req.data.args[1], (unsigned long)sentinel_path);
+
+ pin.id = req.id;
+ pin.memfd = memfd;
+ pin.target_addr = 0;
+ pin.size = PIN_SIZE;
+ EXPECT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_PIN_INSTALL, &pin)) {
+ if (errno == EINVAL) {
+ kill(pid, SIGKILL);
+ waitpid(pid, &status, 0);
+ SKIP(goto cleanup,
+ "Kernel lacks pinned-memfd remote install");
+ }
+ }
+
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 1;
+ redir.ptr_mask = 1U << 1;
+ redir.memfd = memfd;
+ redir.ptr_len[1] = strlen(safe_path) + 1;
+ redir.args[1] = pin.target_addr;
+ EXPECT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT,
+ &redir)) {
+ kill(pid, SIGKILL);
+ }
+
+ EXPECT_EQ(waitpid(pid, &status, 0), pid);
+ EXPECT_EQ(true, WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status)) {
+ switch (WEXITSTATUS(status)) {
+ case 11:
+ TH_LOG("child exit 11: openat returned -errno");
+ break;
+ case 12:
+ TH_LOG("child exit 12: ABI violation -- RSI not restored after redirect");
+ break;
+ default:
+ TH_LOG("child exit %d (unexpected)", WEXITSTATUS(status));
+ }
+ }
+
+cleanup:
+ munmap(sup_view, PIN_SIZE);
+ close(memfd);
+ close(listener);
+}
+
+static void redir_sigusr1_handler(int signo)
+{
+ /* _exit() is async-signal-safe; bail with a distinct code if the
+ * signal frame was clobbered so the handler sees the wrong signo.
+ */
+ if (signo != SIGUSR1)
+ _exit(12);
+}
+
+/*
+ * Regression test: a redirect's deferred arg-register restore must run
+ * before a signal frame is built, not after.
+ *
+ * The restore is queued as a TWA_RESUME task_work. get_signal() runs
+ * task_work_run() before it dequeues a signal, so the restore executes
+ * before handle_signal() builds the handler frame (regs->di = signo,
+ * regs->si = &siginfo, regs->dx = &ucontext): the trapped syscall's
+ * original argument values are back in pt_regs first and never clobber
+ * the frame. A broken restore that instead ran after the frame was
+ * built would overwrite those registers and enter the handler with a
+ * corrupted signal number.
+ *
+ * The child traps on pause(), the supervisor redirects arg0 (RDI), and
+ * then interrupts it with SIGUSR1. The handler must observe
+ * signo == SIGUSR1, not the leaked original RDI sentinel.
+ */
+TEST(user_notification_redirect_signal_abi)
+{
+ pid_t pid;
+ long ret;
+ int status, listener;
+ struct seccomp_notif req = {};
+ struct seccomp_notif_resp_redirect redir = {};
+ /* A recognizable original RDI the broken restore would leak in. */
+ const unsigned long RDI_SENTINEL = 0x5a5a5a5aUL;
+
+ ret = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ ASSERT_EQ(0, ret) {
+ TH_LOG("Kernel does not support PR_SET_NO_NEW_PRIVS!");
+ }
+
+ listener = user_notif_syscall(__NR_pause,
+ SECCOMP_FILTER_FLAG_NEW_LISTENER |
+ SECCOMP_FILTER_FLAG_REDIRECT);
+ ASSERT_GE(listener, 0);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct sigaction sa = {
+ .sa_handler = redir_sigusr1_handler,
+ };
+ long rc;
+
+ if (sigaction(SIGUSR1, &sa, NULL))
+ _exit(10);
+
+ /* Raw pause() carrying a controlled RDI sentinel. */
+ asm volatile(
+ "syscall"
+ : "=a"(rc)
+ : "0"((long)__NR_pause),
+ "D"(RDI_SENTINEL)
+ : "rcx", "r11", "memory");
+
+ if (rc != -EINTR)
+ _exit(11);
+ _exit(0);
+ }
+
+ ASSERT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req));
+ EXPECT_EQ(req.data.nr, __NR_pause);
+ EXPECT_EQ(req.data.args[0], RDI_SENTINEL);
+
+ /* Redirect arg0 (non-pointer); this arms the original-RDI restore. */
+ redir.id = req.id;
+ redir.flags = SECCOMP_REDIRECT_FLAG_CONTINUE;
+ redir.args_mask = 1U << 0;
+ redir.args[0] = 0;
+ EXPECT_EQ(0, ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT,
+ &redir)) {
+ int einval = (errno == EINVAL);
+
+ kill(pid, SIGKILL);
+ waitpid(pid, &status, 0);
+ if (einval)
+ SKIP(goto cleanup,
+ "Kernel lacks SECCOMP_IOCTL_NOTIF_SEND_REDIRECT");
+ goto cleanup;
+ }
+
+ usleep(100000);
+ EXPECT_EQ(0, kill(pid, SIGUSR1));
+
+ EXPECT_EQ(waitpid(pid, &status, 0), pid);
+ EXPECT_EQ(true, WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status)) {
+ switch (WEXITSTATUS(status)) {
+ case 10:
+ TH_LOG("child exit 10: sigaction failed");
+ break;
+ case 11:
+ TH_LOG("child exit 11: pause() did not return -EINTR");
+ break;
+ case 12:
+ TH_LOG("child exit 12: handler saw wrong signo (frame clobbered)");
+ break;
+ default:
+ TH_LOG("child exit %d (unexpected)", WEXITSTATUS(status));
+ }
+ }
+
+cleanup:
+ close(listener);
+}
+#endif /* __x86_64__ */
+
#ifndef SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP
#define SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP (1UL << 0)
#define SECCOMP_IOCTL_NOTIF_SET_FLAGS SECCOMP_IOW(4, __u64)
--
2.43.0
^ permalink raw reply related [flat|nested] 8+ messages in thread
end of thread, other threads:[~2026-07-04 23:19 UTC | newest]
Thread overview: 8+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-04 23:18 [PATCH v5 0/7] seccomp: non-cooperative pinned-memfd argument redirect Cong Wang
2026-07-04 23:18 ` [PATCH v5 1/7] mm: add __do_mmap() and vm_mmap_remote()/vm_munmap_remote() Cong Wang
2026-07-04 23:18 ` [PATCH v5 2/7] seccomp: introduce SECCOMP_IOCTL_NOTIF_PIN_INSTALL Cong Wang
2026-07-04 23:18 ` [PATCH v5 3/7] seccomp: add __NR_seccomp_* aliases for rt_sigreturn and clone/fork Cong Wang
2026-07-04 23:18 ` [PATCH v5 4/7] seccomp: add kernel-installed pinned-memfd redirect Cong Wang
2026-07-04 23:18 ` [PATCH v5 5/7] seccomp: re-validate a redirected syscall against outer filters Cong Wang
2026-07-04 23:18 ` [PATCH v5 6/7] docs/seccomp: document pinned-memfd redirect ioctls Cong Wang
2026-07-04 23:18 ` [PATCH v5 7/7] selftests/seccomp: cover non-cooperative pinned-memfd install Cong Wang
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox