Re: [PATCH v2] entry: Fix seccomp bypass after ptrace with TSYNC
From: Jinjie Ruan
Date: Tue Jul 14 2026 - 03:42:28 EST
On 7/13/2026 10:57 AM, Jinjie Ruan wrote:
> Sashiko review pointed out the following issue.
>
> If a thread is stopped in syscall_trace_enter() for ptrace, another
> thread can install a seccomp filter with SECCOMP_FILTER_FLAG_TSYNC
> (e.g., via seccomp_attach_filter()). This will successfully set
> SYSCALL_WORK_SECCOMP on the stopped thread, but syscall_trace_enter()
> evaluates a cached 'work' variable sampled on entry. Consequently,
> the subsequent check for SYSCALL_WORK_SECCOMP misses the newly
> assigned flag, and the filter is silently bypassed.
>
> This race condition could allow an unprivileged process to execute
> a prohibited system call (e.g., execve) that the newly installed filter
> was intended to block, especially since the tracer might have modified
> the system call number during the ptrace stop.
>
> Fix this by re-reading the syscall_work flags after ptrace handling,
> so that any new SYSCALL_WORK_SECCOMP flag set by another thread via
> TSYNC during the ptrace stop is observed before the subsequent
> seccomp check.
>
> Cc: Kees Cook <kees@xxxxxxxxxx>
> Cc: Thomas Gleixner <tglx@xxxxxxxxxx>
> Cc: Andy Lutomirski <luto@xxxxxxxxxxxxxx>
> Cc: Will Drewry <wad@xxxxxxxxxxxx>
> Cc: stable@xxxxxxxxxxxxxxx
> Link: https://lore.kernel.org/all/20260629132914.1135C1F000E9@xxxxxxxxxxxxxxx/
> Fixes: 142781e108b1 ("entry: Provide generic syscall entry functionality")
> Signed-off-by: Jinjie Ruan <ruanjinjie@xxxxxxxxxx>
> ---
> v2:
> - Adopt lighter-weight approach suggested by Thomas: re-read syscall_work
> after ptrace instead of calling seccomp_permit_syscall() unconditionally.
> - Update the commit message to reflect the actual implementation.
> ---
> include/linux/entry-common.h | 3 +++
> 1 file changed, 3 insertions(+)
>
> diff --git a/include/linux/entry-common.h b/include/linux/entry-common.h
> index 07d97def7dcb..299f13c78a6f 100644
> --- a/include/linux/entry-common.h
> +++ b/include/linux/entry-common.h
> @@ -97,6 +97,9 @@ static __always_inline long syscall_trace_enter(struct pt_regs *regs, unsigned l
> if (!arch_ptrace_report_syscall_permit_entry(regs) ||
> (work & SYSCALL_WORK_SYSCALL_EMU))
> return -1L;
> +
> + /* ptrace might have changed work flags */
> + work = READ_ONCE(current_thread_info()->syscall_work);
Hi, Thomas,
This issue can be reproduced itself on x86 on v7.2-rc1 and this patch
can fix the issue.
I used DeepSeek to help write the following test method and script,
which can successfully reproduce the issue.
# Test Approach
## How the test works:
A child process is created and traced with ptrace(PTRACE_SYSCALL).
The child raises SIGSTOP to synchronise with the tracer, then calls getpid.
When the child stops at the syscall‑enter tracepoint, the tracer signals
a helper thread (via a pipe) to install a seccomp filter using
SECCOMP_FILTER_FLAG_TSYNC.
The filter kills any getpid invocation, while the child’s getpid is
still paused inside syscall_trace_enter().
After a short sleep to guarantee installation, the tracer resumes the child.
## Expected results:
Without the fix: The cached syscall_work flag does not see the newly
added SYSCALL_WORK_SECCOMP; the getpid call executes normally, the child
exits cleanly, and the test prints [tracer] child exited normally -> BYPASS.
With the fix: The re‑read of syscall_work after the ptrace stop picks up
the SYSCALL_WORK_SECCOMP flag, __secure_computing() is invoked, the
filter kills the process, and the test prints [tracer] child killed by
SIGSYS -> bug fixed.
# kernel debug patch
--- a/include/linux/entry-common.h
+++ b/include/linux/entry-common.h
@@ -96,6 +96,10 @@ static __always_inline long
syscall_trace_enter(struct pt_regs *regs, unsigned l
ret = arch_ptrace_report_syscall_entry(regs);
if (ret || (work & SYSCALL_WORK_SYSCALL_EMU))
return -1L;
+
+ pr_info("[DEBUG] After ptrace, scno: %d, SECCOMP:
0x%lx\n", syscall_get_nr(current, regs), work & SYSCALL_WORK_SECCOMP);
+ work = READ_ONCE(current_thread_info()->syscall_work);
+ pr_info("[DEBUG] After update, scno: %d, SECCOMP:
0x%lx\n", syscall_get_nr(current, regs), work & SYSCALL_WORK_SECCOMP);
}
# Test results
## baseline (Comment out "work refresh")
# ./tsync_seccomp_bypass_test
[tracer] signaling filter thread
[filter thread] seccomp filter installed
[ 10.497627] [DEBUG] After ptrace, scno: 39, SECCOMP: 0x0
[ 10.498086] [DEBUG] After update, scno: 39, SECCOMP: 0x0
[ 10.498593] [DEBUG] After ptrace, scno: 231, SECCOMP: 0x1
[ 10.499060] [DEBUG] After update, scno: 231, SECCOMP: 0x1
[tracer] child exited normally -> BYPASS
## After refresh work with the patch
# ./tsync_seccomp_bypass_test
[tracer] signaling filter thread
[filter thread] seccomp filter installed
[ 7.590580] [DEBUG] After ptrace, scno: 39, SECCOMP: 0x0
[ 7.591063] [DEBUG] After update, scno: 39, SECCOMP: 0x1
[ 7.591734] audit: type=1326 audit(1784013262.411:2):
auid=4294967295 uid=0 gid=0 ses=4294967295 subj=kernel pid=589
comm="tsync_seccomp_b" exe="/root/tsync0
[tracer] child killed by SIGSYS -> bug fixed
# Test program
cat tsync_seccomp_bypass_test.c
```
/*
* TSYNC seccomp bypass test for x86-64, using only getpid (syscall 39)
*
* Reproduces the race fixed by:
* "entry: Fix seccomp bypass after ptrace with TSYNC"
*
* Expected behavior:
* Without fix: getpid executes and child exits normally (bypass).
* With fix: child is killed by SIGSYS because filter blocks getpid.
*
* Build: gcc -o tsync_seccomp_bypass_test tsync_seccomp_bypass_test.c
* Run: ./tsync_seccomp_bypass_test
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <pthread.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <errno.h>
static int pipefd[2];
/* Thread 2: install TSYNC filter that kills any getpid() call */
static void *install_filter(void *arg)
{
char ch;
if (read(pipefd[0], &ch, 1) != 1) {
perror("pipe read");
pthread_exit(NULL);
}
struct sock_filter filter[] = {
/* Load syscall number */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 0),
/* If it is getpid (39 on x86-64), kill */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpid, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
/* All other syscalls allowed */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {
.len = sizeof(filter) / sizeof(filter[0]),
.filter = filter,
};
if (syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER,
SECCOMP_FILTER_FLAG_TSYNC, &prog) == -1) {
perror("seccomp TSYNC");
} else {
fprintf(stderr, "[filter thread] seccomp filter installed\n");
}
pthread_exit(NULL);
}
int main(void)
{
if (pipe(pipefd) == -1) {
perror("pipe");
exit(1);
}
pid_t child = fork();
if (child < 0) {
perror("fork");
exit(1);
}
if (child == 0) {
/* ========== Child process ========== */
pthread_t tid;
close(pipefd[1]);
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) {
perror("prctl NO_NEW_PRIVS");
exit(1);
}
if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
perror("ptrace TRACEME");
exit(1);
}
if (pthread_create(&tid, NULL, install_filter, NULL) != 0) {
perror("pthread_create");
exit(1);
}
raise(SIGSTOP); /* initial sync with tracer */
/*
* Only call getpid. It will be stopped by ptrace at entry,
* the filter thread will install TSYNC during that stop.
*/
syscall(__NR_getpid);
_exit(0);
}
/* ========== Parent process (tracer) ========== */
close(pipefd[0]);
int status;
if (waitpid(child, &status, 0) == -1) {
perror("waitpid");
return 1;
}
if (!WIFSTOPPED(status) || WSTOPSIG(status) != SIGSTOP) {
fprintf(stderr, "expected initial SIGSTOP\n");
return 1;
}
ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD);
ptrace(PTRACE_SYSCALL, child, 0, 0);
int bypass = 0;
int triggered = 0;
while (1) {
if (waitpid(child, &status, 0) == -1) {
perror("waitpid");
break;
}
if (WIFEXITED(status)) {
fprintf(stderr, "[tracer] child exited normally -> BYPASS\n");
bypass = 1;
break;
}
if (WIFSIGNALED(status)) {
if (WTERMSIG(status) == SIGSYS)
fprintf(stderr, "[tracer] child killed by SIGSYS -> bug
fixed\n");
else
fprintf(stderr, "[tracer] child killed by signal %d\n",
WTERMSIG(status));
break;
}
if (WIFSTOPPED(status)) {
int sig = WSTOPSIG(status);
if (sig == (SIGTRAP | 0x80)) { /* syscall stop */
if (!triggered) {
fprintf(stderr, "[tracer] signaling filter thread\n");
char ch = 'x';
if (write(pipefd[1], &ch, 1) != 1)
perror("pipe write");
usleep(100000); /* wait for TSYNC to complete */
triggered = 1;
}
ptrace(PTRACE_SYSCALL, child, 0, 0);
} else {
ptrace(PTRACE_SYSCALL, child, 0, sig);
}
}
}
close(pipefd[1]);
return bypass ? 1 : 0;
}
```
> }
>
> /* Do seccomp after ptrace, to catch any tracer changes. */