Unaligned-offset read() on UDF files copies up to bsize-1 bytes past the buffer-cache buf into userspace (udf_readatoffset missing in-block size clamp)
Summary
udf_readatoffset() clamps *size by min(resid, max_size, MAXBSIZE) (:1046-1048) but never by the bytes remaining inside the block, while returning *data = &bp->b_data[offset % bsize] (:1057); udf_readlblks (udf.h:96-100) bread()s only roundup(*size, bsize) bytes, so uiomove(data, size, uio) in udf_read (:403) reads up to offset%bsize (= bsize-1 = 2047) bytes past the end of the buffer-cache buffer and copies them to the user. Works on ANY valid UDF filesystem - no crafted structures required. Any unpriv local user who can open one world-readable file on a mounted UDF volume (optical media, downloaded .udf/.iso images) obtains a deterministic, repeatable kernel-heap disclosure of up to 2047 bytes per call, unbounded aggregate: stale buffer-cache contents (other files' data), allocator remnants. VERIFIED guest uid 1001: pread(fd,2048,1000) returned 1000 bytes of non-disk kernel memory where the file's 0xAA-filled second sector was required, identical across 3 runs. FreeBSD carries the equivalent fix - DragonFly's 2003-vintage port never received it. Fix validated (clamp to bsize - offset%bsize): correct 0xAA tail, normal reads unaffected. No write primitive; the leak is the product.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3102 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_img.py | β | 10.9 KB | view raw | |
| df3102.udf | β | 1.0 MB | β download | |
| poc.c | β | 6.2 KB | view raw | |
| build.sh | β | 115 B | view raw | |
| run.sh | β | 89 B | view raw | |
| run.log | β | 3.1 KB | view raw | |
| run.2.log | β | 3.0 KB | view raw | |
| fix_validate.sh | β | 2.5 KB | view raw | |
| df31xx_combined_fix.diff | β | 1.9 KB | view raw | |
| fix.diff | β | 399 B | view raw | |
| fix_run.log | β | 6.2 KB | view raw | |
| fix_build.log | β | 7.4 KB | view raw | |
| verdict.json | β | 3.6 KB | view raw | |
| README.md | β | 2.5 KB | β raw | |
| VERDICT.md | β | 4.0 KB | β raw | |
| env.txt | β | 236 B | view raw | |
| leak_sample.txt | β | 1.4 KB | view raw |
DF-3102 β unaligned-offset read() leaks kernel heap past the UDF buffer
What
udf_readatoffset() (sys/vfs/udf/udf_vnops.c:1017-1059) maps a file offset
to a block, clamps the read size by min(resid, max_size, MAXBSIZE)
(:1046-1048) but NOT by the bytes remaining inside the block, then hands the
caller *data = &bp->b_data[offset % bsize] (:1057). udf_readlblks()
(udf.h:96-100) only bread()s roundup(*size, bsize) bytes, so the
uiomove(data, size, uio) in udf_read() (:403) reads up to
offset % bsize (= up to bsize-1 = 2047) bytes past the end of the
buffer-cache buffer and copies them to userspace.
Works on ANY valid UDF filesystem β no crafted structures needed, only an unaligned read offset. Any unprivileged user who can open one file on a mounted UDF volume (optical media, distributed .udf images) gets a deterministic, repeatable kernel-heap disclosure (stale buffer-cache data = other files' contents, allocator remnants).
FreeBSD fixed this years ago (their udf_readatoffset reads an
offset-adjusted size from the block boundary); DragonFly's 2003-vintage port
never got the fix.
Reproduce
Host side (from this directory):
./run.sh # builds image, pushes to guest, mounts (root),
# triggers pread as uid 1001
On the guest by hand:
python3 craft_img.py df3102.udf # (host) or use prebuilt scp df3102.udf df3102_poc.c dfbsd:/tmp/ # guest, root: kldload udf; vnconfig -c vn0 /tmp/df3102.udf; mount_udf -o ro /dev/vn0 /mnt cc -O2 -Wall -o /tmp/df3102_poc /tmp/df3102_poc.c # guest, unprivileged: su maxx -c '/tmp/df3102_poc leak1 /mnt/F1'
Expected
Stock kernel (vulnerable):
PREFIX-MATCH: first 1048 bytes correct tail (bytes 1048..2048) differs from expected disk data in 1000/1000 bytes tail identical across 3 runs: YES DF3102_LEAK_CONFIRMED: 1000 bytes past the 2048-byte buf boundary ...
The file's second sector is filled with 0xAA on disk; the vulnerable kernel returns 1000 bytes of kernel heap instead (here: zeros β whatever follows the 2048-byte buf allocation), identical across 3 runs.
Patched kernel (fix.diff applied, module rebuilt): tail == 0xAA
(DF3102_NOT_CONFIRMED, exit 1) β the read now returns correct file data
across two loop iterations.
Fix validation
./fix_validate.sh # applies df31xx_combined_fix.diff (DF-3102/3/4),
# rebuilds udf.ko in the guest, re-runs the PoCs
fix_run.log / fix_build.log hold the full untrimmed output.
DF-3102 β VERDICT
Status: REPRODUCED (leak) β unauthenticated-to-kernel heap disclosure via
read() on any mounted UDF filesystem, as an unprivileged user.
Root cause
sys/vfs/udf/udf_vnops.c:1045-1048:
/* Adjust the size so that it is within range */
if (*size == 0 || *size > max_size)
*size = max_size;
*size = min(*size, MAXBSIZE);
followed by :1050 udf_readlblks(udfmp, sector, *size, bp) β which
(udf.h:96-100) bread()s only roundup(*size, bsize) bytes β and :1057:
*data = (uint8_t *)&bp1->b_data[offset % udfmp->bsize];
No clamp of *size by bsize - (offset % bsize) exists. udf_read()
(:398-408) then does uiomove(data, size, uio), which copies size bytes
starting at b_data + offset%bsize β i.e. up to offset%bsize bytes beyond
the buffer end β straight into the user's buffer.
Reproduction
Crafted-but-fully-valid minimal UDF image (craft_img.py, extends the proven
DF-0831 skeleton): file F1 with one short_ad extent {len=4096, pos=4}
(exactly two 2048-byte blocks), sector 4 = ASCII pattern, sector 5 = 0xAA.
pread(fd, buf, 2048, 1000)β bmap lands on sector 4,o = 1000;*size = min(2048, 4096, 65536) = 2048; buffer = 2048 bytes;uiomove(b_data + 1000, 2048)touchesb_data[1000..3048)β 1000 bytes past the 2048-byte allocation.- Reference
pread(fd, g, 3048, 0)(in-bounds 4096-byte read) provides the ground truth.
Observed on the stock INVARIANTS kernel (run.log, uid=1001):
PREFIX-MATCH: first 1048 bytes correct tail (bytes 1048..2048) differs from expected disk data in 1000/1000 bytes tail identical across 3 runs: YES
The correct tail must be 0xAAΓ1000 (disk sector 5); the kernel returned
non-disk kernel memory instead (zeros at this allocation's adjacency β i.e.
whatever follows the buf), stable across runs. Deterministic, repeatable,
bounded only by bsize-1 per call and freely repeatable (arbitrary volume).
Method note (attempts): the first run executed the reference read first,
which cached the same block in a 4096-byte buffer and masked the overread
(getblk returned the larger existing buffer). Reordering the PoC β
unaligned preads FIRST, reference afterwards β reproduces on a cold cache,
which is also the real-world access pattern. The final run.log reflects
the corrected order and the unprivileged (su maxx) execution.
Impact
- Kernel heap info leak, up to
bsize-1(typically 2047) bytes per call, unbounded aggregate; content includes stale buffer-cache pages (cross-file/cross-volume data) and allocator remnants. - No write primitive β read-only filesystem, copy from kernel to user.
- Same class reachable through
udf_readdir()(ds->data[ds->off]walk sharesudf_readatoffset), though directory offsets are usually aligned.
Fix validation (fix.diff)
df31xx_combined_fix.diff (this finding + DF-3103 + DF-3104) applied to the
guest's /usr/src, udf.ko rebuilt in /usr/src/sys/vfs/udf, module
swapped, image remounted, same PoC re-run (fix_run.log):
--- DF-3102 on patched kernel (expect tail == 0xAA, NOT_CONFIRMED) --- tail (bytes 1048..2048) differs from expected disk data in 0/1000 bytes DF3102_NOT_CONFIRMED: tail matched expected 0xAA disk data
Normal file reads still work (od shows the DF3102-S0-DATA- pattern).
The one-line clamp *size = min(*size, udfmp->bsize - (offset % udfmp->bsize));
makes the read complete correctly across two loop iterations (1048 + 1000
bytes) and eliminates the overread entirely.
Upstream reference
FreeBSD sys/fs/udf/udf_vnops.c udf_readatoffset() carries the equivalent
fix ("Because we will read starting at block boundary, we need to adjust how
much we need to read so that all promised data is in").
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedfix.diff applied to guest source, udf.ko rebuilt (fix_build.log, clean -Werror build), module swapped in, same image remounted, exact PoC re-run: the 1000-byte overread is gone, the read returns the correct 0xAA file tail across two loop iterations, and normal file reads are unaffected (fix_run.log).
['fix_run.log', 'fix_build.log', 'df31xx_combined_fix.diff']
Confirmed kernel references
Detail
Exploit chain
any mounted UDF volume -> unprivileged open() of one world-readable file -> pread() at an unaligned offset with count >= bsize - (offset % bsize) -> kernel heap bytes (stale buffer-cache content / allocator remnants) disclosed to userspace, repeatable without bound. No write primitive (read-only path).
Evidence (decisive lines)
["run.log: 'PREFIX-MATCH: first 1048 bytes correct' + 'differs from expected disk data in 1000/1000 bytes' + 'tail identical across 3 runs: YES' + 'DF3102_LEAK_CONFIRMED'", "fix_run.log: patched kernel -> 'differs ... in 0/1000 bytes' (leak gone, correct 0xAA data returned)", 'fix_build.log: udf.ko rebuilt cleanly with the fix', 'craft_img.py: F1 extent {len=4096, pos=4}, sector5=0xAA', 'poc.c leak1(): unaligned-first ordering, 3 runs, reference read last']
PoC changes
Seed PoC authored fresh for pass 2. Two iterations were needed: (1) add
Verified recommended fix
In udf_readatoffset(), after the MAXBSIZE clamp add: size = min(size, udfmp->bsize - (offset % udfmp->bsize));
Verdict
udf_readatoffset() clamps the read size by min(resid, max_size, MAXBSIZE) but not by the in-block remainder, while returning data = b_data + offset%bsize and bread()ing only roundup(size, bsize) bytes; uiomove() in udf_read() therefore copies up to bsize-1 bytes past the buffer-cache buffer to userspace. Reproduced on the stock INVARIANTS guest as uid 1001 with a valid minimal UDF image: pread(fd, 2048, 1000) returned 1000 bytes of non-disk kernel heap (expected 0xAA-filled file tail), identical across 3 runs. Fixed by a one-line clamp; the patched module returns correct file data.
No comments yet.