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

Embedded-ICB read path trusts unvalidated l_ea/l_ad: udf_vget's wrapping int allocation plus udf_readatoffset's &fentry->data[l_ea] with sign-extended negative *size yields a wild OOB kernel-heap read

Summary

udf_vget (udf_vfsops.c:527) computes size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad in wrapping 32-bit arithmetic from disk fields, so crafted values (l_ea=0x1000, l_ad=0xFFFFF150 -> int 0x200) allocate a 512-byte M_UDFFENTRY object while the copied fields stay huge; udf_readatoffset's embedded path (:1037-1039) then returns *data = &fentry->data[0x1000] (~3.7KB past the allocation) and *size = (int)0xFFFFF150 = -5936, which udf_read passes to uiomove as (size_t)-5936 -> copies user-chosen byte counts from out-of-bounds heap. With l_ea=0x40000000 the kernel demonstrably dereferences a pointer 1GB past the object (EFAULT). Distinct sink from pass-1 DF-0880 (vget bcopy source over-read). Crafted UDF image mounted by root -> unpriv open()+read() of the embedded file -> deterministic kernel heap disclosure at attacker-chosen offset (full 32-bit range past a small object) and length. VERIFIED guest uid 1001: EMB read returns 96 bytes of kernel heap from fentry+4272 (3x + 1024/4096-byte reads); EMB2 (l_ea=0x40000000) -> EFAULT proving the wild dereference. Fix validated (vget l_ea/l_ad validation + embedded-path offset handling + size==0 break): patched module rejects EMB/EMB2 with EINVAL before any allocation/read.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3103 Β· 15 files
FileTypeDescriptionSize
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 β€” 27.5 KB view raw
fix.diff β€” 1.4 KB view raw
df31xx_combined_fix.diff β€” 1.9 KB view raw
fix_validate.sh β€” 2.5 KB view raw
fix_build.log β€” 7.4 KB view raw
verdict.json β€” 3.8 KB view raw
README.md β€” 2.5 KB ↓ raw
VERDICT.md β€” 3.9 KB ↓ raw
env.txt β€” 236 B view raw
leak_sample.txt β€” 2.3 KB view raw

DF-3103 β€” embedded-ICB read uses unvalidated l_ea/l_ad: wild OOB heap read

What

Two cooperating defects:

  1. udf_vget() (sys/vfs/udf/udf_vfsops.c:527) computes the file-entry copy size as size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad in wrapping 32-bit int arithmetic from fully attacker-controlled disk fields, then kmalloc(size) + bcopy. Crafted l_ea/l_ad wrap the sum to a small value (e.g. 176 + 0x1000 + 0xFFFFF150 = 0x100000200 β†’ int 0x200) so the M_UDFFENTRY allocation is only 512 bytes while the on-disk fields stay huge in the copied structure.
  2. The embedded-ICB path of udf_readatoffset() (sys/vfs/udf/udf_vnops.c:1037-1039) then does *data = &fentry->data[l_ea]; *size = l_ad; with no bounds check and no offset handling. With the wrapped fields: *data = 4272 bytes into a 512-byte allocation, *size = (int)0xFFFFF150 = -5936, which udf_read()'s uiomove(data, size, uio) (:403) receives as (size_t)-5936 β†’ it happily moves uio_resid bytes of kernel heap from far past the allocation into the user's buffer.

Also demonstrated with l_ea = 0x40000000 (file EMB2): *data lands ~1 GB past the object; uiomove()'s fault handling turns the unmapped wild read into EFAULT β€” proving the kernel dereferences the wild pointer.

Related pass-1 finding: DF-0880 covers the bcopy source over-read at the same udf_vget site; this finding is the distinct vnops sink: the integer-wrap β†’ undersized allocation β†’ &fentry->data[l_ea] + negative *size β†’ sign-extended uiomove chain in udf_readatoffset()/udf_read().

Reproduce

./run.sh     # mounts the crafted image (root), then as uid 1001:
             #  - 3x read(EMB, 96)   -> 96 OOB heap bytes each
             #  - read(EMB, 1024/4096)
             #  - read(EMB2, 96)     -> EFAULT (wild pointer +1GB)

Expected (stock kernel)

read() returned 96 bytes from embedded file (requested 96)
DF3103_LEAK_CONFIRMED: 96 bytes copied from kernel heap at
  fentry->data[0x1000] (~3.7KB past a 512-byte M_UDFFENTRY allocation)
...
read(96) failed: Bad address        <- EMB2 (l_ea=0x40000000)

Patched kernel (fix.diff: vget validation + embedded-path offset handling): open /mnt/EMB: Invalid argument β€” the malformed file entry is rejected before any allocation or read.

Fix validation

See DF-3102/fix_validate.sh + df31xx_combined_fix.diff (shared driver for DF-3102/3103/3104); fix_run.log excerpt in this directory.

VERDICT.md
↓ download raw

DF-3103 β€” VERDICT

Status: REPRODUCED (leak / wild-pointer read) β€” crafted UDF image mounted by root gives any unprivileged user an out-of-bounds kernel-heap read (and a provably wild pointer dereference) via plain open()+read().

Root cause chain

sys/vfs/udf/udf_vfsops.c:527-530:

    size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad;      /* wraps in u32/int */
    unode->fentry = kmalloc(size, M_UDFFENTRY, M_WAITOK | M_ZERO);
    bcopy(bp->b_data, unode->fentry, size);

Crafted FE (icbtag.flags & 7 == 3, embedded data): l_ea = 0x1000, l_ad = 0xFFFFF150 β†’ sum 0x100000200 β†’ size = 0x200 β†’ a 512-byte allocation that still carries the huge raw fields.

sys/vfs/udf/udf_vnops.c:1037-1039 (embedded path of udf_readatoffset):

        fentry = node->fentry;
        *data = &fentry->data[fentry->l_ea];   /* fentry + 176 + 4096 */
        *size = fentry->l_ad;                  /* (int)0xFFFFF150 = -5936 */

No validation of l_ea/l_ad against the allocation, no offset handling, no negative-size guard. udf_read() (:401-403) passes both to uiomove(data, size, uio); the negative int length sign-extends to a huge size_t, so uiomove moves uio_resid (user-chosen) bytes from fentry + 4272 β€” ~3.7 KB past the 512-byte M_UDFFENTRY object.

Reproduction (run.log)

Crafted image: EMB FE at partition-sector 3 with the fields above, inf_len = 0x10000; EMB2 FE with l_ea = 0x40000000, l_ad = 0xC0000140 (same 512-byte wrap). Runs as su maxx (uid 1001):

  • read(EMB, 96) Γ—3: 96 bytes returned each time from kernel heap past the allocation (DF3103_LEAK_CONFIRMED Γ—3, deterministic).
  • read(EMB, 1024) and read(EMB, 4096): same, no fault β€” the 512-byte malloc zone is backed by large slabs, so the overread stays mapped.
  • read(EMB2, 96): EFAULT (Bad address) β€” uiomove faulted reading fentry->data[0x40000000] (1 GB past the object); DF's copy-fault handling converted the kernel-mode wild read into EFAULT instead of a panic. This is direct proof the kernel dereferenced the attacker-chosen wild offset.

A "correct" kernel would have rejected this file entry outright (an FE is one block; l_ea + l_ad cannot legitimately reach 2^32) β€” see fix.

Impact

  • Crafted-media heap OOB read with attacker-chosen offset (l_ea fully controlled: any offset up to 4 GB past a small heap object) and attacker-chosen length (read count). Deterministic leak of kernel heap; an unmapped choice yields EFAULT (or, on kernels without the copy-fault guard, a kernel page fault/panic β†’ DoS).
  • Read primitive only β€” no write, so no direct uid=0 chain from this bug alone. Combined with DF-0831-style write primitives on the same read-only-image attack surface, it provides the info-leak half of an exploit pair (heap layout disclosure).

Fix validation (fix.diff)

  • udf_vget(): reject l_ea/l_ad that don't fit in one logical block (bsize - UDF_FENTRY_SIZE), which kills the wrap and the DF-0880 bcopy over-read at once.
  • udf_readatoffset() embedded path: FreeBSD-style offset handling (*data += offset; *size -= offset; / *size = 0 past EOF).
  • udf_read(): break on size == 0 so a short embedded file cannot spin the loop.

With the patched module (fix_run.log, same image, same PoC):

--- DF-3103 on patched kernel (expect open EMB to fail) ---
open /mnt/EMB: Invalid argument
--- DF-3103 EMB2 on patched kernel (expect open to fail) ---
open /mnt/EMB2: Invalid argument

No allocation, no read, clean EINVAL β€” behavior eliminated at the root.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Combined fix (vget validation + embedded-path offset handling + zero-size loop break) applied, udf.ko rebuilt, same image remounted: open('/mnt/EMB') and open('/mnt/EMB2') now fail with EINVAL at vget before any allocation or read; no OOB access occurs (fix_run.log).

['fix_run.log', 'fix_build.log', 'fix.diff', 'df31xx_combined_fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 (same kernel; udf.ko module rebuilt from patched /usr/src/sys/vfs/udf and kldload'ed)

Confirmed kernel references

Detail

Exploit chain

crafted UDF image (attacker-supplied media or downloaded image mounted by root) -> unprivileged open()+read() of the embedded file -> deterministic kernel heap disclosure at attacker-chosen offset/length; wild-offset variant yields EFAULT/page-fault (DoS on kernels without copy-fault guards). Read primitive only; serves as the info-leak half of an exploit pair with the DF-0831 write primitive on the same surface.

Evidence (decisive lines)

["run.log: 'read() returned 96 bytes from embedded file' x3 + 'DF3103_LEAK_CONFIRMED' x3 (deterministic), 1024- and 4096-byte reads also returned OOB heap", "run.log: 'read(96) failed: Bad address' for EMB2 (l_ea=0x40000000 -> EFAULT on the wild kernel read)", "fix_run.log: 'open /mnt/EMB: Invalid argument' and 'open /mnt/EMB2: Invalid argument' on the patched module", 'craft_img.py: EMB l_ea=0x1000 l_ad=0xFFFFF150 (176+...=0x100000200 -> int 0x200); EMB2 l_ea=0x40000000 l_ad=0xC0000140']

PoC changes

Seed PoC authored fresh for pass 2 (extends the DF-0831 crafter). One iteration: added ; added the EMB2 wild-pointer variant after the first successful leak runs to prove the primitive beyond the slab-mapped neighborhood.

Verified recommended fix

Validate l_ea/l_ad in udf_vget against bsize - UDF_FENTRY_SIZE (FE is one block), and give udf_readatoffset()'s embedded path offset/remaining-length handling (FreeBSD-style).

Verdict

udf_vget() computes the fentry allocation size as UDF_FENTRY_SIZE + l_ea + l_ad in wrapping 32-bit arithmetic from disk-controlled fields, so crafted l_ea/l_ad wrap it to a small kmalloc while udf_readatoffset()'s embedded-ICB path (udf_vnops.c:1037-1039) still uses &fentry->data[l_ea] with *size = l_ad (sign-extended negative int); uiomove() then copies user-chosen byte counts from up to 4 GB past the undersized M_UDFFENTRY allocation. Reproduced as uid 1001: read(EMB, 96/1024/4096) each returned OOB kernel heap bytes; EMB2 with l_ea=0x40000000 produced EFAULT, proving the kernel dereferences the attacker-chosen wild pointer. Patched module rejects the malformed file entry with EINVAL at vget.