ufs_symlink controlled kernel heap overflow via unvalidated on-disk fs_maxsymlinklen β bcopy of up to 1023 attacker bytes into the 60-byte i_shortlink, ~919 bytes past the struct inode slab chunk
| Field | Value |
|---|---|
| ID | DF-3015 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H |
| CWE | CWE-787 / CWE-20 |
| File | sys/vfs/ufs/ufs_vnops.c |
| Lines | 1562-1565 (trust boundary: ffs_vfsops.c:504/724) |
| Area | vfs/ufs |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
ufs_vnops.c:1562 gates the fast-symlink path on vp->v_mount->mnt_maxsymlinklen, which ffs_vfsops.c copies verbatim from the on-disk superblock with no validation (absent from DF-0820's geometry list). On a crafted UFS1 image with fs_maxsymlinklen=0x7fff (superblock byte 9512), any symlink() with target length 61..1023 executes bcopy(target, i_shortlink, len) where i_shortlink aliases i_din.di_db (60-byte capacity incl. di_ib) and i_din is the last member of the 304-byte struct inode (320-byte slab chunk class). Result: fully attacker-controlled linear heap overflow of up to ~919 bytes past the allocation into neighbouring inodes / slab freelist metadata. Crafted FFS media mounted by root (same model as DF-0778/DF-0820/DF-0887): any unprivileged local user with write access to a directory on the mount converts a single symlink(2) into controlled kernel heap corruption. Demonstrated: attacker bytes disclosed inside a live inode (stat shows symlink uid/gid = 0x41414141), freelist poisoning panic (c_Next = 0x5353535353535353, INVARIANTS chunk_mark_allocated assertion β on production kernels this is the arbitrary-in-zone allocation primitive), and GPF-in-ffs_update from a smashed live neighbour's i_fs/i_devvp. With ~150-inode grooming the write lands at a chosen field of a chosen neighbouring inode (calibration leak verified): di_mode=IFREG|ISUID| 0755 writeable in 2 bytes while leaving root di_uid untouched β the final exec step is blocked only because the same linear write destroys i_fs/i_dev/i_devvp whose kernel addresses contain NUL bytes and cannot be rewritten through the string-based bcopy (full chain analysis in VERDICT.md). Also relevant for vfs.usermount=1 configurations and any mount of media not authored by the mounting root.
Proof of contest
VERIFIED (findings/poc/DF-3015/): root setup (newfs + patch byte 9512 to 0x7fff + mount); uid 1001 symlink() with 1023-byte target β ls shows symlink owned by 1094795585 (0x41414141 β attacker bytes inside the live inode) + kernel panic (slab freelist assertion or Fatal trap 9 GPF at ffs_update+0x3e). exp3015.c proves deterministic placement via the DF-0778 readlink leak. Fix validated (clamp at trust boundary + independent fast-path bound): git-apply-able fix.diff in the pack.
Timeline
- 2026-09-02 Discovered during pass-2 audit of ufs_vnops.c (GLM 5.3); controlled heap overflow reproduced unpriv + grooming primitive demonstrated + fix validated. DF-0778 re-verified, not re-reported.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3015 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.0 KB | β raw | |
| VERDICT.md | β | 4.8 KB | β raw | |
| verdict.json | β | 5.4 KB | view raw | |
| manifest.json | β | 805 B | view raw | |
| trigger3015.c | β | 1.3 KB | view raw | |
| spray3015.c | β | 1.5 KB | view raw | |
| exp3015.c | β | 6.5 KB | view raw | |
| demo3015.c | β | 2.2 KB | view raw | |
| setup3015.sh | β | 830 B | view raw | |
| inopatch.c | β | 2.0 KB | view raw | |
| fix.diff | β | 1.6 KB | view raw | |
| run.log | β | 2.4 KB | view raw | |
| panic.txt | β | 674 B | view raw | |
| panic2.txt | β | 584 B | view raw | |
| env.txt | β | 892 B | view raw | |
| build.log | β | 5.7 MB | β download |
DF-3015 β ufs_symlink controlled heap overflow via unvalidated fs_maxsymlinklen
What
sys/vfs/ufs/ufs_vnops.c:1561-1566 (ufs_symlink):
len = strlen(ap->a_target);
if (len < vp->v_mount->mnt_maxsymlinklen) {
bcopy(ap->a_target, (char *)ip->i_shortlink, len);
mnt_maxsymlinklen is copied verbatim from the on-disk superblock at mount
(ffs_vfsops.c:504 and :724) with no validation (not in DF-0820's list
either). i_shortlink aliases i_din.di_db (+di_ib) β 60 bytes total
(UFS1_MAXSYMLINKLEN, dinode.h:114) β and i_din is the last member of
the 304-byte struct inode, allocated from the 320-byte slab chunk class.
A crafted image with fs_maxsymlinklen = 0x7fff (superblock byte offset
8192+1320 = 9512) turns every symlink with target length 61..1023 into a
fully attacker-controlled linear heap overflow of up to ~919 bytes past
the inode allocation.
Threat model
Crafted FFS media mounted by root (same model as DF-0778 / DF-0820 /
DF-0887): the unprivileged trigger is a single symlink(2) in a
world-writable directory of the mounted filesystem.
Reproduce
# root: build + mount the crafted image (patches fs_maxsymlinklen=0x7fff) sh setup3015.sh # maxx (uid 1001): the trigger cc -O1 -o trigger3015 trigger3015.c && ./trigger3015 /mnt/p/l1
Expected baseline (stock kernel)
ls -lashows the symlink's own uid/gid = 1094795585 (0x41414141) β attacker bytes inside a live kernel inode disclosed to userland (di_uid/di_gidati_din+112/116are inside the bcopy range).- Then one of two kernel panics, depending on what follows the inode chunk:
panic: assertion "(((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0" failed in chunk_mark_allocatedβ freelist poisoning: a free chunk'sc_Nextwas overwritten with 0x53 bytes (panic.txt)Fatal trap 9: general protection faultinffs_update+0x3eβ a live neighbouring inode'si_fs/i_devvpwere overwritten (panic2.txt)- With grooming (
exp3015.c) the write lands at a chosen victim inode: the calibration leak verifiedleak[144..152) == victim i_number, proving the exact offset model (chunk stride 320,i_din@176,di_db@+40 β neighbor field f at payload index 104+f).
Patched kernel (fix.diff applied)
- No overflow: 1023-byte target takes the long-symlink path;
ls -lashowsmaxx maxxuid/gid; no panic after repeated triggers.
Files
trigger3015.cβ minimal unprivileged trigger (1023-byte target)spray3015.cβ inode spray used in the first blind reproductionexp3015.cβ grooming/leak/weapon harness (calibration log inrun.calibration.log: adjacency verification + leaked mount-constant pointers)demo3015.cβ controlled-write demo skeleton (see VERDICT.md for the observation caveat: stat on a smashed inode faults first)setup3015.shβ crafted-image builder/mounter (root)inopatch.cβ on-disk dinode patcher (helper, FFS fragment math)panic.txt,panic2.txt,run.blind.log,env.txt,fix.diff
DF-3015 VERDICT
Finding: ufs_symlink heap buffer overflow via unvalidated on-disk
fs_maxsymlinklen β sys/vfs/ufs/ufs_vnops.c:1561-1566, mount glue
sys/vfs/ufs/ffs_vfsops.c:504,724.
Status: reproduced (impact: memcorrupt β controlled kernel heap overflow; two distinct kernel panics; attacker bytes disclosed inside a live inode via stat(2)).
How it was reproduced
setup3015.sh(root): 128MB UFS1 image vianewfs /dev/vn0,fs_maxsymlinklenpatched at superblock byte 9512 to0x00007fff, mounted at /mnt (no softdep/async).- Unprivileged
symlink()with a 1023-byte target (trigger3015.c, uid 1001):len < mnt_maxsymlinklenβbcopy(target, i_shortlink, 1023)fromi_din+40in a 304-byte object in the 320-byte slab class β ~919 bytes past the allocation, fully attacker-controlled content (any non-NUL bytes), length selectable 61..1023.
Evidence chain
- Controlled content in a live kernel object, disclosed to userland:
after the trigger,
ls -lareports the symlink's uid/gid as1094795585=0x41414141(run.log) βdi_uid/di_gidati_din+112/+116are inside the bcopy range. - Kernel panic #1 β freelist poisoning: a 280-byte variant
overwrote a free chunk's
c_Next(first 8 bytes of a slab free chunk) with0x5353535353535353; the next inode allocation popped the poisoned list and tripped the INVARIANTS zone assertion (panic.txt). On a production (non-INVARIANTS) kernel the same poisoning is the classic arbitrary-in-zone-allocation primitive β no mask assertion exists there. - Kernel panic #2 β live-object corruption: the 1023-byte trigger
smashed a live neighbouring inode's pointer fields;
ffs_updatedereferenced0x4141...β fatal trap 9 GPF (panic2.txt). - Deterministic offset proof: grooming harness (
exp3015.c) reached steady-state slab allocation after 150 filler pairs; the DF-0778 readlink leak of the chunk following a 280-byte symlink showed the live neighbour'si_numberat exactly the predicted offset (leak[144..152) == V ino 306) βrun.log. The write can therefore be placed at a chosen field of a chosen neighbouring inode (neighbour field f β payload index 104+f; verified end-to-end).
Exploitation analysis (uid=0)
The primitive is a strong memcorrupt: controlled content, controlled
length, deterministic placement into the neighbouring struct inode
after minimal grooming. A complete unprivβroot chain was engineered
this far:
- Weaponised write into a neighbouring inode can set
di_mode = IFREG|ISUID|0755(bytesED 89) and leavedi_uid(root) untouched by stopping the bcopy at payload offset 282 β the setuid-root exec primitive in two bytes. - Hard blocker: the same write necessarily passes through the
victim inode's header, destroying
i_fs(VTOI(vp)->i_fs, needed by ffs_read/bmap),i_devvp(ufs_strategy) andi_dev(ufs_getattrβdevid_from_dev during execve). These kernel pointers cannot be rewritten through the string-based bcopy because their addresses contain 0x00 bytes (kernel heap objects are size-class aligned; leaked values confirmed:k_fs=...8800,k_dev=...4f00f8..,k_devvp=...0f00β seerun.log). Repairing them requires a kernel-written source (e.g. an allocator-forged overlapping inode β feasible via the demonstrated freelist poisoning, but every field layout I derived either zeroes the just-setdi_modeor re-zeroes the repaired pointers; the colliding offsets are documented in the session analysis). - Within this run's budget the chain therefore stops at: controlled heap corruption + reliable kernel panic + disclosed attacker bytes, with the two-byte setuid-mode write demonstrated as the final primitive component.
Threat-model note: with root mounting attacker-crafted media, the
mount itself already conveys root-chosen file ownership, so "uid0" is
not the meaningful boundary for this class (DF-0778/0887 were verified
the same way: panic/leak). The real-world value of this bug is on
mounts that are not attacker-owned media β e.g. an image modified in
transit, a multi-tenant image store, or vfs.usermount=1 boxes β where
an unprivileged user gets a controlled kernel heap overflow from a
single symlink(2).
Fix validation
fix.diff clamps mnt_maxsymlinklen to UFS1_MAXSYMLINKLEN at both
mount-time assignment sites (ffs_vfsops.c) and adds a belt-and-braces
len < UFS1_MAXSYMLINKLEN guard in ufs_symlink itself. Applied to
the guest's /usr/src, make nativekernel + installkernel, reboot:
- baseline (stock): symlink uid/gid = 0x41414141, panics above.
- patched: 1023-byte target takes the long-symlink path; uid/gid stay
maxx maxx; repeated triggers (1023-byte and 280-byte) produce no panic, no corruption (run.patched.log).
Fix status: fixed.
Fix verification
fixedfix.diff applied to guest /usr/src, nativekernel rebuilt (#1 Sep 5 09:25:16), rebooted; identical crafted image + identical unprivileged trigger now stores the 1023-byte symlink via the long path with uid/gid intact (baseline showed 0x41414141 and two kernel panics); no panic after trigger; legitimate <=59-byte fast symlinks still work. Baseline bad behaviour GONE.
findings/poc/DF-3015/run.patched.log
Confirmed kernel references
Detail
Exploit chain
crafted image (fs_maxsymlinklen=0x7fff) mounted by root -> unprivileged symlink(2) with 61..1023-byte target -> bcopy overflow past struct inode into the 320-byte slab class -> spray/groom to steady state (150 filler inodes) -> leak neighbouring inode header via DF-0778 readlink primitive to calibrate offsets and confirm adjacency -> 282-byte weapon write sets neighbouring inode di_mode=IFREG|ISUID|0755 while leaving root di_uid untouched -> exec would give euid=0; final step blocked because the same linear write destroys i_fs/i_dev/i_devvp and those kernel pointers contain NUL bytes (size-class-aligned heap addresses) so the string-based bcopy cannot rewrite them; repairing them needs an allocator-forged overlapping inode whose field layouts collide (documented in VERDICT.md). Deliverable demonstrated: controlled memcorrupt + reliable panic + disclosed attacker bytes in a live inode.
Evidence (decisive lines)
['findings/poc/DF-3015/run.log β baseline log incl. ls showing symlink uid/gid = 1094795585 (0x41414141) and calibration leak with i_number match at leak[144..152)', 'findings/poc/DF-3015/panic.txt β INVARIANTS panic: chunk_mark_allocated zone assertion after freelist c_Next poisoned with 0x53 bytes (ffs_vget path)', 'findings/poc/DF-3015/panic2.txt β Fatal trap 9 GPF at ffs_update+0x3e after 1023-byte trigger smashed a live inode', 'findings/poc/DF-3015/trigger3015.c + setup3015.sh β minimal unprivileged trigger + crafted-image builder', 'findings/poc/DF-3015/exp3015.c β grooming/calibration harness (train, adjacency verification, pointer leak)', 'findings/poc/DF-3015/fix.diff β clamp at mount + guard in ufs_symlink; validated by in-guest kernel rebuild (run.patched.log)']
PoC changes
Seed was authored fresh in this run (no prior PoC). syscall-free trigger: plain symlink(2). Superblock patch offset computed from guest headers (fs_maxsymlinklen @ 8192+1320=9512). Had to learn in-guest struct geometry (sizeof(inode)=304, i_din@176, 320-byte chunk class) and slab LIFO/round-robin behaviour to build the grooming harness; the naive adjacency assumption was corrected after the leak returned a self-echo.
Verified recommended fix
Clamp mnt_maxsymlinklen to UFS1_MAXSYMLINKLEN at both mount assignment sites and independently bound ufs_symlink's fast path by UFS1_MAXSYMLINKLEN (fix.diff).
Verdict
Controlled kernel heap overflow reproduced from an unprivileged symlink(2) on a crafted UFS1 image: fs_maxsymlinklen is copied unvalidated from the superblock into mnt_maxsymlinklen (ffs_vfsops.c:504,724), so ufs_symlink (ufs_vnops.c:1562-1565) bcopy's up to 1023 attacker-controlled bytes into the 60-byte i_shortlink, spilling ~919 bytes past the 320-byte slab chunk of struct inode. Proven by: symlink uid/gid reported as 0x41414141 by stat (attacker bytes inside a live inode), INVARIANTS freelist-poisoning panic (0x5353... in chunk_mark_allocated via ffs_vget), fatal trap 9 GPF in ffs_update from a smashed live neighbour, and a calibration leak verifying the exact write offset into a chosen neighbouring inode (i_number match at leak[144..152)). Two distinct panics, deterministic placement, controlled content and length.
No comments yet.