DragonFlyBSD Kernel Audit
← triage · dashboard
DF-0914

ufs_bmaparray OOB read of indirect block buffer via unvalidated fs_nindir

Summary

ufs_bmap.c:221 daddr=((ufs_daddr_t*)bp->b_data)[xap->in_off]. in_off from ufs_getlbns:315 off=(bn/blockcnt)%MNINDIR(ump)=um_nindir=fs_nindir from disk (vfsops.c:729 NO validation). Buffer is fs_bsize bytes = fs_bsize/4 entries. Crafted fs_nindir=4096 vs fs_bsize/4=1024: in_off up to 4095 reads 12KB+ past 4KB buffer. OOB heap read. :223-228 cluster walk also OOB. OOB value used as disk block address = confused-deputy read or panic. Same root cause as DF-0894 different exploitation path (bmap read vs balloc write). Also affects ext2 (shares ufs_bmap). Fix: validate fs_nindir==fs_bsize/sizeof(ufs_daddr_t) at mount.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0914 · 18 files
FileTypeDescriptionSize
craft_img.c trigger-source UFS superblock fs_nindir patcher (reuses DF-0894 pattern) 4.3 KB view raw
harness.c trigger-source deterministic OOB-read characterization (transcribes ufs_getlbns + bap[in_off] for READ path) 5.1 KB view raw
trigger.c trigger-source Phase A: write at lbn=12 + ftruncate on correct image 1.9 KB view raw
trigger_ro.c trigger-source Phase B: read at lbn=8203 on forged image (triggers ufs_bmaparray OOB) 1.7 KB view raw
reproduce.sh trigger-source two-phase flow: write on correct image -> patch fs_nindir -> read on forged image 3.5 KB view raw
build.sh build-script builds craft_img, harness, trigger, trigger_ro 467 B view raw
run.sh run-script runs reproduce.sh 136 B view raw
run.log run-log baseline run on #0: Phase A OK, Phase B triggers panic (timeout=124) 2.7 KB view raw
fix_run.log run-log patched #1 kernel run: mount rejected, no panic 3.0 KB view raw
fix_build.log build-log full make nativekernel + installkernel output for the single-fix kernel 5.7 MB ↓ download
panic.txt panic-signature Fatal trap 12 page fault at ufs_bmaparray+0x15c: movslq (%rsi,%rax,4),%r12 673 B view raw
dmesg.txt dmesg ffs_mountfs: bad fs_nindir 8192 (expected 4096) — fix validation kprintf 96 B view raw
env.txt environment uname, cc version, sysctls 531 B view raw
fix.diff suggested-fix mount-time fs_nindir validation at ffs_vfsops.c:647 (same as DF-0894) 884 B view raw
README.md readme summary, build/run instructions, threat model, DF-0894 relationship 3.3 KB ↓ raw
VERDICT.md verdict full narrative: mechanism, evidence, impact, fix validation, DF-0894 relationship 6.4 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme summary, build/run instructions, threat model, DF-0894 relationship
↓ download raw

DF-0914 — ufs_bmaparray OOB read of indirect-block buffer via unvalidated fs_nindir

Summary

ufs_bmaparray() at ufs_bmap.c:221 reads daddr = ((ufs_daddr_t *)bp->b_data)[xap->in_off] where in_off is computed by ufs_getlbns() at ufs_bmap.c:315 as off = (bn / blockcnt) % MNINDIR(ump). MNINDIR(ump) equals ump->um_nindir, which is copied verbatim from the on-disk fs->fs_nindir field at ffs_vfsops.c:729 with no validation that it equals fs_bsize / sizeof(ufs_daddr_t).

The indirect-block buffer is fs_bsize bytes = fs_bsize/4 entries. A forged fs_nindir larger than fs_bsize/4 (e.g. 8192 vs 4096 for fs_bsize=16384) drives in_off up to fs_nindir-1 (8191), reading up to ~16KB past the buffer — an OOB heap READ. The OOB value is then used as a disk block address at ufs_bmap.c:241 (daddr = blkptrtodb(ump, daddr)), a confused-deputy read.

Same root cause as DF-0894 (unvalidated fs_nindir), but a different exploitation path: DF-0914 is the READ path (ufs_bmaparray on read/seek), DF-0894 is the WRITE path (ffs_balloc on write). The same mount-time fix (validating fs_nindir) closes both.

Build / Run

Build (in guest as root):

cd /root/poc && sh ./build.sh

Builds craft_img, harness, trigger (Phase A write), trigger_ro (Phase B read).

Run (in guest as root):

cd /root/poc && sh ./reproduce.sh

Expected on unpatched #0 GENERIC: Phase A succeeds (write+ftruncate on correct fs_nindir). Phase B mounts the forged image, reads at lbn=8203 → Fatal trap 12: page fault while in kernel mode at ufs_bmaparray+0x15c (movslq (%rsi,%rax,4),%r12 — the bap[in_off] OOB read).

Expected on patched #1 kernel: Phase B mount rejected — ffs_mountfs: bad fs_nindir 8192 (expected 4096)MOUNT_B_RC=1, no OOB, no panic.

Threat model / access

UFS mount is root-only on DragonFly (vfs.usermount=0, SYSCAP_RESTRICTEDROOT). The attack vector is root-context mount of attacker-supplied media → kernel OOB read. This is a root→kernel hardening gap / info leak / DoS, not an unprivileged→root LPE.

Relationship to DF-0894

Aspect DF-0894 DF-0914
Root cause unvalidated fs_nindir unvalidated fs_nindir
Sink ffs_balloc.c:297 (bap[in_off] R/W) ufs_bmap.c:221 (bap[in_off] READ)
Path WRITE (file extension) READ (file read/seek)
Impact OOB write/read → panic or heap corruption OOB read → panic or confused-deputy
Fix validate fs_nindir at mount same fix (validate fs_nindir at mount)

DF-0894's validated fix.diff already validates fs_nindir != fs_bsize/sizeof(ufs_daddr_t). This check closes DF-0914 too. DF-0914's fix.diff is the same check.

Files

  • craft_img.c — patches fs_nindir in UFS superblock (reuses DF-0894 pattern)
  • harness.c — deterministic OOB-read characterization (transcribes ufs_getlbns + bap[in_off])
  • trigger.c — Phase A: write at lbn=12 + ftruncate (on correct image)
  • trigger_ro.c — Phase B: read at lbn=8203 (on forged image)
  • reproduce.sh — full two-phase flow (Phase A correct → Phase B forged)
  • fix.diff — mount-time fs_nindir validation (closes DF-0894 + DF-0914)
  • panic.txt — Fatal trap 12 at ufs_bmaparray+0x15c (READ-path OOB proof)
VERDICT.md verdict full narrative: mechanism, evidence, impact, fix validation, DF-0894 relationship
↓ download raw

DF-0914 — VERDICT

Verdict: REPRODUCED (panic), fix VALIDATED

The bug is real: fs_nindir (an on-disk UFS superblock field) is copied verbatim into ump->um_nindir at ffs_vfsops.c:729 with no validation that it equals fs_bsize / sizeof(ufs_daddr_t). The mount-time check at ffs_vfsops.c:642-646 validates only fs_magic and the fs_bsize range. A forged fs_nindir=8192 (vs the correct 4096 for fs_bsize=16384) drives daddr = ((ufs_daddr_t *)bp->b_data)[xap->in_off] at ufs_bmap.c:221 up to ~16KB past the indirect-block buffer, because in_off = bn % MNINDIR(ump) uses the forged value. This is the READ-path OOB (vs DF-0894's write path).

Mechanism (trigger → primitive → effect)

  1. Trigger (Phase A — correct image): root newfs a UFS1 image (fs_bsize=16384, fs_nindir=4096). Mount RW, write 1 byte at lbn=12 (offset 196608) to allocate i_ib[0] (single indirect block) with correct in_off=0 (in bounds). ftruncate the file to 134283265 bytes (covers lbn=8203). Unmount cleanly.

  2. Trigger (Phase B — forged image): patch fs_nindir 4096→8192 in the superblock (craft_img.c). Mount RW — the forged fs_nindir is accepted (ffs_vfsops.c:729 copies it with no check). Read 1 byte at lbn=8203 (offset 134283264). ffs_read (ufs_readwrite.c:110) calls ffs_blkatoff_ra(vp, offset, ...)bread(vp, lbn=8203, ...)VOP_BMAPufs_bmapufs_bmaparray(vp, 8203, ...).

  3. Primitive: ufs_getlbns(vp, 8203) at ufs_bmap.c:315 computes off = (8191 / 1) % 8192 = 8191 (with forged MNINDIR=8192). Back in ufs_bmaparray at :221: c daddr = ((ufs_daddr_t *)bp->b_data)[xap->in_off]; // bap[8191] = bp->b_data + 32764 The indirect-block buffer is fs_bsize=16384 bytes (4096 entries, indices 0..4095). bap[8191] reads at offset 32764 — 16380 bytes past the end of the 16384-byte buffer.

  4. Effect: On default GENERIC (INVARIANTS ON), the 16KB OOB read crosses into unmapped kernel virtual memory → page faultFatal trap 12: page fault while in kernel mode at ufs_bmaparray+0x15c: movslq (%rsi,%rax,4),%r12 — the exact bap[in_off] instruction. Supervisor read data, page not present — confirming it is a READ-path OOB, distinct from DF-0894's write-path OOB at ffs_balloc.

Confirmed evidence

  • Panic signature (panic.txt): Fatal trap 12: page fault while in kernel mode at ufs_bmaparray+0x15c: movslq (%rsi,%rax,4),%r12. Fault virtual address 0xfffff80055cbdfe0, fault code supervisor read data, page not present. Instruction pointer 0xffffffff80913b7c. This is distinct from DF-0894 which panics at ffs_balloc+0x5e7: movl (%rbx,%rax,4),%eax (write-path).

  • Two-phase isolation: Phase A writes on the CORRECT image (fs_nindir=4096, all ffs_balloc operations in-bounds). Phase B only reads on the FORGED image. This cleanly separates DF-0914's read-path OOB from DF-0894's write-path OOB.

  • Harness (harness.c): transcribes ufs_getlbns + the OOB index math, confirming in_off=8191 indexes bap[8191] at bp->b_data + 32764 — 16384 bytes past the 16384-byte buffer.

Impact ceiling

  • Privilege: UFS is not user-mountable on DragonFly (vfs.usermount=0, SYSCAP_RESTRICTEDROOT). The attack vector is root-context mount of attacker-supplied media → kernel OOB read. This is a root→kernel hardening gap, not an unprivileged→root LPE.

  • OOB extent: up to ~16KB (forged fs_nindir=8192, in_off_max=8191). OOB READ at ufs_bmap.c:221. The OOB value becomes daddr, used as a disk block address at :241confused-deputy read of an arbitrary disk block, or page-fault panic (INVARIANTS).

  • On GENERIC (INVARIANTS ON): deterministic page-fault panic for large OOB offsets. For small offsets (4 bytes past), the OOB read silently returns adjacent kernel memory (info leak) and the confused-deputy read targets an arbitrary disk block.

Why this is NOT a uid0 escalation

The trigger requires mount -t ufs, which needs root privileges (vfs.usermount=0). A non-root user cannot mount UFS on DragonFly. Therefore this is root→kernel corruption (a hardening gap), not unprivileged→root. The Phase 6 escalation chain is not applicable: there is no privilege boundary to cross (root→kernel is game-over by definition).

Fix validation (Phase 8)

fix.diff adds a check at ffs_vfsops.c:647 (after the existing magic/bsize validation):

if (fs->fs_nindir != fs->fs_bsize / sizeof(ufs_daddr_t)) {
    kprintf("ffs_mountfs: bad fs_nindir %d (expected %d)\n", ...);
    error = EINVAL;
    goto out;
}

This is identical to DF-0894's fix — the same mount-time fs_nindir check closes both bugs because both depend on the forged fs_nindir being accepted at mount.

  • Before (unpatched #0): Phase B mount succeeds (MOUNT_B_RC=0), read at lbn=8203 → Fatal trap 12 at ufs_bmaparray+0x15c.
  • After (patched #1): Phase B mount rejectedffs_mountfs: bad fs_nindir 8192 (expected 4096) in dmesg, mount_ufs: incorrect super block, MOUNT_B_RC=1. No OOB, no panic.

Relationship to DF-0894

Same root cause, same fix, different sink. Both bugs exploit the unvalidated fs_nindir at ffs_vfsops.c:729:

DF-0894 DF-0914
Sink ffs_balloc.c:297 (write path) ufs_bmap.c:221 (read path)
Panic RIP ffs_balloc+0x5e7 ufs_bmaparray+0x15c
Instruction movl (%rbx,%rax,4),%eax movslq (%rsi,%rax,4),%r12
Trigger file write (extension) file read (bmap on read)
Fix validate fs_nindir at mount same (validate fs_nindir at mount)

DF-0894's fix.diff already contains this check. DF-0914's fix.diff is the same check (with a comment referencing both finding IDs). Applying either fix closes both bugs.

PoC changes

  • craft_img.c — new: patches fs_nindir (reuses DF-0894 pattern with DF-0914-specific comments).
  • harness.c — new: transcribes ufs_getlbns + OOB-read math for the READ path (ufs_bmaparray:221).
  • trigger.c — new: Phase A (write 1 byte at lbn=12 + ftruncate on correct image).
  • trigger_ro.c — new: Phase B (read at lbn=8203 on forged image).
  • reproduce.sh — new: two-phase flow (write on correct → patch → read on forged) that cleanly isolates the READ-path OOB from DF-0894's write path.
  • fix.diff — mount-time fs_nindir validation (same as DF-0894).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: on the unpatched #0 baseline (6.5-DEVELOPMENT #0), DF-0914 reproduces -- Phase A writes+ftruncate on correct fs_nindir=4096 succeeds, Phase B patches to 8192 and mounts, read at lbn=8203 triggers Fatal trap 12 page fault at ufs_bmaparray+0x15c (supervisor READ data, page not present). On the single-fix #1 kernel (same source + the fs_nindir validation check at ffs_vfsops.c:647), Phase B mount is REJECTED -- dmesg shows 'ffs_mountfs: bad fs_nindir 8192 (expected 4096)', MOUNT_B_RC=1, no OOB read, no panic. The fix closes DF-0914 (and also DF-0894, same root cause).

BEFORE (baseline #0): MOUNT_B_RC=0 (mount succeeds with forged fs_nindir=8192) -> read at lbn=8203 -> Fatal trap 12 page fault at ufs_bmaparray+0x15c: movslq (%rsi,%rax,4),%r12 (OOB READ 16KB past buffer). AFTER (patched #1): mount_ufs: incorrect super block, MOUNT_B_RC=1 (mount rejected). dmesg: ffs_mountfs: bad fs_nindir 8192 (expected 4096). No panic, no OOB -- trigger_ro fails to open file (mount never happened).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 7 07:47:24 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

N/A (valid hard blocker -- root-only reachability). The trigger requires mount -t ufs which needs root privileges (vfs.usermount=0, SYSCAP_RESTRICTEDROOT). This is a root->kernel OOB read, not an unprivileged->root escalation. The OOB read at ufs_bmap.c:221 reads up to 16KB past the indirect-block buffer; the OOB value becomes a disk block address used at :241 (blkptrtodb) -- a confused-deputy read of an arbitrary disk block or page-fault panic. Impact ceiling: DoS (deterministic panic on GENERIC with large OOB offsets) or info leak (for small offsets that read adjacent kernel memory without faulting). No chain file written -- this is a non-corruption-class finding (root->kernel OOB read / hardening gap).

Evidence (decisive lines)

=== BASELINE (#0 unpatched) panic === Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff80055cbdfe0 / fault code = supervisor read data, page not present / instruction pointer = 0x8:0xffffffff80913b7c / Stopped at ufs_bmaparray+0x15c: movslq (%rsi,%rax,4),%r12 / db>. === PATCHED (#1) fix validation === mount_ufs: /dev/vn0 on /mnt/test: incorrect super block / MOUNT_B_RC=1 / (dmesg) ffs_mountfs: bad fs_nindir 8192 (expected 4096) / No panic, no OOB -- mount rejected at the validation check.

PoC changes

Created findings/poc/DF-0914/ from scratch. craft_img.c reuses DF-0894's fs_nindir patcher pattern. harness.c transcribes ufs_getlbns + the READ-path bap[in_off] OOB math. trigger.c (Phase A) writes 1 byte at lbn=12 + ftruncates on the CORRECT image (avoids triggering DF-0894's write-path OOB). trigger_ro.c (Phase B) reads at lbn=8203 on the FORGED image (triggers ufs_bmaparray:221 OOB). reproduce.sh implements the two-phase flow: newfs->write->ftruncate->unmount on correct fs_nindir, then patch->mount->read on forged fs_nindir. This cleanly isolates DF-0914's read-path OOB from DF-0894's write-path OOB. fix.diff validates fs_nindir at mount (identical to DF-0894's fix -- same root cause, same check closes both).

Verified recommended fix

Add a mount-time validation at ffs_vfsops.c:647 (after the existing fs_magic/fs_bsize check): if (fs->fs_nindir != fs->fs_bsize / sizeof(ufs_daddr_t)) { error = EINVAL; goto out; }. This rejects forged fs_nindir values at mount, preventing the OOB index in both ufs_bmaparray:221 (DF-0914 read path) and ffs_balloc:297 (DF-0894 write path). Matches DF-0894's fix proposal -- the same check closes both bugs because both depend on the forged fs_nindir being accepted at mount. The full git-apply-able diff lives in findings/poc/DF-0914/fix.diff.

Verdict

REPRODUCED. The bug is real: fs_nindir (an on-disk UFS superblock field) is copied verbatim into ump->um_nindir at ffs_vfsops.c:729 with NO validation that it equals fs_bsize/sizeof(ufs_daddr_t). The mount-time check at ffs_vfsops.c:642-646 validates only fs_magic and fs_bsize range. A forged fs_nindir=8192 (vs correct 4096 for fs_bsize=16384) drives daddr=((ufs_daddr_t*)bp->b_data)[xap->in_off] at ufs_bmap.c:221 up to ~16KB past the indirect-block buffer because in_off = bn % MNINDIR(ump) uses the forged value. Confirmed by Fatal trap 12 page fault at ufs_bmaparray+0x15c: movslq (%rsi,%rax,4),%r12 (supervisor READ data, page not present) -- the exact bap[in_off] READ instruction, DISTINCT from DF-0894's write-path panic at ffs_balloc+0x5e7. Two-phase PoC (write on correct image -> patch fs_nindir -> read on forged image) cleanly isolates the READ-path OOB from DF-0894's write path.