ufs_readlink OOB read / panic via i_size truncation to int β crafted FFS image heap info leak or DoS
Summary
ufs_vnops.c:1740 int isize. :1742 isize=ip->i_size silently narrows uint64 i_size to signed int. :1743-1744 if(isize<mnt_maxsymlinklen||di_blocks==0) β mnt_maxsymlinklen copied verbatim from superblock no validation (ffs_vfsops.c:504,724) di_blocks==0 fallback has NO bound on isize. :1745 uiomove(i_shortlink,isize,uio) β i_shortlink is i_din.di_db only 48 bytes (+di_ib = 60 UFS1_MAXSYMLINKLEN). Mode A info leak: di_size=200 di_blocks=0 reads 200 bytes from 60-byte i_shortlink = ~140 bytes past inode struct into adjacent slab (kernel pointers KASLR bypass). Mode B DoS: di_size=0x80000000 isize=INT_MIN int->size_t sign-extends to ~2^63 uiomove page fault panic. Inodes read verbatim ffs_vfsops.c:1147 ip->i_din=*((ufs1_dinode*)bp->b_data+ino_to_fsbo) no validation. Trigger: crafted FFS image mount then unprivileged readlink(). Same systemic defect at ufs_readwrite.c:82 ffs_inode.c:165. Fix: int64_t isize + if(isize>0&&isize<=UFS1_MAXSYMLINKLEN&&...) uiomove bounded.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0778 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger_readlink.c | trigger-source | unprivileged readlink trigger; hexdumps + counts leaked KVA pointers | 2.7 KB | view raw |
| trigger_readlink_big.c | trigger-source | Mode B variant with mmap'd large buffer to reach the panic ceiling | 2.0 KB | view raw |
| craft_image.sh | trigger-source | root: builds a crafted UFS1 image and patches symlink inode 3 di_size/di_blocks via fsdb | 2.3 KB | view raw |
| build.sh | build-script | compiles the triggers | 246 B | view raw |
| run.sh | run-script | drives a reproduction run (Mode A / Mode B) | 1.1 KB | view raw |
| VERDICT.md | verdict | full root-cause trace, path:line evidence, fix validation | 6.1 KB | β raw |
| README.md | readme | human-facing reproduction guide | 2.9 KB | β raw |
| fix.diff | suggested-fix | validated git-apply-able fix: int64_t isize + bound to UFS1_MAXSYMLINKLEN | 1.2 KB | view raw |
| run.log | run-log | Mode A decisive run: 200 bytes returned, 152 OOB, 5 KVA ptrs | 870 B | view raw |
| run.2.log | run-log | Mode A stress run 2 | 870 B | view raw |
| run.3.log | run-log | Mode A stress run 3 | 870 B | view raw |
| leak_sample.txt | leak-sample | raw leaked bytes Mode A across runs | 1.1 KB | view raw |
| panic.txt | panic-signature | Mode B panic: vm_fault stack guard in ufs_readlink+0x42 -> std_copyout | 550 B | view raw |
| fix_build.log | build-log | full single-fix kernel build, NK_DONE rc=0 | 5.6 MB | β download |
| fix_run.log | run-log | patched #1 kernel: Mode A/B return EINVAL, no panic, no regression | 401 B | view raw |
| env.txt | environment | uname, cc 8.3, vfs.usermount=0 | 474 B | view 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-0778 β ufs_readlink OOB read / panic via i_size truncation to int
Status: REPRODUCED (Mode A leak + Mode B panic) Β· FIX VALIDATED Severity: Medium Β· CWE-197 (Integer Truncation) / CWE-125 (OOB Read) / CWE-200 (Info Leak)
What this proves
ufs_readlink() truncates the on-disk uint64 i_size of a symlink inode to a
signed int and feeds it to uiomove() against the 48-byte inline
i_shortlink buffer with no upper bound. A crafted UFS/FFS image whose
symlink inode has di_blocks==0 and a malicious di_size produces, via an
unprivileged readlink() after the image is mounted:
- Mode A (
di_size=200): OOB read of 152 bytes past the inode into adjacent kernel slab β info leak incl. kernel KVA pointers (KASLR bypass). - Mode B (
di_size=0x80000000):isizebecomesINT_MIN, sign-extends to ~2^63 as asize_t; a sufficiently large user buffer makesuiomovepage-fault in kernel mode β kernel panic (vm_fault: fault on stack guardinufs_readlink+0x42).
Files
| File | Purpose |
|---|---|
trigger_readlink.c |
unprivileged readlink trigger, hexdumps + counts leaked KVA ptrs |
trigger_readlink_big.c |
Mode B variant β mmap'd large buffer to find the OOB/panic ceiling |
craft_image.sh |
root β builds a crafted UFS1 image, patches symlink inode 3 via fsdb |
build.sh |
compiles the triggers |
run.sh |
drives a reproduction run |
VERDICT.md |
full root-cause analysis + path:line trace + fix validation |
fix.diff |
the validated git-apply-able fix (int64_t isize + bound) |
run.log/run.2.log/run.3.log |
Mode A leak across 3 runs (variance/stability) |
leak_sample.txt |
raw leaked bytes (Mode A) |
panic.txt |
Mode B panic signature from boot.log |
fix_build.log |
full single-fix kernel build output (rc=0) |
fix_run.log |
before/after contrast on patched #1 kernel |
env.txt |
guest environment |
How to reproduce
# 1. (root on guest) craft the malicious UFS1 image β Mode A or B
ssh dfbsd '/root/craft_image.sh 200' # Mode A = di_size 200 (OOB leak)
ssh dfbsd '/root/craft_image.sh modeB' # Mode B = di_size 0x80000000 (panic)
# 2. (unprivileged) trigger
ssh dfbsd-maxx '/home/maxx/trigger_readlink /mnt/test/mylink'
ssh dfbsd-maxx '/home/maxx/trigger_readlink_big /mnt/test/mylink 16777216' # Mode B
Preconditions
- An admin mounts the crafted UFS/FFS image (
vfs.usermount=0β root mount; realistic: downloaded disk image, USB volume, attached storage). - Then any unprivileged local user triggers via
readlink().
The fix (validated)
int isize β int64_t isize; only use the inline i_shortlink when
0 <= isize <= UFS1_MAXSYMLINKLEN. See fix.diff and VERDICT.md.
DF-0778 β ufs_readlink OOB read / panic via i_size truncation to int
Verdict: REPRODUCED (both modes) + FIX VALIDATED
ufs_readlink() (sys/vfs/ufs/ufs_vnops.c:1740-1747) declared int isize,
assigned isize = ip->i_size (uint64 β signed int truncation), and fed it
straight to uiomove(i_shortlink, isize, uio) with no upper bound on the
48-byte i_shortlink (= di_db) buffer. A crafted UFS/FFS image whose
symlink inode has di_blocks == 0 (inline shortlink) and a malicious
di_size turns this into an OOB read (info leak) or a kernel panic
(DoS), triggered by an unprivileged readlink() after an admin mounts the
image.
Reachability / threat model
Mount-time filesystem-image parsing (root mounts a crafted FFS image β
e.g. a downloaded disk image, USB stick, or attached volume). Once mounted,
any unprivileged local user can trigger the bug with readlink() on the
crafted symlink. vfs.usermount=0 on this guest, so the mount itself needs
root β but that is a realistic admin action, and the exploitation is fully
unprivileged. This matches the Medium severity (local, requires crafted image).
The two failure modes (both reproduced live)
| Mode | di_size (on-disk) | isize after int trunc |
Observed on #0 unpatched |
Impact |
|---|---|---|---|---|
| A | 200 | 200 (positive) | readlink returns 200 bytes from a 48-byte buffer β 152 bytes OOB kernel heap leak (5+ KVA pointers 0xfffff8008dβ¦) |
info leak / KASLR bypass |
| B | 0x80000000 (2^31) | INT_MIN = β2^31 β sign-extends to ~2^63 as size_t |
small buf: 4095-byte leak; 16 MB buf: kernel PANIC vm_fault: fault on stack guard in ufs_readlink+0x42 β std_copyout β trap_pfault |
DoS / panic |
Mode A evidence (leak, guest survives)
readlink returned 200 bytes (shortlink buffer is only 48) --- bytes after offset 48 (past i_shortlink): 152 --- LEAKED_KERNEL_PTRS=5 β¦3033a38d 00f8ffffβ¦ = 0xfffff8008da33330 (KVA)
Full hexdump across 3 runs in leak_sample.txt. Stable per-mount (rest of
the on-disk inode struct + its slab neighbours).
Mode B evidence (panic)
panic: vm_fault: fault on stack guard, addr: 0xfffff80090204000
--- trap 000000000000000c ---
std_copyout() at std_copyout+0x15a
ufs_readlink() at ufs_readlink+0x42 <-- the bug
Debugger("panic")
Full trace in panic.txt.
Root-cause trace (path:line, all confirmed)
sys/vfs/ufs/ufs_vnops.c:1740βint isize;(signed 32-bit).:1742βisize = ip->i_size;silently narrows the uint64i_sizeto signedint.i_sizeisint64_t(sys/vfs/ufs/inode.h), the on-diskdi_sizeisuint64_t(sys/vfs/ufs/dinode.h:76).:1743-1744βif ((isize < mnt_maxsymlinklen) || (di_blocks == 0)).mnt_maxsymlinklenis copied verbatim from the attacker-controlled superblock with no validation (sys/vfs/ufs/ffs_vfsops.c:504mp->mnt_maxsymlinklen = fs->fs_maxsymlinklen). Thedi_blocks == 0OR branch is taken for every inline symlink regardless ofisize, so even a huge/negativeisizereachesuiomove.:1745βuiomove((char *)ip->i_shortlink, isize, ap->a_uio);i_shortlinkoverlaysdi_db(sys/vfs/ufs/inode.h:126#define i_shortlink i_din.di_shortlinkβsys/vfs/ufs/dinode.h:112#define di_shortlink di_db), which isufs_daddr_t[12]= 48 bytes (UFS1_MAXSYMLINKLEN = (12+3)*4 = 60includes the indirect array,sys/vfs/ufs/dinode.h:114). Theint isizeis implicitly converted tosize_tforuiomove's 2nd arg β negativeisizebecomes ~2^63.
The inode is read verbatim from disk with no di_size validation
(sys/vfs/ufs/ffs_vfsops.c:1147 ip->i_din = *((ufs1_dinode*)bp->b_data+...)),
so a crafted image controls di_size and di_blocks directly.
Escalation
None. This is a read-only primitive (OOB read / info leak / DoS panic) β no write capability, so there is no privilege-escalation chain. The realistic impact ceiling is kernel heap info leak (KASLR bypass / pointer disclosure) + local DoS (panic) from an unprivileged user after a crafted-image mount. Correctly classified Medium.
The fix (fix.diff, validated)
Use int64_t isize (no truncation) and only trust the inline i_shortlink
buffer when 0 <= isize <= UFS1_MAXSYMLINKLEN. A corrupted/huge di_size
falls through to VOP_READ, which returns EINVAL/reads zero blocks safely
(di_blocks==0 β no data) instead of OOB-reading the inline buffer.
- int isize;
- isize = ip->i_size;
- if ((isize < vp->v_mount->mnt_maxsymlinklen) ||
- (ip->i_din.di_blocks == 0)) { /* XXX - for old fastlink support */
- uiomove((char *)ip->i_shortlink, isize, ap->a_uio);
+ int64_t isize; /* was: int -- ip->i_size is uint64_t */
+ isize = ip->i_size;
+ if (isize >= 0 && isize <= UFS1_MAXSYMLINKLEN &&
+ ((isize < vp->v_mount->mnt_maxsymlinklen) ||
+ (ip->i_din.di_blocks == 0))) { /* XXX - for old fastlink support */
+ uiomove((char *)ip->i_shortlink, (size_t)isize, ap->a_uio);
Fix validation (Phase 8) β single-fix kernel built + booted
- Baseline
#0(unpatched,6.5-DEVELOPMENT #0Jul 2): Mode A leaks 152 bytes OOB; Mode B (16 MB buf) panics (vm_faultinufs_readlink+0x42). - Patched
#1(6.5-DEVELOPMENT #1Fri Jul 10 02:56:28, sha256e024132bβ¦, single-fix kernel): Mode A βreadlink: Invalid argument(0 bytes leaked); Mode B (16 MB buf) βreadlink: Invalid argument, guest stays up, no panic (3/3 runs). No regression: a normal 46-byte short symlink reads correctly (readlinkβ target, rc=0).
See fix_build.log (full kernel build, rc=0) and fix_run.log
(before/after contrast).
How to reproduce
# root: build a crafted UFS1 image, patch the symlink inode, mount it
ssh dfbsd '/root/craft_image.sh 200' # Mode A (leak); 'modeB' for panic
# unprivileged user: trigger
ssh dfbsd-maxx '/home/maxx/trigger_readlink /mnt/test/mylink'
build.sh compiles the trigger; run.sh drives both modes; craft_image.sh
(root) creates the crafted FFS image via newfs + fsdb chlen.
Fix verification
fixedVALIDATED. Applied fix.diff (int->int64_t isize + UFS1_MAXSYMLINKLEN bound) to /usr/src, built a single-fix kernel with make -j6 nativekernel KERNCONF=X86_64_GENERIC (NK_DONE rc=0), installed kernel.debug as /boot/kernel/kernel, rebooted into #1 (kern.version confirms Fri Jul 10 02:56:28 build ts). BEFORE (unpatched #0 baseline): Mode A readlink returned 200 bytes / 152 bytes OOB / 5 KVA pointers leaked; Mode B 16MB readlink PANICKED (vm_fault in ufs_readlink+0x42, guest down). AFTER (patched #1): Mode A readlink -> EINVAL, 0 bytes leaked; Mode B 16MB readlink -> EINVAL, guest stays up (3/3 runs deterministic); normal 46-byte short symlink still reads correctly (no regression, rc=0). The fix closes both the OOB-read info leak and the panic. Note: must install kernel.DEBUG (not kernel.stripped) as /boot/kernel/kernel on this guest -- the DragonFly loader rejects the stripped kernel ('don't know how to load module kernel'); the working /boot/kernel/kernel is the unstripped debug format.
BEFORE (unpatched #0): Mode A -> 'readlink returned 200 bytes (shortlink buffer is only 48) / bytes after offset 48: 152 / LEAKED_KERNEL_PTRS=5'; Mode B 16MB -> 'panic: vm_fault: fault on stack guard, addr 0xfffff80090204000 / ufs_readlink() at ufs_readlink+0x42' (guest down). AFTER (patched #1, sha256 e024132b...): Mode A -> 'readlink: Invalid argument (errno=22)' (0 bytes leaked); Mode B 16MB -> 'readlink(bufsz=16777216) = -1 errno=22 (Invalid argument)' (guest up, 3/3); regression check -> normal symlink 'readlink /mnt/test/goodlink' = 'this_is_a_normal_short_symlink_target_ok' rc=0.
Confirmed kernel references
Detail
Exploit chain
none -- this is a read-only primitive (OOB read / info leak + panic DoS). There is no write capability anywhere in the bug path, so no privilege-escalation chain is derivable. The realistic impact ceiling is kernel heap info leak (KASLR/pointer disclosure, Mode A, ~152 bytes per call, repeatedly) and local DoS via kernel panic (Mode B). Correctly classified Medium (local, requires crafted-image mount). No exploit.c was written because there is no escalation to develop.
Evidence (decisive lines)
Mode A (di_size=200, unpatched #0): readlink returned 200 bytes (shortlink buffer is only 48) -> bytes after offset 48 (past i_shortlink): 152 -> LEAKED_KERNEL_PTRS=5 (e.g. 0xfffff8008da33330, 0xfffff8008dac56d8). Mode B (di_size=0x80000000, 16MB buf, unpatched #0): panic: vm_fault: fault on stack guard, addr: 0xfffff80090204000 / --- trap 000000000000000c --- / std_copyout() at std_copyout+0x15a / ufs_readlink() at ufs_readlink+0x42 / Debugger("panic"). Guest down. PATCHED #1 kernel: Mode A -> readlink: Invalid argument (errno=22), 0 bytes leaked; Mode B 16MB -> readlink: Invalid argument, guest stays up (3/3); normal 46-byte symlink still reads correctly (no regression).
PoC changes
The finding markdown and poc/DF-0778/ folder did not exist on disk (only the pre-rendered www/findings/DF-0778.html and the audit.db row existed), so the ENTIRE evidence pack was authored from scratch: trigger_readlink.c (unprivileged readlink trigger with hexdump + KVA-pointer counting), trigger_readlink_big.c (Mode B variant with mmap'd 16MB buffer to reach the panic ceiling), craft_image.sh (root script that builds a real UFS1 image via newfs, creates a normal short symlink, then patches inode 3's di_size via fsdb chlen while keeping di_blocks==0, and remounts), build.sh/run.sh, VERDICT.md, README.md, fix.diff, manifest.json, plus full logs. Both trigger programs compile cleanly with guest cc 8.3.
Verified recommended fix
In ufs_readlink() (sys/vfs/ufs/ufs_vnops.c:1740-1747): change int isize to int64_t isize (no truncation of the uint64 i_size) and gate the inline i_shortlink uiomove on isize >= 0 && isize <= UFS1_MAXSYMLINKLEN so a corrupted/huge di_size cannot drive an OOB read of the 48-byte buffer; pass (size_t)isize to uiomove. Malformed inodes fall through to VOP_READ which returns EINVAL safely. This is a new fix authored during verification (the finding markdown had no fix proposal to supersede); the full git-apply-able diff is in findings/poc/DF-0778/fix.diff and was validated by building + booting a single-fix kernel.
Verdict
REPRODUCED (both modes) on the unpatched #0 master DEV kernel, then FIX VALIDATED on a single-fix #1 kernel. The bug is real: ufs_readlink() (sys/vfs/ufs/ufs_vnops.c:1740-1747) declares int isize, assigns isize = ip->i_size (uint64 -> signed-int truncation at :1742), and with NO upper bound passes it to uiomove(i_shortlink, isize, uio) at :1745 against the 48-byte inline i_shortlink buffer (= di_db, sys/vfs/ufs/dinode.h:112). Because the di_blocks==0 OR-branch at :1744 is taken for every inline symlink regardless of isize, a crafted UFS1 image whose symlink inode has di_blocks==0 and a malicious di_size drives an OOB read. Mode A (di_size=200): unprivileged readlink returns 200 bytes from a 48-byte buffer = 152 bytes of kernel heap leaked incl. KVA pointers 0xfffff8008d... (info leak / KASLR bypass, guest survives). Mode B (di_size=0x80000000): isize truncates to INT_MIN, sign-extends to ~2^63 as size_t; with a 16MB user buffer uiomove page-faults in kernel mode -> kernel PANIC vm_fault: fault on stack guard with stack trace std_copyout <- ufs_readlink+0x42 <- trap_pfault (confirmed in panic.txt). The inode's di_size is read verbatim from the crafted image (ffs_vfsops.c:1147) and mnt_maxsymlinklen is copied unvalidated from the superblock (ffs_vfsops.c:504). Threat model is mount-time image parsing (admin mounts crafted FFS image) then ANY unprivileged local user triggers via readlink().
No comments yet.