β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-5624

hard_sniff() flame-graph frame walk reads 8 bytes past the interrupted thread's kernel stack (fatal trap 12 / kernel-heap qword disclosure)

Summary

The walk guard checks rbp > top-8 but the load at rbp+8 spans [rbp+8, rbp+16): rbp==top-8 (equal passes) reads [top, top+8) - 8 bytes past the kstack. Unmapped adjacent page (~1 in 43 fresh-process kstacks) = fatal trap 12 in hard_sniff (reproduced twice: 'Stopped at hard_sniff+0x9e: movq 0x8(%rdx),%rax', guest dead); mapped = adjacent kernel-heap qword silently recorded into the flame entry (observed kprintf scratch) readable via the root-only sink. Root must arm the diagnostic, but afterwards every sampled thread - including unprivileged ones - executes the walk via the user-pollable kern.sniff_enable path and the ipiq-stall debug sniffs. Same guard bug duplicated in pcpu_timer_always (machdep.c:3656). rbp>top-16 fix validated on a rebuilt kernel (150 fresh-process loads, zero panics, >=111 unmapped-neighbour survivals). NOTE: file is the flame-graph stack sampler, not the tsc/PIPT display the task brief described - audited as-found.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5624 Β· 17 files
FileTypeDescriptionSize
README.md β€” 4.3 KB ↓ raw
VERDICT.md β€” 6.0 KB ↓ raw
flameoob.c β€” 4.8 KB view raw
flame_dump.c β€” 2.1 KB view raw
Makefile β€” 56 B ↓ download
build.sh β€” 141 B view raw
run.sh β€” 193 B view raw
decisive.sh β€” 1020 B view raw
decisive2.sh β€” 1.4 KB view raw
build.log β€” 7.2 KB view raw
fix_build_tail.log β€” 2.0 KB view raw
panic.txt β€” 1.0 KB view raw
panic-v1-module.txt β€” 1012 B view raw
run_fixed.log β€” 1.9 KB view raw
env.txt β€” 190 B view raw
fix.diff β€” 763 B view raw
verdict.json β€” 4.7 KB view raw

DF-5624 β€” hard_sniff() off-by-8 OOB read past kernel stack top (panic / heap disclosure)

File: sys/platform/pc64/x86_64/mp_flame.c (flame-graph stack sampler, Xsniff vector target) Lines: 135–143 (the bug is the guard at :136 vs the load at :138) Class: CWE-125 out-of-bounds read Β· Severity: Medium Β· Confidence: certain Verified on guest: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (baseline) and patched #1: Thu Oct 8 22:24:57 UTC 2026 (fix validation), x86_64, 6 CPUs.

The bug

bot = (char *)td->td_kstack + PAGE_SIZE;          /* mp_flame.c:124 */
top = (char *)td->td_kstack + td->td_kstack_size; /* mp_flame.c:125 */
...
for (n = 1; n < FLAME_GRAPH_FRAMES - 1; ++n) {
        if (rbp < bot || rbp > top - 8 || ((intptr_t)rbp & 7))   /* :136 */
                break;
        fge->rips[n] = (intptr_t)*(char **)(rbp + 8);            /* :138 */

The guard admits rbp == top - 8 (equal is not greater), but the x86-64 frame at rbp spans [rbp, rbp+16) (saved %rbp + return address), so the load at rbp+8 reads [top, top+8) β€” 8 bytes past the end of the interrupted thread's kernel stack. When the page after the kstack is unmapped (kstacks are kmem allocations placed against holes β€” seen naturally in ~1/43 fresh processes on this guest) this is a fatal kernel-mode page fault in the Xsniff path β†’ panic, all CPUs stopped. When mapped, the qword of adjacent kernel heap is silently copied into the flame entry (rips[1]), readable via debug.flame_graph_data (RESTRICTEDROOT).

Reproduce

# in guest, as root:
cd /root/df5624
sh build.sh            # builds flame_dump (userland) + flameoob.ko (in-tree kmod)
ITERS=150 sh decisive2.sh   # loop: fresh-process kldloads with tf_rbp == kstack-top-8

Buggy kernel (expected, observed): first iteration whose kstack has an unmapped neighbour page panics:

DF5624: ... top=0xfffff801185da000 NEIGHBOR UNMAPPED
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xfffff801185da000        (== top)
fault code = supervisor read data, page not present
Stopped at hard_sniff+0x9e: movq 0x8(%rdx),%rax   (== mp_flame.c:138)

(see panic.txt β€” v2 rig; panic-v1-module.txt β€” first, incidental capture)

Mapped-neighbour iterations instead record the OOB qword: earlier sampling runs observed rips[1] = 0x203a343236354644 = "DF5624: " β€” kprintf scratch in the adjacent heap page (serial-console transcript of the 2026-10-08 run).

Fixed kernel: loop completes; UNMAPPED-neighbour loads survive ("survived UNMAPPED-neighbour sniff"); all entries have rips[1]=0 (guard refuses rbp==top-8). See run_fixed.log.

How hard_sniff is reached

  • Root arms: sysctl -w debug.flame_graph_enable=1 (sysctl writes require SYSCAP_NOSYSCTL_WR, kern_sysctl.c:1446-1448).
  • Sniff IPIs (Xsniff β†’ hard_sniff) then fire from: sysctl_flame_graph_sniff (mp_flame.c:193, root), sysctl_cputime with kern.sniff_enable=1 (kern_clock.c:161-174, root-armed, any user can then poll kern.cputime), and the automatic ipiq-stall debug path (lwkt_ipiq.c:264,272). The interrupted thread can be ANY thread on the box, including unprivileged user threads whose kstack neighbour state is attacker-influenced only by allocation churn.

Threat model

Diagnostic feature must be armed by root once; afterwards any sampled thread with rbp == top-8 and an unmapped neighbour panics the machine (local DoS), and mapped neighbours leak one kernel-heap qword per sample into a root-readable buffer (no privilege boundary crossed). No write primitive; a uid=0 chain is not constructible from this bug (read-only sink) β€” see VERDICT.md "Exploitability".

Fix

fix.diff: require rbp + 16 <= top (guard becomes rbp > top - 16). Validated by full kernel build + reboot + identical loop (see VERDICT.md). Twin code with the same guard exists in pcpu_timer_always() (sys/platform/pc64/x86_64/machdep.c:3656) β€” out of this file's scope, flagged for that file's audit.

Artifacts

flameoob.c (PoC kmod, v2 β€” safe DMAP page-table present-check), flame_dump.c (userland observer), Makefile, build.sh, run.sh, decisive.sh (v1 loop), decisive2.sh (v2 loop, baseline+fixed), build.log, fix_build_tail.log, panic.txt, panic-v1-module.txt, run_fixed.log, env.txt, fix.diff, manifest.json, verdict.json.

VERDICT.md
↓ download raw

DF-5624 VERDICT β€” REPRODUCED (panic; fixed by fix.diff)

Bottom line

hard_sniff() (the Xsniff flame-graph sampler) bounds-checks the frame-pointer chain with rbp > top - 8 but then dereferences rbp + 8, so a frame pointer exactly at top - 8 β€” admitted by the guard β€” makes the kernel load [top, top+8), 8 bytes past the end of the interrupted thread's kernel stack (sys/platform/pc64/x86_64/mp_flame.c:136-138). On a guest DragonFly 6.5-DEVELOPMENT kernel this was reproduced twice as a fatal trap 12 page fault inside hard_sniff (hard_sniff+0x9e: movq 0x8(%rdx),%rax) panicking the machine, with an instrumented rig proving causality end to end, and the one-line guard fix (top - 8 β†’ top - 16) was built, installed, and shown to eliminate both the panic and the silent out-of-bounds capture.

Reproduction narrative (all on the audit QEMU/KVM guest)

  1. Direct proof the guard admits the boundary (mapped neighbour): KLD flameoob.ko calls the production hard_sniff() with a crafted trapframe tf_rbp = top - 8 of the current thread's kstack. Guest output (run.sh): guard inputs printed (tf_rbp=0xfffff801166dbff8 == top-8, 8-aligned, >= bot), entry recorded (windex 0β†’1, rips[0] = magic), the OOB qword captured in rips[1]. Across a 24-load sampling loop one neighbour contained live kernel heap: the module's reference read printed qword at [top, top+8) = 203a343236354644 = ASCII "DF5624: " β€” kprintf scratch β€” i.e. adjacent-heap disclosure is real, not theoretical.
  2. Panic proof #1 (v1 rig, incidental): during the nonzero-neighbour hunt the loop hit a kstack followed by an unmapped page: kstack=[0xfffff80118810000,0xfffff80118814000), fatal fault at 0xfffff80118814000 == top, Stopped at hard_sniff+0x9e: movq 0x8(%rdx),%rax (panic-v1-module.txt). 43 loads this boot, 1 unmapped neighbour β†’ ~1/43 occurrence rate under ordinary process churn.
  3. Panic proof #2 (v2 rig, instrumented, matched to the fix run): v2 adds a safe read-only 4-level page-table walk (through the DMAP, present-bit checked at every level β€” cannot itself fault) so the console records whether the neighbour page is mapped before hard_sniff is called. Baseline loop: NEIGHBOR UNMAPPED printed for top=0xfffff801185da000, then Fatal trap 12 ... fault virtual address = 0xfffff801185da000 ... supervisor read data, page not present ... Stopped at hard_sniff+0x9e (panic.txt). Causality is now airtight: the faulting load is the return-address read at mp_flame.c:138 with rbp == top-8, the exact case the guard fails to exclude.
  4. Fix validation: fix.diff (guard rbp > top - 16) applied to the guest's /usr/src, make -j8 nativekernel KERNCONF=X86_64_GENERIC (RC=0), make installkernel (RC=0), reboot into #1: Thu Oct 8 22:24:57 UTC 2026. Identical v2 loop, 150 iterations: no panic, guest stayed up, β‰₯111 unmapped-neighbour loads survived ("survived UNMAPPED-neighbour sniff"), and the flame dump showed 0 entries with rips[1] β‰  0 β€” the fixed guard refuses rbp==top-8 before the load, so no OOB read occurs at all (run_fixed.log). Baseline vs patched behaviour differs exactly as the fix predicts.

Honest impact classification

  • Observation = guest dead in DDB after fatal kernel-mode page fault, ssh gone, vm.sh status β‡’ down β†’ reproduced / impact=panic (local DoS).
  • The read is 8 bytes, value lands in a buffer readable only with SYSCAP_RESTRICTEDROOT β€” no privilege boundary is crossed by the leak.

Exploitability (why no uid=0 chain)

The primitive is a read-only 8-byte overshoot whose sink (the flame entry) is root-only. There is no write primitive, no register control, no use-after-free; the faulting address is chosen by stack layout, not the attacker. An unprivileged user cannot arm the sampler (sysctl writes require SYSCAP_NOSYSCTL_WR, sys/kern/kern_sysctl.c:1446-1448); once an administrator arms debug.flame_graph_enable, unprivileged activity (including the automatic ipiq-stall sniff at sys/kern/lwkt_ipiq.c:264,272 and any user polling kern.cputime with kern.sniff_enable=1, sys/kern/kern_clock.c:161-174) supplies the interrupts, and any sampled thread whose kstack abuts an unmapped page crashes the machine. Hard blocker for escalation: read-only sink β‡’ ceiling is DoS + root-only heap-qword disclosure. Severity Medium ("local DoS requiring unusual config/privilege" per the rubric), confidence certain.

Kernel references

Notes / discrepancies

  • The task brief described this file as "tsc Frequency/PIPT frame buffer scrolling / mp relay-barrier helpers" β€” the file contains none of that; it is the flame-graph sampler. Audited as-found (see final JSON negative_notes for the classes checked and killed).
  • ebytes header at mp_flame.c:161-162 understates the per-CPU record size (sizeof(fge)*256 is what's emitted); harmless β€” the only consumer (usr.bin/flame_graph/collect.c:83-97) ignores it and uses fg->nentries.
  • Guest left clean: vm.sh reset with-src after validation (guest_dirty=0).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src; make -j8 nativekernel KERNCONF=X86_64_GENERIC RC=0; make installkernel RC=0; rebooted into #1 Thu Oct 8 22:24:57 UTC 2026. Baseline (stock kernel): fatal trap 12 in hard_sniff+0x9e at first unmapped-neighbour load (panic.txt). Patched: identical 150-iteration loop, >=111 unmapped-neighbour loads survived, no panic, flame dump 0 entries with rips[1]!=0 -- bad behavior gone.

run_fixed.log; fix_build_tail.log (nativekernel RC=0); panic.txt (baseline); VERDICT.md sections 3-4
↓ fix.diffper-fix-DF-5624

Confirmed kernel references

Detail

Exploit chain

No escalation chain: primitive is an 8-byte read-only overshoot whose sink (debug.flame_graph_data) requires SYSCAP_RESTRICTEDROOT; no write primitive, no register control, fault address not attacker-chosen. Ceiling = panic (DoS) once an admin arms debug.flame_graph_enable; unprivileged users then supply triggers via kern.cputime polling (kern_clock.c:161-174) or the automatic ipiq-stall sniff (lwkt_ipiq.c:264,272).

Evidence (decisive lines)

panic.txt (v2 rig: NEIGHBOR UNMAPPED for top=0xfffff801185da000 then Fatal trap 12, fault VA == top, Stopped at hard_sniff+0x9e); panic-v1-module.txt (first incidental panic, same signature, 1-in-43 loads); run_fixed.log (patched kernel: survived UNMAPPED-neighbour sniffs, 0 nonzero rips[1]); build.log (kmod build); fix_build_tail.log (nativekernel RC=0); flame_dump.c output in transcripts (entry windex=1 rips[0]=magic rips[1]=OOB qword); VERDICT.md for the full narrative.

PoC changes

Rewrote the seed concept into a KLD that calls the exported production hard_sniff() with a crafted trapframe (tf_rbp == kstack-top-8, magic tf_rip) instead of relying on probabilistic natural sampling; v2 added a safe read-only 4-level page-table walk via PHYS_TO_DMAP (present-bit checked each level, never dereferences a non-present target) so the console records NEIGHBOR MAPPED|UNMAPPED before the call, plus a userland flame_dump.c observer that parses debug.flame_graph_data to read back the recorded OOB qword. Module writes no kernel memory.

Verified recommended fix

Change the hard_sniff() chain-walk guard from 'rbp > top - 8' to 'rbp > top - 16' (frame spans [rbp, rbp+16)); same one-liner for the twin in machdep.c pcpu_timer_always().

Verdict

hard_sniff()'s frame-chain guard (mp_flame.c:136 'rbp > top - 8') admits rbp == top-8, and the return-address load at rbp+8 (mp_flame.c:138) then reads [top, top+8) -- 8 bytes past the interrupted thread's kernel stack. Reproduced twice on the stock guest kernel as a fatal trap 12 page fault inside hard_sniff (hard_sniff+0x9e: movq 0x8(%rdx),%rax) with the faulting VA exactly == kstack top and the neighbour page recorded UNMAPPED by the instrumented rig; mapped neighbours silently copy an adjacent kernel-heap qword into the flame entry (observed 0x203a343236354644 = kprintf scratch 'DF5624: '). One-line guard fix (top-8 -> top-16) built via nativekernel, installed, rebooted: identical 150-iteration loop completes with >=111 unmapped-neighbour survivals, zero panics, and zero entries with rips[1] != 0. Local DoS ceiling: read-only sink, root-gated arming (SYSCAP_NOSYSCTL_WR), so no uid=0 chain exists from this bug.