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

AE_SPLIT parent-fixup bcopy(ranp, AB_LASTANP(rabp), 16) writes one alnode past the entry array; split source bcopy reads past the buffer (hpfs_addextent:410 / hpfs_addextentr:599 / hpfs_splitalsec:229)

Summary

The 16-byte split-fixup bcopy overwrites the last busy alnode AND the entry at index ab_busycnt; the only bound is the unvalidated on-disk ab_freecnt>0 (:572). With a crafted AB_NODES AlSec (busycnt=61 > capacity 60, freecnt=1 - the last alnode an[60] still resides in the sector's 16 slack bytes, fully attacker-controlled), a 1-byte unpriv pwrite writes {~0, chosen-lsn} at b_data[508..516) - 4 bytes past the 512-byte kernel heap buffer - and the follow-up self-split's source span [268,516) OOB-reads the same word back into a new alsec's an_lsn which the children-reparenting loop then dereferences (breadalsec). Distinct sinks from DF-0859 (which covered the split/concat/alblk2alsec bcopy sizes only). DIRECTLY OBSERVED: the neighboring 512-byte heap chunk was a live buffer whose as_magic was overwritten with the chosen value 0x200; the kernel itself logged 'hpfs_breadstruct: MAGIC DOESN'T MATCH (0x00000200 != 0x37e40aae)' (no crafted sector contains that value - verified by scan - it can only have arrived via the OOB write). The 40-file variant killed the kernel mid-run. Fix validated (same alblk-invariant validation as DF-3137 rejects the 61-node AlSec): identical trigger -> EINVAL, no corruption.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3138 Β· 14 files
FileTypeDescriptionSize
craft_img.py β€” 7.4 KB view raw
poc.c β€” 1.2 KB view raw
mount_rw.c β€” 1.3 KB view raw
build.sh β€” 117 B view raw
run.sh β€” 277 B view raw
build.log β€” 140 B view raw
run.log β€” 186 B view raw
panic.txt β€” 1.1 KB view raw
fix.diff β€” 4.3 KB view raw
fix_run.log β€” 545 B view raw
VERDICT.md β€” 3.8 KB ↓ raw
README.md β€” 2.3 KB ↓ raw
env.txt β€” 464 B view raw
df3138_one.img β€” 2.0 MB ↓ download

DF-3138 β€” AE_SPLIT parent-fixup bcopy(ranp, AB_LASTANP(rabp), 16) writes past the entry array (hpfs_addextent:410 / hpfs_addextentr:599); split source bcopy reads past the buffer (:229)

Build

./build.sh          # crafts df3138.img (40 trigger files) on host,
                    # pushes + compiles on guest

Run

./run.sh            # mounts RW, snapshot of raw device before,
                    # 40 triggers as uid 1001, stability + diff after

Single-file variant (used for the captured evidence): craft_img.py df3138_one.img 1, mount, run the poc (it triggers F00 first; missing F01+ just fail to open).

Expected / observed

  • The :599 bcopy writes an[61] = {~0, <new-AlSec-lsn>} at P.b_data[508..516) β€” the last 4 bytes LAND PAST the 512-byte kernel heap buffer, with the value (allocated lsn, here 0x200) chosen by the image's bitmap free-run layout.
  • The self-split's source bcopy (:229, span [268,516)) reads those same 4 OOB bytes back into the new AlSec's last an_lsn, which the children-reparenting loop dereferences.
  • OBSERVED (deterministic, single file): the neighbor 512-byte chunk was the just-allocated N1 buffer's data; its as_magic got overwritten with 0x00000200, and the kernel itself reported it: hpfs_breadstruct: MAGIC DOESN'T MATCH (0x00000200 != 0x37e40aae) followed by hpfs_addextentr: FAILED 22 β€” pwrite returns EINVAL. No sector of the crafted image starts with 0x00000200 (verified by scan) β€” the value can only have arrived via the OOB write into adjacent kernel heap memory.
  • The earlier 40-file run crashed the kernel (trigger ssh died, next boot ran fsck = unclean shutdown); the panic signature scrolled out of the small serial buffer and was not captured (see run.log).

Root cause: loop bound is the on-disk ab_freecnt > 0 (hpfs_alsubr.c:572); nothing enforces busycnt+1 <= capacity (nodes: 0x1E0/8 = 60). With busycnt=61, freecnt=1 the last busy alnode (an[60]) still lives in the sector slack bytes [500,508) (attacker data), so the recursion is fully controlled and the fixup bcopy writes one alnode past the array end.

Files: craft_img.py, poc.c, mount_rw.c, run.log (40-file crash run), panic.txt (captured console evidence), fix.diff (in DF-3137 pack β€” shared alblk-validation fix), fix_run.log (patched-kernel rerun).

VERDICT.md
↓ download raw

DF-3138 VERDICT β€” REPRODUCED (4-byte OOB heap write + OOB read past a 512-byte kernel buffer; adjacent-object corruption kernel-logged; one kernel crash)

Setup

Same guest/mount/uid-1001 protocol as DF-3137 (crafted HPFS image, RW mount via direct mount(2), unprivileged trigger). Tree per file: fnode(AB_NODES,1) -> P(AB_NODES, busycnt=61, freecnt=1, an[60] in sector slack -> C) -> C(leaves, busycnt=40 full, freecnt=0). A 1-byte pwrite at offset 512 makes C split immediately (its insert loop is skipped), AE_SPLIT propagates to P's frame.

Observed, single-file run (deterministic)

  1. P's frame executes bcopy(ranp, AB_LASTANP(P), 16) at sys/vfs/hpfs/hpfs_alsubr.c:599. LASTANP = an[60] at [500,508) (slack, crafted); the second alnode an[61] = {0xFFFFFFFF, 0x00000200} lands at [508,516) β€” 4 bytes past the 512-byte b_data. 0x200 is the lsn allocated for the split AlSec (attacker-chosen via bitmap layout).
  2. P overflows (busycnt 62, freecnt 0) and splits itself; the source bcopy at :229 reads [268,516) β€” a 4-byte OOB READ β€” copying the same 0x200 into the new AlSec's an[30].an_lsn.
  3. The children-reparenting loop dereferences it: breadalsec(0x200) returns the cached N1 buffer, whose as_magic had just been corrupted by step 1 (the neighbor chunk of P's data was N1's data): hpfs_breadstruct: MAGIC DOESN'T MATCH (0x00000200 != 0x37e40aae) hpfs_addextentr: FAILED 22 ... hpfs_write: hpfs_extend FAILED 22 pwrite returns EINVAL; the kernel is still up (the adjacent object was a buffer, not a freelist pointer).
  4. Proof the 0x200 came from OOB memory: an exhaustive scan of the crafted image finds NO sector whose first u32 is 0x00000200; the only writer of that value into that location is the :599 bcopy's OOB half, and the only reader path is the :229 OOB read-back (dmesg excerpts in panic.txt).

Observed, 40-file run

The unprivileged trigger loop killed the kernel mid-run: the trigger ssh session died with no output (RUN_EXIT=124), the follow-up ssh could not complete a banner exchange, and the next boot ran fsck on / (unclean shutdown β€” boot.log). With 40 different P-buffer allocations the OOB write hits varying neighbors (free chunks, freelist words, other live objects), one of which took the system down. The panic text scrolled out of the small serial console buffer before it could be captured β€” crash evidence = unclean shutdown + guest loss, signature not retained.

Primitive / escalation

4-byte write of an attacker-chosen small integer (allocated lsn) at b_data+512, plus a 4-byte OOB read of the same word that is then used as a pointer (an_lsn dereferenced by breadalsec) β€” i.e. the OOB read can also be steered into random-sector reads. A reliable uid0 chain needs the neighbor chunk to be a freelist/pointer-bearing object (1/64 chance per allocation for a page-edge chunk; the crash run shows fatal adjacency occurs in practice) β€” full uid0 not demonstrated within budget; corruption + crash are.

Fix validation

Shared alblk-invariant fix (fix.diff in the DF-3137 pack) rejects P (busycnt 61 > 60) in hpfs_addextentr before any bcopy. Patched kernel: identical trigger returns EINVAL ("hpfs_addextentr: INSANE ALBLK" on console), no corruption, no crash (fix_run.log).

Verdict

REPRODUCED β€” memcorrupt. Kernel refs: sys/vfs/hpfs/hpfs_alsubr.c:572 (loop bound), :578 (AB_LASTANP), :592-601 (fixup bcopy), :401-411 (fnode-root twin), :225-235 (split source read), hpfs.h:157 (AB_LASTANP macro), sys/vfs/hpfs/hpfs_vfsops.c:535 (no validation).

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

identical trigger on patched kernel: pwrite EINVAL ('hpfs_addextentr: INSANE ALBLK'), no bcopy, no corruption, guest up

fix_run.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2: Mon Sep 7 00:17:43 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

crafted image -> RW mount -> pwrite(1)@512 -> fnode -> P(busycnt=61,freecnt=1) -> C(full leaves) splits -> ranp fixup bcopy in P's frame writes 4 bytes past P's 512B chunk (value = allocated AlSec lsn, chosen via bitmap free-run) -> P self-splits, OOB-reads it back into N2.an[30].an_lsn -> dereferenced by breadalsec. uid0 route would need the neighbor chunk to be freelist/pointer-bearing (page-edge adjacency ~1/64 per allocation; the 40-file crash shows fatal adjacency occurs) β€” not demonstrated to uid0 within budget; adjacent-object corruption and kernel death are proven.

Evidence (decisive lines)

['run.log: 40-file run, RUN_EXIT=124, guest lost, next boot fsck (unclean shutdown)', "panic.txt: dmesg MAGIC DOESN'T MATCH (0x00000200) + FAILED 22 chain = adjacent buffer corrupted by the OOB write, observed via kernel log", 'fix_run.log: patched kernel rejects P with INSANE ALBLK, no corruption']

PoC changes

written fresh; single-file variant (df3138_one.img) used for the captured evidence after the 40-file variant crashed the guest without preserving the panic text in the small serial buffer

Verified recommended fix

same alblk-invariant validation as DF-3137 (busycnt <= capacity kills the >60 alnode array)

Verdict

The AE_SPLIT parent-fixup bcopy(ranp, AB_LASTANP(rabp), 16) at hpfs_addextent:410 / hpfs_addextentr:599 writes one alnode PAST the last busy entry with no bound except the unvalidated on-disk ab_freecnt>0; with a crafted AB_NODES AlSec (busycnt=61 > capacity 60, freecnt=1, last alnode resident in the sector's 16 slack bytes) an unprivileged 1-byte pwrite makes it write {~0, chosen-lsn} at b_data[508..516) β€” 4 bytes past the 512-byte kernel heap buffer β€” and the follow-up self-split's source bcopy (alsubr.c:229, span [268,516)) OOB-reads the same word back and dereferences it as an an_lsn. Directly observed on the guest: the neighboring 512-byte chunk was a live buffer whose as_magic got overwritten with the chosen value 0x200 β€” kernel logged 'hpfs_breadstruct: MAGIC DOESN'T MATCH (0x00000200 != 0x37e40aae)'; no crafted sector contains that value (verified by scan), so it can only have arrived via the OOB write. The 40-file variant killed the kernel mid-run (trigger ssh died, unclean shutdown / fsck on next boot). Fix (busycnt<=capacity validation) validated: identical trigger returns EINVAL, no corruption.