[PATCH v2 1/2] ARM: ptrace: Fix seccomp seeing stale syscall arguments

Jinjie Ruan ruanjinjie at huawei.com
Thu Oct 29 23:36:29 PDT 2026


[Background]
When user space enters a syscall, the ARM entry code copies r0 into
ARM_ORIG_r0.  r0 carries the first syscall argument, but the syscall
also returns its value in r0, so the kernel keeps a copy.

4 places use the first argument:
 - seccomp and the sys_enter tracepoint read ARM_ORIG_r0, through
   syscall_get_arguments();
 - audit_syscall_entry() reads r0;
 - the syscall itself runs with r0;
 - do_signal() copies ARM_ORIG_r0 back into r0 when a syscall restarts.

[The problem]
A tracer can change r0 while the tracee is stopped before the syscall
runs, at a syscall-entry stop (PTRACE_SETREGS, PTRACE_POKEUSR) or at a
SECCOMP_RET_TRACE stop.  Only r0 changes then, so ARM_ORIG_r0 keeps the
old value:
 - seccomp and the tracepoint see the old argument, while the syscall
   runs with the new one.  With SECCOMP_RET_TRACE this is easy to hit:
   the tracer changes the argument, the filters run again, and they
   still match on the old value;
 - a syscall that returns -ERESTARTSYS restarts with the old argument,
   because the restart path copies ARM_ORIG_r0 into r0.

A tracer that uses SECCOMP_RET_TRACE to check syscalls changes the
first argument at the PTRACE_EVENT_SECCOMP stop, and expects
the filters that run again to see the new value.

arm64 has the same problem and fixed in commit 88b839ce497c ("arm64:
ptrace: Keep 'orig_x0' in-sync with x0 on syscall entry").

x86 does not have this problem.  32-bit x86 saves only the syscall
number in orig_ax, and every reader gets it back with syscall_get_nr():

  do_int80_emulation()
    -> regs->orig_ax = regs->ax & GENMASK(31, 0)
       regs->ax = -ENOSYS
    -> nr = syscall_32_enter()
       -> regs->orig_ax
    -> nr = syscall_enter_from_user_mode_work(regs, &nr)
         -> syscall_trace_enter()
              -> arch_ptrace_report_syscall_permit_entry()
              -> __seccomp_permit_syscall()
                 -> syscall_get_nr() -> regs->orig_ax
              -> trace_syscall_enter()
                 -> syscall_get_nr() -> regs->orig_ax
              -> syscall_enter_audit()
                  -> syscall_get_nr() -> regs->orig_ax
         -> nr = syscall_get_nr() -> regs->orig_ax
    -> do_syscall_32_irqs_on(regs, nr) // Use orig_ax as the system call number
                                       // to execute the system call. This is\
                                       // consistent with seccomp, audit, and
                                       // tracepoint.

arm32 instead has two copies of the first argument.  seccomp and the
tracepoints read one copy, and the syscall runs with the other:

  vector_swi
    -> str r0, [sp, #S_OLD_R0]          // backup r0 to ARM_ORIG_r0
    -> syscall_trace_enter()
         -> report_syscall()
         -> seccomp_permit_syscall()
              -> syscall_get_arguments() -> ARM_ORIG_r0
         -> trace_sys_enter()
              -> syscall_get_arguments() -> ARM_ORIG_r0
         -> audit_syscall_entry(scno, ARM_r0, ARM_r1, ARM_r2, ARM_r3)
				      ^^^^^^
    -> invoke_syscall, reload r0-r6
       -> use r0 here!
             ^^^^

So after a tracer changes r0, seccomp and the tracepoints still read the
old ARM_ORIG_r0, audit reads the new ARM_r0, and the syscall runs with
the new ARM_r0.

riscv and loongarch also keep a copy of the first argument (orig_a0),
but they pass that copy to both the syscall and
syscall_get_arguments(), so they stay consistent.  On arm32 the syscall
runs with a different register than the one seccomp and the tracepoints
read.

The syscall number has a similar copy (abi_syscall), but there the
readers and the dispatcher both use syscall_get_nr(), so only the first
argument is affected.

[Fix]
Copy r0 into ARM_ORIG_r0 after a ptrace register write, but only when
the tracee is stopped before its syscall runs:
 - at a syscall-entry stop or a SECCOMP_RET_TRACE stop, r0 still holds
   the first argument, and seccomp, the tracepoints, audit and the
   syscall itself are about to read it;
 - at a syscall-exit stop, r0 holds the return value (-ERESTARTSYS and
   friends).  ARM_ORIG_r0 must stay, or a restarted syscall would run
   with the error code as its first argument;
 - for any other exception, the entry code sets ARM_ORIG_r0 to ~0 and it
   has no meaning, so it must not be overwritten.

The stop type is the same classification that
ptrace_get_syscall_info_op() uses for PTRACE_GET_SYSCALL_INFO.

audit_syscall_entry() takes the first argument from r0 directly.  Give
it ARM_ORIG_r0, as seccomp and the tracepoints do, so audit describes
the syscall that really runs.

Tested on arm32 with a real gdb in qemu-system-arm: after
"catch syscall getpid" the tracer sets r0 to a value the seccomp filter
rejects.  With this patch the filter refuses the syscall, without it the
syscall runs with the old argument.

Cc: stable at vger.kernel.org
Fixes: bf2c9f986692 ("ARM: 7373/1: add support for the generic syscall.h interface")
Link: https://lore.kernel.org/all/al98jdcleu-6tMDL@willie-the-truck/
Suggested-by: Will Deacon <will at kernel.org>
Link: https://lkml.org/lkml/2020/9/11/1282
Signed-off-by: Jinjie Ruan <ruanjinjie at huawei.com>
---
 arch/arm/kernel/ptrace.c | 41 +++++++++++++++++++++++++++++++++++++++-
 1 file changed, 40 insertions(+), 1 deletion(-)

diff --git a/arch/arm/kernel/ptrace.c b/arch/arm/kernel/ptrace.c
index ed7a2a87a670..6ec81e5a205e 100644
--- a/arch/arm/kernel/ptrace.c
+++ b/arch/arm/kernel/ptrace.c
@@ -163,6 +163,42 @@ static inline long get_user_reg(struct task_struct *task, int offset)
 	return task_pt_regs(task)->uregs[offset];
 }
 
+/*
+ * Is @target stopped after entering a syscall, but before it has been
+ * dispatched?  This is the same classification that
+ * ptrace_get_syscall_info_op() performs for PTRACE_GET_SYSCALL_INFO.
+ */
+static bool stopped_in_syscall_entry(struct task_struct *target)
+{
+	struct kernel_siginfo *info = target->last_siginfo;
+
+	if (!info)
+		return false;
+
+	if (info->si_code == (SIGTRAP | 0x80))
+		return target->ptrace_message == PTRACE_EVENTMSG_SYSCALL_ENTRY;
+
+	return info->si_code == (SIGTRAP | (PTRACE_EVENT_SECCOMP << 8));
+}
+
+static void update_syscall_orig_r0_after_ptrace(struct task_struct *target)
+{
+	struct pt_regs *regs = task_pt_regs(target);
+
+	/*
+	 * ARM_r0 holds the first syscall argument only before the syscall is
+	 * dispatched; seccomp, the syscall tracepoints and the syscall restart
+	 * code all read that argument from ARM_ORIG_r0 instead.  At a syscall
+	 * exit stop ARM_r0 holds the return value and must not be copied, and
+	 * for other exceptions ARM_ORIG_r0 is set to ~0 and carries no
+	 * meaning.
+	 */
+	if (!stopped_in_syscall_entry(target))
+		return;
+
+	regs->ARM_ORIG_r0 = regs->ARM_r0;
+}
+
 /*
  * this routine will put a word on the processes privileged stack.
  * the offset is how far from the base addr as stored in the THREAD.
@@ -180,6 +216,8 @@ put_user_reg(struct task_struct *task, int offset, long data)
 
 	if (valid_user_regs(&newregs)) {
 		regs->uregs[offset] = data;
+		if (offset == 0)
+			update_syscall_orig_r0_after_ptrace(task);
 		ret = 0;
 	}
 
@@ -566,6 +604,7 @@ static int gpr_set(struct task_struct *target,
 		return -EINVAL;
 
 	*task_pt_regs(target) = newregs;
+	update_syscall_orig_r0_after_ptrace(target);
 	return 0;
 }
 
@@ -868,7 +907,7 @@ asmlinkage int syscall_trace_enter(struct pt_regs *regs)
 	if (test_thread_flag(TIF_SYSCALL_TRACEPOINT))
 		trace_sys_enter(regs, scno);
 
-	audit_syscall_entry(scno, regs->ARM_r0, regs->ARM_r1, regs->ARM_r2,
+	audit_syscall_entry(scno, regs->ARM_ORIG_r0, regs->ARM_r1, regs->ARM_r2,
 			    regs->ARM_r3);
 
 	return scno;
-- 
2.34.1




More information about the linux-arm-kernel mailing list