ufs_direnter compaction loop heap OOB write via crafted d_namlen (DIRSIZ >> d_reclen) with debug.dircheck=0 β attacker-controlled kernel heap corruption past directory fragment buffers
Summary
ufs_lookup's slot walk validates entries only for forward progress (d_reclen != 0 and fitting the DIRBLKSIZ remainder, :264-266); with debug.dircheck=0 (stock kernels - dirchk is #ifdef DIAGNOSTIC; guest confirmed 0) d_namlen is never checked. ufs_direnter's compaction loop then computes dsize=DIRSIZ(nep) from the unvalidated namlen (up to 264 for namlen 255 vs reclen 4-8) and bcopy's dsize bytes to the running destination ep, advancing ep by dsize per iteration while the source chain advances by reclen (:808-844). Destination runs (region/4)*264 bytes past the directory buffer - demonstrated 16 bytes past a 512-byte fragment buffer (variant A) and ~22.7KB past it faulting on an unmapped page (variant B). The compact1/compact2 panic checks (:857-862) fire only AFTER the writes (arithmetically guaranteed with >=2 poison entries), so corruption-then-panic is unconditional. Crafted UFS/FFS image mounted RW (root or vfs.usermount=1); any file creation with the right name length in the crafted directory triggers - including by an unprivileged user when the dir is world-writable. Primitive: repeated 264-byte writes of fully attacker-crafted on-disk bytes (arbitrary content incl. NULs) at deterministic 264-byte offsets forward from the buffer end; extent attacker-chosen (tens of KB). VERIFIED guest: variant A -> panic 'ufs_direnter: compact2' after bcopy at dirbuf+264..528 in the 512B buffer (16B OOB); variant B -> Fatal trap 12 supervisor write in memmove ~22.7KB past the buffer. Fix validated (validate each entry inside the compaction loop, reject corrupted regions EIO): both variants return EIO, no panic, compaction regression passes. uid=0 not achieved (hard blocker: the corruptor always panics immediately after the writes).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3106 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| patch_dir.c | β | 6.6 KB | view raw | |
| craft_image.sh | β | 1.0 KB | view raw | |
| trigger.sh | β | 1.5 KB | view raw | |
| build.sh | β | 65 B | view raw | |
| build.log | β | 211 B | view raw | |
| env.txt | β | 608 B | view raw | |
| runA.log | β | 380 B | view raw | |
| panicA.txt | β | 2.2 KB | view raw | |
| runB.log | β | 380 B | view raw | |
| panicB.txt | β | 2.1 KB | view raw | |
| runA.patched.log | β | 470 B | view raw | |
| runB.patched.log | β | 878 B | view raw | |
| fix.diff | β | 1.1 KB | view raw | |
| VERDICT.md | β | 5.9 KB | β raw | |
| manifest.json | β | 1.5 KB | view raw | |
| verdict.json | β | 5.4 KB | view raw |
DF-3106 β VERDICT
Status: reproduced (memory corruption, then panic). Impact at PoC level: panic with a fully-characterized kernel heap OOB write. Confidence: certain.
Root cause
ufs_lookup()'s slot search validates each walked entry only for forward
progress β d_reclen != 0 && d_reclen <= DIRBLKSIZ - (off & (DIRBLKSIZ-1))
(sys/vfs/ufs/ufs_lookup.c:264-266). Full validation (ufs_dirbadentry():
d_reclen >= DIRSIZ(fmt,ep), namlen <= MAXNAMLEN, NUL-termination) runs only
when debug.dircheck (= dirchk) is nonzero, and dirchk is 0 on stock
kernels (#ifdef DIAGNOSTIC only; sys/vfs/ufs/ufs_lookup.c:64-68; verified
debug.dircheck: 0 on the audit guest).
ufs_direnter()'s compaction loop (sys/vfs/ufs/ufs_lookup.c:808-844) then
re-walks that region trusting d_namlen:
dsize = DIRSIZ(OFSFMT(dvp), nep); /* :831 β up to 264 for namlen 255 */
spacefree += nep->d_reclen - dsize;
...
bcopy((caddr_t)nep, (caddr_t)ep, dsize); /* :843 β ep advances by dsize */
A crafted entry with d_reclen=4/8 and d_namlen=255 passes the walk
(reclen small but nonzero and within the dirblock), contributes nothing to
the slot accounting (size = reclen - DIRSIZ <= 0, skipped at :287), yet in
direnter each such entry copies 264 bytes and advances the destination
pointer 264 bytes while the source chain advances only 4-8. The
destination runs (region/4)*264 bytes past the directory buffer.
The panic("ufs_direnter: compact1/compact2") safety nets (:857-862) are
checked only after the loop β the OOB writes are unconditional, and with
= 2 poison entries
spacefree = Ξ£reclen β Ξ£dsize β€ 512 β 528 < newentrysize, so the panic is guaranteed after the corruption (corruption-then-panic).
Reproduction (baseline kernel #0, stock INVARIANTS X86_64_GENERIC)
Crafted image: newfs -b 4096 -f 512, directory /d = one 512-byte
fragment (di_size=512, di_db[0]), dirblock poisoned by patch_dir:
- variant A (precise):
[e0 free reclen 168][e1 poison reclen 8 namlen 255] [e2 free reclen 4][W-fill {04 00 00 FF}*]with a chain that ends exactly at 512; region = [0,180) chosen COMPACT by a 160-char create name (DIRECTSIZ(160)=172). - variant B (massive): W-fill poisons across [168,496), free trigger @500 reclen 12; region = [0,512); ~86 live iterations.
Observed:
- Variant A β
panic: ufs_direnter: compact2, backtraceufs_direnter β ufs_makeinode β ufs_create β vop_old_create(panicA.txt, runA.log). Derivation of the write: iter1 bcopy lands atdirbuf+0..264(in-bounds); iter2 lands atdirbuf+264..528in a 512-byte buffer β 16 bytes OOB write, then the loop exits (loc=180=i_count) and the post-loop check panics compact2 (spacefree = 168+(8β264)+(4β264) = β348 < 172; ep->d_ino != 0 because the OOB bcopy itself wrote W bytes there). The panic is only reachable through >= 2 compacted entries with DIRSIZ >> reclen β impossible on a valid filesystem. - Variant B β
Fatal trap 12: page fault while in kernel mode,fault code = supervisor write data, page not present, stopped atmemmove+0x32(panicB.txt, runB.log) β the compaction bcopy itself faulted writing at0xfffff80053b07000after walking ~22.7KB (86 Γ 264B) past the 512-byte fragment buffer. The write walked through adjacent kernel heap (buffer-cache memory) until an unmapped page.
Trigger ran as the unprivileged user maxx (root only mounts the image;
the only authorization is VOP_EACCESS(VWRITE) at :378, satisfied by mode
0777 on /d).
Primitive characterization
- What: repeated 264-byte
bcopys of attacker-crafted on-disk bytes (arbitrary content, NULs allowed β raw disk data). - Where: forward from the end of a kernel buffer-cache fragment buffer (512B, 1KB, ... β attacker picks the fs geometry); deterministic offsets (264-byte multiples).
- Extent: attacker-chosen number of iterations (region layout) Γ 264 bytes; variant B demonstrated ~22.7KB past a 512B buffer.
- Limit: the guaranteed post-loop panic means the corrupting thread dies right after the writes β exploitation to uid=0 would need a second CPU to consume the corrupted heap objects in the window before the panic completes (SMP consumers of buffer-cache neighbors), or the corruption to land in data whose consumer runs concurrently. Not pursued to uid=0 in this run; the primitive (control + extent + content) is fully established.
Fix validation
fix.diff adds entry validation to the compaction loop (reclen != 0,
reclen within region, reclen >= DIRSIZ for live entries; on failure
B_INVAL + brelse + EIO). Applied to the guest's /usr/src,
make nativekernel (KERNEL_RC=0) + installkernel (INSTALL_RC=0), reboot
into kernel #1:
- Variant A:
touch: ...: Input/output error(EIO), no panic, guest up (runA.patched.log). - Variant B: same clean EIO, no page fault, no panic, guest up (runB.patched.log).
- Regression on the patched kernel: 40 create / 20 delete / 30 create in a normal UFS dir (exercises the compaction path) β 50 files, all good β valid-directory compaction is unaffected.
Notes
- The walk itself has a smaller adjacent flaw demonstrated during PoC
development: an entry header starting within 8 bytes of the buffer end
makes
ep->d_reclen(checked at :264) read up to 6 bytes past the buffer (OOB read feeding only the bounds check). Same trust-gap family; not separately IDed. ufs_dirbad()panicking on RW-mounted corrupted directories is tracked separately as DF-3083's UFS instance (DF-3108).
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
fixedfix.diff (compaction-loop entry validation, B_INVAL+brelse+EIO on corrupt regions) applied in-guest, nativekernel rebuilt (KERNEL_RC=0), installed, rebooted into #1: variant A and B triggers both return EIO with no panic and no page fault; guest stays up. Normal create/delete compaction regression passes (50/50 files). Baseline (#0) had panicked on both variants.
['findings/poc/DF-3106/fix_run.log (patched runs A+B + regression)', 'findings/poc/DF-3106/fix_build.log', 'findings/poc/DF-3106/panicA.txt', 'findings/poc/DF-3106/panicB.txt']
Confirmed kernel references
Detail
Exploit chain
crafted UFS image -> mount RW (root or vfs.usermount=1) -> unprivileged creat(2) with 160-char name in crafted 512B-frag dir -> ufs_lookup returns COMPACT region [slotoffset, slotoffset+slotsize) containing poison entries -> ufs_direnter compaction bcopy writes 264B per poison entry at dirbuf+264*k, walking past the 512B buffer -> adjacent kernel heap (buffer-cache neighbors) corrupted with attacker bytes -> panic (compact1/compact2) immediately after. OOB write established; privilege escalation blocked by the guaranteed post-corruption panic (no surviving corruptor thread) - documented as the hard blocker.
Evidence (decisive lines)
["findings/poc/DF-3106/panicA.txt: 'panic: ufs_direnter: compact2' with ufs_direnter<-ufs_makeinode<-ufs_create backtrace", "findings/poc/DF-3106/panicB.txt: 'Fatal trap 12 ... supervisor write data, page not present' stopped in memmove+0x32 - the compaction bcopy faulting on its own OOB write", "findings/poc/DF-3106/runA.patched.log: fixed kernel #1 returns 'Input/output error' (EIO), guest stays up", 'findings/poc/DF-3106/runB.patched.log: fixed kernel, variant B: clean EIO, no page fault', 'findings/poc/DF-3106/VERDICT.md: full deterministic derivation of the 16B OOB (variant A) and 22.7KB walk (variant B)']
PoC changes
Seed sketch had no on-disk layout; authored patch_dir.c with two layouts. Two craft corrections were needed during verification: (1) a poison entry's d_reclen bytes collide with the next entry's d_ino bytes, so the first poison needs a hand-built 8-byte header (reclen=8) to hop onto the free trigger entry; (2) the 4-periodic W pattern must cover the tail entry's inode field and the chain must land exactly on the buffer end (an entry header straddling the end trips the walk's mangled-entry check - which itself exposed a 2-6 byte OOB read in ufs_lookup:264, noted in the finding).
Verified recommended fix
In ufs_direnter's compaction loop, validate nep (d_reclen != 0, d_reclen <= dp->i_count - loc, and d_reclen >= DIRSIZ(nep) for live entries) before any ep mutation/bcopy; bail with B_INVAL+brelse+EIO on corrupted regions (see fix.diff).
Verdict
Reproduced on the stock INVARIANTS guest kernel: a crafted UFS image (4K/512 geometry, one 512-byte directory fragment) makes ufs_direnter's compaction loop bcopy 264-byte blocks from poison entries whose DIRSIZ(255)=264 dwarfs their d_reclen of 4-8, walking the destination pointer past the end of the buffer-cache buffer. Variant A proves the precise 16-byte OOB write (bcopy at dirbuf+264..528 in a 512-byte buffer) followed by panic: ufs_direnter: compact2; variant B proves massive uncontrolled-extent corruption (Fatal trap 12, supervisor WRITE page fault inside memmove after ~22.7KB/86 iterations past the buffer). Content is fully attacker-crafted on-disk bytes; extent is attacker-chosen (region layout x 264B). The post-loop compact1/compact2 panic is arithmetically guaranteed after the writes, so the corrupting thread always dies right after smashing adjacent kernel heap - uid=0 escalation was not achieved in this run (would require an SMP consumer of the corrupted objects in the pre-panic window). fix.diff (compaction-loop entry validation) validated on a rebuilt kernel: both variants return clean EIO with no panic and normal create/delete compaction regression passes.
No comments yet.