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)
PoC verification
Evidence pack
findings/poc/DF-0880 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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).
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 viavfs.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
fixedVALIDATED 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.
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.
No comments yet.