Description
On arm64, when a seccomp filter returns SECCOMP_RET_TRAP, the ucontext_t handed to the SIGSYS
handler has the syscall number in uc_mcontext.regs[0] where Linux leaves the syscall's
first argument. The first argument appears in no register of the frame (we checked
regs[0]–regs[8]). Arguments 1–5 (regs[1]–regs[5]) are intact, and a return value the
handler writes into regs[0] reaches the trapped caller correctly.
x86_64 is unaffected: under the same release, on both the systrap and ptrace platforms, all six
arguments arrive in rdi/rsi/rdx/r10/r8/r9.
Handlers that emulate a trapped syscall read its arguments from the SIGSYS frame. On arm64 Linux
this works because __seccomp_filter calls syscall_rollback() for SECCOMP_RET_TRAP before
forcing the signal, and arm64's rollback restores regs[0] from orig_x0. On gVisor arm64 the same handler decides on the wrong first argument.
We found this while running a seccomp-trap-based sandbox under runsc. Its first casualty was
rt_sigprocmask: the handler read the how argument as 0x87 and returned EINVAL.
Steps to reproduce
// repro.c — cc -O1 -o repro repro.c (static or dynamic; no dependencies)
#define _GNU_SOURCE
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <signal.h>
#include <stddef.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <ucontext.h>
#include <unistd.h>
static volatile unsigned long seen[6];
static void h(int s, siginfo_t *si, void *u) {
ucontext_t *uc = u;
#if defined(__aarch64__)
for (int i = 0; i < 6; i++) seen[i] = uc->uc_mcontext.regs[i];
uc->uc_mcontext.regs[0] = 4242; /* return value */
#elif defined(__x86_64__)
static const int r[6] = {REG_RDI, REG_RSI, REG_RDX, REG_R10, REG_R8, REG_R9};
for (int i = 0; i < 6; i++) seen[i] = uc->uc_mcontext.gregs[r[i]];
uc->uc_mcontext.gregs[REG_RAX] = 4242;
#endif
}
int main(void) {
struct sigaction sa = {0};
sa.sa_sigaction = h;
sa.sa_flags = SA_SIGINFO;
sigaction(SIGSYS, &sa, 0);
struct sock_filter f[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpriority, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRAP),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog p = {sizeof f / sizeof f[0], f};
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &p)) { perror("seccomp"); return 1; }
long r = syscall(__NR_getpriority, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66);
printf("args=%lx %lx %lx %lx %lx %lx ret=%ld\n",
seen[0], seen[1], seen[2], seen[3], seen[4], seen[5], r);
return 0;
}
runsc --root=/tmp/state --platform=systrap --network=none --ignore-cgroups do -- ./repro
Expected (native Linux arm64 and x86_64, and gVisor x86_64 on systrap and ptrace):
args=11 22 33 44 55 66 ret=4242
Actual (gVisor arm64, systrap and ptrace):
args=8d 22 33 44 55 66 ret=4242
0x8d is __NR_getpriority on arm64.
runsc version
release-20260817.0 (checksum-verified binary from storage.googleapis.com/gvisor/releases/release/20260817.0/aarch64)
Environment
- arm64: runsc nested in a privileged Docker container on an arm64 Linux VM (OrbStack, Apple
silicon). Both --platform=systrap and --platform=ptrace.
- x86_64 control: Debian 13 on a Hetzner VM. Both platforms; clean.
We have not read the sentry code. The symptom looks like the arm64 equivalent of Linux's
syscall_rollback() being missing on the trap path, so the frame is built from register state in
which x0 has already been overwritten.
Description
On arm64, when a seccomp filter returns
SECCOMP_RET_TRAP, theucontext_thanded to the SIGSYShandler has the syscall number in
uc_mcontext.regs[0]where Linux leaves the syscall'sfirst argument. The first argument appears in no register of the frame (we checked
regs[0]–regs[8]). Arguments 1–5 (regs[1]–regs[5]) are intact, and a return value thehandler writes into
regs[0]reaches the trapped caller correctly.x86_64 is unaffected: under the same release, on both the systrap and ptrace platforms, all six
arguments arrive in
rdi/rsi/rdx/r10/r8/r9.Handlers that emulate a trapped syscall read its arguments from the SIGSYS frame. On arm64 Linux
this works because
__seccomp_filtercallssyscall_rollback()forSECCOMP_RET_TRAPbeforeforcing the signal, and arm64's rollback restores
regs[0]fromorig_x0. On gVisor arm64 the same handler decides on the wrong first argument.We found this while running a seccomp-trap-based sandbox under runsc. Its first casualty was
rt_sigprocmask: the handler read thehowargument as0x87and returnedEINVAL.Steps to reproduce
runsc --root=/tmp/state --platform=systrap --network=none --ignore-cgroups do -- ./reproExpected (native Linux arm64 and x86_64, and gVisor x86_64 on systrap and ptrace):
Actual (gVisor arm64, systrap and ptrace):
0x8dis__NR_getpriorityon arm64.runsc version
Environment
silicon). Both
--platform=systrapand--platform=ptrace.We have not read the sentry code. The symptom looks like the arm64 equivalent of Linux's
syscall_rollback()being missing on the trap path, so the frame is built from register state inwhich
x0has already been overwritten.