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

Heap over-read in udf_vget via unbounded file-entry l_ea/l_ad

Summary

udf_vfsops.c:514 RDSECTOR reads bsize bytes into bp. :527 size=UDF_FENTRY_SIZE+fe->l_ea+fe->l_ad from on-disk uint32 UNCHECKED against bsize. :528 kmalloc(size) destination OK. :530 bcopy(bp->b_data,unode->fentry,size) reads size bytes from bsize-byte buffer. l_ad=0xFFFF size=65711 from 2048B buffer = 63KB heap over-read. Fires on first ls/stat after mount (udf_root->udf_vget). Also via NFS fhtovp. Panic on page boundary or garbage ICB pointers drive disk reads.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0880 Β· 16 files
FileTypeDescriptionSize
craft_img.py trigger-source UDF image builder: root File Entry with l_ea=0, l_ad=0xFFFF -> size=65711 >> bsize=2048 8.2 KB view raw
harness.c trigger-source deterministic transcription of udf_vget :514/:520/:527/:530 with poisoned allocator proving 63KB over-read 6.3 KB view raw
run.sh trigger-source guest-side: vnconfig + mount_udf (root) + ls (unprivileged maxx) -> panic 1.3 KB view raw
build.sh build-script host-side: python3 craft_img.py + cc harness 471 B view raw
df0880.udf disk-image crafted UDF image (1MB, 512 sectors); root FE sector 65 has l_ad=0xFFFF 1.0 MB ↓ download
build.log build-log host-side image generation output 235 B view raw
run.log run-log harness deterministic over-read proof (full output) 1003 B view raw
panic.txt panic-signature Fatal trap 12 page-fault in memmove+0x10a (read-fault), backtrace memmove <- udf_root 1.0 KB view raw
fix.diff suggested-fix bounds check size>bsize after :527 before :530 bcopy (git-apply-able) 929 B view raw
fix_build.log build-log patched udf.ko module build output (rc=0, no warnings) 7.5 KB view raw
fix_run.log run-log patched-module validation: mount OK, ls/stat -> EINVAL, no panic, dmesg shows fix check firing 1.1 KB view raw
env.txt environment uname, kern.version, cc version, udf.ko sha256, hardening 838 B view raw
VERDICT.md verdict full narrative: mechanism, evidence, impact ceiling, fix validation 7.6 KB ↓ raw
README.md readme human reproduce doc 3.2 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 human reproduce doc
↓ download raw

DF-0880 β€” PoC evidence pack

Heap over-read (CWE-125) in udf_vget() via unbounded File-Entry l_ea/l_ad. sys/vfs/udf/udf_vfsops.c:527 computes size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad from on-disk uint32_t fields that are never validated against bsize; :530 bcopy(bp->b_data, unode->fentry, size) then reads size bytes from the bsize-byte (2048) buffer. With l_ad=0xFFFF, size=65711 = a 63663-byte (63 KB) heap over-read. Fires on first ls/stat after mount (udf_root β†’ udf_vget). Also reachable via NFS fhtovp.

Files

file purpose
craft_img.py builds df0880.udf β€” a valid UDF image whose root File Entry has l_ad=0xFFFF
harness.c deterministic transcription of the udf_vget arithmetic + poisoned allocator (proves 63KB over-read)
run.sh guest-side: vnconfig + mount_udf (root) then ls as maxx β†’ panic
build.sh host-side: generate df0880.udf + compile harness
df0880.udf crafted UDF image (1 MB, 512 sectors)
fix.diff bounds check size > bsize after :527, before :530 β€” git apply-able
VERDICT.md full root-cause + reproduction + fix-validation narrative
run.log harness deterministic over-read proof (full output)
panic.txt kernel panic signature + backtrace from boot.log
fix_build.log patched udf.ko module build output
fix_run.log patched-module validation (mount OK, ls/stat β†’ EINVAL, no panic)
env.txt guest uname / kern.version / cc version / udf.ko sha256

How to reproduce

The image is generated on a host with python3 (the guest has none); the harness builds on either. run.sh runs on the DragonFly guest as root (the trigger itself is unprivileged β€” ls/stat as any user).

# host
./build.sh                                        # -> df0880.udf, harness
scp df0880.udf run.sh dfbsd:/root/poc/

# guest (DragonFly 6.5-DEVELOPMENT #0 GENERIC, INVARIANTS ON)
kldload udf
vnconfig -c vn0 /root/poc/df0880.udf
mount_udf -o ro /dev/vn0 /mnt
su maxx -c 'ls /mnt'                              # -> kernel panic (memmove read-fault)

Expected (bug present): kernel page-fault in memmove/bcopy called from udf_root β†’ udf_vget; fault code = supervisor read data, page not present (a READ over-read). Guest frozen / rebooting.

Expected (after fix.diff applied, udf.ko rebuilt): ls/stat return Invalid argument (EINVAL); no panic; guest stays up. dmesg shows udf_vget: file entry too large (65711 > 2048).

Deterministic harness (no image needed)

cc -O2 -Wall -o harness harness.c && ./harness

Proves the exact arithmetic (size=65711 from a 2048-byte buffer) and faults the bcopy at the 2048-byte source boundary via a PROT_NONE guard page, demonstrating the read is unbounded by bsize.

Impact

Read-only OOB (CWE-125) β€” no write primitive, no escalation chain (valid Phase-6 hard blocker). Deterministic manifestation: panic (DoS). Secondary (unreliable) info-leak ceiling if the over-read completes without faulting. Root-only mount + unprivileged ls/stat trigger (or vfs.usermount=1 + root-created image owned by attacker).

VERDICT.md verdict full narrative: mechanism, evidence, impact ceiling, fix validation
↓ download raw

DF-0880 β€” VERDICT

Verdict: REPRODUCED (heap over-read / CWE-125), FIX VALIDATED

The bug is real and confirmed on DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC (INVARIANTS ON). It is a heap over-read (CWE-125) in udf_vget() driven by attacker-controlled l_ea/l_ad fields read off a crafted UDF filesystem image. The deterministic on-guest manifestation is a kernel page-fault panic (the 63 KB over-read crosses an unmapped page). The authored fix.diff closes it: validated by rebuilding udf.ko and hot-swapping β€” the same image then returns EINVAL with no panic.

Mechanism (trigger β†’ primitive β†’ effect)

The vulnerable code is sys/vfs/udf/udf_vfsops.c in udf_vget():

:514  if ((error = RDSECTOR(devvp, sector, udfmp->bsize, &bp)) != 0) { ... }
      /* RDSECTOR expands to bread(devvp, ..., udfmp->bsize, bp)  (udf.h:87)
         -> bp->b_data holds EXACTLY udfmp->bsize (2048) bytes. */
:520  fe = (struct file_entry *)bp->b_data;
:521  if (udf_checktag(&fe->tag, TAGID_FENTRY)) { ... }          /* tag only */
:527  size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad;
      /* UDF_FENTRY_SIZE=176 (ecma167-udf.h:352); l_ea/l_ad are uint32_t
         read straight off disk (ecma167-udf.h:348-349), NEVER validated
         against bsize anywhere between :527 and :530. */
:528  unode->fentry = kmalloc(size, M_UDFFENTRY, M_WAITOK | M_ZERO);  /* dest OK */
:530  bcopy(bp->b_data, unode->fentry, size);
      /* reads `size` bytes from the 2048-byte SOURCE buffer bp->b_data. */

With a crafted root File Entry carrying l_ea=0, l_ad=0xFFFF:

size = 176 + 0 + 65535 = 65711
over-read past bp->b_data = 65711 - 2048 = 63663 bytes (62.2 KB)

bcopy reads 65711 bytes starting at bp->b_data (a 2048-byte buffer-cache buffer). The read walks 63 KB past the buffer into neighbouring kernel pages; when it reaches an unmapped page it page-faults β†’ panic.

Why mount succeeds but ls/stat panics

The mount-time read of the root File Entry (udf_mount, :372-386) only reads the sector and checks the descriptor tag (TAGID_FENTRY) β€” it does not compute size or bcopy. So mount_udf succeeds on the crafted image. The over-read fires on the first access that needs the root vnode β€” ls/stat on the mountpoint β†’ udf_root() (:439-450) β†’ udf_vget() (:484) β†’ the unchecked bcopy at :530.

Reproduction evidence

1. Live kernel panic (default GENERIC #0, INVARIANTS ON)

Crafted image df0880.udf (root FE l_ad=0xFFFF) β†’ vnconfig + mount_udf (root) β†’ ls /mnt (unprivileged) β†’ panic:

Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xfffff800522f7000
fault code             = supervisor read data, page not present   <- READ fault
instruction pointer    = 0x8:0xffffffff80bcaa0a
...
Stopped at      memmove+0x10a:  repe movsq  (%rsi),%es:(%rdi)     <- the bcopy

Backtrace (captured with debug.debugger_on_panic=0, debug.trace_on_panic=1):

memmove() at memmove+0x10a      <- bcopy(bp->b_data, unode->fentry, 65711)
udf_root() at udf_root+0x25     <- udf_root calls udf_vget (:450)

The fault is explicitly a "supervisor read data, page not present" β€” a READ fault, confirming the heap over-read (CWE-125), not a write. The repe movsq (%rsi),%es:(%rdi) is the block-copy reading from rsi (bp->b_data) past the page boundary.

2. Deterministic harness (no UDF image needed)

harness.c transcribes udf_vget's :514/:520/:527/:530 arithmetic verbatim and performs the identical bcopy with the source buffer placed at the end of a page backed by a PROT_NONE guard page β€” so the over-read faults exactly at the 2048-byte boundary, proving the read length is unbounded by bsize:

size = UDF_FENTRY_SIZE(176) + l_ea(0) + l_ad(65535) = 65711
over-read past bp->b_data = 65711 - 2048 = 63663 bytes (62.2 KB)
FAULT caught: bcopy of 65711 bytes crossed the 2048-byte source boundary

Impact ceiling (read-only primitive β€” valid Phase-6 hard blocker)

This is a read-only OOB read (CWE-125). The write side (unode->fentry = kmalloc(size)) is correctly sized and in-bounds; only the read from bp->b_data overflows. Per the Phase-6 hard-blocker list, a genuinely read-only primitive has no escalation chain to develop β€” there is no write/corruption to convert. The realistic impact is:

  • DoS (panic): the deterministic outcome β€” the 63 KB over-read page-faults. Triggerable by an unprivileged user (ls/stat) once an admin has mounted (or made mountable via vfs.usermount=1) a crafted UDF image. The mount itself requires root (or a root-created image owned by the attacker β€” an acceptable precondition per the exploit-environment realism test).
  • Info leak (secondary, unreliable): if the over-read happens not to fault (the buffer sits in the middle of a large mapped region), the over-read bytes are copied into unode->fentry->data[] (the allocation descriptor area). This is kernel heap residue, not directly exfiltrated to userspace via an obvious path, and the fault is the overwhelming likelihood.

impact = panic (the deterministic, reproducible manifestation on the default GENERIC kernel).

PoC changes from the seeded scaffolding

Created from scratch (no seeded PoC existed for DF-0880): - craft_img.py β€” adapted the proven DF-0831 UDF image builder; sets the root File Entry (sector 65) to l_ea=0, l_ad=0xFFFF so UDF_FENTRY_SIZE + l_ea + l_ad = 65711 >> bsize=2048. Directory extents are irrelevant (the panic fires in udf_vget before readdir). - harness.c β€” deterministic transcription of the udf_vget :514/:520/:527/:530 arithmetic with a poisoned source allocator, proving the 63 KB over-read. - run.sh β€” guest-side: vnconfig + mount_udf (root) then ls (unprivileged) β†’ panic. - fix.diff β€” git apply-able bounds check.

Fix validation (Phase 8 β€” single-fix module)

Approach: udf is a loadable KLD module (udf.ko). Applied fix.diff to /usr/src/sys/vfs/udf/udf_vfsops.c, rebuilt udf.ko (make in the module dir, rc=0, no warnings), hot-swapped into /boot/kernel/udf.ko, kldload.

Before (unpatched #0 udf.ko): mount_udf OK; ls /mnt β†’ Fatal trap 12 page fault in memmove+0x10a (read-fault), backtrace memmove ← udf_root. Guest DOWN.

After (patched udf.ko, same #0 kernel): mount_udf OK; ls /mnt2 β†’ Invalid argument (EINVAL); stat /mnt2 β†’ EINVAL; NO PANIC, guest UP. dmesg shows the fix's check firing:

udf_vget: file entry too large (65711 > 2048)

This proves the patched code path executes and rejects the oversized entry before the bcopy, instead of over-reading.

Fix verdict: VALIDATED β€” clean before/after on the same #0 kernel; the patched module eliminates the panic and the over-read.

The fix (fix.diff)

Adds a bounds check immediately after the size computation (:527) and before the kmalloc/bcopy (:528/:530), matching the existing error-return style (kfree(unode) + brelse(bp) + return):

size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad;
if (size > udfmp->bsize || size < (int)UDF_FENTRY_SIZE) {
    kprintf("udf_vget: file entry too large (%d > %d)\n", size, udfmp->bsize);
    error = EINVAL;
    brelse(bp);
    kfree(unode, M_UDFNODE);
    return(error);
}

The size < UDF_FENTRY_SIZE arm also catches the case where l_ea + l_ad (uint32) overflows the int size back below the header size. ECMA-167 [4/14.9] specifies a file entry occupies exactly one block, so size <= bsize is the correct invariant. This matches the finding markdown's ## Recommended fix proposal (clamp size against bsize).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: on the unpatched #0 baseline (udf.ko as shipped) the crafted l_ad=0xFFFF image mounts cleanly but ls/stat -> Fatal trap 12 page-fault in memmove+0x10a (read-fault) via udf_root->udf_vget bcopy (guest DOWN). On the same #0 kernel with the single-fix udf.ko hot-swapped, the same image mounts cleanly and ls/stat return EINVAL with NO panic (guest UP); dmesg shows the fix's check firing 'udf_vget: file entry too large (65711 > 2048)', proving the patched path rejects the oversized entry before the bcopy. Clean before/after => fix closes DF-0880.

BASELINE (unpatched): mount OK; ls /mnt -> Fatal trap 12 page fault, memmove+0x10a 'supervisor read data, page not present', backtrace memmove<-udf_root; guest DOWN. PATCHED (single-fix udf.ko): VNCONFIG_OK vn3; MOUNT_OK /mnt2; ls /mnt2 -> 'Invalid argument' (rc=1); stat /mnt2 -> 'Invalid argument' (rc=1); dmesg: 'udf_vget: file entry too large (65711 > 2048)'; guest UP, no panic.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (same #0 kernel; udf is a loadable KLD module so the single fix was applied to /usr/src/sys/vfs/udf/udf_vfsops.c, udf.ko rebuilt rc=0, and hot-swapped into /boot/kernel/udf.ko, sha256 ed9e3e402b81c30ee83f4dac48e37bcb3f3eaf9624b9ba44d97f5ccddfe5ef71, then kldloaded)

Confirmed kernel references

Detail

Exploit chain

Read-only OOB (CWE-125) -- VALID Phase-6 hard blocker, no escalation chain. The write side (unode->fentry = kmalloc(size) at :528) is correctly sized and in-bounds; ONLY the read from bp->b_data overflows. Per the Phase-6 hard-blocker list, a genuinely read-only primitive has no write/corruption to convert into uid0. Impact ceiling: panic (DoS) is the deterministic outcome (the 63KB over-read page-faults crossing an unmapped page); a secondary, unreliable info-leak if the over-read completes without faulting (over-read bytes land in unode->fentry->data[] = the alloc-descriptor area, kernel heap residue, no obvious userspace exfiltration path). Root-only mount + unprivileged ls/stat trigger (or vfs.usermount=1 + root-created image owned by attacker -- an acceptable precondition). No chain developed because there is no write primitive -- this is the documented stop point for a read-only bug, not a bail. Files: harness.c (deterministic primitive proof), craft_img.py (image builder), run.sh (panic trigger).

Evidence (decisive lines)

BASELINE panic (unpatched #0 GENERIC, INVARIANTS ON): Fatal trap 12: page fault while in kernel mode / fault code = supervisor read data, page not present / instruction pointer = 0x8:0xffffffff80bcaa0a / Stopped at memmove+0x10a: repe movsq (%rsi),%es:(%rdi) / backtrace: memmove() at memmove+0x10a, udf_root() at udf_root+0x25. Harness: size=UDF_FENTRY_SIZE(176)+l_ea(0)+l_ad(65535)=65711, over-read=63663 bytes, FAULT caught: bcopy of 65711 bytes crossed the 2048-byte source boundary. PATCHED (hot-swapped udf.ko, same #0 kernel): MOUNT_OK, ls /mnt2 -> 'Invalid argument' (EINVAL), dmesg: 'udf_vget: file entry too large (65711 > 2048)', guest UP, no panic.

PoC changes

Created findings/poc/DF-0880/ from scratch (no seeded PoC existed). craft_img.py adapts the proven DF-0831 UDF image builder to set the root File Entry (sector 65) l_ea=0, l_ad=0xFFFF (size=65711 >> bsize=2048); directory extents are irrelevant since the panic fires in udf_vget before readdir. harness.c transcribes udf_vget :514/:520/:527/:530 verbatim with a poisoned source allocator proving the 63KB over-read. run.sh does vnconfig+mount_udf(root)+ls(maxx)->panic. fix.diff adds the bounds check.

Verified recommended fix

In udf_vget (udf_vfsops.c) immediately after the :527 size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad; and before the :528 kmalloc / :530 bcopy, add: if (size > udfmp->bsize || size < (int)UDF_FENTRY_SIZE) { kprintf(...); error = EINVAL; brelse(bp); kfree(unode, M_UDFNODE); return(error); } matching the existing error-return style. The size< arm catches uint32 overflow wrapping the int below the header size. ECMA-167 [4/14.9] specifies a file entry occupies exactly one block, so size<=bsize is the correct invariant. Matches the finding markdown's Recommended fix proposal (clamp size against bsize). The full git-apply-able diff lives in findings/poc/DF-0880/fix.diff.

Verdict

REPRODUCED. The bug is real: udf_vget() (sys/vfs/udf/udf_vfsops.c) reads exactly udfmp->bsize (2048) bytes into bp->b_data via RDSECTOR (:514), then at :527 computes size = UDF_FENTRY_SIZE(176) + fe->l_ea + fe->l_ad from on-disk uint32 fields (ecma167-udf.h:348-349) that are NEVER validated against bsize, and at :530 bcopy(bp->b_data, unode->fentry, size) reads size bytes from the 2048-byte source. With a crafted root File Entry (l_ea=0, l_ad=0xFFFF) size=65711 = a 63663-byte (63KB) heap over-read. Confirmed by (a) a live kernel panic: Fatal trap 12 page-fault in memmove+0x10a with 'fault code = supervisor read data, page not present' (a READ fault) and backtrace memmove <- udf_root (udf_root:450 calls udf_vget whose :530 bcopy faults), captured in panic.txt; and (b) a deterministic harness (harness.c) that transcribes the :514/:520/:527/:530 arithmetic verbatim and faults the bcopy exactly at the 2048-byte source boundary via a PROT_NONE guard page. Mount succeeds (mount-time root-FE read at :372-386 only checks the tag, no size/bcopy); the panic fires on first ls/stat -> udf_root -> udf_vget.