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 ------------------ 1. 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). 2. 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). 3. 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. 4. A read (or readdir) at an offset past the first descriptor's coverage. Proof of the bug ---------------- Three independent proofs: 1. 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. 2. 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)". 3. 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)