[PATCH 26/29] perf session: Bound nr_cpus_avail and validate sample CPU

From: Arnaldo Carvalho de Melo

Date: Sun May 24 2026 - 21:10:55 EST


From: Arnaldo Carvalho de Melo <acme@xxxxxxxxxx>

Several downstream consumers (timechart, kwork, sched) use fixed-size
arrays indexed by CPU. A crafted perf.data can supply arbitrary CPU
values that index past these arrays, causing out-of-bounds access.

Validate sample.cpu against min(nr_cpus_avail, MAX_NR_CPUS) in
perf_session__deliver_event() before any tool callback runs. The
cap at MAX_NR_CPUS protects fixed-size downstream arrays; the true
nr_cpus_avail is preserved in env for header parsing (e.g.
process_cpu_topology) which needs the real count.

Fall back to MAX_NR_CPUS when HEADER_NRCPUS is missing (truncated
files, pipe mode, pre-2017 perf).

Only validate when PERF_SAMPLE_CPU is set in sample_type — when
absent, evsel__parse_sample() leaves sample.cpu as (u32)-1, a
sentinel that downstream tools (script, inject) check to identify
events without CPU info. Clamping it to 0 would break those checks.

Inline evlist__parse_sample() into perf_session__deliver_event()
so the evsel lookup needed for sample_type checking reuses the same
evsel that parsed the sample, avoiding a second evlist__event2evsel()
call on every event.

For pipe-mode streams where HEADER_NRCPUS may arrive late or not at
all, the MAX_NR_CPUS fallback ensures the bounds check is still
effective against the fixed-size downstream arrays.

Reported-by: sashiko-bot@xxxxxxxxxx # Running on a local machine
Cc: Ian Rogers <irogers@xxxxxxxxxx>
Cc: Jiri Olsa <jolsa@xxxxxxxxxx>
Cc: Namhyung Kim <namhyung@xxxxxxxxxx>
Assisted-by: Claude Opus 4.6 (1M context) <noreply@xxxxxxxxxxxxx>
Signed-off-by: Arnaldo Carvalho de Melo <acme@xxxxxxxxxx>
---
tools/perf/util/header.c | 30 +++++++++++++
tools/perf/util/session.c | 88 ++++++++++++++++++++++++++++++++++++++-
2 files changed, 117 insertions(+), 1 deletion(-)

diff --git a/tools/perf/util/header.c b/tools/perf/util/header.c
index af8781f81b574b88..351369ac4dc2c0a2 100644
--- a/tools/perf/util/header.c
+++ b/tools/perf/util/header.c
@@ -48,6 +48,7 @@
#include <api/io_dir.h>
#include "asm/bug.h"
#include "tool.h"
+#include "../perf.h"
#include "time-utils.h"
#include "units.h"
#include "util/util.h" // perf_exe()
@@ -2895,6 +2896,17 @@ static int process_nrcpus(struct feat_fd *ff, void *data __maybe_unused)
if (ret)
return ret;

+ /*
+ * Cap at 1M CPUs — generous for any real system but prevents
+ * stack overflow from VLA allocations sized by nr_cpus_avail
+ * (e.g. DECLARE_BITMAP in builtin-c2c.c node_entry()).
+ */
+ if (nr_cpus_avail > (1U << 20)) {
+ pr_err("Invalid HEADER_NRCPUS: nr_cpus_avail (%u) exceeds maximum (%u)\n",
+ nr_cpus_avail, 1U << 20);
+ return -1;
+ }
+
if (nr_cpus_online > nr_cpus_avail) {
pr_err("Invalid HEADER_NRCPUS: nr_cpus_online (%u) > nr_cpus_avail (%u)\n",
nr_cpus_online, nr_cpus_avail);
@@ -5250,6 +5262,24 @@ int perf_session__read_header(struct perf_session *session)
#endif
}

+ /*
+ * Without nr_cpus_avail the sample CPU bounds check in
+ * perf_session__deliver_event() is bypassed, allowing crafted
+ * CPU IDs to reach downstream consumers that index fixed-size
+ * arrays (timechart, kwork, sched — all sized MAX_NR_CPUS).
+ *
+ * This can happen with truncated files (interrupted recording
+ * loses all feature sections), very old files that predate
+ * HEADER_NRCPUS, or crafted files that omit it. Fall back to
+ * MAX_NR_CPUS so the bounds check is still effective — any
+ * CPU ID below that limit is safe for all downstream arrays.
+ */
+ if (header->env.nr_cpus_avail == 0) {
+ header->env.nr_cpus_avail = MAX_NR_CPUS;
+ pr_warning("WARNING: perf.data is missing HEADER_NRCPUS, using MAX_NR_CPUS (%d) as CPU bound\n",
+ MAX_NR_CPUS);
+ }
+
return 0;
out_errno:
return -errno;
diff --git a/tools/perf/util/session.c b/tools/perf/util/session.c
index 9271885e3920f897..6de665d3c9054179 100644
--- a/tools/perf/util/session.c
+++ b/tools/perf/util/session.c
@@ -2110,14 +2110,100 @@ static int perf_session__deliver_event(struct perf_session *session,
const char *file_path)
{
struct perf_sample sample;
+ struct evsel *evsel;
int ret;

perf_sample__init(&sample, /*all=*/false);
- ret = evlist__parse_sample(session->evlist, event, &sample);
+ evsel = evlist__event2evsel(session->evlist, event);
+ if (!evsel) {
+ pr_err("No evsel found for event type %u\n",
+ event->header.type);
+ ret = -EFAULT;
+ goto out;
+ }
+ ret = evsel__parse_sample(evsel, event, &sample);
if (ret) {
pr_err("Can't parse sample, err = %d\n", ret);
goto out;
}
+ /*
+ * evsel__parse_sample() doesn't populate machine_pid/vcpu,
+ * which are needed by machines__find_for_cpumode() to
+ * attribute samples to guest VMs. The SID table maps
+ * sample IDs to the guest that owns the event.
+ */
+ if (perf_guest && sample.id) {
+ struct perf_sample_id *sid = evlist__id2sid(session->evlist, sample.id);
+
+ if (sid) {
+ sample.machine_pid = sid->machine_pid;
+ sample.vcpu = sid->vcpu.cpu;
+ }
+ }
+
+ /*
+ * Validate sample.cpu before any callback can use it as an
+ * array index (kwork cpus_runtime, timechart cpus_cstate_*,
+ * sched cpu_last_switched).
+ *
+ * When PERF_SAMPLE_CPU is absent, evsel__parse_sample() leaves
+ * sample.cpu as (u32)-1 — a sentinel that downstream tools
+ * (script, inject) check to identify events without CPU info.
+ * Only check when sample.cpu was actually populated from event
+ * data: PERF_RECORD_SAMPLE always has it when PERF_SAMPLE_CPU
+ * is set; non-sample events only have it when sample_id_all is
+ * enabled. Otherwise sample.cpu is the (u32)-1 sentinel from
+ * evsel__parse_sample() and must not be validated or clamped.
+ */
+ if ((evsel->core.attr.sample_type & PERF_SAMPLE_CPU) &&
+ (event->header.type == PERF_RECORD_SAMPLE ||
+ evsel->core.attr.sample_id_all)) {
+ int nr_cpus_avail = perf_session__env(session)->nr_cpus_avail;
+
+ /*
+ * For perf.data files the MAX_NR_CPUS fallback in
+ * perf_session__read_header() guarantees this is set.
+ * For pipe mode, HEADER_NRCPUS may arrive late or not
+ * at all (pre-2017 perf, third-party tools). Fall
+ * back to MAX_NR_CPUS so the bounds check still works
+ * against fixed-size downstream arrays.
+ *
+ * Do NOT write back to env: this function runs during
+ * recording (synthesized events) when nr_cpus_avail is
+ * legitimately 0. Writing MAX_NR_CPUS would cause
+ * write_cpu_topology() to emit 4096 core_id/socket_id
+ * pairs instead of the real CPU count, corrupting the
+ * topology section in the generated perf.data.
+ */
+ if (nr_cpus_avail <= 0)
+ nr_cpus_avail = MAX_NR_CPUS;
+ /*
+ * Cap at MAX_NR_CPUS for the bounds check — downstream
+ * consumers use fixed-size arrays of that size. Keep
+ * the true nr_cpus_avail in env for header parsing
+ * (e.g. process_cpu_topology) which needs the real count.
+ */
+ if (nr_cpus_avail > MAX_NR_CPUS)
+ nr_cpus_avail = MAX_NR_CPUS;
+ if (sample.cpu >= (u32)nr_cpus_avail &&
+ sample.cpu != (u32)-1) {
+ /*
+ * Warn rather than abort: synthesized events
+ * (MMAP, COMM) lack sample_id_all data, so
+ * parse_id_sample reads garbage from the event
+ * payload. Clamping to 0 protects downstream
+ * array indexing while keeping the session alive.
+ *
+ * Preserve (u32)-1: perf script and perf inject
+ * use it as a sentinel for "CPU not applicable."
+ * Downstream array users (timechart, kwork) have
+ * their own per-callback bounds checks.
+ */
+ pr_warning_once("WARNING: sample CPU %u >= nr_cpus_avail %u, clamping to 0\n",
+ sample.cpu, nr_cpus_avail);
+ sample.cpu = 0;
+ }
+ }

ret = auxtrace__process_event(session, event, &sample, tool);
if (ret < 0)
--
2.54.0