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)
PoC verification
Evidence pack
findings/poc/DF-0784 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
- 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 256KMount 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 - 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' - 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
#0GENERIC kernel:readlinkreturns 4078 bytes past the 18-byte legit target (kernel-heap disclosure). A 1 MB buffer panics the kernel (vm_faultfromext2_readlink+0x48βstd_copyout). - Patched
ext2fs.ko:readlinkreturnserrno=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.
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_sizeisuint64_t(sys/vfs/ext2fs/inode.h:102).isize = ip->i_sizewithi_size = 0x80000000yieldsisize = -2147483648.-2147483648 < 60(EXT2_MAXSYMLINKLEN,ext2_dinode.h:102β 15 Γ 4 = 60) β TRUE.uiomove(i_shortlink, isize, uio)βisize(int) is implicitly promoted tosize_t(sys/kern/kern_subr.c:96:uiomove(caddr_t cp, size_t n, ...)), sign-extending to0xFFFFFFFF80000000.kern_subr.c:117while (n > 0 && uio->uio_resid)then loopscopyout(cp, iov_base, cnt)fromi_shortlink(=i_db[12],inode.h:133) into the user buffer, advancingcppast the inode struct into adjacent kernel heap, untiluio_residis exhausted orcopyoutfaults 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
copyoutwalk 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.usermountif enabled) loaded by an admin gives any local user with execute permission on the mountpoint a kernel-heap disclosure / panic primitive viareadlink(2).
Fix (fix.diff)
Two changes in ext2_readlink:
int isizeβuint64_t isize(kills the truncation at the source).- Tighten the fast-path condition to
isize <= mnt_maxsymlinklen && ip->i_blocks == 0and add a fallbackif (ip->i_blocks == 0) return (EINVAL);so a corrupted inode withisize > maxsymlinklenand no backing blocks returns cleanly instead of falling through toVOP_READon a vnode with no VM object (which would otherwise panic ingetblk).
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
fixedVALIDATED. 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'.
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).
No comments yet.