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

Heap overflow in udf_getfid via negative frag_size from 4-byte FID alignment β€” size_t(-3) bcopy

Summary

udf_vnops.c:605 ds->off += (total_fid_size + 3) & ~0x03 β€” 4-byte alignment rounds up by 0..3 bytes. :539 guard only checks ds->off+total_fid_size<=ds->size so aligned increment can push ds->off up to 3 past ds->size. Next call: :505 end-of-dir compares ds->offset+ds->off to ds->fsize NOT ds->size so multi-extent directory continues. :543 frag_size=ds->size-ds->off becomes -1/-2/-3 (signed int). :544 if(frag_size>=bsize) signed comparison -3>=2048 false bypasses check. :555 bcopy(fid,ds->buf,frag_size) β€” frag_size int->size_t sign-extends to 0xFFFFFFFFFFFFFFFD on 64-bit = catastrophic kernel heap overflow past ds->buf. Trigger: crafted UDF image multi-extent directory FID total_fid_size not multiple of 4 mount then ls/readdir/stat. Fix: if(frag_size<0||frag_size>=bsize) + fetch next extent when ds->off>=ds->size.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0831 Β· 15 files
FileTypeDescriptionSize
craft_img.py trigger-source builds df0831.udf: valid UDF image, root dir spans 2 extents, non-4-aligned FID at extent-0 boundary 12.8 KB view raw
harness.c trigger-source deterministic transcription of udf_getfid() arithmetic + poison allocator proving size_t(-3) overrun 9.1 KB view raw
run.sh repro-script guest-side: vnconfig + mount_udf (root) then ls as maxx -> panic 1.1 KB view raw
build.sh repro-script host-side: generate df0831.udf + compile harness 471 B view raw
fix.diff suggested-fix git-apply-able: clamp negative frag_size to 0 before int->size_t bcopy length 803 B view raw
VERDICT.md verdict full root-cause + reproduction + fix-validation narrative 8.0 KB ↓ raw
README.md readme evidence-pack overview + reproduce instructions 2.5 KB ↓ raw
build.log build-log harness compile + image gen output 651 B view raw
run.log run-log decisive unpatched run with panic trace 2.2 KB view raw
fix_run.log run-log decisive patched run: clean readdir, no panic 1.5 KB view raw
fix_build.log build-log patched udf.ko module build output (rc=0) 5.3 KB view raw
panic.txt panic-signature memmove+0x24f <- udf_readdir page-fault panic from boot.log 693 B view raw
env.txt environment guest uname / kern.version / cc version 293 B 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 evidence-pack overview + reproduce instructions
↓ download raw

DF-0831 β€” PoC evidence pack

Heap overflow in udf_getfid() via a negative frag_size produced by 4-byte FID alignment overshoot. sys/vfs/udf/udf_vnops.c:605 alignment can advance ds->off up to 3 bytes past ds->size; the next call's fragmented-FID branch then computes frag_size = ds->size - ds->off (negative), the signed guard at :544 (frag_size >= bsize) is bypassed, and bcopy(..., frag_size) at :555 sign-extends int(-3) to size_t(0xFFFFFFFFFFFFFFFD) β†’ catastrophic kernel heap write β†’ page fault β†’ panic.

Files

file purpose
craft_img.py builds df0831.udf β€” a valid UDF image whose root dir spans 2 extents with a non-4-aligned FID at the extent-0 boundary
harness.c deterministic transcription of the udf_getfid() arithmetic + poison allocator (proves size_t(-3) overrun)
run.sh guest-side: vnconfig + mount_udf (root) then ls as maxx β†’ panic
build.sh host-side: generate df0831.udf + compile harness
fix.diff one-line clamp (if (frag_size < 0) frag_size = 0;) β€” git apply-able
VERDICT.md full root-cause + reproduction + fix-validation narrative
run.log decisive unpatched run (panic trace)
fix_run.log decisive patched run (clean readdir, no panic)
panic.txt kernel panic signature from boot.log
fix_build.log patched udf.ko build output
env.txt guest uname / kern.version / cc version

How to reproduce

The image must be 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 readdir trigger itself is unprivileged).

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

# guest (DragonFly 6.5-DEVELOPMENT #0 GENERIC)
sh /root/poc/DF-0831/run.sh                  # -> kernel panic (memmove+0x24f)

Expected (bug present): kernel page-fault in memmove/bcopy called from udf_readdir; guest frozen at the db> prompt.

Expected (after fix.diff): ls /mnt returns the directory entries (., .., AB, X) with no panic.

Determinism

Reproduced 5Γ— across 3 image revisions; the fault address varies per run (0xfffff800566b5ff9, 0xfffff80054ec5ff9, 0xfffff80118132000, 0xfffff80118274000, …), confirming a real unbounded read and not a fixed-address artifact.

VERDICT.md verdict full root-cause + reproduction + fix-validation narrative
↓ download raw

DF-0831 β€” VERDICT

Status: REPRODUCED Β· Impact: panic (kernel heap OOB write, DoS) Β· Confidence: certain Fix: VALIDATED (single-line clamp; patched udf.ko survives the PoC cleanly)

The bug (root cause, line-by-line)

udf_getfid() in sys/vfs/udf/udf_vnops.c iterates File Identifier Descriptors (FIDs) of a UDF directory. The directory data is read one extent at a time into ds->data with ds->size = bytes available in the current extent, while ds->fsize = the directory's total inf_len (udf_vnops.c:673), which can span multiple extents.

The four lines that combine into a catastrophic heap write:

  1. udf_vnops.c:605 β€” after a non-fragmented FID, the cursor is advanced with 4-byte alignment: c ds->off += (total_fid_size + 3) & ~0x03; When total_fid_size is not a multiple of 4, this rounds up by 1–3 bytes. The fragmentation guard at :539–:540 only requires ds->off + total_fid_size <= ds->size, so the aligned advance can push ds->off up to 3 bytes past ds->size.

  2. udf_vnops.c:505 β€” the next call's end-of-directory test compares against ds->fsize, not ds->size: c if (ds->offset + ds->off >= ds->fsize) { ... return NULL; } For a multi-extent directory fsize > size, so a 1–3 byte overshoot does not terminate the loop. (A single-extent directory has fsize == size, which is why the bug needs β‰₯2 extents β€” see "Reachability".)

  3. udf_vnops.c:543 β€” the (now mis-aligned) cursor enters the fragmented-FID branch and computes the fragment size: c frag_size = ds->size - ds->off; /* int : 81 - 84 == -3 */

  4. udf_vnops.c:544 and :555 β€” the guard is a signed compare and the length is passed to bcopy whose third argument is size_t: c if (frag_size >= ds->udfmp->bsize) { ... } /* -3 >= 2048 -> FALSE -> BYPASS */ ... ds->buf = kmalloc(ds->udfmp->bsize, M_UDFFID, M_WAITOK | M_ZERO); bcopy(fid, ds->buf, frag_size); /* int(-3) -> size_t 0xFFFFFFFFFFFFFFFD */ The negative int sign-extends to β‰ˆ 16 EB on 64-bit β†’ an unbounded kernel heap write out of the freshly kmalloc(2048)'d ds->buf.

Reproduction (default GENERIC kernel, INVARIANTS ON)

A minimal but valid UDF image (craft_img.py) is built whose root directory has two extents:

extent bytes contents
0 81 FID_A (parent, l_fi=0) at off 0 (size 38, aligned 40); FID_B (OSTA-8bit name "AB", l_fi=3) at off 40 (size 41, aligned 44)
1 40 FID_C (terminal, name "X", l_fi=2) β€” placed at block offset 81 to satisfy the offset % bsize data-pointer quirk in udf_readatoffset (:1057)

With ds->size = 81 for extent 0: * FID_B passes the guard (40 + 41 == 81 <= 81), takes the non-fragmented branch, and :605 advances ds->off to 40 + 44 == 84 β†’ overshoots ds->size by 3. * Next udf_getfid() call: :505 0 + 84 < 121 β†’ not end-of-dir; :539 84 + 38 > 81 β†’ fragmented branch; frag_size = 81 - 84 = -3; signed guard bypassed; bcopy(..., (size_t)-3) β†’ page fault.

Trigger (root mounts the image; the readdir is unprivileged):

vnconfig -c vn0 df0831.udf
mount_udf -o ro /dev/vn0 /mnt
su maxx -c 'ls /mnt'      # -> kernel panic

Panic (5 reproductions, varying fault address β€” a real unbounded read):

panic: vm_fault: fault on stack guard, addr: 0xfffff80118274000
--- trap 000000000000000c, rip = ffffffff80bcab4f ---
memmove() at memmove+0x24f 0xffffffff80bcab4f          <- bcopy() backend
udf_readdir() at udf_readdir+0x138 0xffffffff82602298  <- caller

memmove+0x24f is repe movsq (%rsi),%es:(%rdi) β€” the bulk copy faulting on the source running off the end because the length is 0xFFFFFFFFFFFFFFFD.

Deterministic harness

harness.c transcribes the udf_getfid() arithmetic verbatim (the :605 alignment, the :543 negative frag_size, the :544 signed-guard bypass, the :555 int→size_t bcopy) with a poison allocator (one RW page followed by a PROT_NONE guard page). Output:

udf_getfid() call #2: aligned advance -> ds->off=84  *** OVERSHOOT by 3 (ds->size=81) ***
udf_getfid() call #3: frag_size(int) = -3
                     (size_t)frag_size = 0xfffffffffffffffd  (18446744073709551613 bytes)
                     signed check `frag_size>=bsize` -> BYPASSED
bcopy overrun into poison region -> FAULT caught (crossed the buffer boundary)

Exploit-chain assessment (why impact = panic, not uid0)

The primitive is a genuine heap OOB write (CWE-787), but its magnitude is the whole story: the bad length is always size_t(-1|-2|-3) β‰ˆ 16 EB β€” it is not attacker-tunable to a small, controlled value (frag_size is fixed by the alignment overshoot of at most 3 bytes). Consequently:

  • The repe movsq copy loop in bcopy/memmove runs straight off the end of the kmalloc(2048) destination (and the bp-buffer source) and page-faults on the first unmapped page β€” within a page or two β€” before any code path can dereference a corrupted victim object. The fault is in the copy itself, not in a later consumer, so there is no window in which a corrupted function-pointer / ucred * / refcount gets called or dereferenced.
  • There is no way through this bug to produce a precise, bounded overwrite of a chosen slab object; the sign-extended length is intrinsically maximal, so slab grooming cannot convert it into control-flow hijack on the default GENERIC kernel (and the same page-fault happens on an INVARIANTS-OFF kernel too β€” INVARIANTS/KASSERT are not even reached).

This is a valid stop for the escalation phase: the primitive's nature (maximal-length, immediately-faulting copy) cannot be shaped into a controlled write that survives long enough to be leveraged. The realistic impact ceiling is therefore local kernel panic / DoS from a crafted filesystem image (mount requires root; the triggering readdir is unprivileged β€” the standard "admin mounted attacker media" model). No uid=0 is claimed.

Reachability note (single-extent dirs are safe)

The bug requires a multi-extent directory so that the :505 fsize-based end-of-dir test does not catch the 1–3 byte overshoot (a single extent has fsize == ds->size, so :505 terminates immediately). mkudffs-style images typically produce single-extent directories, which is why this needs a crafted image β€” but multi-extent directories are entirely legal UDF (large dirs, certain writer layouts), so the path is genuinely reachable, not dead code.

The fix (fix.diff)

Minimal, targeted at the root cause β€” clamp the negative frag_size to 0 before it reaches the intβ†’size_t bcopy length promotion. When frag_size == 0 the subsequent code reads the entire FID from the next extent (:566–:593), which is the semantically-correct behavior for a FID that begins in the next extent (the overshoot bytes were the previous FID's alignment padding):

frag_size = ds->size - ds->off;
if (frag_size < 0)        /* <- added: alignment overshoot; FID is in next extent */
    frag_size = 0;
if (frag_size >= ds->udfmp->bsize) { ... }

Fix validation (Phase 8)

fix.diff applied to /usr/src, udf.ko rebuilt with make SYSDIR=/usr/src/sys KERNCONF=X86_64_GENERIC, swapped into /boot/kernel/udf.ko, kldloaded, and the same PoC re-run:

unpatched #0 GENERIC patched udf.ko
mount_udf OK OK
ls /mnt (getdents) panic memmove+0x24f ← udf_readdir (guest dead) 4 dirents (., .., AB, X), errno 0, no panic, guest up

git apply --check passes; the module compiles clean (rc=0); before/after is a clean panic→no-panic. fix_status: fixed.

(ls -l reports "Cannot allocate memory" when stat-ing the AB/X entries because those file inodes (lb_num 4/5) have no File Entry in this minimal image β€” udf_vget returns ENOMEM. This is a property of the tiny test image, not the readdir path, which returns cleanly.)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. fix.diff applied to /usr/src (git apply --check clean), udf.ko rebuilt (make SYSDIR=/usr/src/sys KERNCONF=X86_64_GENERIC, rc=0), swapped into /boot/kernel/udf.ko and kldload-ed. BEFORE (unpatched #0): same PoC panics -- 'panic: vm_fault ... memmove+0x24f <- udf_readdir' (guest dead at db>). AFTER (patched udf.ko): same PoC returns the directory cleanly -- readdir yields 4 dirents ('.', '..', 'AB', 'X'), errno=0, NO panic, guest stays up. The bcopy size_t(-3) overflow is eliminated. => fix closes the bug.

BEFORE (unpatched #0 GENERIC): panic: vm_fault: fault on stack guard, addr: 0xfffff80118274000 / memmove() at memmove+0x24f <- udf_readdir+0x138   [guest frozen at db>]. AFTER (patched udf.ko, same image): readdir probe: entry '.' / '..' / 'AB'(d_ino=4) / 'X'(d_ino=5) / readdir total=4 errno=0 ; guest uptime 11 mins, NO panic.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel unchanged -- udf is a loadable KLD; fix validated by rebuilding udf.ko from patched /usr/src/sys/vfs/udf and kldload-ing it, the standard module hot-swap. kern.version stays #0 because the kernel binary itself is unmodified.)

Confirmed kernel references

Detail

Exploit chain

Primitive IS a genuine kernel heap OOB write (CWE-787) out of a kmalloc(2048) ds->buf, but its magnitude is the whole story: the bad length is always size_t(-1|-2|-3) (~16 EB), fixed by the 3-byte alignment overshoot and NOT attacker-tunable to a small/controlled value. The bcopy/memmove repe movsq loop therefore runs straight off both the source bp-buffer and the destination slab and page-faults on the first unmapped page (within a page or two) BEFORE any code path can dereference a corrupted victim object -- the fault is in the copy itself, leaving no window to hijack a function-pointer/ucred*/refcount. There is no path through this bug to a precise, bounded overwrite of a chosen slab object, so slab grooming cannot convert it to control-flow hijack on GENERIC (and the same page-fault occurs on an INVARIANTS-OFF kernel too -- KASSERT is never reached). This is a valid Phase-6 stop: the primitive's nature (maximal-length, immediately-faulting copy) physically cannot be shaped into a surviving controlled write, so the realistic impact ceiling is local kernel panic / DoS from a crafted filesystem image (mount=root, triggering readdir=unprivileged). No uid0 is claimed. harness.c and craft_img.py hold the trigger; no separate chain file because no escalation chain is derivable from this primitive.

Evidence (decisive lines)

UNPATCHED run (mount + ls as maxx): panic: vm_fault: fault on stack guard, addr: 0xfffff80118274000 / --- trap 000000000000000c, rip = ffffffff80bcab4f --- / memmove() at memmove+0x24f 0xffffffff80bcab4f <- bcopy() backend / udf_readdir() at udf_readdir+0x138 0xffffffff82602298 <- caller (udf_getfid inlined). harness.c proof: frag_size(int) = -3 ; (size_t)frag_size = 0xfffffffffffffffd (18446744073709551613 bytes) / signed check `frag_size>=bsize` -> BYPASSED ; bcopy overrun -> FAULT caught.

PoC changes

Created findings/poc/DF-0831/ from scratch. craft_img.py: a hand-built minimal-but-valid UDF image (AVDP/VRS/PVD/PD/LVD/FSD/FE) whose root directory has 2 short_ad extents, with FID_A (parent) + FID_B (OSTA-8bit 'AB', total_fid_size=41, non-4-aligned) in extent0 sized 81B so the :605 alignment overshoots ds->off by 3; extent1 holds terminal FID_C placed at block-offset 81 to satisfy the offset%bsize data-pointer quirk at :1057. Had to fix several struct-offset bugs in the builder (part_desc start_loc/part_len at 188/192 not 204/208; logvol_desc imp_id/integrity/maps offsets) that first caused 'Couldn't find the fsd'. harness.c: verbatim transcription of udf_getfid() with a poison allocator (RW page + PROT_NONE guard) proving the size_t(-3) overrun faults at the buffer boundary; fixed a longjmp-into-uninit-jmpbuf (added setjmp guard). run.sh: guest-side vnconfig+mount_udf (root) then su maxx -c 'ls /mnt'. fix.diff: one-line if (frag_size < 0) frag_size = 0; clamp.

Verified recommended fix

In sys/vfs/udf/udf_vnops.c at line 543 (udf_getfid fragmented branch), after frag_size = ds->size - ds->off; add if (frag_size < 0) frag_size = 0; before the signed frag_size >= bsize guard and the int->size_t bcopy at :555. This clamps the alignment-overshoot negative value so bcopy's length can never sign-extend to ~16 EB; with frag_size==0 the subsequent code reads the whole FID from the next extent, which is the semantically-correct behavior for a FID whose 3 overshoot bytes were the previous FID's alignment padding. Matches the finding proposal (if(frag_size<0||frag_size>=bsize) + next-extent fetch). The full git-apply-able diff is in findings/poc/DF-0831/fix.diff.

Verdict

REPRODUCED. The bug is real and reachable on the default GENERIC kernel (INVARIANTS ON). Traced udf_getfid() line-by-line: the 4-byte FID alignment at udf_vnops.c:605 advances ds->off up to 3 bytes PAST ds->size; the end-of-dir test at :505 uses ds->fsize (multi-extent total) instead of ds->size so a multi-extent directory is not terminated; the fragmented branch at :543 then computes frag_size = ds->size - ds->off = -3 (negative int); the signed guard frag_size >= bsize at :544 evaluates -3 >= 2048 == false and is BYPASSED; bcopy(..., frag_size) at :555 promotes int(-3) -> size_t 0xFFFFFFFFFFFFFFFD (~16 EB). A crafted UDF image whose root directory spans 2 extents with a non-4-aligned FID (total_fid_size=41, aligned 44) at the extent-0 boundary triggers it on mount+readdir. Confirmed by a kernel panic: 'panic: vm_fault ... memmove() at memmove+0x24f <- udf_readdir()' (5 reproductions, varying fault address -> genuine unbounded read). A deterministic harness (harness.c) independently reproduces the exact arithmetic with a poison allocator, showing the size_t(-3) overrun faults at the buffer boundary.