
Verified PoC and analysis for a Linux perf/core permission-check bypass that leaks kernel callchain addresses to unprivileged Android tasks, enabling KASLR slide derivation.
English | 简体中文
Linux perf/core permission-check bypass: an unprivileged task can request
kernel callchains while setting exclude_kernel=1, skipping the
perf_allow_kernel() gate and disclosing kernel stack frames through the perf
ring buffer. This repository contains the analysis and a verified PoC; the bug
gives an information leak, not a direct privilege-escalation primitive.
| Item | Value |
|---|---|
| CVE | CVE-2026-98937 |
| Class | Permission bypass / information disclosure (CWE-284/CWE-269) |
| Target | Android arm64 (verified on HONOR YLP-W00 and Redmi K70) |
| Kernel | YLP-W00: 6.12.38-android16-5-gfde7767f6ef6-abogki481467632-4k; K70: 5.15.178-android13-8-00021-g6f2f96be86b9-ab13729987 |
| SELinux | Enforcing (u:r:shell:s0) |
| Patched | No; fixed upstream in 6.12.113 / 6.18.56 / 7.2.10 / 7.3+ |
| Status | PoC verified; KASLR slide oracle validated on 6.12.38 and 5.15.178 |
| Pre-check | src/vuln_precheck.h |
Kernels before the fix below are affected. Fixed by
a4b016d0a29038a5b7c96bf62c054bf7832451d9
("perf/core: Check kernel access when kernel callchains are requested",
Dapeng Mi, merged 2026-06-30) and the corresponding stable backports.
perf_event_open() used to gate kernel profiling only on
!attr.exclude_kernel:
if (!attr.exclude_kernel) {
err = perf_allow_kernel(&attr);
if (err)
return err;
}
perf_allow_kernel() contains both the perf_event_paranoid > 1 check and the
SELinux perf_event { kernel } hook. A task can set exclude_kernel=1 while
still requesting kernel callchains (PERF_SAMPLE_CALLCHAIN with
exclude_callchain_kernel=0); perf_callchain() then collects the kernel part
independently of the sampling filter. Hardware PMU events counting only user
mode can take their overflow interrupt in kernel context (skid), so
perf_callchain_kernel() unwinds the kernel stack and writes kernel text
addresses into the ring buffer.
The fix calls perf_allow_kernel() when either kernel sampling or kernel
callchains are requested:
if (!attr.exclude_kernel ||
((attr.sample_type & PERF_SAMPLE_CALLCHAIN) &&
!attr.exclude_callchain_kernel)) {
err = perf_allow_kernel();
if (err)
return err;
}
The snippet above is the 6.12.113 stable-tree form, in which
perf_allow_kernel() no longer takes an attribute pointer; the upstream
commit uses the same condition with perf_allow_kernel(&attr).
See docs/analysis.md for the full call path, skid
mechanics, SELinux audit evidence and device measurements.
The PoC runs three cases as an unprivileged shell process:
| Case | perf_event attributes | Expected on a vulnerable kernel |
|---|---|---|
| A | exclude_kernel=0 | EACCES (kernel-perf gate active) |
| B | exclude_kernel=1, exclude_callchain_kernel=1 | event allowed, no kernel callchain frames |
| C | exclude_kernel=1, exclude_callchain_kernel=0 | event allowed, kernel callchain frames disclosed |
On the verified YLP-W00 (kernel 6.12.38, SELinux Enforcing):
EACCES, with
avc: denied { kernel } ... tclass=perf_event in the audit log.0xffffffde...
kernel range.On the verified Redmi K70 (kernel 5.15.178, third-party Android 17 ROM, SELinux Enforcing), the same pattern was observed:
EACCES, same SELinux { kernel } audit line.0xffffffdd... kernel range.On the verified devices, a fixed kernel would reject case C at
perf_event_open() with EACCES because the kernel-callchain request now
reaches perf_allow_kernel().
With a matching vmlinux, the leaked callchain frames become a per-boot KASLR
slide oracle. The arm64 virtual slide is 2 MiB aligned, and every leaked text
address must satisfy _stext <= addr - slide < _etext; intersecting those
intervals normally leaves exactly one slide.
make slide \
SLIDE_VMLINUX=/path/to/vmlinux-6.12.38.elf \
SLIDE_LINK_BASE=0xffffffc080000000 \
SLIDE_LOG=logs/poc-ylp-w00-20261027-020133-period1000.log
On the verified YLP-W00 6.12.38 boot, three frames give the unique
slide=0x1DCFC00000 (runtime _text=0xffffffde4fc00000):
| Leaked address | Link-time address | Symbol |
|---|---|---|
0xffffffde50748c78 | 0xffffffc080b48c78 | local_daif_restore+0x4 (msr DAIF, x0; ret) |
0xffffffde50d406e0 | 0xffffffc0811406e0 | el0t_64_sync_handler+0x70 (after bl el0_svc) |
0xffffffde4fc11588 | 0xffffffc080011588 | el0t_64_sync+0x1a8 (after bl el0t_64_sync_handler) |
These are core kernel text frames, not modules: the pending PMU interrupt is
delivered exactly at the ret after msr DAIF, x0 in the syscall entry
path. Raw addresses cannot be symbolized directly under KASLR;
subtract the slide first. If a frame is a module address, no slide places all
addresses in [_stext, _etext) and the script reports that instead of
guessing. The slide changes on every boot and must not be cached across
reboots.
The Redmi K70 (5.15.178) has also been validated. Its matching image was
pulled from /sdcard/boot.img (boot v4; the embedded version string matches
the running kernel) and vmlinux-to-elf recovered 158,668 symbols. Seven unique
frames from one period-1000 run give slide=0x1D93800000 (runtime
_text=0xffffffdd9b800000), mapping to syscall_trace_enter,
el0_svc_common, do_el0_svc, el0_svc, el0t_64_sync_handler,
el0t_64_sync, and syscall_trace_exit.
The link-time base is branch-specific — do not reuse the 6.12 value on 5.15:
| Kernel branch | KIMAGE_VADDR |
|---|---|
| Android 13-5.15 (K70) | 0xffffffc008000000 |
| Android 16-6.12 (YLP-W00) | 0xffffffc080000000 |
| upstream v5.15 (different layout) | 0xffffffc010000000 |
vmlinux-to-elf auto-detected 0xffffffc008000000 for the K70 image, matching
AOSP android13-5.15. If the auto-detected ELF base differs from the branch's
KIMAGE_VADDR, pass --link-base (or SLIDE_LINK_BASE=): symbolization stays
in ELF space, the reported slide is made relative to the true base, and the
runtime _text is invariant to a constant base shift. Feeding the K70 leak to
the 6.12.38 image remains a deliberate negative test and yields no candidate.
Requirements:
/opt/android-ndk-r29 (override with NDK=)make # PoC, arm64, API 24
make ARCH=arm API=29 # other ABI / API level
make host # static build for qemu-aarch64
make emu # build and run the static PoC locally
make help
Artifacts are written to build/ and are git-ignored.
make check # push + read-only environment pre-check
make run # push + full three-case verification
# directly
adb push build/poc /data/local/tmp/poc
adb shell chmod 755 /data/local/tmp/poc
adb shell /data/local/tmp/poc --check
adb shell /data/local/tmp/poc --duration 2 --period 1000
Options:
--check pre-check only, no perf event is opened
--force run on an unlisted kernel release
--duration N sampling seconds per case (default 2)
--period N sample period in cycles (default 1000)
Exit codes: