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

ext2_readlink truncates i_size (uint64) to signed int β€” unbounded kernel heap disclosure via uiomove (ext2 analog of DF-0778)

Summary

ext2_vnops.c:1350 int isize. :1352 isize=ip->i_size silently truncates uint64 to signed int. :1353 if(isize<mnt_maxsymlinklen) signed compare β€” corrupted i_size with bit 31 set yields negative isize passes compare. :1354 uiomove(i_shortlink,isize,uio) int->size_t sign-extends to ~SIZE_MAX kern_subr.c:117 loop copies kernel heap from i_shortlink (48 bytes) through inode struct into adjacent heap to user readlink. ext2 analog of DF-0778 (ufs_readlink same bug). EXT2F_ROCOMPAT_LARGE_FILE enables e2di_size_high full 64-bit attacker-controlled. Trigger: crafted ext2 image symlink inode i_size=0x80000000 mount then unprivileged readlink(). Fix: uint64_t isize + if(isize>EXT2_MAXSYMLINKLEN||isize>INT_MAX||i_blocks!=0) return VOP_READ.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0784 Β· 19 files
FileTypeDescriptionSize
readlink_poc.c trigger-source unprivileged readlink(2) trigger with selectable buffer size 3.5 KB view raw
harness.c trigger-source deterministic arithmetic transcription of ext2_readlink + uiomove 6.6 KB view raw
craft_img.py image-patcher parses ext2 superblock/group-desc, rewrites symlink inode e2di_size 3.6 KB view raw
ext2_patched.img crafted-image 256KB ext2 image with symlink inode 12 i_size=0x80000000 256.0 KB ↓ download
ext2_clean.img control-image 256KB ext2 image with legit 18-byte symlink (positive control) 256.0 KB ↓ download
build.sh build-script cc -O2 -o readlink_poc readlink_poc.c; cc -O2 -o harness harness.c 282 B view raw
run.sh run-script live trigger + harness 925 B view raw
run.log run-log decisive before-fix run: 4096B returned from 18B symlink 2.6 KB view raw
harness_run.log run-log harness output: 4078B over-read, size_t=0xffffffff80000000 1.3 KB view raw
leak_sample.txt leak-sample 3x stress runs, all 4078B leak 321 B view raw
panic.txt panic-signature vm_fault panic in ext2_readlink+0x48 from 1MB readlink 713 B view raw
fix.diff suggested-fix uint64_t isize + i_blocks guard + EINVAL fallback 1.4 KB view raw
fix_build.log build-log patched ext2fs.ko module build, full output 15.3 KB view raw
fix_run.log run-log after-fix: readlink returns errno=22 (EINVAL) 122 B view raw
env.txt environment uname, cc version, kldstat, vfs.usermount 657 B view raw
VERDICT.md verdict full narrative analysis 7.4 KB ↓ raw
README.md readme reproduce instructions 1.8 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
README.md readme reproduce instructions
↓ download raw

DF-0784 β€” ext2_readlink integer-truncation heap leak β€” PoC

Reproduction package for DragonFlyBSD ext2_readlink integer-truncation unbounded kernel-heap disclosure via readlink(2).

Bug

sys/vfs/ext2fs/ext2_vnops.c:1350 declares int isize;; line 1352 isize = ip->i_size silently truncates a uint64_t to signed int; line 1353 isize < mnt_maxsymlinklen is a signed compare (negative passes); line 1354 uiomove(i_shortlink, isize, uio) sign-extends isize back to a huge size_t, driving an unbounded copyout of kernel heap to user RAM.

Reproduce

  1. Craft the image (on a host with mke2fs, e.g. Linux): sh mke2fs -t ext2 -b 1024 -O ^metadata_csum,^64bit,^resize_inode,^dir_index \ -F image.img 256K Mount it inside the DragonFly guest (or any OS that can write ext2), create a short symlink (ln -s target_foobar_link slink_test), note its inode (e.g. 12), unmount. sh python3 craft_img.py image.img 12 0x80000000 # rewrite e2di_size
  2. Copy into the guest and mount (root, one-time per boot): sh scp image.img dfbsd:/root/ext2_patched.img ssh dfbsd 'kldload ext2fs; vnconfig -c vn0 /root/ext2_patched.img && mount -t ext2fs -o ro /dev/vn0 /mnt'
  3. Build + run as unprivileged user: sh ssh dfbsd-maxx 'cd /tmp && cc -O2 -o readlink_poc readlink_poc.c && ./readlink_poc /mnt/slink_test 4'

Expected

  • Unpatched #0 GENERIC kernel: readlink returns 4078 bytes past the 18-byte legit target (kernel-heap disclosure). A 1 MB buffer panics the kernel (vm_fault from ext2_readlink+0x48 β†’ std_copyout).
  • Patched ext2fs.ko: readlink returns errno=22 (EINVAL), no leak, no panic, guest stays up. Legit symlinks still resolve normally.

See VERDICT.md for the full analysis, fix.diff for the fix.

VERDICT.md verdict full narrative analysis
↓ download raw

DF-0784 β€” ext2_readlink integer-truncation unbounded kernel heap disclosure

Verdict: REPRODUCED (info leak / DoS via panic on large buffers)

ext2_readlink narrows a uint64_t i_size into a signed int, then passes that int to uiomove() where it is sign-extended back to a huge size_t. A crafted ext2 image with a symlink inode whose i_size = 0x80000000 (bit 31 set) drives an unbounded copyout() of kernel heap to an unprivileged readlink(2) caller, bounded only by the caller's buffer size β€” and panics the kernel once copyout walks past the slab into an unmapped page.

Root cause (path:line)

sys/vfs/ext2fs/ext2_vnops.c β€” ext2_readlink:

1345: static int
1346: ext2_readlink(struct vop_readlink_args *ap)
1347: {
1348:     struct vnode *vp = ap->a_vp;
1349:     struct inode *ip = VTOI(vp);
1350:     int isize;                                       /* signed 32-bit */
1351:
1352:     isize = ip->i_size;                              /* uint64 -> int TRUNCATE */
1353:     if (isize < vp->v_mount->mnt_maxsymlinklen) {    /* signed compare */
1354:         uiomove((char *)ip->i_shortlink, isize, ap->a_uio);
1355:         return (0);                                  /* isize promoted to size_t */
1356:     }
1357:     return (VOP_READ(vp, ap->a_uio, 0, ap->a_cred));
1358: }
  • ip->i_size is uint64_t (sys/vfs/ext2fs/inode.h:102).
  • isize = ip->i_size with i_size = 0x80000000 yields isize = -2147483648.
  • -2147483648 < 60 (EXT2_MAXSYMLINKLEN, ext2_dinode.h:102 β†’ 15 Γ— 4 = 60) β†’ TRUE.
  • uiomove(i_shortlink, isize, uio) β€” isize (int) is implicitly promoted to size_t (sys/kern/kern_subr.c:96: uiomove(caddr_t cp, size_t n, ...)), sign-extending to 0xFFFFFFFF80000000.
  • kern_subr.c:117 while (n > 0 && uio->uio_resid) then loops copyout(cp, iov_base, cnt) from i_shortlink (= i_db[12], inode.h:133) into the user buffer, advancing cp past the inode struct into adjacent kernel heap, until uio_resid is exhausted or copyout faults on an unmapped page.

There is no i_blocks != 0 guard in ext2_readlink (the UFS analog at sys/vfs/ufs/ufs_vnops.c:1740-1745 does check ip->i_din.di_blocks == 0, but via an OR that does not actually close this bug). No KKASSERT fires before the leak on GENERIC β€” uiomove's KASSERTs only check uio_rw / uio_segflg, not the magnitude of n.

Reproduction

Image crafting

mke2fs -t ext2 -b 1024 -O ^metadata_csum,^64bit,^resize_inode,^dir_index (256 KB image, no metadata checksum to invalidate). A symlink slink_test -> target_foobar_link (18 bytes, inode 12) is created inside the image while mounted in the guest, then craft_img.py parses the ext2 superblock + group descriptor, locates the on-disk inode, and rewrites e2di_size (offset +4 in the on-disk inode, ext2_dinode.h:110) from 18 (0x00000012) to 0x80000000. The patched image is vnconfig'd and mount -t ext2fs'd; any local user can then readlink /mnt/slink_test.

Live trigger (unprivileged, GENERIC #0, INVARIANTS ON)

readlink_poc /mnt/slink_test 4 (4 KB buffer) returns 4096 bytes from an 18-byte symlink β€” 4078 bytes of kernel heap past the legit target. The leaked bytes on this quiet test slab are zero (fresh slab), but the over-read IS unbounded by the symlink content. With a 1 MB buffer the read walks past the slab and panics the kernel:

panic: vm_fault: fault on stack guard, addr: 0xfffff8011838c000
Trace:
vm_fault() at vm_fault+0x12eb
trap_pfault() at trap_pfault+0x9a
trap() at trap+0x17c
calltrap() at calltrap+0x9
--- trap 000000000000000c, rip = ffffffff80bcaeaa ---
std_copyout() at std_copyout+0x15a
ext2_readlink() at ext2_readlink+0x48

(The full signature is in panic.txt.)

Deterministic harness (harness.c)

Transcribes ext2_vnops.c:1345-1357 and kern_subr.c:96-154 verbatim with a poisoned "inode" allocation. Confirms the arithmetic: int isize = -2147483648, promoted to size_t = 0xffffffff80000000, fast-path taken, 4078 bytes over-read past the 18-byte target. Run output in harness_run.log.

Impact ceiling

  • Read-only primitive β€” no write, no corruption. There is no escalation chain; this is a pure kernel-heap info-leak / DoS, not a memory-corruption finding. The Phase 6 escalation chain is none (genuinely read-only class).
  • Leak extent on production (INVARIANTS-OFF) kernels: bounded only by the user buffer up to the first unmapped kernel page following the inode slab β€” in practice tens to hundreds of KB of adjacent slab/heap content per call.
  • On default GENERIC (#0, INVARIANTS ON): the leak is still live for small buffers (4 KB, 64 KB) β€” INVARIANTS does not trip here. Only at ~1 MB does copyout walk past the slab and panic.
  • DoS: any unprivileged user can panic the kernel by passing a large readlink buffer against a malicious ext2 mount.
  • Realistic threat model: a malicious ext2 image (USB stick, downloaded disk image, mountable by vfs.usermount if enabled) loaded by an admin gives any local user with execute permission on the mountpoint a kernel-heap disclosure / panic primitive via readlink(2).

Fix (fix.diff)

Two changes in ext2_readlink:

  1. int isize β†’ uint64_t isize (kills the truncation at the source).
  2. Tighten the fast-path condition to isize <= mnt_maxsymlinklen && ip->i_blocks == 0 and add a fallback if (ip->i_blocks == 0) return (EINVAL); so a corrupted inode with isize > maxsymlinklen and no backing blocks returns cleanly instead of falling through to VOP_READ on a vnode with no VM object (which would otherwise panic in getblk).

The second guard was discovered during fix validation: the naive "uint64_t isize + fall-through to VOP_READ" fix converted the leak into a new panic (getblk: vnode has no object) because fast-symlink vnodes never get a VM object (see ext2_vnops.c:1688-1693). The refined fix returns EINVAL for the corrupted case.

Fix validation (Phase 8)

Built the patched ext2fs.ko module (make in /usr/src/sys/vfs/ext2fs, sha256 9f651a77…), hot-swapped it via kldunload/kldload, re-mounted the same crafted image, and re-ran the trigger:

kernel state readlink /mnt/slink_test 4 readlink … 1024 (1 MB)
before fix (#0 GENERIC) 4096 B returned, 4078 B leak panic vm_fault in ext2_readlink+0x48
after fix (patched .ko) errno=22 (EINVAL), no leak errno=22 (EINVAL), no panic, guest up
positive control (legit 18 B symlink, patched .ko) 18 B returned β€”

The fix closes both the leak and the panic. Full logs: run.log, fix_run.log, fix_build.log, panic.txt, leak_sample.txt.

Files

file purpose
readlink_poc.c live trigger: unprivileged readlink(2) with selectable buffer
harness.c deterministic arithmetic transcription of the bug
craft_img.py ext2 image patcher: rewrites symlink inode e2di_size
build.sh / run.sh reproducible build/run
fix.diff git-apply-able one-function fix
run.log / harness_run.log decisive run outputs (before fix)
fix_run.log / fix_build.log after-fix module build + re-run
panic.txt vm_fault panic signature from boot.log
leak_sample.txt 3Γ— stress-test of the leak
env.txt guest environment
manifest.json artifact catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. Built the patched ext2fs.ko KLD module (the vulnerable code lives entirely in this loadable module, no kernel rebuild needed), hot-swapped via kldunload/kldload, re-mounted the SAME crafted image, re-ran the SAME trigger. Before fix on #0 GENERIC: readlink(/mnt/slink_test, 4096) returned 4096 bytes (4078B kernel-heap leak), and readlink(..., 1048576) panicked the kernel (vm_fault from ext2_readlink+0x48). After fix (patched ext2fs.ko): readlink(..., 4096/65536/1048576) all return errno=22 (EINVAL) -- no leak, no panic, guest stays up. Positive control: a legit 18-byte fast symlink on the patched module still resolves correctly (returns exactly 18 bytes 'target_foobar_link'). The fix closes both the leak and the panic without breaking normal symlinks.

fix.diff (uint64_t isize + i_blocks==0 guard + EINVAL fallback); fix_build.log (clean module build, rc=0, ext2fs.ko=117520B); fix_run.log: '[*] readlink("/mnt/slink_test") bufsz=4096 ... [!] readlink returned -1: errno=22 (Invalid argument)' -- contrast with run.log baseline: '[+] readlink returned 4096 bytes ... [!] LEAK: 4078 bytes of kernel heap beyond the legit target' and panic.txt 'panic: vm_fault: fault on stack guard ... ext2_readlink+0x48'.
↓ fix.diff6.5-DEVELOPMENT #0 baseline kernel + hot-swapped patched ext2fs.ko (rebuilt via `make` in /usr/src/sys/vfs/ext2fs; sha256 9f651a77c712181a9b219fba1b51fd6c26775d979ffc7993501777c1f91c013; kldunload/kldload)

Confirmed kernel references

Detail

Exploit chain

none (genuinely read-only primitive -- Phase 6 valid hard blocker). This is an integer-truncation info-leak, not a memory-corruption write/UAF/double-free. The primitive is a copyOUT of kernel heap to user RAM; there is no write to kernel memory, no object corruption, no refcount/pointer overwrite. No escalation chain is derivable. Impact ceiling = unbounded kernel-heap disclosure to unprivileged readlink() (bounded only by user buffer / first unmapped page), plus local DoS via panic with a large buffer. The deterministic harness (harness.c / harness_run.log) transcribes ext2_vnops.c:1345-1357 + kern_subr.c:96-154 verbatim with a poisoned allocator and confirms isize(int)=-2147483648 -> size_t=0xffffffff80000000 -> 4078-byte over-read past the 18-byte legit target.

Evidence (decisive lines)

findings/poc/DF-0784/: run.log (4096B returned from 18B symlink, 4078B LEAK), panic.txt (vm_fault from ext2_readlink+0x48/std_copyout on 1MB buffer), harness_run.log (deterministic arithmetic proof, size_t=0xffffffff80000000), leak_sample.txt (3x stable 4078B leak), fix_run.log (after fix: errno=22 EINVAL), fix_build.log (patched ext2fs.ko module build), fix.diff, ext2_patched.img (crafted image), VERDICT.md.

PoC changes

Built the evidence pack from scratch (folder was empty): readlink_poc.c (unprivileged readlink(2) trigger with selectable buffer + hexdump + leak detector), harness.c (deterministic line-for-line transcription of ext2_readlink + uiomove with poisoned inode showing the int->size_t sign-extension and over-read), craft_img.py (ext2 superblock/group-descriptor parser that locates any inode and rewrites its e2di_size), build.sh/run.sh, README.md, VERDICT.md, manifest.json. Also captured the crafted ext2 image (ext2_patched.img, symlink inode 12 e2di_size=0x80000000) and a clean control image (ext2_clean.img).

Verified recommended fix

fix.diff (git-apply-able, verified) makes two changes in ext2_readlink: (1) int isize -> uint64_t isize to kill the truncation at the source, with an explicit (size_t)isize cast at the uiomove call; (2) tighten the fast-path condition to isize <= mnt_maxsymlinklen && ip->i_blocks == 0 and add if (ip->i_blocks == 0) return (EINVAL); before the VOP_READ fallthrough -- the second guard was discovered during Phase 8 validation (the naive uint64-only fix converted the leak into a NEW panic 'getblk: vnode has no object' because fast-symlink vnodes have no VM object per ext2_vnops.c:1688-1693). Supersedes the finding proposal (which suggested the uint64 change + isize>INT_MAX/i_blocks check but missed the EINVAL fallback needed to avoid the VOP_READ-on-no-VM-object panic).

Verdict

REPRODUCED on #0 GENERIC (INVARIANTS ON). sys/vfs/ext2fs/ext2_vnops.c:1350 declares int isize; :1352 isize = ip->i_size truncates uint64_t (inode.h:102) to signed int; with on-disk e2di_size patched to 0x80000000, isize = -2147483648; :1353 isize < mnt_maxsymlinklen (60, ext2_dinode.h:102) is a signed compare (-2^31 < 60 -> TRUE); :1354 uiomove(i_shortlink, isize, uio) promotes int->size_t == 0xffffffff80000000 (kern_subr.c:96,117). A crafted 256KB ext2 image (no metadata_csum) with symlink inode 12's e2di_size rewritten via craft_img.py, mounted by root, lets unprivileged uid=1001 readlink(2) return 4096 bytes from an 18-byte symlink = 4078 bytes of kernel-heap disclosure (run.log, leak_sample.txt: 3x stable). A 1MB buffer walks copyout past the slab and panics: 'panic: vm_fault: fault on stack guard' from ext2_readlink+0x48 -> std_copyout (panic.txt). INVARIANTS does NOT trip here -- uiomove's KASSERTs only check uio_rw/uio_segflg, not n's magnitude. The leak extent scales with the caller's buffer up to the first unmapped kernel page (demonstrated 64KB clean).