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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0778 Β· 18 files
FileTypeDescriptionSize
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
README.md readme human-facing reproduction guide
↓ download 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): isize becomes INT_MIN, sign-extends to ~2^63 as a size_t; a sufficiently large user buffer makes uiomove page-fault in kernel mode β†’ kernel panic (vm_fault: fault on stack guard in ufs_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.

VERDICT.md verdict full root-cause trace, path:line evidence, fix validation
↓ download raw

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)

  1. sys/vfs/ufs/ufs_vnops.c:1740 β€” int isize; (signed 32-bit).
  2. :1742 β€” isize = ip->i_size; silently narrows the uint64 i_size to signed int. i_size is int64_t (sys/vfs/ufs/inode.h), the on-disk di_size is uint64_t (sys/vfs/ufs/dinode.h:76).
  3. :1743-1744 β€” if ((isize < mnt_maxsymlinklen) || (di_blocks == 0)). mnt_maxsymlinklen is copied verbatim from the attacker-controlled superblock with no validation (sys/vfs/ufs/ffs_vfsops.c:504 mp->mnt_maxsymlinklen = fs->fs_maxsymlinklen). The di_blocks == 0 OR branch is taken for every inline symlink regardless of isize, so even a huge/negative isize reaches uiomove.
  4. :1745 β€” uiomove((char *)ip->i_shortlink, isize, ap->a_uio); i_shortlink overlays di_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 is ufs_daddr_t[12] = 48 bytes (UFS1_MAXSYMLINKLEN = (12+3)*4 = 60 includes the indirect array, sys/vfs/ufs/dinode.h:114). The int isize is implicitly converted to size_t for uiomove's 2nd arg β†’ negative isize becomes ~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 #0 Jul 2): Mode A leaks 152 bytes OOB; Mode B (16 MB buf) panics (vm_fault in ufs_readlink+0x42).
  • Patched #1 (6.5-DEVELOPMENT #1 Fri Jul 10 02:56:28, sha256 e024132b…, 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 10 02:56:28 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (sha256 e024132b5ffc6197153d650946ed54dac7a6b1cdce00a6002b596812e6e72a14)

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().