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)
PoC verification
Evidence pack
findings/poc/DF-0831 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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:
-
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;Whentotal_fid_sizeis not a multiple of 4, this rounds up by 1β3 bytes. The fragmentation guard at:539β:540only requiresds->off + total_fid_size <= ds->size, so the aligned advance can pushds->offup to 3 bytes pastds->size. -
udf_vnops.c:505β the next call's end-of-directory test compares againstds->fsize, notds->size:c if (ds->offset + ds->off >= ds->fsize) { ... return NULL; }For a multi-extent directoryfsize > size, so a 1β3 byte overshoot does not terminate the loop. (A single-extent directory hasfsize == size, which is why the bug needs β₯2 extents β see "Reachability".) -
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 */ -
udf_vnops.c:544and:555β the guard is a signed compare and the length is passed tobcopywhose third argument issize_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 negativeintsign-extends to β 16 EB on 64-bit β an unbounded kernel heap write out of the freshlykmalloc(2048)'dds->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 movsqcopy loop inbcopy/memmoveruns straight off the end of thekmalloc(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
fixedVALIDATED. 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.
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.
No comments yet.