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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3106 Β· 16 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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, backtrace ufs_direnter ← ufs_makeinode ← ufs_create ← vop_old_create (panicA.txt, runA.log). Derivation of the write: iter1 bcopy lands at dirbuf+0..264 (in-bounds); iter2 lands at dirbuf+264..528 in 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 at memmove+0x32 (panicB.txt, runB.log) β€” the compaction bcopy itself faulted writing at 0xfffff80053b07000 after 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Sep 6 10:46:38 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.