mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
* [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify
@ 2026-05-15  4:27 Cong Wang
  2026-05-15  4:27 ` [RFC PATCH v2 1/3] seccomp: add " Cong Wang
                   ` (4 more replies)
  0 siblings, 5 replies; 9+ messages in thread
From: Cong Wang @ 2026-05-15  4:27 UTC (permalink / raw)
  To: Kees Cook, linux-kernel
  Cc: Andy Lutomirski, Will Drewry, Christian Brauner, Cong Wang

From: Cong Wang <cwang@multikernel.io>

This is a complete rework of v1 (PIN_ARGS), reshaped to address the
review feedback that having every syscall-arg fetch site consult a
per-task pin pointer is cross-cutting awareness that does not scale.

v1 thread:
https://lore.kernel.org/lkml/20260504011207.539408-1-xiyou.wangcong@gmail.com

## Changes since v1

The previous proposal (SECCOMP_IOCTL_NOTIF_PIN_ARGS) snapshotted
pointer-arg payloads into kernel buffers and modified four syscall
fetch sites (getname_flags in fs/namei.c, copy_strings in fs/exec.c,
move_addr_to_kernel in net/socket.c, import_ubuf in lib/iov_iter.c
plus new_sync_read/new_sync_write in fs/read_write.c) so the resumed
syscall body would consume from the snapshot instead of re-reading
user memory. The reviewer correctly pointed out that this spreads
"continue-from-snapshotted-state" awareness across the VFS and the
kernel in general, and that the right shape for this kind of feature
is one where the syscall layer does not have to care.

v2 inverts the model. The supervisor no longer pins args for a
resumed syscall body to consume; it describes a substitute syscall
(nr + args[6]) whose pointer-shaped args are encoded as byte offsets
into a kernel-side buffer. On SECCOMP_USER_NOTIF_FLAG_INJECTED, the
trapped task wakes inside seccomp_do_user_notification(), dispatches
into a kernel-mode syscall helper (filp_open / kernel_bind /
kernel_write for v1), and the helper's return value becomes the
trapped syscall's return value. The trapped task's user mm is never
re-read for the substituted syscall.

Footprint comparison:

  v1 (PIN_ARGS):  ~1000 LOC across kernel/, fs/, mm/, net/, lib/
  v2 (INJECT):    ~540  LOC, all in kernel/seccomp_inject.{c,h} plus
                  a small dispatcher in kernel/seccomp.c. fs/, mm/,
                  net/, lib/ are unmodified.

## Motivation (short version)

seccomp_unotify(2) leaves a TOCTOU window: a sibling thread or
CLONE_VM peer can mutate pointer-arg buffers between the supervisor's
process_vm_readv() and the kernel's re-read on
SECCOMP_USER_NOTIF_FLAG_CONTINUE. The race is documented in the man
page. ptrace and /proc/pid/mem are unavailable to unprivileged
supervisors, so today there is no race-free path for content-aware
allow policy on CONTINUE.

The full motivation, including the threat model (adversarial AI
agents in the same address space) and the concrete user (Sandlock,
https://github.com/multikernel/sandlock), is in the v1 cover letter
above.

## Approach

The UAPI is one new ioctl and one new NOTIF_SEND flag:

    struct seccomp_notif_inject {
        __u64 id;
        __u64 nr;
        __u64 args[6];
        __u64 buf;               /* __user, kernel-input bytes */
        __u32 buf_size;
        __u32 args_in_buf_mask;  /* bit i: args[i] is offset into buf */
    };

    #define SECCOMP_IOCTL_NOTIF_INJECT \
        SECCOMP_IOW(5, struct seccomp_notif_inject)
    #define SECCOMP_USER_NOTIF_FLAG_INJECTED (1U << 1)

The struct mirrors ptrace_syscall_info.entry (nr + args[6]); pointer
args are encoded as byte offsets into buf via args_in_buf_mask. The
kernel copies buf at INJECT time and acts on its kernel-side copy
thereafter. The supervisor commits by sending NOTIF_SEND with
FLAG_INJECTED.

v1 injectable-syscall whitelist:

  - openat (filp_open + fd_install)
  - bind   (sockfd_lookup + kernel_bind)
  - write  (kernel_write)

These cover the bulk of Sandlock-shape policies. execveat is deferred
to v2 follow-up (kernel_execveat in the trapped task's context needs
careful framing, and the argv/envp packed format deserves its own
review round). sendmsg/recvmsg are deferred because msghdr's nested
pointers (msg_name + msg_iov array + msg_control with possible
SCM_RIGHTS) do not fit the flat-buffer model without a richer
packed-format design.

Per-syscall plumbing is small: each injector is ~20 LOC. Adding a
new injectable syscall is a self-contained patch (one new entry in
seccomp_injector_for() plus a new injector function). The framework
does not need to know about new syscalls; only the per-syscall
injector parses buf for its own layout.

## Relation to ptrace

ptrace can already inject syscalls via PTRACE_SETREGSET +
PTRACE_POKEDATA. CRIU, gdb and most container tooling use this
pattern. INJECT does not add a new kernel capability. It provides a
listener-fd-gated, syscall-whitelisted, narrower interface to that
capability for unprivileged supervisors, where ptrace's privilege
model (CAP_SYS_PTRACE / Yama PR_SET_PTRACER) and per-syscall overhead
(signal-stop cycle plus O(N) peer-thread coordination required for
race-free reads) are not viable.

SECCOMP_IOCTL_NOTIF_ADDFD set the precedent for this kind of narrow
listener-fd interface to a ptrace-overlapping capability. INJECT is
ADDFD generalized along one more axis: not just fd substitution, but
substitute-with-kernel-validated-args for a handful of syscalls
where Sandlock-shape policies need it.

## Why this closes TOCTOU end-to-end

For pointer args placed in buf, the kernel reads from kernel memory
that the supervisor populated at INJECT time. The agent's mm and
buf are decoupled after that ioctl returns: peers can mutate user
memory all they want, but the kernel never re-reads it for the
substituted syscall. The supervisor commits bytes; the kernel
performs the operation; the trapped task gets the result.

The remaining race (peer mutates agent mm before the supervisor's
process_vm_readv) is a userspace consistency concern, not a kernel
bypass: the supervisor decides on bytes it observed, and the kernel
performs the operation the supervisor authorized. The peer cannot
cause the kernel to act on bytes the supervisor never inspected.

## Lifecycle, bounds, accounting

  - One-shot: the inject record attaches to the knotif on INJECT and
    is consumed by NOTIF_SEND with FLAG_INJECTED. It is freed on
    listener close, task exit, supervisor changing its mind (CONTINUE
    or plain deny), or SIGKILL of the trapped task.
  - 1 MiB hardcoded cap on buf_size (defensive ceiling, not a tunable).
  - GFP_KERNEL_ACCOUNT so the trapped task's memcg pays.
  - The substitute nr must match the trapped syscall's number (no
    confused-deputy conversion from one syscall family to another).

## Testing

The selftest binary covers openat, bind and write end-to-end (child
issues syscall with one set of args; supervisor injects substitute;
verify kernel acted on supervisor-supplied bytes), plus negative
paths: unsupported syscall (-EOPNOTSUPP), trapped/inject syscall
mismatch (-ESRCH), double INJECT (-EEXIST), CONTINUE+INJECTED
conflict (-EINVAL), INJECTED without prior attach (-EINVAL). All
seven cases pass on x86_64.

---
Cong Wang (3):
  seccomp: add SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify
  selftests/seccomp: add seccomp_notif_inject coverage
  Documentation: seccomp: document SECCOMP_IOCTL_NOTIF_INJECT

 .../userspace-api/seccomp_filter.rst          |  42 ++
 MAINTAINERS                                   |   2 +
 include/uapi/linux/seccomp.h                  |  65 +++
 kernel/Makefile                               |   1 +
 kernel/seccomp.c                              | 121 ++++-
 kernel/seccomp_inject.c                       | 281 ++++++++++++
 kernel/seccomp_inject.h                       |  65 +++
 tools/testing/selftests/seccomp/.gitignore    |   1 +
 tools/testing/selftests/seccomp/Makefile      |   2 +-
 .../selftests/seccomp/seccomp_notif_inject.c  | 434 ++++++++++++++++++
 10 files changed, 1011 insertions(+), 3 deletions(-)
 create mode 100644 kernel/seccomp_inject.c
 create mode 100644 kernel/seccomp_inject.h
 create mode 100644 tools/testing/selftests/seccomp/seccomp_notif_inject.c

--
2.43.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [RFC PATCH v2 1/3] seccomp: add SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify
  2026-05-15  4:27 [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
@ 2026-05-15  4:27 ` Cong Wang
  2026-05-15  4:27 ` [RFC PATCH v2 2/3] selftests/seccomp: add seccomp_notif_inject coverage Cong Wang
                   ` (3 subsequent siblings)
  4 siblings, 0 replies; 9+ messages in thread
From: Cong Wang @ 2026-05-15  4:27 UTC (permalink / raw)
  To: Kees Cook, linux-kernel
  Cc: Andy Lutomirski, Will Drewry, Christian Brauner, Cong Wang

From: Cong Wang <cwang@multikernel.io>

seccomp_unotify(2) leaves a documented TOCTOU window for unprivileged
supervisors: a sibling thread or CLONE_VM peer can mutate pointer-arg
buffers between the supervisor's process_vm_readv() and the kernel's
re-read on SECCOMP_USER_NOTIF_FLAG_CONTINUE. ptrace()/proc/pid/mem
are not available to unprivileged supervisors, so today there is no
race-free path for content-aware policy on CONTINUE.

Reshape the v1 PIN_ARGS proposal as a PTRACE_SYSCALL-style redirect.
The supervisor describes a substitute syscall via a single new ioctl
SECCOMP_IOCTL_NOTIF_INJECT. The struct mirrors ptrace_syscall_info.entry
(nr + args[6]) and adds a kernel-input buffer plus an args_in_buf_mask
bitmap; pointer-shaped args are encoded as byte offsets into that
buffer. SECCOMP_IOCTL_NOTIF_SEND with SECCOMP_USER_NOTIF_FLAG_INJECTED
consumes the redirect: the trapped task wakes, dispatches into the
matching kernel-mode syscall helper, and the helper's return value
becomes the trapped syscall's return value. The trapped task's user
mm is never re-read for the substituted syscall, so peer mutations
after attach have no effect.

The v1 injectable-syscall whitelist is openat (filp_open + fd_install),
bind (sockfd_lookup + kernel_bind) and write (kernel_write). The
substitute nr must match the trapped syscall's number, preventing a
malicious supervisor from converting "task tried to bind()" into
"kernel does an openat() on the task's behalf". Total cumulative
buffer size is capped at a hardcoded 1 MiB; allocations are
GFP_KERNEL_ACCOUNT so the trapped task's memcg pays the cost.

This is intentionally a strict subset of what ptrace can already do
via PTRACE_POKEDATA + PTRACE_SETREGSET. It does not add a kernel
capability. It provides a listener-fd-gated, syscall-whitelisted,
narrower interface to that capability suitable for unprivileged
seccomp_unotify supervisors, where ptrace's privilege model
(CAP_SYS_PTRACE) and per-syscall overhead (signal-stop cycle plus
O(N) peer-thread coordination per syscall) are not viable.
SECCOMP_IOCTL_NOTIF_ADDFD set the precedent for this kind of narrow
listener-fd interface to a ptrace-overlapping capability.

Lifecycle: the inject record attaches to the knotif on the INJECT
ioctl and is consumed at NOTIF_SEND with FLAG_INJECTED. It is freed
on listener close, task exit, supervisor changing its mind (CONTINUE
without INJECTED, or plain deny), or SIGKILL of the trapped task.
The whole feature lives in a new kernel/seccomp_inject.c plus a small
dispatcher in kernel/seccomp.c. fs/, mm/, net/ and lib/ are
unmodified.

Assisted-by: Claude:claude-opus-4.6
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
 MAINTAINERS                  |   2 +
 include/uapi/linux/seccomp.h |  65 ++++++++
 kernel/Makefile              |   1 +
 kernel/seccomp.c             | 121 ++++++++++++++-
 kernel/seccomp_inject.c      | 281 +++++++++++++++++++++++++++++++++++
 kernel/seccomp_inject.h      |  65 ++++++++
 6 files changed, 533 insertions(+), 2 deletions(-)
 create mode 100644 kernel/seccomp_inject.c
 create mode 100644 kernel/seccomp_inject.h

diff --git a/MAINTAINERS b/MAINTAINERS
index 6aa3fe2ee1bb..120f913b58b1 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -24097,6 +24097,8 @@ F:	Documentation/userspace-api/seccomp_filter.rst
 F:	include/linux/seccomp.h
 F:	include/uapi/linux/seccomp.h
 F:	kernel/seccomp.c
+F:	kernel/seccomp_inject.c
+F:	kernel/seccomp_inject.h
 F:	tools/testing/selftests/kselftest_harness.h
 F:	tools/testing/selftests/kselftest_harness/
 F:	tools/testing/selftests/seccomp/*
diff --git a/include/uapi/linux/seccomp.h b/include/uapi/linux/seccomp.h
index dbfc9b37fcae..fa51790f51cf 100644
--- a/include/uapi/linux/seccomp.h
+++ b/include/uapi/linux/seccomp.h
@@ -108,6 +108,18 @@ struct seccomp_notif {
  */
 #define SECCOMP_USER_NOTIF_FLAG_CONTINUE (1UL << 0)
 
+/*
+ * SECCOMP_USER_NOTIF_FLAG_INJECTED — consume a syscall-redirect
+ * description previously attached via SECCOMP_IOCTL_NOTIF_INJECT for
+ * this notification. The trapped task wakes, dispatches into the
+ * matching kernel-mode syscall helper using the supervisor-provided
+ * (in-kernel) buffer for any pointer-shaped argument, and the helper's
+ * return value becomes the trapped syscall's return value.
+ *
+ * Mutually exclusive with SECCOMP_USER_NOTIF_FLAG_CONTINUE.
+ */
+#define SECCOMP_USER_NOTIF_FLAG_INJECTED (1UL << 1)
+
 struct seccomp_notif_resp {
 	__u64 id;
 	__s64 val;
@@ -137,6 +149,52 @@ struct seccomp_notif_addfd {
 	__u32 newfd_flags;
 };
 
+/**
+ * struct seccomp_notif_inject — describe a kernel-validated syscall
+ * to perform on behalf of a trapped task.
+ *
+ * The supervisor attaches one of these to a pending notification via
+ * SECCOMP_IOCTL_NOTIF_INJECT, then commits with SECCOMP_IOCTL_NOTIF_SEND
+ * setting SECCOMP_USER_NOTIF_FLAG_INJECTED. The kernel substitutes the
+ * trapped task's syscall with @nr/@args[6] and dispatches into a
+ * kernel-mode helper for the syscall, with any pointer argument backed
+ * by an offset into @buf (a kernel-side copy of the supervisor's
+ * bytes), so the trapped task's user mm is not re-read.
+ *
+ * @id: notification id from SECCOMP_IOCTL_NOTIF_RECV.
+ * @nr: substitute syscall number (matches ptrace_syscall_info.entry.nr).
+ * @args: substitute syscall arguments. For each i with the i'th bit
+ *	  set in @args_in_buf_mask, args[i] is interpreted as a byte
+ *	  offset into @buf rather than as the raw syscall argument
+ *	  value; the kernel materializes the corresponding pointer
+ *	  from its in-kernel copy of @buf before dispatch. Other args
+ *	  pass through as scalars (matches
+ *	  ptrace_syscall_info.entry.args).
+ * @buf: __user pointer to kernel-input bytes. The kernel copies
+ *	  @buf_size bytes from this buffer at SECCOMP_IOCTL_NOTIF_INJECT
+ *	  time and acts on its kernel-side copy thereafter.
+ * @buf_size: bytes available at @buf, capped at
+ *	  SECCOMP_NOTIF_INJECT_MAX_BYTES.
+ * @args_in_buf_mask: bitmask. Bit i set means args[i] is a byte
+ *	  offset into @buf rather than a raw argument value.
+ */
+struct seccomp_notif_inject {
+	__u64 id;
+	__u64 nr;
+	__u64 args[6];
+	__u64 buf;
+	__u32 buf_size;
+	__u32 args_in_buf_mask;
+};
+
+/*
+ * Hard cap on the cumulative bytes a single SECCOMP_IOCTL_NOTIF_INJECT
+ * request may copy. Defensive bound only — typical injects are a few
+ * KiB (one PATH_MAX path, an argv block, etc.). Hardcoded rather than
+ * a sysctl: there is no legitimate reason to tune this at runtime.
+ */
+#define SECCOMP_NOTIF_INJECT_MAX_BYTES	(1UL << 20)	/* 1 MiB */
+
 #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 +212,11 @@ struct seccomp_notif_addfd {
 
 #define SECCOMP_IOCTL_NOTIF_SET_FLAGS	SECCOMP_IOW(4, __u64)
 
+/* Attach a kernel-validated syscall redirect to a pending notification.
+ * Consumed by SECCOMP_IOCTL_NOTIF_SEND with
+ * SECCOMP_USER_NOTIF_FLAG_INJECTED.
+ */
+#define SECCOMP_IOCTL_NOTIF_INJECT	SECCOMP_IOW(5, \
+						struct seccomp_notif_inject)
+
 #endif /* _UAPI_LINUX_SECCOMP_H */
diff --git a/kernel/Makefile b/kernel/Makefile
index 6785982013dc..fa4c129384c7 100644
--- a/kernel/Makefile
+++ b/kernel/Makefile
@@ -106,6 +106,7 @@ obj-$(CONFIG_LOCKUP_DETECTOR) += watchdog.o
 obj-$(CONFIG_HARDLOCKUP_DETECTOR_BUDDY) += watchdog_buddy.o
 obj-$(CONFIG_HARDLOCKUP_DETECTOR_PERF) += watchdog_perf.o
 obj-$(CONFIG_SECCOMP) += seccomp.o
+obj-$(CONFIG_SECCOMP_FILTER) += seccomp_inject.o
 obj-$(CONFIG_RELAY) += relay.o
 obj-$(CONFIG_SYSCTL) += utsname_sysctl.o
 obj-$(CONFIG_TASK_DELAY_ACCT) += delayacct.o
diff --git a/kernel/seccomp.c b/kernel/seccomp.c
index 066909393c38..a891e3e13eef 100644
--- a/kernel/seccomp.c
+++ b/kernel/seccomp.c
@@ -44,6 +44,8 @@
 #include <linux/anon_inodes.h>
 #include <linux/lockdep.h>
 
+#include "seccomp_inject.h"
+
 /*
  * When SECCOMP_IOCTL_NOTIF_ID_VALID was first introduced, it had the
  * wrong direction flag in the ioctl number. This is the broken one,
@@ -97,6 +99,15 @@ struct seccomp_knotif {
 
 	/* outstanding addfd requests */
 	struct list_head addfd;
+
+	/*
+	 * Outstanding SECCOMP_IOCTL_NOTIF_INJECT record, attached by
+	 * the supervisor under filter->notify_lock. Consumed by the
+	 * trapped task on SECCOMP_USER_NOTIF_FLAG_INJECTED, freed in
+	 * the same path. Also freed if the knotif is dropped without
+	 * being injected.
+	 */
+	struct seccomp_inject_record *inject;
 };
 
 /**
@@ -1248,8 +1259,41 @@ 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) {
+		/*
+		 * Discard any inject the supervisor attached then changed
+		 * its mind about.
+		 */
+		seccomp_inject_record_free(n.inject);
 		return 0;
+	}
+
+	/*
+	 * Userspace requested kernel-side syscall injection. Run the
+	 * helper in the trapped task's context, free the record, and
+	 * deliver the helper's result as the syscall return value.
+	 */
+	if (flags & SECCOMP_USER_NOTIF_FLAG_INJECTED) {
+		long inject_ret = -EINVAL;
+
+		if (n.inject) {
+			inject_ret = seccomp_inject_dispatch(n.inject);
+			seccomp_inject_record_free(n.inject);
+		}
+		if (inject_ret < 0)
+			syscall_set_return_value(current, current_pt_regs(),
+						 (int)inject_ret, 0);
+		else
+			syscall_set_return_value(current, current_pt_regs(),
+						 0, inject_ret);
+		return -1;
+	}
+
+	/*
+	 * Interrupted, listener gone, or normal deny/allow: free any
+	 * inject the supervisor attached but never consumed.
+	 */
+	seccomp_inject_record_free(n.inject);
 
 	syscall_set_return_value(current, current_pt_regs(),
 				 err, ret);
@@ -1632,13 +1676,22 @@ static long seccomp_notify_send(struct seccomp_filter *filter,
 	if (copy_from_user(&resp, buf, sizeof(resp)))
 		return -EFAULT;
 
-	if (resp.flags & ~SECCOMP_USER_NOTIF_FLAG_CONTINUE)
+	if (resp.flags & ~(SECCOMP_USER_NOTIF_FLAG_CONTINUE |
+			   SECCOMP_USER_NOTIF_FLAG_INJECTED))
+		return -EINVAL;
+
+	if ((resp.flags & SECCOMP_USER_NOTIF_FLAG_CONTINUE) &&
+	    (resp.flags & SECCOMP_USER_NOTIF_FLAG_INJECTED))
 		return -EINVAL;
 
 	if ((resp.flags & SECCOMP_USER_NOTIF_FLAG_CONTINUE) &&
 	    (resp.error || resp.val))
 		return -EINVAL;
 
+	if ((resp.flags & SECCOMP_USER_NOTIF_FLAG_INJECTED) &&
+	    (resp.error || resp.val))
+		return -EINVAL;
+
 	ret = mutex_lock_interruptible(&filter->notify_lock);
 	if (ret < 0)
 		return ret;
@@ -1655,6 +1708,12 @@ static long seccomp_notify_send(struct seccomp_filter *filter,
 		goto out;
 	}
 
+	/* INJECTED requires a prior SECCOMP_IOCTL_NOTIF_INJECT for this id. */
+	if ((resp.flags & SECCOMP_USER_NOTIF_FLAG_INJECTED) && !knotif->inject) {
+		ret = -EINVAL;
+		goto out;
+	}
+
 	ret = 0;
 	knotif->state = SECCOMP_NOTIFY_REPLIED;
 	knotif->error = resp.error;
@@ -1823,6 +1882,62 @@ static long seccomp_notify_addfd(struct seccomp_filter *filter,
 	return ret;
 }
 
+static long seccomp_notify_inject(struct seccomp_filter *filter,
+				  struct seccomp_notif_inject __user *uinj)
+{
+	struct seccomp_notif_inject inj;
+	struct seccomp_inject_record *rec = NULL;
+	struct seccomp_knotif *knotif;
+	long ret;
+
+	if (copy_from_user(&inj, uinj, sizeof(inj)))
+		return -EFAULT;
+
+	ret = seccomp_inject_record_build(&inj, &rec);
+	if (ret)
+		return ret;
+
+	ret = mutex_lock_interruptible(&filter->notify_lock);
+	if (ret < 0)
+		goto err_free;
+
+	knotif = find_notification(filter, inj.id);
+	if (!knotif) {
+		ret = -ENOENT;
+		goto err_unlock;
+	}
+
+	if (knotif->state != SECCOMP_NOTIFY_SENT) {
+		ret = -EINPROGRESS;
+		goto err_unlock;
+	}
+
+	/*
+	 * The supervisor cannot redirect a trapped syscall into an
+	 * unrelated syscall (e.g. inject openat into a task trapped on
+	 * bind), which would be a confused-deputy.
+	 */
+	if (inj.nr != (u64)knotif->data->nr) {
+		ret = -ESRCH;
+		goto err_unlock;
+	}
+
+	if (knotif->inject) {
+		ret = -EEXIST;
+		goto err_unlock;
+	}
+
+	knotif->inject = rec;
+	mutex_unlock(&filter->notify_lock);
+	return 0;
+
+err_unlock:
+	mutex_unlock(&filter->notify_lock);
+err_free:
+	seccomp_inject_record_free(rec);
+	return ret;
+}
+
 static long seccomp_notify_ioctl(struct file *file, unsigned int cmd,
 				 unsigned long arg)
 {
@@ -1840,6 +1955,8 @@ static long seccomp_notify_ioctl(struct file *file, unsigned int cmd,
 		return seccomp_notify_id_valid(filter, buf);
 	case SECCOMP_IOCTL_NOTIF_SET_FLAGS:
 		return seccomp_notify_set_flags(filter, arg);
+	case SECCOMP_IOCTL_NOTIF_INJECT:
+		return seccomp_notify_inject(filter, buf);
 	}
 
 	/* Extensible Argument ioctls */
diff --git a/kernel/seccomp_inject.c b/kernel/seccomp_inject.c
new file mode 100644
index 000000000000..2c2be3232f15
--- /dev/null
+++ b/kernel/seccomp_inject.c
@@ -0,0 +1,281 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * SECCOMP_IOCTL_NOTIF_INJECT: kernel-mode syscall execution on
+ * behalf of an unprivileged seccomp_unotify supervisor.
+ *
+ * The supervisor describes a syscall (nr + args[6]) and a kernel-input
+ * buffer; pointer-shaped args are encoded as byte offsets into that
+ * buffer. On SECCOMP_USER_NOTIF_FLAG_INJECTED, the trapped task wakes
+ * inside seccomp_do_user_notification(), dispatches into the matching
+ * kernel-mode syscall helper, and the helper's return value becomes
+ * the trapped syscall's return value. The trapped task's user mm is
+ * never re-read for the substituted syscall, closing the documented
+ * TOCTOU race against CLONE_VM peers.
+ */
+#include <linux/fdtable.h>
+#include <linux/file.h>
+#include <linux/fs.h>
+#include <linux/kernel.h>
+#include <linux/net.h>
+#include <linux/seccomp.h>
+#include <linux/slab.h>
+#include <linux/syscalls.h>
+#include <linux/uaccess.h>
+#include <linux/vmalloc.h>
+#include <net/sock.h>
+#include <uapi/linux/seccomp.h>
+#include <asm/unistd.h>
+
+#include "seccomp_inject.h"
+
+/* Per-syscall injector. Runs in the trapped task's context. */
+typedef long (*seccomp_injector_fn)(const struct seccomp_inject_record *rec);
+
+static long inject_openat(const struct seccomp_inject_record *rec);
+static long inject_bind(const struct seccomp_inject_record *rec);
+static long inject_write(const struct seccomp_inject_record *rec);
+
+/*
+ * The injectable-syscall whitelist. Keep this small and explicit:
+ * each entry pins a kernel-mode helper to a specific syscall number.
+ * A NULL injector for an unlisted nr means "supervisor cannot inject
+ * this syscall" and seccomp_inject_record_build() rejects it with
+ * -EOPNOTSUPP.
+ */
+static seccomp_injector_fn seccomp_injector_for(u64 nr)
+{
+	switch (nr) {
+#ifdef __NR_openat
+	case __NR_openat:
+		return inject_openat;
+#endif
+#ifdef __NR_bind
+	case __NR_bind:
+		return inject_bind;
+#endif
+#ifdef __NR_write
+	case __NR_write:
+		return inject_write;
+#endif
+	default:
+		return NULL;
+	}
+}
+
+/*
+ * Treat args[i] as a byte offset into rec->buf and return a kernel
+ * pointer at that offset, with @needed bytes required to be accessible
+ * (i.e. offset + needed <= buf_size). NULL on out-of-bounds.
+ */
+static const void *inject_buf_at(const struct seccomp_inject_record *rec,
+				 unsigned int i, size_t needed)
+{
+	u64 off;
+
+	if (i >= 6)
+		return NULL;
+	if (!(rec->args_in_buf_mask & (1U << i)))
+		return NULL;
+	off = rec->args[i];
+	if (off > rec->buf_size)
+		return NULL;
+	if (rec->buf_size - off < needed)
+		return NULL;
+	return (const u8 *)rec->buf + off;
+}
+
+/* NUL-terminated string at args[i] within rec->buf, or NULL on bound miss. */
+static const char *inject_buf_cstr(const struct seccomp_inject_record *rec,
+				   unsigned int i)
+{
+	const char *s;
+	u64 off;
+	size_t avail;
+
+	if (i >= 6)
+		return NULL;
+	if (!(rec->args_in_buf_mask & (1U << i)))
+		return NULL;
+	off = rec->args[i];
+	if (off >= rec->buf_size)
+		return NULL;
+	s = (const char *)rec->buf + off;
+	avail = rec->buf_size - off;
+	if (memchr(s, '\0', avail) == NULL)
+		return NULL;
+	return s;
+}
+
+static long inject_openat(const struct seccomp_inject_record *rec)
+{
+	int dfd = (int)rec->args[0];
+	int flags = (int)rec->args[2];
+	umode_t mode = (umode_t)rec->args[3];
+	const char *path;
+	struct file *f;
+	int fd;
+
+	/* v1: dfd must be AT_FDCWD. Other dfd values land in v2. */
+	if (dfd != AT_FDCWD)
+		return -EOPNOTSUPP;
+
+	path = inject_buf_cstr(rec, 1);
+	if (!path)
+		return -EINVAL;
+
+	f = filp_open(path, flags, mode);
+	if (IS_ERR(f))
+		return PTR_ERR(f);
+
+	fd = get_unused_fd_flags(flags);
+	if (fd < 0) {
+		filp_close(f, NULL);
+		return fd;
+	}
+	fd_install(fd, f);
+	return fd;
+}
+
+static long inject_bind(const struct seccomp_inject_record *rec)
+{
+	int sockfd = (int)rec->args[0];
+	int addrlen = (int)rec->args[2];
+	struct sockaddr_storage addr;
+	const void *src;
+	struct socket *sock;
+	long ret;
+	int err;
+
+	if (addrlen < 0 || addrlen > sizeof(addr))
+		return -EINVAL;
+
+	src = inject_buf_at(rec, 1, addrlen);
+	if (!src)
+		return -EINVAL;
+	memcpy(&addr, src, addrlen);
+
+	sock = sockfd_lookup(sockfd, &err);
+	if (!sock)
+		return err;
+
+	ret = kernel_bind(sock, (struct sockaddr_unsized *)&addr, addrlen);
+	sockfd_put(sock);
+	return ret;
+}
+
+static long inject_write(const struct seccomp_inject_record *rec)
+{
+	int fd = (int)rec->args[0];
+	size_t count = (size_t)rec->args[2];
+	const void *src;
+	ssize_t ret;
+	struct fd f;
+	loff_t pos;
+
+	if (count > MAX_RW_COUNT)
+		count = MAX_RW_COUNT;
+
+	src = inject_buf_at(rec, 1, count);
+	if (!src)
+		return -EINVAL;
+
+	f = fdget_pos(fd);
+	if (!fd_file(f))
+		return -EBADF;
+
+	pos = fd_file(f)->f_pos;
+	ret = kernel_write(fd_file(f), src, count, &pos);
+	if (ret > 0)
+		fd_file(f)->f_pos = pos;
+	fdput_pos(f);
+	return ret;
+}
+
+void seccomp_inject_record_free(struct seccomp_inject_record *rec)
+{
+	if (!rec)
+		return;
+	kvfree(rec->buf);
+	kfree(rec);
+}
+
+/*
+ * Validate @uinj fields (syscall_nr in whitelist, args_in_buf_mask
+ * within bounds, offsets in bounds, buf_size capped), copy in the
+ * supervisor's buf, and return a record the caller owns. Caller is
+ * responsible for seccomp_inject_record_free() on success path if
+ * the record is not subsequently attached to a knotif.
+ */
+long seccomp_inject_record_build(const struct seccomp_notif_inject *uinj,
+				 struct seccomp_inject_record **out)
+{
+	struct seccomp_inject_record *rec;
+	void __user *user_buf;
+	long ret;
+	unsigned int i;
+
+	*out = NULL;
+
+	if (!seccomp_injector_for(uinj->nr))
+		return -EOPNOTSUPP;
+
+	/* args_in_buf_mask must reference args[0..5] only. */
+	if (uinj->args_in_buf_mask & ~((1U << 6) - 1))
+		return -EINVAL;
+
+	if (uinj->buf_size > SECCOMP_NOTIF_INJECT_MAX_BYTES)
+		return -E2BIG;
+
+	user_buf = (void __user *)(uintptr_t)uinj->buf;
+	if (uinj->buf_size && !user_buf)
+		return -EINVAL;
+
+	/* Bounds-check each in-buf arg before any allocation. */
+	for (i = 0; i < 6; i++) {
+		if (!(uinj->args_in_buf_mask & (1U << i)))
+			continue;
+		if (uinj->args[i] > uinj->buf_size)
+			return -EINVAL;
+	}
+
+	rec = kzalloc(sizeof(*rec), GFP_KERNEL_ACCOUNT);
+	if (!rec)
+		return -ENOMEM;
+
+	rec->nr = uinj->nr;
+	memcpy(rec->args, uinj->args, sizeof(rec->args));
+	rec->args_in_buf_mask = uinj->args_in_buf_mask;
+	rec->buf_size = uinj->buf_size;
+
+	if (uinj->buf_size) {
+		rec->buf = kvmalloc(uinj->buf_size, GFP_KERNEL_ACCOUNT);
+		if (!rec->buf) {
+			ret = -ENOMEM;
+			goto err;
+		}
+		if (copy_from_user(rec->buf, user_buf, uinj->buf_size)) {
+			ret = -EFAULT;
+			goto err;
+		}
+	}
+
+	*out = rec;
+	return 0;
+
+err:
+	seccomp_inject_record_free(rec);
+	return ret;
+}
+
+/*
+ * Top-level dispatch. Runs in the trapped task's context (current is
+ * the trapped task). Returns the kernel helper's result.
+ */
+long seccomp_inject_dispatch(const struct seccomp_inject_record *rec)
+{
+	seccomp_injector_fn injector = seccomp_injector_for(rec->nr);
+
+	if (!injector)
+		return -EOPNOTSUPP;
+	return injector(rec);
+}
diff --git a/kernel/seccomp_inject.h b/kernel/seccomp_inject.h
new file mode 100644
index 000000000000..b85e4399381c
--- /dev/null
+++ b/kernel/seccomp_inject.h
@@ -0,0 +1,65 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Internal interfaces for SECCOMP_IOCTL_NOTIF_INJECT.
+ *
+ * The inject record allocation, validation, per-syscall injectors and
+ * the dispatch entrypoint live in kernel/seccomp_inject.c to keep
+ * kernel/seccomp.c focused on the notify state machine.
+ */
+#ifndef _KERNEL_SECCOMP_INJECT_H
+#define _KERNEL_SECCOMP_INJECT_H
+
+#include <linux/types.h>
+#include <uapi/linux/seccomp.h>
+
+struct seccomp_knotif;
+struct seccomp_notif_inject;
+
+/**
+ * struct seccomp_inject_record - kernel-side per-knotif inject state.
+ * @nr:		substitute syscall number, validated against the injectable
+ *		whitelist.
+ * @args:	substitute syscall arguments. For each i with the i'th bit
+ *		set in @args_in_buf_mask, args[i] is a byte offset into
+ *		@buf rather than the raw argument value.
+ * @args_in_buf_mask: bitmask of pointer-shaped args backed by @buf.
+ * @buf_size:	bytes valid in @buf.
+ * @buf:	kernel-owned copy of the supervisor-supplied bytes; allocated
+ *		at attach time, freed at consumption (or knotif teardown).
+ *
+ * Allocated by SECCOMP_IOCTL_NOTIF_INJECT, attached to the knotif under
+ * filter->notify_lock, consumed by the trapped task on
+ * SECCOMP_USER_NOTIF_FLAG_INJECTED, freed in the same path. Also freed
+ * if the knotif is dropped without being injected (listener close,
+ * task exit, supervisor changes its mind).
+ */
+struct seccomp_inject_record {
+	u64	nr;
+	u64	args[6];
+	u32	args_in_buf_mask;
+	u32	buf_size;
+	void	*buf;
+};
+
+#ifdef CONFIG_SECCOMP_FILTER
+
+/* Allocate, validate, and copy in @uinj. Caller takes ownership of *out. */
+long seccomp_inject_record_build(const struct seccomp_notif_inject *uinj,
+				 struct seccomp_inject_record **out);
+
+/* Free a record built by seccomp_inject_record_build(). */
+void seccomp_inject_record_free(struct seccomp_inject_record *rec);
+
+/* Dispatch a built record into the matching kernel-mode syscall helper.
+ * Runs in the trapped task's context (current is the trapped task).
+ * Returns the helper's result, which becomes the syscall return value.
+ */
+long seccomp_inject_dispatch(const struct seccomp_inject_record *rec);
+
+#else /* !CONFIG_SECCOMP_FILTER */
+
+static inline void seccomp_inject_record_free(struct seccomp_inject_record *rec) { }
+
+#endif /* CONFIG_SECCOMP_FILTER */
+
+#endif /* _KERNEL_SECCOMP_INJECT_H */
-- 
2.43.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [RFC PATCH v2 2/3] selftests/seccomp: add seccomp_notif_inject coverage
  2026-05-15  4:27 [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
  2026-05-15  4:27 ` [RFC PATCH v2 1/3] seccomp: add " Cong Wang
@ 2026-05-15  4:27 ` Cong Wang
  2026-05-15  4:27 ` [RFC PATCH v2 3/3] Documentation: seccomp: document SECCOMP_IOCTL_NOTIF_INJECT Cong Wang
                   ` (2 subsequent siblings)
  4 siblings, 0 replies; 9+ messages in thread
From: Cong Wang @ 2026-05-15  4:27 UTC (permalink / raw)
  To: Kees Cook, linux-kernel
  Cc: Andy Lutomirski, Will Drewry, Christian Brauner, Cong Wang

From: Cong Wang <cwang@multikernel.io>

Add a standalone selftest binary for SECCOMP_IOCTL_NOTIF_INJECT
covering positive end-to-end injection on each of the v1 syscalls
(openat, bind, write) and the negative paths (unsupported syscall,
trapped/inject syscall mismatch, double inject, CONTINUE+INJECTED
conflict, INJECTED without prior attach).

The end-to-end tests fork a child that issues a filtered syscall
with one set of arguments and verify that, after the supervisor
attaches a substitute via SECCOMP_IOCTL_NOTIF_INJECT and replies
with SECCOMP_USER_NOTIF_FLAG_INJECTED, the kernel acts on the
supervisor-supplied bytes rather than the child's user mm.

Lives in its own file rather than seccomp_bpf.c since the feature
is unrelated to the BPF filter machinery.

Assisted-by: Claude:claude-opus-4.6
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
 tools/testing/selftests/seccomp/.gitignore    |   1 +
 tools/testing/selftests/seccomp/Makefile      |   2 +-
 .../selftests/seccomp/seccomp_notif_inject.c  | 434 ++++++++++++++++++
 3 files changed, 436 insertions(+), 1 deletion(-)
 create mode 100644 tools/testing/selftests/seccomp/seccomp_notif_inject.c

diff --git a/tools/testing/selftests/seccomp/.gitignore b/tools/testing/selftests/seccomp/.gitignore
index dec678577f9c..096f77a0b136 100644
--- a/tools/testing/selftests/seccomp/.gitignore
+++ b/tools/testing/selftests/seccomp/.gitignore
@@ -1,3 +1,4 @@
 # SPDX-License-Identifier: GPL-2.0-only
 seccomp_bpf
 seccomp_benchmark
+seccomp_notif_inject
diff --git a/tools/testing/selftests/seccomp/Makefile b/tools/testing/selftests/seccomp/Makefile
index 584fba487037..8a4ca92f1af3 100644
--- a/tools/testing/selftests/seccomp/Makefile
+++ b/tools/testing/selftests/seccomp/Makefile
@@ -3,5 +3,5 @@ CFLAGS += -Wl,-no-as-needed -Wall $(KHDR_INCLUDES)
 LDFLAGS += -lpthread
 LDLIBS += -lcap
 
-TEST_GEN_PROGS := seccomp_bpf seccomp_benchmark
+TEST_GEN_PROGS := seccomp_bpf seccomp_benchmark seccomp_notif_inject
 include ../lib.mk
diff --git a/tools/testing/selftests/seccomp/seccomp_notif_inject.c b/tools/testing/selftests/seccomp/seccomp_notif_inject.c
new file mode 100644
index 000000000000..3d5bb97b8df9
--- /dev/null
+++ b/tools/testing/selftests/seccomp/seccomp_notif_inject.c
@@ -0,0 +1,434 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Selftest for SECCOMP_IOCTL_NOTIF_INJECT.
+ *
+ * Exercises end-to-end syscall injection via the listener-fd
+ * supervisor pattern: the child issues a filtered syscall, the
+ * supervisor describes a substitute syscall in a kernel buffer, the
+ * kernel runs the substitute on the child's behalf using kernel-mode
+ * helpers (filp_open/kernel_bind/kernel_write), and the result lands
+ * as the child's syscall return value. The trapped task's user mm is
+ * never re-read for the substituted syscall.
+ */
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <linux/filter.h>
+#include <linux/seccomp.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <sys/prctl.h>
+#include <sys/resource.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+#include <sys/types.h>
+#include <sys/un.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "../kselftest_harness.h"
+
+#ifndef __NR_seccomp
+#define __NR_seccomp 317
+#endif
+
+static int seccomp_install(int nr)
+{
+	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, 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,
+	};
+
+	return syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER,
+		       SECCOMP_FILTER_FLAG_NEW_LISTENER, &prog);
+}
+
+/* ----------------------------------------------------------------
+ * openat injection.
+ * ----------------------------------------------------------------
+ */
+TEST(notif_inject_openat)
+{
+	char tmp_real[] = "/tmp/seccomp-inject-XXXXXX";
+	int real_fd, listener, status;
+	pid_t pid;
+
+	real_fd = mkstemp(tmp_real);
+	ASSERT_GE(real_fd, 0);
+	ASSERT_EQ(write(real_fd, "real-data", 9), 9);
+	ASSERT_EQ(close(real_fd), 0);
+
+	ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));
+	listener = seccomp_install(__NR_openat);
+	ASSERT_GE(listener, 0);
+
+	pid = fork();
+	ASSERT_GE(pid, 0);
+	if (pid == 0) {
+		char readback[16] = {0};
+		int fd;
+
+		fd = openat(AT_FDCWD, "/this/path/does/not/exist", O_RDONLY);
+		if (fd < 0)
+			_exit(10);
+		if (read(fd, readback, sizeof(readback) - 1) <= 0)
+			_exit(11);
+		_exit(memcmp(readback, "real-data", 9) == 0 ? 0 : 12);
+	}
+
+	struct seccomp_notif req = {0};
+	struct seccomp_notif_resp resp = {0};
+	struct seccomp_notif_inject inj = {0};
+
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req), 0);
+	EXPECT_EQ(req.data.nr, __NR_openat);
+
+	inj.id = req.id;
+	inj.nr = __NR_openat;
+	inj.args[0] = AT_FDCWD;
+	inj.args[1] = 0;
+	inj.args[2] = O_RDONLY;
+	inj.args[3] = 0;
+	inj.buf = (uintptr_t)tmp_real;
+	inj.buf_size = strlen(tmp_real) + 1;
+	inj.args_in_buf_mask = 1U << 1;
+	ASSERT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_INJECT, &inj), 0) {
+		TH_LOG("INJECT failed: %s", strerror(errno));
+	}
+
+	resp.id = req.id;
+	resp.flags = SECCOMP_USER_NOTIF_FLAG_INJECTED;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), 0);
+
+	EXPECT_EQ(waitpid(pid, &status, 0), pid);
+	EXPECT_TRUE(WIFEXITED(status));
+	EXPECT_EQ(WEXITSTATUS(status), 0) {
+		TH_LOG("child exit status: %d", WEXITSTATUS(status));
+	}
+
+	unlink(tmp_real);
+	close(listener);
+}
+
+/* ----------------------------------------------------------------
+ * write injection.
+ * Child issues write(fd, "agent-data", 10); supervisor injects
+ * write(fd, "kernel-bytes", 12); verify file content matches the
+ * kernel-injected bytes.
+ * ----------------------------------------------------------------
+ */
+TEST(notif_inject_write)
+{
+	char path[] = "/tmp/seccomp-inject-write-XXXXXX";
+	static const char inject_bytes[] = "kernel-bytes";
+	int file_fd, listener, status;
+	char file_content[32];
+	pid_t pid;
+
+	file_fd = mkstemp(path);
+	ASSERT_GE(file_fd, 0);
+
+	ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));
+	listener = seccomp_install(__NR_write);
+	ASSERT_GE(listener, 0);
+
+	pid = fork();
+	ASSERT_GE(pid, 0);
+	if (pid == 0) {
+		ssize_t n;
+
+		n = write(file_fd, "agent-data", 10);
+		_exit(n > 0 ? 0 : 10);
+	}
+
+	struct seccomp_notif req = {0};
+	struct seccomp_notif_resp resp = {0};
+	struct seccomp_notif_inject inj = {0};
+
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req), 0);
+	EXPECT_EQ(req.data.nr, __NR_write);
+
+	inj.id = req.id;
+	inj.nr = __NR_write;
+	inj.args[0] = req.data.args[0];   /* fd, pass-through */
+	inj.args[1] = 0;
+	inj.args[2] = strlen(inject_bytes);
+	inj.buf = (uintptr_t)inject_bytes;
+	inj.buf_size = strlen(inject_bytes);
+	inj.args_in_buf_mask = 1U << 1;
+	ASSERT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_INJECT, &inj), 0);
+
+	resp.id = req.id;
+	resp.flags = SECCOMP_USER_NOTIF_FLAG_INJECTED;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), 0);
+
+	EXPECT_EQ(waitpid(pid, &status, 0), pid);
+	EXPECT_TRUE(WIFEXITED(status));
+	EXPECT_EQ(WEXITSTATUS(status), 0);
+
+	memset(file_content, 0, sizeof(file_content));
+	ASSERT_EQ(lseek(file_fd, 0, SEEK_SET), 0);
+	ASSERT_EQ(read(file_fd, file_content, sizeof(file_content) - 1),
+		  (ssize_t)strlen(inject_bytes));
+	EXPECT_EQ(memcmp(file_content, inject_bytes, strlen(inject_bytes)), 0) {
+		TH_LOG("file content: '%s'", file_content);
+	}
+
+	close(file_fd);
+	unlink(path);
+	close(listener);
+}
+
+/* ----------------------------------------------------------------
+ * bind injection.
+ * ----------------------------------------------------------------
+ */
+TEST(notif_inject_bind)
+{
+	struct sockaddr_un real_addr = { .sun_family = AF_UNIX };
+	char real_path[] = "/tmp/seccomp-inject-bind-XXXXXX";
+	int listener, status;
+	pid_t pid;
+
+	mktemp(real_path);
+	strcpy(real_addr.sun_path, real_path);
+	unlink(real_addr.sun_path);
+
+	ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));
+	listener = seccomp_install(__NR_bind);
+	ASSERT_GE(listener, 0);
+
+	pid = fork();
+	ASSERT_GE(pid, 0);
+	if (pid == 0) {
+		struct sockaddr_un fake = { .sun_family = AF_UNIX };
+		int s;
+
+		strcpy(fake.sun_path, "/tmp/seccomp-inject-bind-fake");
+		s = socket(AF_UNIX, SOCK_STREAM, 0);
+		if (s < 0)
+			_exit(10);
+		if (bind(s, (struct sockaddr *)&fake, sizeof(fake)) < 0)
+			_exit(11);
+		_exit(0);
+	}
+
+	struct seccomp_notif req = {0};
+	struct seccomp_notif_resp resp = {0};
+	struct seccomp_notif_inject inj = {0};
+	struct stat st;
+
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req), 0);
+	EXPECT_EQ(req.data.nr, __NR_bind);
+
+	inj.id = req.id;
+	inj.nr = __NR_bind;
+	inj.args[0] = req.data.args[0];   /* sockfd, pass-through */
+	inj.args[1] = 0;
+	inj.args[2] = sizeof(real_addr);
+	inj.buf = (uintptr_t)&real_addr;
+	inj.buf_size = sizeof(real_addr);
+	inj.args_in_buf_mask = 1U << 1;
+	ASSERT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_INJECT, &inj), 0);
+
+	resp.id = req.id;
+	resp.flags = SECCOMP_USER_NOTIF_FLAG_INJECTED;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), 0);
+
+	EXPECT_EQ(waitpid(pid, &status, 0), pid);
+	EXPECT_TRUE(WIFEXITED(status));
+	EXPECT_EQ(WEXITSTATUS(status), 0);
+
+	/*
+	 * The kernel-injected path should exist; the agent's intended
+	 * path should not.
+	 */
+	EXPECT_EQ(stat(real_path, &st), 0);
+	EXPECT_EQ(stat("/tmp/seccomp-inject-bind-fake", &st), -1);
+
+	unlink(real_path);
+	close(listener);
+}
+
+/* ----------------------------------------------------------------
+ * Negative paths.
+ * ----------------------------------------------------------------
+ */
+TEST(notif_inject_unsupported_syscall)
+{
+	int listener, status;
+	pid_t pid;
+
+	ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));
+	listener = seccomp_install(__NR_close);
+	ASSERT_GE(listener, 0);
+
+	pid = fork();
+	ASSERT_GE(pid, 0);
+	if (pid == 0) {
+		close(99);
+		_exit(0);
+	}
+
+	struct seccomp_notif req = {0};
+	struct seccomp_notif_resp resp = {0};
+	struct seccomp_notif_inject inj = {0};
+
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req), 0);
+
+	/* close() is not in the injectable whitelist. */
+	inj.id = req.id;
+	inj.nr = __NR_close;
+	inj.args[0] = 99;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_INJECT, &inj), -1);
+	EXPECT_EQ(errno, EOPNOTSUPP);
+
+	/* Cleanly deny so the child can exit. */
+	resp.id = req.id;
+	resp.error = -EBADF;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), 0);
+
+	EXPECT_EQ(waitpid(pid, &status, 0), pid);
+	close(listener);
+}
+
+TEST(notif_inject_syscall_mismatch)
+{
+	int listener, status;
+	pid_t pid;
+
+	ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));
+	listener = seccomp_install(__NR_openat);
+	ASSERT_GE(listener, 0);
+
+	pid = fork();
+	ASSERT_GE(pid, 0);
+	if (pid == 0) {
+		openat(AT_FDCWD, "/nonexistent", O_RDONLY);
+		_exit(0);
+	}
+
+	struct seccomp_notif req = {0};
+	struct seccomp_notif_resp resp = {0};
+	struct seccomp_notif_inject inj = {0};
+
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req), 0);
+
+	/* Trapped on openat, but supervisor tries to inject bind. */
+	inj.id = req.id;
+	inj.nr = __NR_bind;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_INJECT, &inj), -1);
+	EXPECT_EQ(errno, ESRCH);
+
+	resp.id = req.id;
+	resp.error = -EACCES;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), 0);
+
+	EXPECT_EQ(waitpid(pid, &status, 0), pid);
+	close(listener);
+}
+
+TEST(notif_inject_double)
+{
+	char tmp_real[] = "/tmp/seccomp-inject-d-XXXXXX";
+	int real_fd, listener, status;
+	pid_t pid;
+
+	real_fd = mkstemp(tmp_real);
+	ASSERT_GE(real_fd, 0);
+	close(real_fd);
+
+	ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));
+	listener = seccomp_install(__NR_openat);
+	ASSERT_GE(listener, 0);
+
+	pid = fork();
+	ASSERT_GE(pid, 0);
+	if (pid == 0) {
+		openat(AT_FDCWD, "/nonexistent", O_RDONLY);
+		_exit(0);
+	}
+
+	struct seccomp_notif req = {0};
+	struct seccomp_notif_resp resp = {0};
+	struct seccomp_notif_inject inj = {0};
+
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req), 0);
+
+	inj.id = req.id;
+	inj.nr = __NR_openat;
+	inj.args[0] = AT_FDCWD;
+	inj.args[1] = 0;
+	inj.args[2] = O_RDONLY;
+	inj.buf = (uintptr_t)tmp_real;
+	inj.buf_size = strlen(tmp_real) + 1;
+	inj.args_in_buf_mask = 1U << 1;
+	ASSERT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_INJECT, &inj), 0);
+
+	/* Second INJECT for the same id is rejected. */
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_INJECT, &inj), -1);
+	EXPECT_EQ(errno, EEXIST);
+
+	resp.id = req.id;
+	resp.flags = SECCOMP_USER_NOTIF_FLAG_INJECTED;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), 0);
+	EXPECT_EQ(waitpid(pid, &status, 0), pid);
+
+	unlink(tmp_real);
+	close(listener);
+}
+
+TEST(notif_inject_continue_pinned_conflict)
+{
+	int listener, status;
+	pid_t pid;
+
+	ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));
+	listener = seccomp_install(__NR_openat);
+	ASSERT_GE(listener, 0);
+
+	pid = fork();
+	ASSERT_GE(pid, 0);
+	if (pid == 0) {
+		openat(AT_FDCWD, "/nonexistent", O_RDONLY);
+		_exit(0);
+	}
+
+	struct seccomp_notif req = {0};
+	struct seccomp_notif_resp resp = {0};
+
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req), 0);
+
+	/* CONTINUE | INJECTED is rejected. */
+	resp.id = req.id;
+	resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE |
+		     SECCOMP_USER_NOTIF_FLAG_INJECTED;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), -1);
+	EXPECT_EQ(errno, EINVAL);
+
+	/* INJECTED without prior INJECT ioctl is rejected too. */
+	resp.flags = SECCOMP_USER_NOTIF_FLAG_INJECTED;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), -1);
+	EXPECT_EQ(errno, EINVAL);
+
+	/* Cleanly deny so the child exits. */
+	resp.flags = 0;
+	resp.error = -ENOENT;
+	EXPECT_EQ(ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp), 0);
+	EXPECT_EQ(waitpid(pid, &status, 0), pid);
+	close(listener);
+}
+
+TEST_HARNESS_MAIN
-- 
2.43.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [RFC PATCH v2 3/3] Documentation: seccomp: document SECCOMP_IOCTL_NOTIF_INJECT
  2026-05-15  4:27 [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
  2026-05-15  4:27 ` [RFC PATCH v2 1/3] seccomp: add " Cong Wang
  2026-05-15  4:27 ` [RFC PATCH v2 2/3] selftests/seccomp: add seccomp_notif_inject coverage Cong Wang
@ 2026-05-15  4:27 ` Cong Wang
  2026-05-26 18:06 ` [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
  2026-05-26 19:03 ` Andy Lutomirski
  4 siblings, 0 replies; 9+ messages in thread
From: Cong Wang @ 2026-05-15  4:27 UTC (permalink / raw)
  To: Kees Cook, linux-kernel
  Cc: Andy Lutomirski, Will Drewry, Christian Brauner, Cong Wang

From: Cong Wang <cwang@multikernel.io>

Add a "Syscall Injection" subsection to the user-notification chapter
covering the motivation (closing the documented TOCTOU window for
unprivileged supervisors), the substitute-syscall flow via
SECCOMP_IOCTL_NOTIF_INJECT and SECCOMP_USER_NOTIF_FLAG_INJECTED, the
ptrace-shaped struct layout, the kernel-buffer-backed pointer
arguments, the listener-fd capability model, and the relationship to
ptrace's existing register/memory manipulation.

Assisted-by: Claude:claude-opus-4.6
Signed-off-by: Cong Wang <cwang@multikernel.io>
---
 .../userspace-api/seccomp_filter.rst          | 42 +++++++++++++++++++
 1 file changed, 42 insertions(+)

diff --git a/Documentation/userspace-api/seccomp_filter.rst b/Documentation/userspace-api/seccomp_filter.rst
index cff0fa7f3175..9057505b2b92 100644
--- a/Documentation/userspace-api/seccomp_filter.rst
+++ b/Documentation/userspace-api/seccomp_filter.rst
@@ -289,6 +289,48 @@ 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.
 
+Syscall Injection
+-----------------
+
+For unprivileged supervisors, ``ptrace()`` and ``/proc/pid/mem`` are not
+available, and reading the tracee's memory via ``process_vm_readv()``
+remains racy: a sibling thread or ``CLONE_VM`` peer can mutate pointer-arg
+buffers between the supervisor's read and the kernel's re-read on
+``SECCOMP_USER_NOTIF_FLAG_CONTINUE``. ``SECCOMP_IOCTL_NOTIF_INJECT``
+closes that race by letting the supervisor describe a substitute syscall
+(``nr`` plus ``args[6]``, mirroring ``ptrace_syscall_info.entry``) whose
+pointer arguments are backed by a kernel-side copy of supervisor-supplied
+bytes rather than the tracee's user mm.
+
+The supervisor receives a notification as today, then issues
+``ioctl(SECCOMP_IOCTL_NOTIF_INJECT, &inj)`` with a
+``struct seccomp_notif_inject`` describing the substitute. Each pointer-
+shaped argument is encoded as a byte offset into ``inj.buf`` (a user
+buffer the kernel copies in at attach time); the ``args_in_buf_mask``
+field flags which ``args[i]`` are offsets versus raw scalar values.
+The substitute is consumed by ``ioctl(SECCOMP_IOCTL_NOTIF_SEND)`` with
+``SECCOMP_USER_NOTIF_FLAG_INJECTED``: the trapped task wakes, dispatches
+into the matching kernel-mode helper (``filp_open`` for ``openat``,
+``kernel_bind`` for ``bind``, ``kernel_write`` for ``write``), and the
+helper's return value becomes the trapped syscall's return value.
+
+The trapped task's user mm is never re-read for the substituted syscall,
+so peer mutations after ``SECCOMP_IOCTL_NOTIF_INJECT`` returns have no
+effect.
+
+Injection is gated by listener-fd possession (the same capability model
+as the rest of the user-notification interface) and by an explicit
+kernel-side whitelist of injectable syscalls. The substitute ``nr`` must
+match the trapped syscall's number, preventing a malicious supervisor
+from converting "task tried to bind()" into "kernel does an openat() on
+the task's behalf".
+
+This is intentionally a strict subset of ``PTRACE_SYSCALL`` +
+``PTRACE_POKEDATA`` + ``PTRACE_SETREGSET``: the same kernel capability
+(running a syscall in the trapped task's context with kernel-validated
+args), exposed to unprivileged listener-fd-holding supervisors with a
+narrowed surface and no need for ``CAP_SYS_PTRACE``.
+
 Sysctls
 =======
 
-- 
2.43.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify
  2026-05-15  4:27 [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
                   ` (2 preceding siblings ...)
  2026-05-15  4:27 ` [RFC PATCH v2 3/3] Documentation: seccomp: document SECCOMP_IOCTL_NOTIF_INJECT Cong Wang
@ 2026-05-26 18:06 ` Cong Wang
  2026-05-26 19:03 ` Andy Lutomirski
  4 siblings, 0 replies; 9+ messages in thread
From: Cong Wang @ 2026-05-26 18:06 UTC (permalink / raw)
  To: Kees Cook, linux-kernel
  Cc: Andy Lutomirski, Will Drewry, Christian Brauner, Cong Wang

Hi,

On Thu, May 14, 2026 at 9:27 PM Cong Wang <xiyou.wangcong@gmail.com> wrote:
>
> From: Cong Wang <cwang@multikernel.io>
>
> This is a complete rework of v1 (PIN_ARGS), reshaped to address the
> review feedback that having every syscall-arg fetch site consult a
> per-task pin pointer is cross-cutting awareness that does not scale.
>
> v1 thread:
> https://lore.kernel.org/lkml/20260504011207.539408-1-xiyou.wangcong@gmail.com
>
> ## Changes since v1
>
> The previous proposal (SECCOMP_IOCTL_NOTIF_PIN_ARGS) snapshotted
> pointer-arg payloads into kernel buffers and modified four syscall
> fetch sites (getname_flags in fs/namei.c, copy_strings in fs/exec.c,
> move_addr_to_kernel in net/socket.c, import_ubuf in lib/iov_iter.c
> plus new_sync_read/new_sync_write in fs/read_write.c) so the resumed
> syscall body would consume from the snapshot instead of re-reading
> user memory. The reviewer correctly pointed out that this spreads
> "continue-from-snapshotted-state" awareness across the VFS and the
> kernel in general, and that the right shape for this kind of feature
> is one where the syscall layer does not have to care.
>
> v2 inverts the model. The supervisor no longer pins args for a
> resumed syscall body to consume; it describes a substitute syscall
> (nr + args[6]) whose pointer-shaped args are encoded as byte offsets
> into a kernel-side buffer. On SECCOMP_USER_NOTIF_FLAG_INJECTED, the
> trapped task wakes inside seccomp_do_user_notification(), dispatches
> into a kernel-mode syscall helper (filp_open / kernel_bind /
> kernel_write for v1), and the helper's return value becomes the
> trapped syscall's return value. The trapped task's user mm is never
> re-read for the substituted syscall.

Please let me know your thoughts on this v2 design. I would like to
get feedback before removing the RFC tag.

Thanks!

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify
  2026-05-15  4:27 [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
                   ` (3 preceding siblings ...)
  2026-05-26 18:06 ` [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
@ 2026-05-26 19:03 ` Andy Lutomirski
  2026-05-28 17:41   ` Cong Wang
  4 siblings, 1 reply; 9+ messages in thread
From: Andy Lutomirski @ 2026-05-26 19:03 UTC (permalink / raw)
  To: Cong Wang
  Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner, Cong Wang

On Thu, May 14, 2026 at 9:27 PM Cong Wang <xiyou.wangcong@gmail.com> wrote:
>
> From: Cong Wang <cwang@multikernel.io>
>
> This is a complete rework of v1 (PIN_ARGS), reshaped to address the
> review feedback that having every syscall-arg fetch site consult a
> per-task pin pointer is cross-cutting awareness that does not scale.
>
> v1 thread:
> https://lore.kernel.org/lkml/20260504011207.539408-1-xiyou.wangcong@gmail.com
>
> ## Changes since v1

Here are some thoughts:

>
> v2 inverts the model. The supervisor no longer pins args for a
> resumed syscall body to consume; it describes a substitute syscall
> (nr + args[6]) whose pointer-shaped args are encoded as byte offsets
> into a kernel-side buffer. On SECCOMP_USER_NOTIF_FLAG_INJECTED, the
> trapped task wakes inside seccomp_do_user_notification(), dispatches
> into a kernel-mode syscall helper (filp_open / kernel_bind /
> kernel_write for v1), and the helper's return value becomes the
> trapped syscall's return value. The trapped task's user mm is never
> re-read for the substituted syscall.

This sounds like it could be done well or it could be done poorly.
Doing it poorly sounds like it would resemble set_fs(), and set_fs()
was awful.  Please don't reintroduce it or anything like it.

Doing it well sounds like introducing a bunch of new entrypoints.  In
some sense this seems like a nice plan, except that essentially every
syscall doesn't work like that.  So getting any sort of decent
coverage could involve extensive kernel changes and might involve
adding lots of new entrypoints that are only used for this new system,
which isn't great.

> The full motivation, including the threat model (adversarial AI
> agents in the same address space) and the concrete user (Sandlock,
> https://github.com/multikernel/sandlock), is in the v1 cover letter
> above.

Whoa there.  "adversarial AI agents" aren't a threat model that makes
sense in this context.  I think you mean "multiple tasks, all running
untrusted code, potentially sharing an address space".


But here are some other thoughts:

> v1 injectable-syscall whitelist:
>
>   - openat (filp_open + fd_install)
>   - bind   (sockfd_lookup + kernel_bind)
>   - write  (kernel_write)

How gnarly would an actual API for this be?  By "actual API" I mean an
fd that represents complete control over a target task (which the
existing seccomp fd sort of is) and syscalls issued against that fd
that do openat, bind, read, write, etc.

Or... what if there was a nice way to create a pinned mapping (and
verify it via seccompfd), MAP_SHARED, PROT_READ, of a memfd that the
supervisor owns.  Then the supervisor could write syscall args into it
and re-point pointers into it.

Also, for actual correct ABI compatibility, by the time the syscall's
caller is resumed, the original arguments except the return value
should be restored, because those registers are caller-saved at least
on x86.

--Andy

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify
  2026-05-26 19:03 ` Andy Lutomirski
@ 2026-05-28 17:41   ` Cong Wang
  2026-05-28 18:15     ` Andy Lutomirski
  0 siblings, 1 reply; 9+ messages in thread
From: Cong Wang @ 2026-05-28 17:41 UTC (permalink / raw)
  To: Andy Lutomirski
  Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner, Cong Wang

Hi Andy,

On Tue, May 26, 2026 at 12:03 PM Andy Lutomirski <luto@amacapital.net> wrote:
>
> On Thu, May 14, 2026 at 9:27 PM Cong Wang <xiyou.wangcong@gmail.com> wrote:
> >
> > From: Cong Wang <cwang@multikernel.io>
> >
> > This is a complete rework of v1 (PIN_ARGS), reshaped to address the
> > review feedback that having every syscall-arg fetch site consult a
> > per-task pin pointer is cross-cutting awareness that does not scale.
> >
> > v1 thread:
> > https://lore.kernel.org/lkml/20260504011207.539408-1-xiyou.wangcong@gmail.com
> >
> > ## Changes since v1
>
> Here are some thoughts:
>
> >
> > v2 inverts the model. The supervisor no longer pins args for a
> > resumed syscall body to consume; it describes a substitute syscall
> > (nr + args[6]) whose pointer-shaped args are encoded as byte offsets
> > into a kernel-side buffer. On SECCOMP_USER_NOTIF_FLAG_INJECTED, the
> > trapped task wakes inside seccomp_do_user_notification(), dispatches
> > into a kernel-mode syscall helper (filp_open / kernel_bind /
> > kernel_write for v1), and the helper's return value becomes the
> > trapped syscall's return value. The trapped task's user mm is never
> > re-read for the substituted syscall.
>
> This sounds like it could be done well or it could be done poorly.
> Doing it poorly sounds like it would resemble set_fs(), and set_fs()
> was awful.  Please don't reintroduce it or anything like it.

Noted.

For v2, the injectors call filp_open(), kernel_bind(), kernel_write(),
kernel-pointer entrypoints that exist specifically so kernel callers
don't have to spoof user-space.

>
> Doing it well sounds like introducing a bunch of new entrypoints.  In
> some sense this seems like a nice plan, except that essentially every
> syscall doesn't work like that.  So getting any sort of decent
> coverage could involve extensive kernel changes and might involve
> adding lots of new entrypoints that are only used for this new system,
> which isn't great.

This is a valid concern.

Currently, we only have 3 entrypoints which all have pre-existing
kernel-side API's. In the future, we may need to extend it, for example,
for execve().

However, the number of syscalls we inject is still very small, compared
with the total number of syscalls on Linux. I'd never anticipate this list
to grow beyond 10, since most of the syscall injections don't have
TOCTOU issues at all.

>
> > The full motivation, including the threat model (adversarial AI
> > agents in the same address space) and the concrete user (Sandlock,
> > https://github.com/multikernel/sandlock), is in the v1 cover letter
> > above.
>
> Whoa there.  "adversarial AI agents" aren't a threat model that makes
> sense in this context.  I think you mean "multiple tasks, all running
> untrusted code, potentially sharing an address space".

Right, I will update the wording.

>
>
> But here are some other thoughts:
>
> > v1 injectable-syscall whitelist:
> >
> >   - openat (filp_open + fd_install)
> >   - bind   (sockfd_lookup + kernel_bind)
> >   - write  (kernel_write)
>
> How gnarly would an actual API for this be?  By "actual API" I mean an
> fd that represents complete control over a target task (which the
> existing seccomp fd sort of is) and syscalls issued against that fd
> that do openat, bind, read, write, etc.

Excellent suggestion! How about the following API?

    ioctl(lfd, SECCOMP_IOCTL_NOTIF_RECV, &req);
    /* req.data.nr == __NR_openat; args[1] is target's pointer. */

    read_target_string(req.pid, req.data.args[1], path, sizeof(path));

    if (policy_allows(path)) {
        struct seccomp_notif_target_call call = {
            .id   = req.id,
            .nr   = __NR_openat,
            .args = { AT_FDCWD, (uintptr_t)path, O_RDONLY, 0, 0, 0 },
        };
        ioctl(lfd, SECCOMP_IOCTL_NOTIF_TARGET_CALL, &call);

        struct seccomp_notif_resp resp = {
            .id    = req.id,
            .val   = call.ret >= 0 ? call.ret : 0,
            .error = call.ret >= 0 ? 0 : (int)call.ret,
        };
        ioctl(lfd, SECCOMP_IOCTL_NOTIF_SEND, &resp);
    } else {
        struct seccomp_notif_resp resp = {
            .id = req.id, .error = -EACCES,
        };
        ioctl(lfd, SECCOMP_IOCTL_NOTIF_SEND, &resp);
    }


>
> Or... what if there was a nice way to create a pinned mapping (and
> verify it via seccompfd), MAP_SHARED, PROT_READ, of a memfd that the
> supervisor owns.  Then the supervisor could write syscall args into it
> and re-point pointers into it.

One concrete deployment constraint worth surfacing: Sandlock (and
similar wrappers: Firejail, Bubblewrap-style sandboxes) work by
fork+execve of arbitrary target binaries. The pinned-memfd
approach needs the seal installed in a trusted window, but
execve() replaces the address space, so anything mapped pre-exec
is lost. The window between execve and the first instruction of
the untrusted binary belongs to the dynamic loader (or to nothing
at all for static binaries). not to the supervisor.

>
> Also, for actual correct ABI compatibility, by the time the syscall's
> caller is resumed, the original arguments except the return value
> should be restored, because those registers are caller-saved at least
> on x86.

The current implementation already complies.

Thanks!

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify
  2026-05-28 17:41   ` Cong Wang
@ 2026-05-28 18:15     ` Andy Lutomirski
  2026-05-29  5:07       ` Cong Wang
  0 siblings, 1 reply; 9+ messages in thread
From: Andy Lutomirski @ 2026-05-28 18:15 UTC (permalink / raw)
  To: Cong Wang
  Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner, Cong Wang

On Thu, May 28, 2026 at 10:42 AM Cong Wang <xiyou.wangcong@gmail.com> wrote:
>
> Hi Andy,
>
> On Tue, May 26, 2026 at 12:03 PM Andy Lutomirski <luto@amacapital.net> wrote:
> >
> > On Thu, May 14, 2026 at 9:27 PM Cong Wang <xiyou.wangcong@gmail.com> wrote:
> > >
> > > From: Cong Wang <cwang@multikernel.io>
> > >
> > > This is a complete rework of v1 (PIN_ARGS), reshaped to address the
> > > review feedback that having every syscall-arg fetch site consult a
> > > per-task pin pointer is cross-cutting awareness that does not scale.
> > >
> > > v1 thread:
> > > https://lore.kernel.org/lkml/20260504011207.539408-1-xiyou.wangcong@gmail.com
> > >
> > > ## Changes since v1
> >
> > Here are some thoughts:
> >
> > >
> > > v2 inverts the model. The supervisor no longer pins args for a
> > > resumed syscall body to consume; it describes a substitute syscall
> > > (nr + args[6]) whose pointer-shaped args are encoded as byte offsets
> > > into a kernel-side buffer. On SECCOMP_USER_NOTIF_FLAG_INJECTED, the
> > > trapped task wakes inside seccomp_do_user_notification(), dispatches
> > > into a kernel-mode syscall helper (filp_open / kernel_bind /
> > > kernel_write for v1), and the helper's return value becomes the
> > > trapped syscall's return value. The trapped task's user mm is never
> > > re-read for the substituted syscall.
> >
> > This sounds like it could be done well or it could be done poorly.
> > Doing it poorly sounds like it would resemble set_fs(), and set_fs()
> > was awful.  Please don't reintroduce it or anything like it.
>
> Noted.
>
> For v2, the injectors call filp_open(), kernel_bind(), kernel_write(),
> kernel-pointer entrypoints that exist specifically so kernel callers
> don't have to spoof user-space.
>
> >
> > Doing it well sounds like introducing a bunch of new entrypoints.  In
> > some sense this seems like a nice plan, except that essentially every
> > syscall doesn't work like that.  So getting any sort of decent
> > coverage could involve extensive kernel changes and might involve
> > adding lots of new entrypoints that are only used for this new system,
> > which isn't great.
>
> This is a valid concern.
>
> Currently, we only have 3 entrypoints which all have pre-existing
> kernel-side API's. In the future, we may need to extend it, for example,
> for execve().
>
> However, the number of syscalls we inject is still very small, compared
> with the total number of syscalls on Linux. I'd never anticipate this list
> to grow beyond 10, since most of the syscall injections don't have
> TOCTOU issues at all.
>
> >
> > > The full motivation, including the threat model (adversarial AI
> > > agents in the same address space) and the concrete user (Sandlock,
> > > https://github.com/multikernel/sandlock), is in the v1 cover letter
> > > above.
> >
> > Whoa there.  "adversarial AI agents" aren't a threat model that makes
> > sense in this context.  I think you mean "multiple tasks, all running
> > untrusted code, potentially sharing an address space".
>
> Right, I will update the wording.
>
> >
> >
> > But here are some other thoughts:
> >
> > > v1 injectable-syscall whitelist:
> > >
> > >   - openat (filp_open + fd_install)
> > >   - bind   (sockfd_lookup + kernel_bind)
> > >   - write  (kernel_write)
> >
> > How gnarly would an actual API for this be?  By "actual API" I mean an
> > fd that represents complete control over a target task (which the
> > existing seccomp fd sort of is) and syscalls issued against that fd
> > that do openat, bind, read, write, etc.
>
> Excellent suggestion! How about the following API?
>
>     ioctl(lfd, SECCOMP_IOCTL_NOTIF_RECV, &req);
>     /* req.data.nr == __NR_openat; args[1] is target's pointer. */
>
>     read_target_string(req.pid, req.data.args[1], path, sizeof(path));
>
>     if (policy_allows(path)) {
>         struct seccomp_notif_target_call call = {
>             .id   = req.id,
>             .nr   = __NR_openat,
>             .args = { AT_FDCWD, (uintptr_t)path, O_RDONLY, 0, 0, 0 },
>         };
>         ioctl(lfd, SECCOMP_IOCTL_NOTIF_TARGET_CALL, &call);
>
>         struct seccomp_notif_resp resp = {
>             .id    = req.id,
>             .val   = call.ret >= 0 ? call.ret : 0,
>             .error = call.ret >= 0 ? 0 : (int)call.ret,
>         };
>         ioctl(lfd, SECCOMP_IOCTL_NOTIF_SEND, &resp);
>     } else {
>         struct seccomp_notif_resp resp = {
>             .id = req.id, .error = -EACCES,
>         };
>         ioctl(lfd, SECCOMP_IOCTL_NOTIF_SEND, &resp);
>     }
>
>
> >
> > Or... what if there was a nice way to create a pinned mapping (and
> > verify it via seccompfd), MAP_SHARED, PROT_READ, of a memfd that the
> > supervisor owns.  Then the supervisor could write syscall args into it
> > and re-point pointers into it.
>
> One concrete deployment constraint worth surfacing: Sandlock (and
> similar wrappers: Firejail, Bubblewrap-style sandboxes) work by
> fork+execve of arbitrary target binaries. The pinned-memfd
> approach needs the seal installed in a trusted window, but
> execve() replaces the address space, so anything mapped pre-exec
> is lost. The window between execve and the first instruction of
> the untrusted binary belongs to the dynamic loader (or to nothing
> at all for static binaries). not to the supervisor.

I don't think this matters.  A good implementation would have the
seccomp ioctl interface (or a new syscall or whatever) be able to set
up the new pinned mapping without any particular cooperation from the
target process.  So the process would start, and it would run freely
until its first syscall, and then you would install the pinned region.
And you would get notified on execve (ideally via a new notification
telling you, specifically, that the address space got cleared) so that
you know that the pinned region is gone (and that no other threads are
running concurrently in that address space!).

And (see below) you still want a way to redirect syscall args such
that they get un-redirected on return.

>
> >
> > Also, for actual correct ABI compatibility, by the time the syscall's
> > caller is resumed, the original arguments except the return value
> > should be restored, because those registers are caller-saved at least
> > on x86.
>
> The current implementation already complies.

Of course it compiles.

But someone out there probably has code that does something like:

void *param = foo;
some_syscall(param);
something_else(param);

On x86_64, param goes in RDI.  Now psABI does *not* say that RDI is
preserved on return from some_syscall, so you *think* that the
compiler will reload RDI prior to calling something_else.  But
syscalls don't obey psABI, and people love to inline them, and I bet
there are programs out there that used inline asm or a compiler that
doesn't target psABI (or a compiler that does but that, with
increasing use of LTO and such, can analyze some_syscall and determine
that it's inline asm inside) and they've set their constraints such
that RDI is *not* clobbered, and the generated code resembles:

SYSCALL
CALL something_else

or

CALL some_syscall_wrapper
CALL something_else

and it works!  Or at least it works as long as syscall restart isn't
hitting one of its excessively weird cases here, which it usually
isn't.  And then they run it under seccomp and it fails because now
RDI really is clobbered.  And it's absolutely miserable to debug.  And
it needs a kernel patch to fix because you don't have a clean,
performant fix in your seccomp user code.

--Andy

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify
  2026-05-28 18:15     ` Andy Lutomirski
@ 2026-05-29  5:07       ` Cong Wang
  0 siblings, 0 replies; 9+ messages in thread
From: Cong Wang @ 2026-05-29  5:07 UTC (permalink / raw)
  To: Andy Lutomirski
  Cc: Kees Cook, linux-kernel, Will Drewry, Christian Brauner, Cong Wang

On Thu, May 28, 2026 at 11:15 AM Andy Lutomirski <luto@amacapital.net> wrote:
>
> On Thu, May 28, 2026 at 10:42 AM Cong Wang <xiyou.wangcong@gmail.com> wrote:
> >
> > Hi Andy,
> >
> > On Tue, May 26, 2026 at 12:03 PM Andy Lutomirski <luto@amacapital.net> wrote:
> > >
> > > Or... what if there was a nice way to create a pinned mapping (and
> > > verify it via seccompfd), MAP_SHARED, PROT_READ, of a memfd that the
> > > supervisor owns.  Then the supervisor could write syscall args into it
> > > and re-point pointers into it.
> >
> > One concrete deployment constraint worth surfacing: Sandlock (and
> > similar wrappers: Firejail, Bubblewrap-style sandboxes) work by
> > fork+execve of arbitrary target binaries. The pinned-memfd
> > approach needs the seal installed in a trusted window, but
> > execve() replaces the address space, so anything mapped pre-exec
> > is lost. The window between execve and the first instruction of
> > the untrusted binary belongs to the dynamic loader (or to nothing
> > at all for static binaries). not to the supervisor.
>
> I don't think this matters.  A good implementation would have the
> seccomp ioctl interface (or a new syscall or whatever) be able to set
> up the new pinned mapping without any particular cooperation from the
> target process.  So the process would start, and it would run freely
> until its first syscall, and then you would install the pinned region.
> And you would get notified on execve (ideally via a new notification
> telling you, specifically, that the address space got cleared) so that
> you know that the pinned region is gone (and that no other threads are
> running concurrently in that address space!).

You are right. I thought it would be hard to implement this non-cooperative
pinned memfd, it turns out it is much easier than I thought.

Please let me know your thoughts on the following design:

  /* 1. Supervisor receives a trap. */
  ioctl(listener_fd, SECCOMP_IOCTL_NOTIF_RECV, &req);

  /* 2. Install a sealed pin in the trapped task's mm. */
  struct seccomp_notif_pin_install pin = {
      .id          = req.id,
      .memfd       = my_memfd,
      .target_addr = PIN_ADDR,
      .size        = PIN_SIZE,
  };
  ioctl(listener_fd, SECCOMP_IOCTL_NOTIF_PIN_INSTALL, &pin);

  /* 3. Write the substitute arg into the pin via our own memfd view. */
  strcpy(sup_view, "/dev/null");

  /* 4. Redirect args[1] into the pin and resume the syscall. */
  struct seccomp_notif_resp_redirect redir = {
      .id        = req.id,
      .flags     = SECCOMP_REDIRECT_FLAG_CONTINUE,
      .args_mask = 1U << 1,
      .ptr_mask  = 1U << 1,
      .args      = { 0, PIN_ADDR, 0, 0, 0, 0 },
  };
  ioctl(listener_fd, SECCOMP_IOCTL_NOTIF_SEND_REDIRECT, &redir);


>
> And (see below) you still want a way to redirect syscall args such
> that they get un-redirected on return.
>
> >
> > >
> > > Also, for actual correct ABI compatibility, by the time the syscall's
> > > caller is resumed, the original arguments except the return value
> > > should be restored, because those registers are caller-saved at least
> > > on x86.
> >
> > The current implementation already complies.
>
> Of course it compiles.
>
> But someone out there probably has code that does something like:
>
> void *param = foo;
> some_syscall(param);
> something_else(param);
>
> On x86_64, param goes in RDI.  Now psABI does *not* say that RDI is
> preserved on return from some_syscall, so you *think* that the
> compiler will reload RDI prior to calling something_else.  But
> syscalls don't obey psABI, and people love to inline them, and I bet
> there are programs out there that used inline asm or a compiler that
> doesn't target psABI (or a compiler that does but that, with
> increasing use of LTO and such, can analyze some_syscall and determine
> that it's inline asm inside) and they've set their constraints such
> that RDI is *not* clobbered, and the generated code resembles:

The above pinned-memfd handles this directly. SEND_REDIRECT
saves the trapped task's original arg registers into the knotif before calling
syscall_set_arguments() with the supervisor's substituted values, and
queues a task_work via task_work_add(TWA_RESUME). The callback
fires at the user-mode boundary in syscall_exit_to_user_mode_work,
before control returns to userspace, and rewrites the masked positions
back to the saved originals via syscall_set_arguments(). The caller
observes its original register contents on the resume.

Thanks!

^ permalink raw reply	[flat|nested] 9+ messages in thread

end of thread, other threads:[~2026-05-29  5:07 UTC | newest]

Thread overview: 9+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-05-15  4:27 [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
2026-05-15  4:27 ` [RFC PATCH v2 1/3] seccomp: add " Cong Wang
2026-05-15  4:27 ` [RFC PATCH v2 2/3] selftests/seccomp: add seccomp_notif_inject coverage Cong Wang
2026-05-15  4:27 ` [RFC PATCH v2 3/3] Documentation: seccomp: document SECCOMP_IOCTL_NOTIF_INJECT Cong Wang
2026-05-26 18:06 ` [RFC PATCH v2 0/3] seccomp: SECCOMP_IOCTL_NOTIF_INJECT for race-free unotify Cong Wang
2026-05-26 19:03 ` Andy Lutomirski
2026-05-28 17:41   ` Cong Wang
2026-05-28 18:15     ` Andy Lutomirski
2026-05-29  5:07       ` Cong Wang

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox

all inboxes | Powered by JetHome®