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)
PoC verification
Evidence pack
findings/poc/DF-3138 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
:599bcopy writesan[61] = {~0, <new-AlSec-lsn>}atP.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_magicgot overwritten with 0x00000200, and the kernel itself reported it:hpfs_breadstruct: MAGIC DOESN'T MATCH (0x00000200 != 0x37e40aae)followed byhpfs_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).
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)
- 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). - 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.
- 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).
- 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
fixedidentical trigger on patched kernel: pwrite EINVAL ('hpfs_addextentr: INSANE ALBLK'), no bcopy, no corruption, guest up
fix_run.log
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.
No comments yet.