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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3015 Β· 16 files
FileTypeDescriptionSize
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 -la shows the symlink's own uid/gid = 1094795585 (0x41414141) β€” attacker bytes inside a live kernel inode disclosed to userland (di_uid/di_gid at i_din+112/116 are 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's c_Next was overwritten with 0x53 bytes (panic.txt)
  • Fatal trap 9: general protection fault in ffs_update+0x3e β€” a live neighbouring inode's i_fs/i_devvp were overwritten (panic2.txt)
  • With grooming (exp3015.c) the write lands at a chosen victim inode: the calibration leak verified leak[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 -la shows maxx maxx uid/gid; no panic after repeated triggers.

Files

  • trigger3015.c β€” minimal unprivileged trigger (1023-byte target)
  • spray3015.c β€” inode spray used in the first blind reproduction
  • exp3015.c β€” grooming/leak/weapon harness (calibration log in run.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
VERDICT.md
↓ download raw

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

  1. setup3015.sh (root): 128MB UFS1 image via newfs /dev/vn0, fs_maxsymlinklen patched at superblock byte 9512 to 0x00007fff, mounted at /mnt (no softdep/async).
  2. Unprivileged symlink() with a 1023-byte target (trigger3015.c, uid 1001): len < mnt_maxsymlinklen β‡’ bcopy(target, i_shortlink, 1023) from i_din+40 in 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 -la reports the symlink's uid/gid as 1094795585 = 0x41414141 (run.log) β€” di_uid/di_gid at i_din+112/+116 are 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) with 0x5353535353535353; 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_update dereferenced 0x4141... β‡’ 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's i_number at 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 (bytes ED 89) and leave di_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) and i_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 β€” see run.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-set di_mode or 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 5 09:25:16 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.