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

Heap OOB read in sparing-table scan rt_l unbounded by st_size

Summary

udf_vfsops.c:662 kmalloc(st_size). :681 bcopy st_size bytes. :692 for(i=0;i<s_table->rt_l;i++) bounded by rt_l uint16 NOT st_size. entries[] starts at offset 56 each 8B. st_size=64 rt_l=1000 reads ~8KB past 64B heap. s_table_entries=i set from OOB entries drives udf_translate further OOB. Mount+file I/O across sparing region triggers.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0881 Β· 14 files
FileTypeDescriptionSize
craft_evil_udf.py trigger-source Python UDF image crafter: st_size=32768, rt_l=65535 5.8 KB view raw
evil.udf trigger-binary Crafted UDF image (647168 bytes) 632.0 KB ↓ download
build.sh build-script Runs craft_evil_udf.py to produce evil.udf 366 B view raw
run.sh run-script vnconfig + mount -t udf commands 794 B view raw
fix.diff suggested-fix Bound rt_l by max_entries=(st_size-56)/8; validate st_size 1.5 KB view raw
VERDICT.md verdict Full analysis: mechanism, evidence, fix validation 6.3 KB ↓ raw
README.md readme Summary and reproduce instructions 1.6 KB ↓ raw
run.log run-log Unpatched #0 kernel panic output 1.9 KB view raw
fix_run.log run-log Patched #1 kernel clean output (2 runs) 960 B view raw
fix_build.log build-log Single-fix kernel build log (nativekernel rc=0) 5.6 MB ↓ download
panic.txt panic-signature Fatal trap 12: page fault in udf_mount.part.2+0x8c9 401 B view raw
env.txt environment Guest uname, kernel hashes, slab allocator config 1.1 KB view 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 and reproduce instructions
↓ download raw

DF-0881 β€” Heap OOB read in sparing-table scan: rt_l unbounded by st_size

Summary

In udf_find_partmaps() (sys/vfs/udf/udf_vfsops.c:692), the sparing-table entry scan loop iterates rt_l times (a uint16_t from the on-disk table), but rt_l is never bounded by st_size (the declared table size used for kmalloc). A crafted UDF image with rt_l >> st_size causes the loop to read entries[i] far past the allocation β€” a heap OOB read / info-leak / panic.

Reproduce

# On the DragonFlyBSD guest (root):
./build.sh                    # crafts evil.udf (st_size=32768, rt_l=65535)
kldload udf                   # load UDF module
mkdir -p /mnt/udf
./run.sh                      # vnconfig + mount β†’ panic on unpatched kernel

Expected behavior

  • Unpatched kernel (#0): mount hangs / guest panics with Fatal trap 12: page fault ... Stopped at udf_mount.part.2+0x8c9
  • Fixed kernel (#1): mount returns EINVAL cleanly, guest stays up

Files

File Description
craft_evil_udf.py Python UDF image crafter (parameterized st_size/rt_l)
evil.udf Crafted image: st_size=32768, rt_l=65535
build.sh Runs the crafter
run.sh vnconfig + mount commands
fix.diff git-apply-able fix: bound rt_l by st_size
VERDICT.md Full analysis: mechanism, evidence, fix validation
run.log Unpatched kernel panic output
fix_run.log Patched kernel clean output
fix_build.log Single-fix kernel build log
panic.txt Panic signature from serial console
env.txt Guest environment
manifest.json Machine-readable catalog
VERDICT.md verdict Full analysis: mechanism, evidence, fix validation
↓ download raw

DF-0881 β€” Heap OOB read in sparing-table scan: rt_l unbounded by st_size

Verdict: REPRODUCED (panic on unpatched kernel) + FIX VALIDATED

Severity: Medium (heap OOB read / info-leak / mount-time DoS via crafted UDF image) Confidence: certain Status: reproduced β†’ fix authored β†’ fix built β†’ fix validated (before/after on single-fix kernel)


Mechanism

The bug is in udf_find_partmaps() (sys/vfs/udf/udf_vfsops.c), called during UDF mount when the Logical Volume Descriptor contains a Type 2 Sparable Partition Map. The flow:

  1. udf_vfsops.c:662 β€” udfmp->s_table = kmalloc(pms->st_size, M_UDFMOUNT, M_WAITOK | M_ZERO); Allocates st_size bytes for the sparing table. st_size comes from the on-disk partition map (struct part_map_spare.st_size, a uint32_t).

  2. udf_vfsops.c:681 β€” bcopy(bp->b_data, udfmp->s_table, pms->st_size); Copies st_size bytes from the disk buffer into the allocation. This copy is correctly bounded by st_size.

  3. udf_vfsops.c:692 β€” THE BUG: c for (i = 0; i < udfmp->s_table->rt_l; i++) { udfmp->s_table_entries = i; if (udfmp->s_table->entries[i].org >= 0xfffffff0) break; } The loop iterates rt_l times (from struct udf_sparing_table.rt_l, a uint16_t read from the on-disk sparing table). rt_l is NOT validated against st_size. Since entries[] starts at offset 56 (offsetof(struct udf_sparing_table, entries) = tag(16)+regid(32)+rt_l(2)+ reserved(2)+seq_num(4) = 56) and each entry is 8 bytes (sizeof(struct spare_map_entry) = org(4)+map(4)), the loop reads: - entries[0] at offset 56 (valid if st_size >= 64) - entries[k] at offset 56 + 8*k β€” OOB when 56 + 8*k >= st_size

With st_size=64 and rt_l=65535, entries[1] (offset 64) is already OOB, and the loop reads up to offset 524,328 β€” ~512 KB past a 64-byte allocation.

  1. Downstream impact β€” udfmp->s_table_entries = i is set from the OOB-derived loop counter, then used in udf_translate() (udf_vnops.c:1167) to scan the same entries[] array again for sector remapping, compounding the OOB.

Reproduction

PoC: crafted UDF image (evil.udf)

A Python script (craft_evil_udf.py) generates a minimal UDF image with: - Anchor VDP at sector 256 β†’ VDS at sector 0 - Partition Descriptor (TAGID_PARTITION=5) at sector 0 - Logical Volume Descriptor (TAGID_LOGVOL=6) at sector 1 with a Type 2 Sparable Partition Map: st_size=32768, st_loc[0]=34, packet_len=2048 - Sparing Table at sector 34: tag id=0, rt_l=65535, 1 entry (org=0)

st_size=32768 forces the allocation through the kmem page-zone (allocations > ZALLOC_ZONE_LIMIT=16384 get dedicated pages), so the OOB read quickly hits an unmapped page β†’ deterministic panic.

Before (unpatched kernel #0):

vnconfig -c vn0 evil.udf
mount -t udf -o rdonly /dev/vn0 /mnt/udf

β†’ Kernel panic:

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xfffff80118632000
fault code = supervisor read data, page not present
current process = 952 (mount)
Stopped at udf_mount.part.2+0x8c9: cmpl $-0x11,0x38(%rsi,%rdx,8)

The disassembly cmpl $-0x11,0x38(%rsi,%rdx,8) is exactly entries[i].org >= 0xfffffff0: offset 0x38=56, %rsi=s_table, %rdx=i, $-0x11=0xFFFFFFEF (the >= compiles to > 0xFFFFFFEF). udf_mount.part.2 is GCC's name for udf_find_partmaps() partially inlined into udf_mount().

After (patched kernel #1):

Same PoC β†’ mount_udf: /dev/vn0: Invalid argument (EINVAL), guest alive, no panic. The EINVAL is from the intentionally-missing File Set Descriptor, not from the bug. The sparing-table scan completed within bounds (no OOB read).

Exploit chain

Class: heap OOB read (read-only primitive). No write capability. Escalation: none possible β€” this is a pure read primitive. The OOB read leaks kernel heap data (~512 KB of adjacent slab/kmem objects per mount attempt) and corrupts s_table_entries (driving further OOB reads in udf_translate). On the GENERIC kernel (INVARIANTS ON), this manifests as a panic when the read crosses a page boundary; with smaller st_size (slab-zone allocation), the OOB reads mapped heap silently (info leak).

Impact ceiling: Mount-time heap info-leak + DoS (panic). Realistic precondition: root mounts an attacker-supplied UDF image (or vfs.usermount=1 + root-created image owned by the attacker).

Fix

fix.diff β€” two changes in udf_find_partmaps():

  1. Validate st_size before kmalloc: reject if st_size < sizeof(struct udf_sparing_table) (header + at least 1 entry must fit).

  2. Bound the entry scan loop by the number of entries that actually fit in st_size: c max_entries = (pms->st_size - offsetof(struct udf_sparing_table, entries)) / sizeof(struct spare_map_entry); for (i = 0; i < udfmp->s_table->rt_l && i < max_entries; i++) {

Fix validation (Phase 8)

Phase Kernel Result
Baseline (unpatched #0) 6.5-DEVELOPMENT #0 PANIC β€” page fault in udf_mount.part.2+0x8c9
Patched (#1, fix applied) 6.5-DEVELOPMENT #1 CLEAN β€” EINVAL, no panic, guest alive (Γ—2 runs)

The fix is deterministic: rt_l is now clamped to max_entries, the scan stays within the allocation, and no page fault occurs.

PoC changes

Authored from scratch (no prior PoC existed): - craft_evil_udf.py β€” Python UDF image crafter (parameterized st_size/rt_l) - evil.udf β€” crafted image with st_size=32768, rt_l=65535 - build.sh / run.sh β€” exact reproduction commands - fix.diff β€” git-apply-able fix (validated on built+booted kernel)

Kernel references

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: the same evil.udf (st_size=32768, rt_l=65535) causes a page-fault PANIC on the unpatched #0 baseline (Stopped at udf_mount.part.2+0x8c9) and does NOT panic on the single-fix #1 kernel (mount returns EINVAL cleanly, guest stays alive, sparing scan bounded by max_entries). Tested twice on patched kernel -- deterministic. The fix clamps the entry-scan loop to i < max_entries where max_entries=(st_size-56)/8, preventing any OOB read regardless of the on-disk rt_l value.

BASELINE (#0 unpatched): Fatal trap 12: page fault while in kernel mode / fault virtual address=0xfffff80118632000 / Stopped at udf_mount.part.2+0x8c9: cmpl $-0x11,0x38(%rsi,%rdx,8) / db> (guest dead). PATCHED (#1 fix applied): mount_udf: /dev/vn0: Invalid argument / MOUNT_RC=1 / Guest alive: up 2 mins / dmesg: 'Couldn't find the fsd' (scan completed within bounds, no OOB).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Jul 11 22:11:06 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

Class: heap OOB read (read-only primitive). No write capability, so no uid=0 escalation is possible. The OOB read leaks ~512KB of adjacent kernel heap data per mount attempt and corrupts s_table_entries (set from OOB-derived loop counter), which drives further OOB reads in udf_translate() (udf_vnops.c:1167) during subsequent file I/O. On the GENERIC kernel (INVARIANTS ON), with a kmem-page-zone allocation (st_size=32768), the OOB read deterministically hits an unmapped page and panics. With smaller st_size (slab-zone allocation, e.g. 64), the OOB reads mapped slab pages silently (info-leak without panic). Impact ceiling: mount-time heap info-leak + DoS (panic). Realistic precondition: root mounts attacker-supplied UDF image (or vfs.usermount=1 + root-created image owned by attacker).

Evidence (decisive lines)

UNPATCHED #0: Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff80118632000 / fault code = supervisor read data, page not present / current process = 952 (mount) / Stopped at udf_mount.part.2+0x8c9: cmpl $-0x11,0x38(%rsi,%rdx,8) / db>. PATCHED #1: mount_udf: /dev/vn0: Invalid argument (MOUNT_RC=1), guest alive (uptime 2 mins), dmesg: 'Couldn't find the fsd' (scan bounded, no OOB).

PoC changes

Authored the entire evidence pack from scratch (no prior PoC existed): craft_evil_udf.py (Python UDF image crafter, parameterized st_size/rt_l), evil.udf (crafted image with st_size=32768, rt_l=65535), build.sh/run.sh repro scripts, fix.diff (bound rt_l by max_entries=(st_size-56)/8), VERDICT.md, manifest.json. The image places a minimal Anchor+VDS(PD+LVD with Type-2 Sparable Partition Map)+Sparing Table to reach udf_find_partmaps during mount.

Verified recommended fix

In udf_find_partmaps() at sys/vfs/udf/udf_vfsops.c: (1) validate st_size >= sizeof(struct udf_sparing_table) before kmalloc (reject tiny/forged sizes); (2) compute max_entries = (st_size - offsetof(udf_sparing_table, entries)) / sizeof(spare_map_entry) and change the loop at :692 from 'for (i=0; irt_l; i++)' to 'for (i=0; irt_l && i<max_entries; i++)'. This is a standalone git-apply-able diff in findings/poc/DF-0881/fix.diff. No prior finding proposal existed to compare against.

Verdict

REPRODUCED. The bug at sys/vfs/udf/udf_vfsops.c:692 is real and confirmed: the sparing-table entry scan loop iterates rt_l times (uint16 from on-disk table) WITHOUT bounding rt_l against st_size (the kmalloc allocation size). A crafted UDF image with st_size=32768 and rt_l=65535 causes the loop to read entries[4089..65534] -- 491KB past the 32KB kmem allocation -- triggering a page fault. Confirmed by: 'Fatal trap 12: page fault while in kernel mode, fault virtual address=0xfffff80118632000, page not present, Stopped at udf_mount.part.2+0x8c9: cmpl $-0x11,0x38(%rsi,%rdx,8)' -- that instruction is exactly entries[i].org >= 0xfffffff0 at offset 0x38=56 (offsetof entries), with %rsi=s_table and %rdx=i (the unbounded counter). udf_mount.part.2 is GCC's name for udf_find_partmaps() inlined into udf_mount().