kselftest/arm64: Add testcase for SECCOMP_RET_TRACE orig_x0 bypass

Add a selftest that verifies the kernel re-evaluates a seccomp filter
with the correct (ptrace-modified) first argument after
a SECCOMP_RET_TRACE stop. On arm64, syscall_get_arguments() reads
the first argument from orig_x0, which may be stale if the tracer modified
regs->regs[0] but orig_x0 was not synced.  This can cause the filter to
see an old argument and incorrectly allow a syscall that it should
have rejected.

The child installs a filter that:
 - TRACEs write() when fd == 2
 - returns ERRNO(EPERM) when fd == 1

The parent catches the SECCOMP event, changes x0 (fd) from 2 to 1,
and resumes the child.

If the seccomp re-evaluation sees the stale orig_x0 (fd=2) the filter
returns TRACE again and the kernel (with recheck_after_trace=true)
allows the syscall to proceed – write succeeds and the child exits 0.
If the seccomp re-evaluation sees the new value (fd=1) the filter
returns ERRNO(EPERM), write fails and the child exits non-zero.
The test passes only when the write fails (child exit != 0).

Before the fix:
	# ./seccomp_ret_trace_x0_bypass
	TAP version 13
	1..1
	not ok 1 write succeeded, orig_x0 bypass likely
	# Totals: pass:0 fail:1 xfail:0 xpass:0 skip:0 error:0

After the fix:
	# ./seccomp_ret_trace_x0_bypass
	TAP version 13
	1..1
	ok 1 seccomp correctly denied modified syscall
	# Totals: pass:1 fail:0 xfail:0 xpass:0 skip:0 error:0

Cc: Kees Cook <kees@kernel.org>
Cc: Will Deacon <will@kernel.org>
Cc: Catalin Marinas <catalin.marinas@arm.com>
Cc: Mark Rutland <mark.rutland@arm.com>
Link: https://lore.kernel.org/all/20260717182758.17111-1-will@kernel.org/
Link: https://lore.kernel.org/all/20260716120640.6590-1-will@kernel.org/
Link: https://lore.kernel.org/all/202607152004.DEA95D63@keescook/
Suggested-by: Kees Cook <kees@kernel.org>
Signed-off-by: Jinjie Ruan <ruanjinjie@huawei.com>
Signed-off-by: Will Deacon <will@kernel.org>
This commit is contained in:
Jinjie Ruan
2026-08-02 09:50:52 +00:00
committed by Will Deacon
parent 2fcbc4adf9
commit 21e37da120
3 changed files with 206 additions and 1 deletions
@@ -1,5 +1,6 @@
hwcap
ptrace
seccomp_ptrace_x0_bypass
seccomp_ret_trace_x0_bypass
syscall-abi
tpidr2
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: GPL-2.0
# Copyright (C) 2021 ARM Limited
TEST_GEN_PROGS := hwcap ptrace syscall-abi tpidr2 seccomp_ptrace_x0_bypass
TEST_GEN_PROGS := hwcap ptrace syscall-abi tpidr2 seccomp_ptrace_x0_bypass seccomp_ret_trace_x0_bypass
include ../../lib.mk
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: GPL-2.0
/*
* Test for SECCOMP_RET_TRACE argument modification bypass
* via stale orig_x0 during filter re-evaluation.
*
* On arm64, syscall_get_arguments() reads the first argument from
* regs->orig_x0. When a seccomp filter returns SECCOMP_RET_TRACE,
* ptrace may modify regs->regs[0] while orig_x0 remains unchanged.
* The kernel then re-evaluates the filter; if it sees the stale
* orig_x0, it may incorrectly allow a syscall that the tracer intended
* to block.
*
* This test installs a filter that:
* - TRACEs write() when fd == 2
* - returns ERRNO(EPERM) when fd == 1
* - allows all other syscalls
*
* The child calls write(2, ...). The parent catches the SECCOMP stop,
* changes x0 (fd) from 2 to 1, and resumes the child.
*
* If re-evaluation sees the old fd=2 (stale orig_x0), the filter
* returns TRACE again; because recheck_after_trace is true, the kernel
* allows the syscall to proceed. write(1, ...) succeeds, child exits 0.
* -> test FAIL (bypass detected).
*
* If re-evaluation sees the new fd=1 (synced orig_x0), the filter
* returns ERRNO(EPERM), write fails, child exits 1.
* -> test PASS (no bypass).
*
* No special privileges required beyond CAP_SYS_PTRACE.
*/
#include <errno.h>
#include <signal.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <sys/prctl.h>
#include <sys/ptrace.h>
#include <sys/uio.h>
#include <sys/wait.h>
#include <linux/elf.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/ptrace.h>
#include "kselftest.h"
#ifndef __NR_write
#define __NR_write 64
#endif
#define PTRACE_EVENT_MASK(status) ((status) >> 16)
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
#define ARG0_OFFSET (offsetof(struct seccomp_data, args))
#else
#define ARG0_OFFSET (offsetof(struct seccomp_data, args) + 4)
#endif
static int do_child(void)
{
long ret;
if (ptrace(PTRACE_TRACEME, 0, NULL, NULL))
_exit(2);
raise(SIGSTOP); /* synchronize with parent */
/*
* Filter:
* if syscall == write:
* if fd == 2 -> TRACE
* if fd == 1 -> ERRNO(EPERM)
* else -> ALLOW
* else -> ALLOW
*/
struct sock_filter filter[] = {
/* Load syscall number */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
/* If not write, allow */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 5),
/* Load first argument (fd) */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, ARG0_OFFSET),
/* fd == 2 ? */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 2, 0, 1),
/* Yes: TRACE */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRACE),
/* fd == 1 ? */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 1, 0, 1),
/* Yes: ERRNO(EPERM) */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)),
/* Other fd: ALLOW */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {
.len = ARRAY_SIZE(filter),
.filter = filter,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0))
_exit(3);
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog))
_exit(4);
/*
* write(2, ...) triggers TRACE, parent changes fd to 1.
* If re-eval sees fd=1 -> ERRNO -> write fails, ret = -EPERM.
* If re-eval sees fd=2 -> TRACE again -> allowed -> write succeeds.
*/
ret = syscall(__NR_write, 2, "", 0);
_exit(ret == 0 ? 0 : 1);
}
int main(void)
{
struct user_pt_regs regs;
struct iovec iov = { .iov_base = &regs, .iov_len = sizeof(regs) };
pid_t child;
int status;
ksft_print_header();
ksft_set_plan(1);
child = fork();
if (child < 0)
ksft_exit_fail_msg("fork failed: %s", strerror(errno));
if (!child)
return do_child();
/* 1. Wait for initial SIGSTOP */
if (waitpid(child, &status, 0) != child)
ksft_exit_fail_msg("waitpid SIGSTOP");
if (!WIFSTOPPED(status) || WSTOPSIG(status) != SIGSTOP)
ksft_exit_fail_msg("unexpected initial stop");
/* 2. Enable SECCOMP ptrace events */
if (ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESECCOMP))
ksft_exit_fail_msg("PTRACE_SETOPTIONS");
/* 3. Continue child to hit SECCOMP stop */
if (ptrace(PTRACE_CONT, child, 0, 0))
ksft_exit_fail_msg("PTRACE_CONT");
/* 4. Wait for SECCOMP stop */
while (1) {
if (waitpid(child, &status, 0) != child)
ksft_exit_fail_msg("waitpid SECCOMP");
if (WIFEXITED(status)) {
ksft_test_result_fail("child exited before SECCOMP stop\n");
goto out;
}
if (WIFSIGNALED(status)) {
ksft_test_result_fail("child killed unexpectedly\n");
goto out;
}
if (WIFSTOPPED(status) &&
WSTOPSIG(status) == SIGTRAP &&
PTRACE_EVENT_MASK(status) == PTRACE_EVENT_SECCOMP)
break;
ptrace(PTRACE_CONT, child, 0, WSTOPSIG(status));
}
/* 5. Modify x0 (fd) from 2 to 1 */
if (ptrace(PTRACE_GETREGSET, child, NT_PRSTATUS, &iov))
ksft_exit_fail_perror("GETREGSET");
if (regs.regs[8] != __NR_write || regs.regs[0] != 2) {
ksft_test_result_fail("unexpected regs: syscall=%llu, x0=%llu\n",
regs.regs[8], regs.regs[0]);
goto out;
}
regs.regs[0] = 1;
if (ptrace(PTRACE_SETREGSET, child, NT_PRSTATUS, &iov))
ksft_exit_fail_perror("SETREGSET");
/* 6. Resume child */
if (ptrace(PTRACE_CONT, child, 0, 0))
ksft_exit_fail_perror("PTRACE_CONT");
/* 7. Reap child must exit normally */
if (waitpid(child, &status, 0) != child)
ksft_exit_fail_msg("final waitpid");
if (!WIFEXITED(status)) {
ksft_test_result_fail("child did not exit normally\n");
goto out;
}
if (WEXITSTATUS(status) != 0)
ksft_test_result_pass("seccomp correctly denied modified syscall\n");
else
ksft_test_result_fail("write succeeded, orig_x0 bypass likely\n");
out:
if (child > 0) {
kill(child, SIGKILL);
waitpid(child, NULL, 0);
}
ksft_print_cnts();
return ksft_get_fail_cnt() ? EXIT_FAILURE : EXIT_SUCCESS;
}