From 2fcbc4adf99790564b83381b2f239294185603d5 Mon Sep 17 00:00:00 2001 From: Jinjie Ruan Date: Tue, 28 Jul 2026 10:11:21 +0800 Subject: [PATCH] kselftest/arm64: Add seccomp ptrace x0 bypass test As Kees suggested, add a test that verifies that seccomp observes the correct first argument after a ptracer modifies x0 at a syscall-enter-stop on arm64. The first syscall argument and the return value share register x0. The original value is saved in orig_x0 on entry and used by syscall_get_arguments(), but ptrace changes to x0 were not automatically reflected there. This test checks the kernel re-syncs orig_x0 after a ptrace stop so that seccomp sees the modified argument. A seccomp filter allows write(2,...) and kills the task for any other fd. The tracer changes fd from 2 to 1 at entry. If orig_x0 remains stale, the child exits normally (bypass, test fails). If orig_x0 is correctly updated, the child is killed by SIGSYS (test passes). Before the fix: ./seccomp_ptrace_x0_bypass TAP version 13 1..1 not ok 1 seccomp_ptrace_x0_bypass # Totals: pass:0 fail:1 xfail:0 xpass:0 skip:0 error:0 After the fix: # ./seccomp_ptrace_x0_bypass TAP version 13 1..1 [ 19.475951] audit: type=1326 audit(1784254846.284:2): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=227 comm="seccomp_ptrace_" exe="/mnt/seccomp0 [ 19.477852] audit: type=1701 audit(1784254846.284:3): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=227 comm="seccomp_ptrace_" exe="/mnt/seccomp1 ok 1 seccomp_ptrace_x0_bypass # Totals: pass:1 fail:0 xfail:0 xpass:0 skip:0 error:0 Cc: Kees Cook Cc: Will Deacon Cc: Catalin Marinas Cc: Mark Rutland 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 Signed-off-by: Jinjie Ruan Signed-off-by: Will Deacon --- tools/testing/selftests/arm64/abi/.gitignore | 1 + tools/testing/selftests/arm64/abi/Makefile | 2 +- .../arm64/abi/seccomp_ptrace_x0_bypass.c | 195 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 tools/testing/selftests/arm64/abi/seccomp_ptrace_x0_bypass.c diff --git a/tools/testing/selftests/arm64/abi/.gitignore b/tools/testing/selftests/arm64/abi/.gitignore index 44f8b80f37e3..39129a9907c7 100644 --- a/tools/testing/selftests/arm64/abi/.gitignore +++ b/tools/testing/selftests/arm64/abi/.gitignore @@ -1,4 +1,5 @@ hwcap ptrace +seccomp_ptrace_x0_bypass syscall-abi tpidr2 diff --git a/tools/testing/selftests/arm64/abi/Makefile b/tools/testing/selftests/arm64/abi/Makefile index 483488f8c2ad..5a16db379bd4 100644 --- a/tools/testing/selftests/arm64/abi/Makefile +++ b/tools/testing/selftests/arm64/abi/Makefile @@ -1,7 +1,7 @@ # SPDX-License-Identifier: GPL-2.0 # Copyright (C) 2021 ARM Limited -TEST_GEN_PROGS := hwcap ptrace syscall-abi tpidr2 +TEST_GEN_PROGS := hwcap ptrace syscall-abi tpidr2 seccomp_ptrace_x0_bypass include ../../lib.mk diff --git a/tools/testing/selftests/arm64/abi/seccomp_ptrace_x0_bypass.c b/tools/testing/selftests/arm64/abi/seccomp_ptrace_x0_bypass.c new file mode 100644 index 000000000000..a00621a8949c --- /dev/null +++ b/tools/testing/selftests/arm64/abi/seccomp_ptrace_x0_bypass.c @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * Test that seccomp, tracepoints and audit observe the correct syscall + * arguments after a ptracer has modified them at syscall-enter-stop. + * + * On arm64, both the first argument and the return value of a syscall + * are passed in register x0. The original x0 is saved in + * pt_regs::orig_x0 during syscall entry and returned as the first + * argument by syscall_get_arguments(). Because ptrace modifications + * to x0 are not automatically reflected in orig_x0, seccomp, tracepoints + * and audit may see a stale value unless orig_x0 is explicitly + * re-synchronised after a ptrace stop. + * + * This test sets up a seccomp filter that allows write(2, ...) but kills + * the task for any other fd. A ptracer changes the fd argument from 2 + * to 1 at the syscall-enter stop. If the orig_x0 re-sync works, seccomp + * sees the modified argument (fd=1) and kills the child with SIGSYS + * (test passes). If orig_x0 is not re-synced, seccomp sees the original + * fd=2, the write succeeds and the child exits normally (test fails, + * vulnerability present). + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kselftest.h" + +#ifndef __NR_write +#define __NR_write 64 +#endif + +#define EXPECTED_TESTS 1 + +#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) +{ + if (ptrace(PTRACE_TRACEME, 0, NULL, NULL)) + ksft_exit_fail_perror("PTRACE_TRACEME"); + + if (raise(SIGSTOP)) + ksft_exit_fail_perror("raise(SIGSTOP)"); + + /* + * Seccomp filter: + * If syscall is not write -> ALLOW + * If syscall is write: + * - If args[0] (fd) == 2 -> ALLOW + * - Otherwise -> KILL + */ + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), /* nr */ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 3), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, ARG0_OFFSET), /* args[0] */ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 2, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL), + 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)) + ksft_exit_fail_perror("prctl NO_NEW_PRIVS"); + + if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) + ksft_exit_fail_perror("prctl SECCOMP"); + + /* + * Invoke write(2, ...) while the tracer will change the first + * argument (fd) from 2 to 1 at syscall entry. + */ + syscall(__NR_write, 2, NULL, 0); + _exit(0); +} + +static int do_parent(pid_t child) +{ + bool bypass = false; + int status; + + /* Wait for the initial SIGSTOP */ + if (waitpid(child, &status, 0) != child) + ksft_exit_fail_msg("waitpid failed"); + + if (!WIFSTOPPED(status) || WSTOPSIG(status) != SIGSTOP) + ksft_exit_fail_msg("unexpected stop status"); + + if (ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD | PTRACE_O_EXITKILL)) + ksft_exit_fail_perror("PTRACE_SETOPTIONS"); + + if (ptrace(PTRACE_SYSCALL, child, 0, 0)) + ksft_exit_fail_perror("PTRACE_SYSCALL"); + + while (1) { + int sig; + + if (waitpid(child, &status, 0) != child) + ksft_exit_fail_msg("waitpid lost child"); + + if (WIFEXITED(status)) { + /* Child exited normally – bypass succeeded */ + bypass = true; + break; + } + + if (WIFSIGNALED(status)) { + sig = WTERMSIG(status); + if (sig == SIGSYS) + break; + ksft_exit_fail_msg("child died unexpectedly from signal %d (%s)", + sig, strsignal(sig)); + } + + if (!WIFSTOPPED(status)) + ksft_exit_fail_msg("unexpected wait status"); + + sig = WSTOPSIG(status); + + if (sig == (SIGTRAP | 0x80)) { + struct user_regs_struct regs; + struct iovec iov = { + .iov_base = ®s, + .iov_len = sizeof(regs), + }; + + if (ptrace(PTRACE_GETREGSET, child, NT_PRSTATUS, &iov)) + ksft_exit_fail_perror("PTRACE_GETREGSET"); + + unsigned long syscall_nr = regs.regs[8]; + unsigned long x0 = regs.regs[0]; + + /* Modify fd from 2 to 1 at write entry */ + if (syscall_nr == __NR_write && x0 == 2) { + regs.regs[0] = 1; + if (ptrace(PTRACE_SETREGSET, child, NT_PRSTATUS, &iov)) + ksft_exit_fail_perror("PTRACE_SETREGSET"); + } + + if (ptrace(PTRACE_SYSCALL, child, 0, 0)) + ksft_exit_fail_perror("PTRACE_SYSCALL"); + } else { + /* Forward other signals */ + if (ptrace(PTRACE_SYSCALL, child, 0, sig)) + ksft_exit_fail_perror("PTRACE_SYSCALL"); + } + } + + /* bypass == true means vulnerability exists -> test fails */ + return bypass ? EXIT_FAILURE : EXIT_SUCCESS; +} + +int main(void) +{ + pid_t child; + + ksft_print_header(); + ksft_set_plan(EXPECTED_TESTS); + + child = fork(); + if (child < 0) + ksft_exit_fail_msg("fork failed: %s", strerror(errno)); + + if (!child) + return do_child(); + + /* + * do_parent() returns EXIT_SUCCESS if the child was killed by + * SIGSYS (i.e. seccomp correctly saw the modified argument), + * and EXIT_FAILURE if the child exited normally (bypass). + */ + int result = do_parent(child); + + ksft_test_result(result == EXIT_SUCCESS, "seccomp_ptrace_x0_bypass\n"); + + ksft_print_cnts(); + return result; +}