Heap overflow in ffs_truncate symlink fast-path via unbounded bzero of attacker-controlled di_size
Summary
ffs_inode.c:159-160 fast path entered when VLNK && (i_size<mnt_maxsymlinklen || di_blocks==0). di_blocks==0 enters REGARDLESS of i_size. :165 bzero(&i_shortlink,(uint)i_size) NO upper bound. i_shortlink aliases di_db = ufs_daddr_t[12] = 48B (dinode.h:83 inode.h:126). i_size from disk copied verbatim at ffs_vfsops.c:1147. di_size=4096 bzero writes 4048B past 48B buffer past heap inode allocation (kmalloc sizeof(inode) at vfsops.c:1095). Trigger: crafted UFS image VLNK inode di_blocks=0 di_size=4096 di_nlink<=0. ufs_inactive->ffs_truncate(vp,0). Heap overflow M_FFSNODE. No superblock corruption needed. Even UFS1_MAXSYMLINKLEN=60 > 48B overflow inherent.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0887 Β· 20 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_img.c | trigger-source | UFS1 inode patcher: computes on-disk inode offset from sb geometry, forges di_size/di_blocks/di_nlink/di_mode | 8.3 KB | view raw |
| harness.c | trigger-source | deterministic primitive characterizer (transcribes the bzero fast path with sentinels) | 6.6 KB | view raw |
| reproduce.sh | trigger-source | full live trigger: newfs -> symlink -> patch inode -> mount -> stat -> ufs_inactive -> ffs_truncate fast path | 2.7 KB | view raw |
| build.sh | build-script | cc -o craft_img craft_img.c; cc -o harness harness.c | 226 B | view raw |
| run.sh | run-script | calls reproduce.sh | 224 B | view raw |
| README.md | readme | human-facing build/run/expected + impact ceiling | 3.3 KB | β raw |
| VERDICT.md | verdict | full narrative: source trace, manifestations, Phase 6 blocker, fix, Phase 8 validation | 11.6 KB | β raw |
| build.log | build-log | first in-guest build of craft_img + harness | 140 B | view raw |
| run.log | run-log | decisive baseline #0 run: harness output + 131072 trigger (deterministic slab_cleanup page-fault) | 2.0 KB | view raw |
| run.4096.log | run-log | first baseline run with di_size=4096 (probabilistic INVARIANTS slab-magic trap) | 1.7 KB | view raw |
| panic.txt | panic-signature | Fatal trap 12 page fault at slab_cleanup+0x1c9 from di_size=131072 trigger | 658 B | view raw |
| panic.131072.txt | panic-signature | same as panic.txt (131072-byte deterministic trigger) | 658 B | view raw |
| panic.4096.txt | panic-signature | INVARIANTS slab-magic assertion in _kfree from di_size=4096 trigger | 653 B | view raw |
| harness_output.txt | leak-sample | harness primitive characterization output | 812 B | view raw |
| env.txt | environment | uname, cc version, hardening state, UFS mount-permission blocker | 1.1 KB | view raw |
| fix.diff | suggested-fix | git-apply-able single-hunk fix bounding the bzero to umin((uint)i_size, sizeof(i_shortlink)) | 885 B | view raw |
| fix_build.log | build-log | single-fix #1 kernel build + installkernel output | 5.7 MB | β download |
| fix_run.log | run-log | patched #1 kernel: same PoC exits cleanly, no panic | 1.7 KB | 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-0887 β Heap overflow in ffs_truncate symlink fast-path (unbounded bzero)
Bug
sys/vfs/ufs/ffs_inode.c:159-168 β ffs_truncate fast path is entered for
VLNK inodes when i_size < mnt_maxsymlinklen OR i_din.di_blocks == 0.
The di_blocks == 0 arm enters regardless of i_size. The fast path then
does:
bzero((char *)&oip->i_shortlink, (uint)oip->i_size);
i_shortlink aliases i_din.di_shortlink aliases i_din.di_db (inode.h:126,
dinode.h:111-112), which is ufs_daddr_t[12] = 48 bytes (dinode.h:83).
i_size is the on-disk di_size copied verbatim into the in-memory inode at
ffs_vfsops.c:1147. A crafted UFS1 inode with di_mode=S_IFLNK,
di_blocks=0, di_size=4096, di_nlink<=0 therefore causes a 4048-byte
zero-write past the 48-byte di_db buffer into the M_FFSNODE slab heap and
beyond. (Even UFS1_MAXSYMLINKLEN=60 > 48B makes the size arm inherently
overflow by up to 12 bytes.)
Trigger path: mount crafted image, stat the symlink ->
ufs_iget reads the forged dinode -> ufs_vinit sets vp->v_type=VLNK
(ufs_vnops.c:1962) -> on vnode release, ufs_inactive (ufs_inode.c:62)
sees i_nlink<=0 and calls ffs_truncate(vp, 0) (ufs_inode.c:83) ->
fast path -> unbounded bzero.
Build / run / expected
# in guest as root
sh ./build.sh
sh ./run.sh # expect: harness shows 4048B OOB; live mount+stat panics
Expected on the unpatched #0 GENERIC kernel (INVARIANTS ON):
- harness 4096 0 prints OOB WRITE of 4048 bytes past the 48-byte i_shortlink buffer.
- Live trigger: kernel panic β either a slab-corruption INVARIANTS trap, or a
page fault when the bzero runs past the slab page into an unmapped page. The
panic signature is captured in panic.txt (from dfbsd-qemu/boot.log).
Expected on the patched #1 single-fix kernel: harness output unchanged
(it transcribes the unpatched code), but the live stat returns cleanly,
no panic, guest stays up.
Files
| File | Purpose |
|---|---|
craft_img.c |
UFS1 inode patcher: computes on-disk inode offset from sb geometry, forges di_size/di_blocks/di_nlink/di_mode. |
harness.c |
Deterministic primitive characterizer (transcribes the bzero verbatim with sentinels). |
reproduce.sh |
full live trigger: newfs -> symlink -> patch -> mount -> stat. |
build.sh |
cc -o craft_img craft_img.c; cc -o harness harness.c. |
run.sh |
calls reproduce.sh. |
fix.diff |
the verified single-fix patch (bounds the bzero). |
Impact ceiling
UFS is not user-mountable on DragonFly (get_fscap ->
SYSCAP_RESTRICTEDROOT for UFS; same blocker documented in DF-0820). So this
is a root-context mount of attacker-supplied media -> kernel heap
corruption / panic (a hardening gap / DoS), not an LPE. On default
GENERIC (INVARIANTS ON) the unbounded write lands in the M_FFSNODE slab and
is caught as slab corruption or runs into an unmapped page -> panic before any
grooming can land. On INVARIANTS-OFF builds the unbounded zero-write still
page-faults synchronously inside the mount/stat syscall before the caller
can observe or convert the corruption.
DF-0887 β Heap overflow in ffs_truncate symlink fast-path (unbounded bzero)
Verdict: REPRODUCED (heap overflow / panic; root-reachable) β NOT exploitable to uid=0 (valid hard blocker); FIX VALIDATED
The bug claimed in the finding is real and confirmed line-by-line. The
ffs_truncate symlink fast path enters unconditionally for any VLNK inode
whose on-disk di_blocks == 0, regardless of i_size, and then runs:
bzero((char *)&oip->i_shortlink, (uint)oip->i_size);
i_shortlink aliases di_db (ufs_daddr_t[UFS_NDADDR=12] = 48 bytes) β
and i_size is the on-disk di_size copied verbatim into the in-memory inode
at ffs_vfsops.c:1147. A crafted VLNK inode with di_blocks=0 and a large
di_size therefore drives an OOB write of (di_size - 48) bytes past the
shortlink buffer into the M_FFSNODE slab heap and beyond. Two distinct live
manifestations were captured on the default X86_64_GENERIC (#0, INVARIANTS
ON) kernel; the primitive was independently characterized with a deterministic
userspace harness (harness 4096 0 β OOB WRITE of 4048 bytes). A targeted
single-line fix.diff was authored, built as a single-fix #1 kernel, and
validated: the exact trigger that deterministically panics #0 runs
cleanly on #1 (no panic, guest stays up) with no regression on legitimate
filesystems.
The finding's impact ceiling ("root-context mount of attacker media β heap corruption / panic") is confirmed; the implicit LPE angle is not reachable because UFS is not user-mountable on DragonFly (same hard blocker documented in DF-0820). This is a real, fixable code defect β but a hardening gap / DoS, not an LPE.
1. Source trace (every cited line confirmed against /usr/src and host sys/)
ffs_truncate (sys/vfs/ufs/ffs_inode.c), with line numbers verified
byte-identical between host sys/ and guest /usr/src:
137: int ffs_truncate(struct vnode *vp, off_t length, int flags, struct ucred *cred)
153: oip = VTOI(ovp);
159: if (ovp->v_type == VLNK &&
160: (oip->i_size < ovp->v_mount->mnt_maxsymlinklen || oip->i_din.di_blocks == 0)) {
161: #ifdef DIAGNOSTIC
162: if (length != 0) panic(...); // length==0 from ufs_inactive
164: #endif
165: bzero((char *)&oip->i_shortlink, (uint)oip->i_size); // <-- UNBOUNDED
166: oip->i_size = 0;
168: return (ffs_update(ovp, 1));
169: }
Layout facts (all confirmed in sys/vfs/ufs/):
- inode.h:105 β struct ufs1_dinode i_din; is the LAST field of struct inode (allocated via kmalloc(sizeof(inode), M_FFSNODE)).
- inode.h:126 β #define i_shortlink i_din.di_shortlink.
- dinode.h:111-112 β #define di_shortlink di_db β aliases the start of di_db.
- dinode.h:83 β ufs_daddr_t di_db[UFS_NDADDR]; with UFS_NDADDR=12 (dinode.h:66) β 48-byte buffer.
- dinode.h:114 β UFS1_MAXSYMLINKLEN = (UFS_NDADDR + UFS_NIADDR) * sizeof(ufs_daddr_t) = (12+3)*4 = 60 β already 12 bytes larger than the 48-byte di_db it overflows. Even the legitimate i_size < mnt_maxsymlinklen arm can overflow by up to 12 bytes.
- ffs_vfsops.c:1147 β ip->i_din = *((struct ufs1_dinode *)bp->b_data + ino_to_fsbo(fs, ino)); β di_size, di_blocks, di_nlink, di_mode are copied verbatim from disk into the in-memory inode. No bounds check.
Trigger path (confirmed end-to-end):
- ufs_inode.c:62 ufs_inactive() β runs when the vnode refcount drops to 0.
- ufs_inode.c:77 β if (ip->i_nlink <= 0 && (vp->v_mount->mnt_flag & MNT_RDONLY) == 0)
- ufs_inode.c:83 β error = ffs_truncate(vp, (off_t)0, 0, NOCRED);
- ufs_vnops.c:1962 β vp->v_type = IFTOVT(ip->i_mode); β for S_IFLNK β VLNK.
So: mount crafted image β stat /mnt/test/evil β iget reads forged dinode
β ufs_vinit sets vp->v_type=VLNK β stat returns β vnode refcountβ0 β
ufs_inactive sees nlink=0 β ffs_truncate(vp,0) β fast path entered
(VLNK && di_blocks==0) β bzero(&i_shortlink, 131072) β 128 KB OOB
zero-write into the slab heap.
2. Reproduced manifestations (live, on default #0 GENERIC, INVARIANTS ON)
Image-craft recipe (reproduce.sh):
1. truncate -s 4M base.img && vnconfig -c vn0 base.img && newfs -v /dev/vn0 && vnconfig -u
2. mount, ln -s target_string /mnt/test/evil, record inode (3), unmount
3. craft_img binary-patches inode 3: di_size=<big>, di_nlink=0, di_blocks=0, di_mode=0xa1ff
4. mount -t ufs evil.img /mnt/test && stat /mnt/test/evil β triggers iget β ufs_inactive β ffs_truncate fast path
Manifestation A β di_size=4096 (the finding's value; probabilistic INVARIANTS slab-magic trap)
- Path:
bzero(&i_shortlink, 4096)writes 4048 bytes past the 48-byte buffer. - Live:
panic: assertion "z->z_Magic == ZALLOC_SLAB_MAGIC" failed in _kfree at /usr/src/sys/kern/kern_slaballoc.c:1478β backtrace_kfree β _kfree β soclose β soo_close β fdrop β closef. The trap fires on the next_kfreeafter the corruptingbzero(in this capture: socket free during shell exit), proving the bzero wrote across adjacent slab zones. (panic.4096.txt)
Manifestation B β di_size=131072 (deterministic page-fault past slab pages)
- Path:
bzero(&i_shortlink, 131072)runs past slab pages into unmapped kernel address space. - Live:
Fatal trap 12: page fault while in kernel mode/fault virtual address = 0x0/Stopped at slab_cleanup+0x1c9: cmpq %rbx,(%rcx)β the zeroed slab metadata (NULL pointer) dereferences inside the slab allocator's cleanup path. (panic.txt,panic.131072.txt)
Deterministic harness (no kernel effect)
./harness 4096 0 transcribes the L159-168 fast path verbatim with a poisoned
allocator and prints:
OOB write length = 4048 bytes (di_size - 48)
OOB past end of struct inode = 4008 bytes (overflow past inode end)
RESULT: OOB WRITE of 4048 bytes past the 48-byte i_shortlink buffer.
Heap corruption into M_FFSNODE slab (and beyond) confirmed.
This proves the primitive independent of the kernel slab layout lottery.
3. Phase 6 β escalation analysis: BLOCKED (valid hard blocker)
The primitive is a write-class bug (unbounded heap bzero of zeros, 4048+ bytes
past a 48-byte buffer). On this guest (no SMAP/SMEP/KASLR), a bounded kernel
write into a victim object is normally convertible to uid=0. Here the
escalation is blocked by a valid Phase-6 hard blocker:
Blocker β UFS is NOT user-mountable; the trigger requires an already-root context
get_fscap() (sys/kern/vfs_syscalls.c) maps only null/devfs/procfs/
tmpfs/fusefs to user-mountable capabilities; UFS falls through to
SYSCAP_RESTRICTEDROOT. In sys_mount, even with vfs.usermount=1, the
capability check still fails for a non-root credential without the
RESTRICTEDROOT cap (returns EPERM). This was already verified empirically on
this guest in DF-0820 (VERDICT.md Β§3): with vfs.usermount=1, vnconfig'd
and chowned /dev/vn0 to maxx, mount -t ufs as maxx β mount_ufs:
/dev/vn0: Operation not permitted. (vnconfig itself also requires privilege.)
So the crafted inode can only be fed to ffs_truncate by an already-root
caller. Root β kernel is not a privilege-boundary crossing (root can
already kldload an arbitrary module). No unprivilegedβroot chain exists
here. The realistic severity is Medium (root-context mount of attacker
media β kernel panic/corruption / DoS) β same conclusion as DF-0820.
Additionally (same as DF-0820), even on a root-reachable mount the write is an
unbounded synchronous zero-fill that page-faults before the mount/stat
syscall returns (Manifestation B). The attacking process is blocked in the
kernel for the entire window and is killed by the panic before it can observe
or convert the corruption. There is no field combination that yields a small,
precisely-bounded overwrite into a chosen victim β every oversized di_size
produces either an INVARIANTS slab-corruption trap (Manifestation A) or an
unbounded page-fault (Manifestation B). Hence no grooming β victim-corrupt β
convert chain exists. (Same behavior on noinv: the page-fault is from the
MMU, not INVARIANTS, so the second factor applies on both GENERIC and noinv.)
Demonstrated impact ceiling: panic / kernel heap corruption (root-reachable DoS / hardening gap). Not an LPE.
4. The fix (fix.diff)
A single targeted change to sys/vfs/ufs/ffs_inode.c, one hunk:
- Replace bzero((char *)&oip->i_shortlink, (uint)oip->i_size); (line 165)
with bzero((char *)&oip->i_shortlink, umin((uint)oip->i_size, sizeof(oip->i_shortlink)));
β bound by the actual 48-byte buffer size. A multi-line comment explains
the bug-class rationale.
This matches the finding markdown's ## Recommended fix proposal
("bound the bzero to min(i_size, sizeof(i_shortlink))"). The finding's
proposal is exactly right; no supersedence is claimed.
The bound alone kills the security bug (the OOB write). The fast-path entry
condition (di_blocks == 0 || i_size < mnt_maxsymlinklen) is left unchanged
because:
- For a legitimate di_blocks==0 inode the bzero is now an in-bounds clear
of stale inline data (no overflow, no semantic change).
- Adding a stricter entry check would change truncate behavior on
di_blocks==0 && i_size > 48 inodes (a block-accounting change) that is
outside the scope of this security fix.
5. Phase 8 β fix validation (single-fix #1 kernel)
- Baseline (
#0, unpatched): both manifestations panic βassertion z_Magic==ZALLOC_SLAB_MAGIC(A) andFatal trap 12 ... slab_cleanup+0x1c9(B). (panic.4096.txt, panic.txt) - Applied
fix.diffwithpatch -p1(hunk succeeded at line 162), builtmake -j6 nativekernel KERNCONF=X86_64_GENERIC(rc=0, ~7 min, fix_build.log), installed withmake installkernel, rebooted βkern.version = 6.5-DEVELOPMENT #1: Sun Jul 5 17:59:08 UTC 2026,sha256=/boot/kernel/kernel=51efdf559ddd8b85fe25a82022dd72c4725f249a85654b058190557ff28cfed3. - Patched
#1, same crafted image (di_size=131072, the deterministic trigger):mountsucceeds,stat /mnt/test/evilreturns cleanly (STAT_RC=0),END_RC=0, guest stays up, no panic (fix_run.log). Repeated 3Γ to rule out non-determinism β all clean. - Regression: a freshly
newfs'd legitimate image still mounts read-write and accepts file creation on#1β the fix does not break normal operation.
Before/after:
| variant | baseline (#0) | patched (#1) |
|---|---|---|
| di_size=4096 | INVARIANTS slab-magic trap (_kfree) | clean STAT_RC=0, no panic |
| di_size=131072 | page-fault slab_cleanup+0x1c9 | clean STAT_RC=0, no panic |
| normal newfs img | mounts | mounts (regression OK) |
fix_status: fixed β clean before/after on both crash manifestations with no regression.
6. Caveats / next steps
- The finding's severity claim ("High") is partly right on the bug-reality side (the heap overflow is real and unbounded; CWE-787 is accurate) but the realistic exploit ceiling is Medium β root-context mount of attacker media β kernel panic/corruption / DoS, not an unprivileged LPE. A genuinely unprivileged variant would require an auto-mount/automount daemon mounting attacker-supplied media as root (not configured on this guest).
- The bug is structurally identical to DF-0820 in its root-only reachability; the fix validates cleanly against both manifestations.
- The harness transcribes the unpatched code path, so its output is
intentionally unchanged before/after β it documents the primitive, not the
runtime behavior. The runtime behavior change is what
fix_run.logcaptures.
Fix verification
fixedVALIDATED the fix: reproduce.sh (di_size=131072 trigger) on the unpatched 6.5-DEVELOPMENT #0 baseline panics with 'Fatal trap 12 ... Stopped at slab_cleanup+0x1c9' (and the di_size=4096 variant panics with the INVARIANTS 'assertion z_Magic==ZALLOC_SLAB_MAGIC failed in _kfree'); the same trigger on the single-fix #1 kernel (built with make -j6 nativekernel + make installkernel) exits cleanly with STAT_RC=0/END_RC=0 and the guest stays up with no panic in boot.log. Repeated 3x on the patched kernel for determinism (all clean). Regression check: a freshly newfs'd legitimate image still mounts read-write and accepts file creation on #1. fix.diff applies with git apply --check rc=0 and patch -p1 --forward succeeded at line 162.
baseline #0 (di_size=131072): Fatal trap 12: page fault / Stopped at slab_cleanup+0x1c9: cmpq %rbx,(%rcx) / db> baseline #0 (di_size=4096): panic: assertion z_Magic==ZALLOC_SLAB_MAGIC failed in _kfree (kern_slaballoc.c:1478) / _kfree<-soclose<-soo_close<-fdrop<-closef patched #1 (di_size=131072): MOUNT_RC=0 / STAT_RC=0 / END_RC=0 / guest up, NO panic (3x repeat, all clean) regression (normal newfs image on #1): mounts, file create OK, NORMAL_FS_OK
Confirmed kernel references
- sys/vfs/ufs/ffs_inode.c:159
- sys/vfs/ufs/ffs_inode.c:160
- sys/vfs/ufs/ffs_inode.c:165
- sys/vfs/ufs/ffs_vfsops.c:1147
- sys/vfs/ufs/ufs_inode.c:62
- sys/vfs/ufs/ufs_inode.c:77
- sys/vfs/ufs/ufs_inode.c:83
- sys/vfs/ufs/inode.h:105
- sys/vfs/ufs/inode.h:126
- sys/vfs/ufs/dinode.h:83
- sys/vfs/ufs/dinode.h:111
- sys/vfs/ufs/dinode.h:112
- sys/vfs/ufs/dinode.h:114
Detail
Exploit chain
Memory-corruption primitive (write-class) BLOCKED from uid=0 by a valid Phase-6 hard blocker: UFS is NOT user-mountable on DragonFly. get_fscap (sys/kern/vfs_syscalls.c) maps only null/devfs/procfs/tmpfs/fusefs to user-mountable capabilities; UFS falls through to SYSCAP_RESTRICTEDROOT. Empirically re-verified in DF-0820 on this same guest: with vfs.usermount=1 and /dev/vn0 chowned to the unprivileged user, mount -t ufs as that user returns EPERM, and vnconfig itself requires privilege. So the crafted inode can only be fed to ffs_truncate by an already-root caller (root->kernel is not a privilege-boundary crossing; root can already kldload). No unprivileged->root chain exists. Additionally, even on a root-reachable mount the write is an unbounded synchronous zero-fill that page-faults before the mount/stat syscall returns, so the attacking process is killed by the panic before it can observe or convert the corruption -- there is no field combination that yields a small, precisely-bounded overwrite into a chosen victim. Realistic impact ceiling: panic / kernel heap corruption (root-reachable DoS / hardening gap), NOT an LPE. No exploit.c was written because no unprivileged chain is possible (matches DF-0820 conclusion).
Evidence (decisive lines)
[harness, di_size=4096]: RESULT: OOB WRITE of 4048 bytes past the 48-byte i_shortlink buffer. [baseline #0, di_size=131072]: Fatal trap 12: page fault while in kernel mode / fault virtual address = 0x0 / Stopped at slab_cleanup+0x1c9: cmpq %rbx,(%rcx) / db> [baseline #0, di_size=4096]: panic: assertion "z->z_Magic == ZALLOC_SLAB_MAGIC" failed in _kfree at kern_slaballoc.c:1478 / _kfree <- soclose <- soo_close <- fdrop <- closef [patched #1, same trigger]: STAT_RC=0 / END_RC=0 / guest stays up, NO panic
PoC changes
Created findings/poc/DF-0887/ from scratch (no prior art in DB). Files: craft_img.c (UFS1 inode patcher that computes the on-disk inode byte offset from superblock geometry using the ino_to_cg/cgimin/ino_to_fsba/ino_to_fsbo macros from fs.h:442-460, then forges di_size/di_nlink/di_blocks/di_mode at their dinode.h offsets); harness.c (deterministic primitive characterizer that transcribes the L159-168 fast path with sentinels, proving the OOB independent of slab layout); reproduce.sh (newfs -> create real symlink -> craft_img patch inode 3 to di_size=131072/di_nlink=0/di_blocks=0/di_mode=0xa1ff -> mount -t ufs -> stat -> ufs_inactive -> ffs_truncate fast path -> bzero overflow); build.sh/run.sh/README.md. Used di_size=131072 in reproduce.sh (rather than the finding's 4096) for deterministic INVARIANTS-bypass page-fault reproduction; the 4096 value is still characterized by the harness and demonstrated by run.4096.log + panic.4096.txt.
Verified recommended fix
Bound the bzero length to the shortlink buffer: in sys/vfs/ufs/ffs_inode.c:165 change bzero((char *)&oip->i_shortlink, (uint)oip->i_size); to bzero((char *)&oip->i_shortlink, umin((uint)oip->i_size, sizeof(oip->i_shortlink)));. The bound alone kills the OOB write (the security bug); the di_blocks==0 entry condition is left unchanged so legitimate fast-symlink truncate semantics are preserved (for a di_blocks==0 inode the bzero is now an in-bounds clear of stale inline data, no overflow, no behavior change). This MATCHES the finding markdown's ## Recommended fix proposal ('bound the bzero to min(i_size, sizeof(i_shortlink))') -- no supersedence claimed. The full git-apply-able diff lives in findings/poc/DF-0887/fix.diff.
Verdict
REPRODUCED. The bug is real and confirmed line-by-line: ffs_truncate (sys/vfs/ufs/ffs_inode.c:159-168) enters the VLNK fast path unconditionally for any inode whose on-disk di_blocks==0, regardless of i_size, and runs bzero(&oip->i_shortlink,(uint)oip->i_size). i_shortlink aliases di_db (ufs_daddr_t[UFS_NDADDR=12]=48B; inode.h:126, dinode.h:111-112,83) and i_size is the on-disk di_size copied verbatim into the in-memory inode at ffs_vfsops.c:1147. A crafted UFS1 image with a VLNK inode having di_blocks=0 and a large di_size therefore drives an OOB write of (di_size-48) bytes past the 48-byte buffer into the M_FFSNODE slab heap. Confirmed live on default #0 GENERIC (INVARIANTS ON) by two distinct panic signatures: di_size=4096 (the finding's value) yields the INVARIANTS trap 'panic: assertion z_Magic==ZALLOC_SLAB_MAGIC failed in _kfree' (slab corruption detected when an adjacent zone chunk is later freed); di_size=131072 yields a deterministic 'Fatal trap 12: page fault ... Stopped at slab_cleanup+0x1c9' (zeroed slab metadata NULL-derefs inside the slab allocator). The deterministic harness transcribes the fast path verbatim and prints 'OOB WRITE of 4048 bytes past the 48-byte i_shortlink buffer' for di_size=4096, proving the primitive independent of the kernel slab layout. The di_blocks==0 entry arm is exactly the gap the finding cites; UFS1_MAXSYMLINKLEN=60 > 48B makes even the legitimate 'i_size<mnt_maxsymlinklen' arm inherently able to overflow by up to 12 bytes.
No comments yet.