Off-by-one OOB read in udf_bmap_internal ICB iteration β ad_offset > l_ad should be + sizeof(ad)
Summary
udf_vnops.c:1104/1128 if(ad_offset>fentry->l_ad) β should be ad_offset+sizeof(short_ad/long_ad)>l_ad. When l_ad is exact multiple of sizeof(short_ad)=8 or sizeof(long_ad)=16: loop iteration with ad_offset==l_ad passes check (> is strict). GETICBLEN reads 8/16 bytes from &fentry->data[l_ea+l_ad] = fully OOB past fentry allocation. Leaked bytes interpreted as disk sector pos+len used for udf_readlblks. Trigger: crafted UDF image file l_ad=8(single short_ad) read past first extent. Fix: ad_offset+sizeof(short_ad)>l_ad / ad_offset+sizeof(long_ad)>l_ad.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0832 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | userspace deterministic harness replicating the verbatim buggy udf_bmap_internal ICB loop | 6.6 KB | view raw |
| udf_oob_kmod.c | exploit-chain | in-kernel kld module that proves the OOB read via a guard sentinel against the real slab allocator | 4.8 KB | view raw |
| make_image.py | trigger-source | Python UDF image builder: crafts a minimal image whose target FE has l_ad=8, inf_len=4096 | 18.6 KB | view raw |
| df0832.udf | trigger-source | pre-built crafted UDF image (320 sectors x 2048 bytes) | 640.0 KB | β download |
| build.sh | build-log | exact build command for the userspace harness | 274 B | view raw |
| run.sh | run-log | exact run command for the userspace harness | 588 B | view raw |
| build.log | build-log | full compiler output of the harness build | 65 B | view raw |
| run.log | run-log | full runtime output: harness detects OOB (exit 1) | 765 B | view raw |
| fix.diff | suggested-fix | git-apply-able diff: > -> >= at udf_vnops.c:1104 and :1128 | 616 B | view raw |
| fix_build.log | build-log | full nativekernel build output of the single-fix kernel (rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | patched-kernel fix validation: real-image read + buggy vs fixed module | 753 B | view raw |
| baseline_evidence.txt | run-log | unpatched #0 kernel evidence: module OOB detection + real-image trigger | 1.2 KB | view raw |
| env.txt | environment | guest uname, kern.version, cc version, INVARIANTS, vfs.usermount | 492 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, proof, fix, fix validation | 6.9 KB | β raw |
| README.md | readme | human reproduce doc | 1.7 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-0832 β Off-by-one OOB read in udf_bmap_internal ICB iteration
Bug
udf_bmap_internal (sys/vfs/udf/udf_vnops.c) iterates an ICB allocation-
descriptor list with a strict > bound check (if (ad_offset > fentry->l_ad)).
When l_ad is an exact multiple of sizeof(short_ad) (8) or sizeof(long_ad)
(16), the iteration where ad_offset == l_ad passes the check and the
subsequent GETICB() dereferences &fentry->data[l_ea + l_ad] β one
descriptor PAST the end of the allocation-descriptor area. This is a heap
OOB read of 8 bytes (short_ad) or 16 bytes (long_ad).
Severity
Medium β OOB read in filesystem image parsing (mount-time / bmap threat model). No write primitive; ceiling is info-leak / DoS.
Files
harness.cβ userspace deterministic harness (replicates the verbatim loop)udf_oob_kmod.cβ in-kernel kld module (deterministic, real slab allocator)make_image.pyβ Python UDF image builderdf0832.udfβ pre-built crafted UDF imagefix.diffβ the fix (>β>=)build.sh/run.shβ build/run the userspace harness
Reproduce
./build.sh && ./run.sh
# Expected (bug present): "OOB read detected: YES", exit 1
In-kernel module (stronger proof)
# as root, on the guest:
cd /tmp/kmod && make # (Makefile alongside udf_oob_kmod.c)
kldload ./udf_oob.ko
dmesg | grep DF-0832
# Expected: "DF-0832: OOB READ DETECTED ..."
kldunload udf_oob
Real UDF image (actual kernel code path)
# as root:
vnconfig -c vn0 df0832.udf
mount_udf -o rdonly /dev/vn0 /mnt
dd if=/mnt/target bs=1 skip=2048 count=1 # -> EINVAL
dmesg | tail # -> "File offset out of bounds"
Fix
sys/vfs/udf/udf_vnops.c lines 1104 and 1128: change > to >=.
See fix.diff.
DF-0832 β Off-by-one OOB read in udf_bmap_internal ICB iteration
Verdict: REPRODUCED (OOB read confirmed via harness + in-kernel module; real-image mount proves the code path is reachable in the running kernel).
Summary
udf_bmap_internal (sys/vfs/udf/udf_vnops.c) iterates an Information
Control Block (ICB) allocation-descriptor list using an ad_offset that
is checked > fentry->l_ad (strict greater-than). When l_ad is an
exact multiple of sizeof(short_ad) (=8) or sizeof(long_ad) (=16), the
iteration where ad_offset == l_ad passes the check (because > is
strict, not >=), and the subsequent GETICB(long_ad, fentry, fentry->l_ea
+ ad_offset) dereferences &fentry->data[l_ea + l_ad] β one descriptor
PAST the end of the allocation-descriptor area. This is an 8-byte (short_ad)
or 16-byte (long_ad) heap OOB read from the M_UDFFENTRY slab allocation.
Root cause (path:line)
sys/vfs/udf/udf_vnops.c:1104 if (ad_offset > fentry->l_ad) (short_ad branch)
sys/vfs/udf/udf_vnops.c:1128 if (ad_offset > fentry->l_ad) (long_ad branch)
At ad_offset==l_ad, the check ad_offset > l_ad is FALSE (8 > 8 == false),
so execution falls through to line 1108/1132:
icb = GETICB(long_ad, fentry, fentry->l_ea + ad_offset);
which expands (ecma167-udf.h:371) to:
(struct long_ad *)&fentry->data[fentry->l_ea + ad_offset]
i.e. &fentry->data[l_ea + l_ad] β exactly one past the end of the live
AD area (data[l_ea .. l_ea+l_ad-1]). The GETICBLEN at line 1109/1133 then
reads ((struct short_ad *)icb)->len β 4 bytes starting at the OOB address,
and ((struct short_ad *)icb)->pos is read at line 1114/1138 β another 4
bytes. Total: 8 bytes OOB for short_ad, 16 bytes for long_ad.
The fentry is allocated in udf_vget (udf_vfsops.c:527-528): size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad; unode->fentry = kmalloc(size, M_UDFFENTRY, M_WAITOK | M_ZERO); The OOB read lands in the slab chunk's padding bytes (or, if the chunk is exactly a power-of-2 slab size, in the adjacent slab chunk).
Trigger conditions
- A UDF filesystem image is mounted (root mount threat model β an admin mounts an attacker-controlled image, or vfs.usermount=1 with a user-owned device).
- A file (FE) whose icbtag.flags & 0x7 == 0 (short_ad) or 1 (long_ad), and whose l_ad is an exact multiple of sizeof(short_ad)/sizeof(long_ad).
- The file's inf_len is larger than the sum of its allocation descriptors' covered lengths β so reading past the last covered offset forces bmap_internal to iterate past the last descriptor.
- A read (or readdir) at an offset past the first descriptor's coverage.
Proof of the bug
Three independent proofs:
-
Userspace harness (harness.c): replicates the verbatim buggy do/while loop with a controlled file_entry buffer and a guard sentinel placed right past the live AD area. With l_ad=8 and offset=2048, the loop reads the sentinel (0xDEADBEEF/0xCAFEBABE) as a live short_ad, proving the off-by-one. Output: "OOB read detected: YES", exit 1.
-
In-kernel module (udf_oob_kmod.c): same loop running in kernel context against a real kmalloc'd file_entry. kldload on the unpatched #0 kernel prints: "DF-0832: OOB READ DETECTED at ad_offset==l_ad==8" and "CONFIRMED: returned values are the guard sentinel (0xcafebabe / 0xdeadbeef)".
-
Real UDF image (make_image.py): a minimal crafted UDF image with a file whose FE has l_ad=8 (one short_ad len=2048 pos=68) but inf_len=4096. Mount + read at offset 2048 triggers udf_bmap_internal on the actual kernel code path. dmesg shows "File offset out of bounds" (the kprintf at udf_vnops.c:1105), confirming the loop ran past the last descriptor. The read returns EINVAL. (The OOB read itself is silent on this kernel because the slab padding bytes are zero β M_ZERO + fresh page β so the OOB icblen is 0, causing the loop to continue to iter 3 where ad_offset=16
l_ad=8 is true and the proper bound check fires. The OOB read DID happen at iter 2, proven by source analysis and the harness/module.)
Exploit chain
Not applicable (pure OOB read, no write primitive). The realistic impact ceiling is: the OOB bytes are interpreted as a disk sector number (pos) and a length (icblen), then used in udf_readlblks to read that sector. If the OOB bytes are non-zero (e.g. INVARIANTS-poisoned adjacent slab chunk with 0xdeadc0de), the kernel attempts to read a sector at ~3.7 billion, which fails with EIO. On a system where the adjacent memory contains a valid sector number, the kernel would return data from an arbitrary disk offset (information disclosure from the wrong part of the disk). There is no write primitive derivable from this bug alone.
Fix
Change > to >= at both call sites, so that the check fires BEFORE the
GETICB dereference when ad_offset reaches l_ad:
sys/vfs/udf/udf_vnops.c:1104 if (ad_offset >= fentry->l_ad)
sys/vfs/udf/udf_vnops.c:1128 if (ad_offset >= fentry->l_ad)
This is a one-character-per-site change. The GETICB/GETICBLEN dereferences at lines 1108-1109/1132-1133 become unreachable when ad_offset==l_ad, preventing the OOB read entirely.
Fix validation
Built the single-fix kernel (make -j6 nativekernel, fix applied to
/usr/src/sys/vfs/udf/udf_vnops.c). Booted 6.5-DEVELOPMENT #1. Re-ran:
-
Real UDF image mount + read at offset 2048: returns EINVAL cleanly, dmesg shows "File offset out of bounds" (now from the
>=check at iter 2, BEFORE the OOB read). Normal reads (offset < 2048) work fine. Observable behavior is identical to unpatched (EINVAL) because the OOB bytes on the unpatched kernel were zero β but the GETICB dereference that WOULD have read them is now prevented by the>=bound. -
In-kernel module with FIXED loop (
>=): prints "DF-0832: no OOB (bound is tight: >= fired at ad_offset==l_ad)", rc=22 (EINVAL), sector=0x0, oob_read=0. Confirms the fix logic. -
In-kernel module with BUGGY loop (
>) on the patched kernel: still detects OOB (the module has its own loop copy, unaffected by the kernel fix) β this demonstrates the bug exists in the loop logic, and the kernel fix corrects exactly that logic in udf_bmap_internal.
Verdict: fix closes the bug. The >= bound prevents the OOB GETICB
dereference at ad_offset==l_ad on both the short_ad and long_ad paths.
PoC files
harness.c β userspace deterministic harness (verbatim loop replica)
udf_oob_kmod.c β in-kernel kld module (deterministic, real slab allocator)
make_image.py β Python UDF image builder (crafts the trigger image)
df0832.udf β pre-built crafted UDF image (320 sectors, 640 KB)
build.sh / run.sh β exact build/run commands for the userspace harness
fix.diff β git-apply-able unified diff (>->>=` at both sites)
Fix verification
fixedVALIDATED the fix: applied fix.diff (> -> >= at udf_vnops.c:1104 and :1128), built single-fix kernel via make -j6 nativekernel (rc=0, ~12 min), installed kernel.stripped to /boot/kernel/kernel (after chflags noschg), booted #1. On the patched kernel: (a) the fixed in-kernel module (>= loop) prints 'no OOB (bound is tight: >= fired at ad_offset==l_ad)' with rc=22(EINVAL) and oob_read=0, vs the buggy module (> loop) which still prints 'OOB READ DETECTED' -- proving the fix logic is correct; (b) the real UDF image mount+read at offset 2048 returns EINVAL cleanly with dmesg 'File offset out of bounds' (now from the >= check at iter 2, BEFORE the GETICB dereference, not from iter 3 after the OOB read); (c) normal reads (offset < 2048) work fine ('AAAA...' returned). The observable userspace behavior (EINVAL) is identical before/after because the OOB bytes were zero on this kernel, but the GETICB dereference at ad_offset==l_ad is now provably unreachable -- the fix closes the bug.
baseline #0: in-kernel module -> 'OOB READ DETECTED... CONFIRMED: sentinel 0xcafebabe/0xdeadbeef' / real image -> EINVAL + 'File offset out of bounds' (OOB read happened at iter 2). patched #1: fixed module -> 'no OOB (bound is tight: >= fired)' rc=22 oob_read=0 / real image -> EINVAL + 'File offset out of bounds' (now from >= check at iter 2, no OOB read). buggy module on #1 -> still 'OOB READ DETECTED' (module has own loop copy, unaffected by kernel fix -- demonstrates the bug exists in the loop logic and the kernel fix corrects exactly that logic).
Confirmed kernel references
Detail
Exploit chain
none -- pure OOB read (no write primitive derivable). The OOB bytes are interpreted as a short_ad {len, pos} and used to compute a disk sector for udf_readlblks. Ceiling: if the adjacent slab memory is non-zero (e.g. INVARIANTS-poisoned with 0xdeadc0de, or residual data from a prior allocation), the kernel reads a garbage sector (~3.7 billion) -> EIO, or on a system where the adjacent bytes happen to form a valid sector number, data from an arbitrary disk offset is returned (limited info disclosure from the wrong part of the disk image). No uid=0 chain is possible from this bug alone.
Evidence (decisive lines)
Userspace harness: 'OOB read detected: YES' / 'CONFIRMED: returned sector/max_size are the guard sentinel (0xcafebabe / 0xdeadbeef)' exit 1. In-kernel module on #0: 'DF-0832: OOB READ DETECTED at ad_offset==l_ad==8... CONFIRMED: returned values are the guard sentinel (0xcafebabe / 0xdeadbeef)'. Real UDF image mount+read at offset 2048 on #0: 'dd: /tmp/mnt/target: Invalid argument' + dmesg 'File offset out of bounds'. Fixed module on #1: 'DF-0832: no OOB (bound is tight: >= fired at ad_offset==l_ad)' rc=22(EINVAL) sector=0x0 oob_read=0.
PoC changes
Created the entire evidence pack from scratch (no prior PoC existed). harness.c: userspace deterministic harness replicating the verbatim buggy do/while loop with a guard sentinel. udf_oob_kmod.c: in-kernel kld module proving the OOB read against the real slab allocator. make_image.py: Python UDF image builder crafting a minimal valid UDF image (anchor VDP, PVD, PD, LVD, FSD, root dir FE+FIDs, target FE with l_ad=8 + inf_len=4096 mismatch) -- required fixing tag checksum algorithm (bytes 0-14 minus byte 4, not 0-15), image size (must include sector 256 for anchor), struct file_entry field sizes (perm is uint32 not uint16, long_ad is 16 bytes not 12), file_type constants (4=VDIR not 2), and FID file_char flags (UDF_FILE_CHAR_PAR=0x08 not 0x10). fix.diff: > to >= at both call sites. build.sh/run.sh: exact repro commands.
Verified recommended fix
Change if (ad_offset > fentry->l_ad) to if (ad_offset >= fentry->l_ad) at sys/vfs/udf/udf_vnops.c:1104 (short_ad branch) and :1128 (long_ad branch). This makes the bound check fire BEFORE the GETICB dereference at lines 1108/1132 when ad_offset reaches l_ad, preventing the OOB heap read entirely. Matches finding proposal (the DB summary states the same fix). The full git-apply-able diff is in findings/poc/DF-0832/fix.diff.
Verdict
REPRODUCED. The off-by-one OOB read in udf_bmap_internal is confirmed at sys/vfs/udf/udf_vnops.c:1104 (short_ad) and :1128 (long_ad): the bound check if (ad_offset > fentry->l_ad) uses strict >, so when l_ad is an exact multiple of sizeof(short_ad)=8 (or sizeof(long_ad)=16), the iteration with ad_offset==l_ad passes the check and GETICB at line 1108/1132 dereferences &fentry->data[l_ea+l_ad] = ONE PAST the allocation-descriptor area -- an 8-byte (short_ad) or 16-byte (long_ad) heap OOB read from the M_UDFFENTRY slab allocation (allocated at udf_vfsops.c:527-528). Three independent proofs: (1) userspace harness replicating the verbatim loop detects the OOB read via a guard sentinel; (2) in-kernel kld module detects the OOB read against the real slab allocator (kldload on #0 prints 'OOB READ DETECTED... CONFIRMED: returned values are the guard sentinel 0xcafebabe/0xdeadbeef'); (3) a crafted UDF image (make_image.py) mounted via mount_udf and read at offset 2048 triggers the actual kernel udf_bmap_internal code path -- dmesg shows 'File offset out of bounds', confirming the loop ran past the last descriptor. The OOB read is silent on the default GENERIC kernel because the slab padding bytes are zero (M_ZERO + fresh page), so the OOB icblen=0 causes the loop to continue to iter 3 where the proper bound check fires -- but the 8-byte OOB GETICB dereference at iter 2 is logically necessary from the source and proven by the harness/module.
No comments yet.