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

Dangling RRIP NM-CONTINUE chain: cd9660_rrip_defname fallback writes the ISO name at the un-reset advanced ana->outbuf β€” OOB write past altname[NAME_MAX] (kernel stack, lookup) and past the isoreaddir d_name area (kernel heap, readdir); verified unprivileged uid=0

Field Value
ID DF-NNNN
Status new
Severity
CVSS 3.1
CWE
File
Lines -
Area
Confidence
Discovered
Pass <1 = GLM 5.2 first pass / 2 = GLM 5.3 second pass>
Bucket
Reported
Known CVE
CVE match

Summary

<2-4 sentences. What is broken, where, and the security consequence.>

Root cause

file:line citations. Name the exact variable, length, check, or lock that is wrong.>

Threat model & preconditions

  • Attacker position:
  • Privileges gained or impact:
  • Required config or capabilities: vfs.mount.<fs> allowed, needs a specific device node>
  • Reachability:

Proof of concept

PoC source: findings/poc/DF-NNNN/<file>

Build & run

<exact commands to compile and run the PoC on a DragonFlyBSD host/VM>

Expected output

<what a successful trigger looks like β€” panic dump, leaked bytes, uid=0, etc.>

Impact

--- a/sys/<path>
+++ b/sys/<path>
@@ -<l>,<n> +<l>,<n> @@
 <context>
-<vulnerable line>
+<fixed line>
 <context>

References

Timeline

  • Discovered during automated audit.
  • Reported to DragonFlyBSD security contact.

Verification (latest)

status: reproduced | impact: uid0_privesc | confidence: certain

Fix status: fixed β€” validated fix.diff lives at findings/poc/DF-3156/fix.diff

Unprivileged uid=0 on the stock INVARIANTS kernel #0. A dangling RRIP NM-CONTINUE chain (final NM fragment in a CE continuation area, no terminating fragment) leaves ana->outbuf advanced by up to 255 bytes; cd9660_rrip_loop's default fallback then runs cd9660_rrip_defname, isofntrans-ing up to 183 attacker-chosen name bytes at base+255 -- past altname[NAME_MAX] on the cd9660_lookup stack (measured saved-RBP at OOB+22, saved-RIP at OOB+30; panic_lstat.txt) and past the dirent name in the kmalloc'd isoreaddir on the readdir path (panic in iso_uiodir+0x25 dereferencing the smashed idp->uio). The heap variant was weaponized: idp->uio redirected to a fake struct uio/iovec in the unprivileged process (no SMAP) turns iso_uiodir's uiomove into an arbitrary 152-byte kernel bcopy; one getdents writes the dirent+NM-chain bytes over &sysent[71]-16, hijacking the obsolete syscall 71 sy_call to a ring0 stub at a fixed user address (no SMEP) while restoring every adjacent live sysent entry; syscall(71) executes the stub which zeroes cr_uid/cr_ruid/cr_svuid, sets cr_ngroups=1 and cr_caps=~0 via %gs:0->gd_curthread->td_proc->p_ucred and returns through the intact syscall path. run.log: 'uid after: 0 euid: 0' as nobody plus the contents of mode-0400 /root/df3156_rootproof, guest stays up. All fragment lengths are individually valid (>=5, each accumulation <= NAME_MAX) -- distinct root cause from DF-3146 (negative-wlen underflow) and DF-0878 (entry-length OOB reads). fix.diff (2-line rewind in cd9660_rrip_defname) built as kernel #1 and validated: both triggers become clean fallback names, no panic, no escalation, uid stays 65534.

Evidence pack: findings/poc/DF-3156/ (README.md, VERDICT.md, sources, full logs, fix.diff, manifest.json).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3156 Β· 23 files
FileTypeDescriptionSize
README.md β€” 4.9 KB ↓ raw
VERDICT.md β€” 9.3 KB ↓ raw
build.sh β€” 251 B view raw
craft_dangle.py β€” 5.6 KB view raw
craft_exploit.py β€” 4.9 KB view raw
df3156_exploit.iso β€” 80.0 KB ↓ download
df3156_pat183.iso β€” 80.0 KB ↓ download
df3156_rop.iso β€” 80.0 KB ↓ download
df3156_x4242.iso β€” 80.0 KB ↓ download
env.txt β€” 746 B view raw
exploit.c β€” 5.9 KB view raw
fix.diff β€” 1.2 KB view raw
fix_build.log β€” 4.6 KB view raw
fix_run_exploit.log β€” 847 B view raw
manifest.json β€” 2.6 KB view raw
panic_dents_serial.log β€” 13.8 KB view raw
panic_lstat_serial.log β€” 13.6 KB view raw
run.2.log β€” 337 B view raw
run.log β€” 288 B view raw
run.sh β€” 509 B view raw
run_shell.log β€” 2.0 KB view raw
run_shell.raw β€” 661 B ↓ download
trigger.c β€” 1.4 KB view raw

DF-3156 β€” dangling RRIP NM-CONTINUE chain makes the defname fallback write the ISO name out of bounds (kernel stack in lookup, kernel heap in readdir) β€” verified to unprivileged uid=0

What (root cause)

cd9660_rrip_altname() (sys/vfs/isofs/cd9660/cd9660_rrip.c:226-284) advances ana->outbuf by every NM fragment (:276-277) and only rewinds it on the > ana->maxlen rejection (:271). A fragment whose FLAGS byte has CONTINUE set never completes the name β€” it returns 0 without setting the ISO_SUSP_ALTNAME result bit (:279-283).

If the SUSP walk then ends without a terminating NM fragment β€” trivially arranged: the last field of the walk is a CONTINUE-flagged NM in a CE continuation area β€” cd9660_rrip_loop()'s default-value fallback (:581-584) invokes cd9660_rrip_defname(), which runs

isofntrans(isodir->name, isonum_711(isodir->name_len),
       ana->outbuf, ana->outlen, ...);          /* rrip.c:289-292 */

at the stale advanced pointer base + N where N is the accumulated fragment length (up to 255, reached via CE continuations), writing the record's translated ISO name (up to ~183 bytes, attacker-chosen content, copied verbatim at joliet_level 0 / original=1) β€” up to 183 bytes past the end of a 255-byte output buffer:

  • cd9660_lookup() (cd9660_lookup.c:109 char altname[NAME_MAX]) β€” kernel stack; OOB bytes 22..29 = saved RBP, 30..37 = saved RIP (measured: frame pointer = 0x444342415a595857 = our cycling A..Z pattern, Stopped at cd9660_lookup+0x461: ret).
  • cd9660_readdir() (cd9660_vnops.c:542, buffer = idp->current.de.d_name inside the kmalloc'd struct isoreaddir) β€” kernel heap; the write smashes the isoreaddir tail: curroff, uio pointer, uio_off, eofflag, cookies, ncookies (measured: Fatal trap 9 ... iso_uiodir+0x25: cmpq %rax,0x18(%r8) β€” the corrupted idp->uio dereferenced).

All fragment lengths are individually valid (>= 5) and each passes the > maxlen check β€” this is not the DF-3146 negative-wlen underflow and not the DF-0878 entry-length OOB-read family; the defect is the missing output-buffer reset on the default-name fallback path.

Verified impact (guest 6.5-DEVELOPMENT #0, stock INVARIANTS kernel)

  1. Unprivileged kernel memory corruption β€” getdents/ls (heap) or any stat/path resolution (stack) under the mount, as any user.
  2. Full unprivileged β†’ uid=0 chain (exploit.c + df3156_exploit.iso): * the heap variant corrupts idp->uio to point at a **fake struct uio
    • struct iovec in the (unprivileged) process address space**;
    • iso_uiodir() β†’ uiomove(dirent, 152, fake_uio) with uio_segflg=UIO_SYSSPACE bcopys the dirent (16-byte header + the NM-chain bytes, all attacker data) to the fake iov_base β€” an arbitrary kernel write with no SMAP to stop it;
    • the write targets &sysent[71]-16, hijacking the obsolete syscall 71 (old mmap) sy_call to a ring0 stub at a fixed user address (no SMEP), while restoring every live sysent entry it touches (munmap/mprotect/madvise/sbrk-neighbors) with their original values;
    • syscall(71) executes the stub in ring0: %gs:0 β†’ gd_curthread(+8) β†’ td_proc(+168) β†’ p_ucred(+16) then cr_uid=cr_ruid=cr_svuid=0, cr_ngroups=1, cr_caps=~0;
    • the syscall returns through the intact syscall path; the process is now root β€” uid after: 0 euid: 0, reads the root-only /root/df3156_rootproof (mode 0400). Guest stays up.
  3. The stack variant gives direct saved-RIP/RBP control (panic evidence in panic_lstat.txt) β€” an alternative escalation route not needed for the demonstrated chain.

Threat model: crafted RRIP ISO mounted by root (same setup as the DF-0878/0879/DF-3124/DF-3146 crafted-media family); after the mount ANY unprivileged user with read access triggers via getdents/stat/ls.

Reproduce

Host: python3 craft_dangle.py pat184 out.iso (pattern trigger), python3 craft_exploit.py df3156_exploit.iso (full chain). Stage the pack into the guest and:

cc -O -o /tmp/exploit /root/exploit.c ; cc -O -o /tmp/trigger /root/trigger.c
echo "SECRET-ROOT-ONLY-DF3156-$(date +%s)" > /root/df3156_rootproof
chmod 400 /root/df3156_rootproof ; mkdir -p /mnt/df3156
vnconfig -c -S 2048 /dev/vn0 /root/df3156_exploit.iso
mount_cd9660 -o ro /dev/vn0 /mnt/df3156
su -m nobody -c "/tmp/exploit /mnt/df3156"     # -> uid after: 0 + secret
# stack-smash proof (panics the box):
vnconfig -u /dev/vn0 ; vnconfig -c -S 2048 /dev/vn0 /root/df3156_pat183.iso
mount_cd9660 -o ro /dev/vn0 /mnt/df3156
su -m nobody -c "/tmp/trigger lstat /mnt/df3156"

Fix

fix.diff β€” rewind ana->outbuf/*ana->outlen to the buffer base at the top of cd9660_rrip_defname() (no-op when no NM fragment was seen). Validated on a rebuilt kernel (#1): both triggers become clean fallback names (name='A', pattern name at base), no panic, no uid0 (fix_run_exploit.log).

VERDICT.md
↓ download raw

DF-3156 VERDICT β€” reproduced, uid0

Bottom line

reproduced / uid0_privesc / certain. A crafted Rock Ridge ISO makes cd9660_rrip_defname() write up to 183 fully attacker-controlled bytes at output_buffer + 255 β€” past a 255-byte kernel-stack array in cd9660_lookup() and past the dirent name area of the kmalloc'd struct isoreaddir in cd9660_readdir(). The heap variant was turned into an arbitrary kernel write (fake struct uio), then into a hijack of sysent[71].sy_call, then into ring0 execution of a userland stub that zeroes the calling process's credentials β€” nobody ends with uid=0 euid=0 and reads a mode-0400 root file, on the stock INVARIANTS kernel #0, from a fresh vm.sh reset. The two-line fix.diff was built as kernel #1 and re-tested: both triggers degrade to clean fallback names, no panic, no escalation.

How it works (path:line)

  1. Dangling chain. cd9660_rrip_altname (sys/vfs/isofs/cd9660/cd9660_rrip.c:226-284) bcopy()s every NM fragment and advances ana->outbuf (:276-277). A fragment with FLAGS&ISO_SUSP_CFLAG_CONTINUE (0x01) returns 0 without setting the ISO_SUSP_ALTNAME result bit (:279-283) β€” the name is "still coming". The craft places such an NM as the final field of a CE continuation area (craft_exploit.py): after it the walk simply runs out of fields (pend reached, iso_ce_len == 0 β†’ loop breaks at :544-545).
  2. Fallback at the stale pointer. Because result lacks ISO_SUSP_ALTNAME, the default-value loop (:581-584) calls cd9660_rrip_defname, which isofntrans()es the record's ISO name at ana->outbuf = base + 255 (:289-292) β€” OOB. Budget arithmetic: reclen(255) = 33 + name_len + NM1(10) + CE(28) β†’ name_len ≀ 183; chain length N = 5 + 250 = 255 (the *outlen > maxlen=NAME_MAX check caps N at 255 β€” both fragments individually legal).
  3. Stack sink. cd9660_lookup (cd9660_lookup.c:109 char altname[NAME_MAX]) β€” 183 controlled bytes from base+255; measured saved RBP at OOB+22, saved RIP at OOB+30: frame pointer = 0x444342415a595857, Stopped at cd9660_lookup+0x461: ret (panic_lstat.txt) β€” direct return-address control.
  4. Heap sink β†’ arbitrary write. In cd9660_readdir (cd9660_vnops.c:542) the buffer is idp->current.de.d_name; the write lands on struct isoreaddir tail fields (d_name ends at idp+816): currooff (name[17..24)), uio (name[25..32)), uio_off, eofflag, cookies (name[53..60) = 0), ncookies. Naive corruption panics in iso_uiodir+0x25 (panic_dents_serial.log); pointing idp->uio at a fake struct uio/iovec in userland (no SMAP) turns iso_uiodir()'s uiomove(dp, _DIRENT_DIRSIZ(dp), idp->uio) into bcopy(dirent, <any kernel address>, 152) (uio_segflg=UIO_SYSSPACE, uio_rw=UIO_READ β€” no td KASSERT on that path, kern_subr.c:105-108, :138-143). curroff set huge exits the readdir loop cleanly; cookies NULL avoids the cookie free; the syscall returns normally.
  5. sysent hijack β†’ ring0 β†’ uid0. The copied dirent = 16-byte header + the NM-chain bytes (both attacker data; the ISO name only supplies the OOB tail smash). Target &sysent[71]-16: header lands on dead sysent[70] (sstk), chain byte 0..8 β†’ sysent[71].{sy_narg=0, sy_rsize=4}, bytes 8..16 β†’ sy_call = ring0 stub at 0x20000040, bytes 24..128 restore sysent[72..77] (vadvise/munmap/mprotect/ madvise/vhangup/vlimit) to their exact original table values so every live syscall (incl. sbrk/mmap used by exec) keeps working. syscall(71) executes the stub in ring0 (no SMEP): %gs:0 β†’ gd_curthread(+8) β†’ td_proc(+168) β†’ p_ucred(+16) β†’ cr_uid(+64)/cr_ruid(+160)/cr_svuid(+164) = 0, cr_ngroups(+68)=1, cr_caps(+176)=~0; returns 0 through the intact syscall frame. Result (run.log): uid after: 0 euid: 0 + contents of /root/df3156_rootproof (0400 root).

Why this is new (not DF-3146 / DF-0878 / DF-0879)

  • DF-3146: negative wlen = length - 5 underflow β†’ u_short wrap β†’ 64KB rewind. Here every fragment length is β‰₯ 5 and every accumulation passes the maxlen check; the write offset is a positive legitimate chain length and the sink is the fallback writer, not altname's bcopy.
  • DF-0878: loop-level entry-length upper-bound OOB reads (handlers reading past short entries). This is an OOB write caused by walk termination semantics, and my entry lengths are honest.
  • DF-0879: unbounded CE chains / infinite loop. Two CE hops only.

Reproduction matrix (guest 6.5-DEVELOPMENT, x86_64, securelevel -1)

run kernel trigger result
run.log #0 stock exploit as nobody uid after: 0 euid: 0, root file read, rc=0, guest up
panic_lstat.txt / panic_lstat_serial.log #0 stock trigger lstat as nobody Fatal trap 9, controlled rbp/rip, cd9660_lookup+0x461: ret
panic_dents_serial.log #0 stock trigger dents as nobody Fatal trap 9 in iso_uiodir+0x25 (corrupted idp->uio)
fix_run_exploit.log #1 patched exploit as nobody poison entry ships as namlen=131 name=A (fallback at base); syscall(71) β†’ SIGSYS from the stock nosys slot; uid stays 65534
fix_run_exploit.log #1 patched trigger lstat/dents SURVIVED, clean pattern fallback name, guest up

Fix kernel: patch -p1 < fix.diff, make -j8 nativekernel, make installkernel, reboot β†’ #1: Mon Sep 7 07:47:17 UTC 2026.

PoC fixes vs the seed

No seed existed (new finding). Iterations that mattered: (1) even-length ISO names need the pad byte before the SUSP area or the walker starts one byte into the first field (cd9660_rrip.c:484-486); (2) the dirent that the hijacked uiomove copies is built from the NM-chain bytes, not the ISO name (the ISO name is consumed by the OOB tail smash); (3) the record's first name byte must not be 0/1 β€” cd9660_rrip_getname (:652-657) treats such records as "."/"..", skips the NM table row and calls defname at the base (no OOB); (4) d_type/d_unused2 of the dirent header are stale heap bytes, so the sysent alignment must keep the 16-byte header on dead fields.

fix.diff (in this pack): rewind ana->outbuf -= *ana->outlen; *ana->outlen = 0; at the top of cd9660_rrip_defname() β€” the fallback always rebuilds the name at the caller's buffer base; the deltas are 0 on every legitimate path that reaches it. Defense-in-depth (not required for correctness): have cd9660_rrip_loop reset the output state whenever the walk terminates with ISO_SUSP_ALTNAME still pending.

Interactive root shell (2026-10-16 maintenance pass, run_shell.log)

The exploit now has a --shell mode (or DF3156_SHELL=1): after the same credential-zeroing syscall(71) and the rootproof read it execl("/bin/sh","sh") into a real interactive # shell β€” captured under script(1) on a pty in run_shell.log/run_shell.raw: tty-echoed commands, id β†’ uid=0(root) gid=0(wheel) groups=0(wheel) inside the shell, rootproof re-read, touch /tmp/DF3156_OWNED landing 0:0-owned (verified from the ssh root context after exit), rc=0, guest stable. Two latent PoC defects were fixed to make the ending work: (1) the ring0 stub wrote cr_caps = ~0, but cr_caps is a restriction bitmap (sys/sys/caps.h: "negatives, i.e. restrictions") β€” ~0 is maximally restricted and made every post-surgery execve fail EACCES (the original run.log's exec'd id never printed for exactly this reason); the stub now writes 0 (unrestricted), after which setgid(0)/setuid(0) also run and exec works (run.2.log now shows the full uid=0(root) ... groups=0(wheel) id output in default mode too). (2) the kernel constants are build-specific: on the current with-src snapshot kernel (a later fix-validation rebuild, sysent=0xffffffff810cb880) the write lands 0x3f68 below the real sysent, the syscall(71) probe SIGSYSes, and the misdirected 152-byte write corrupts unrelated static data β€” reproducibly panicking at exit (funsetfd_locked+0x28, type 12); on the stock clean-install kernel (sysent=0xffffffff810c7900) every constant matches and the chain works first-try. Reproduce on the clean-install snapshot (vm.sh reset, not with-src) and re-derive constants from nm /boot/kernel/kernel when the kernel changes. One exploit run per fresh boot+mount remains the reliable pattern: a repeat run risks the OOB spill (the 131-byte name writes 66 bytes past the 880-byte isoreaddir into an adjacent kmalloc slot) hitting a live neighbor β€” observed once as a funsetfd_locked trap on process exit. The interactive-shell ending changes nothing about the finding or its fix: it is a userspace epilogue after the same verified primitive, so the validated fix.diff kernel stops it at stage 1 identically (the Sep 7 fix kernel #1 no longer exists on the current guest image β€” its snapshot was replaced by the with-src rebuild β€” so no new fix-kernel contrast run was made this pass; the original fix validation in fix_run_exploit.log stands).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

patch -p1 < fix.diff; make -j8 nativekernel && make installkernel; reboot into #1. Baseline (#0): exploit => uid0, lstat/dents => trap-9 panics. Patched (#1): exploit degrades to a clean fallback name (namlen=131 name=A) and syscall(71) SIGSYSes from the stock nosys slot, uid stays 65534; lstat and dents SURVIVED with correct names; guest stable.

['fix_run_exploit.log', 'fix_build.log', 'fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Sep 7 07:47:17 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

mount crafted RRIP ISO (root setup) -> unpriv getdents on the mount -> defname OOB write smashes isoreaddir tail (uio ptr -> fake uio in userland, cookies=0, curroff=huge for clean loop exit) -> iso_uiodir uiomove bcopy(dirent, &sysent[71]-16, 152) = arbitrary kernel write -> sysent[71].sy_call = ring0 stub (adjacent live entries munmap/mprotect/madvise restored to original values in the same write) -> unpriv syscall(71) -> stub: gs:0 -> gd_curthread+8 -> td_proc+168 -> p_ucred+16 -> cr_uid/cr_ruid/cr_svuid=0, cr_caps=~0 -> normal syscall return -> uid=0 process reads root-only file

Evidence (decisive lines)

["run.log: 'uid after: 0 euid: 0' + SECRET-ROOT-ONLY-DF3156 root-file contents as nobody, fresh-reset boot", 'panic_lstat.txt: frame pointer = 0x444342415a595857 (cycling A..Z pattern), Stopped at cd9660_lookup+0x461: ret', 'panic_dents_serial.log: Fatal trap 9 iso_uiodir+0x25 cmpq %rax,0x18(%r8) (smashed idp->uio)', 'fix_run_exploit.log: patched kernel #1 -- poison entry ships as namlen=131 name=A (fallback at base), syscall(71) -> SIGSYS from stock nosys, uid 65534, lstat/dents SURVIVED', 'fix_build.log / fix.diff: validated 2-line fix (rewind ana->outbuf/*ana->outlen in cd9660_rrip_defname)']

PoC changes

new finding, no seed. Key iterations: even-length ISO names need the pad byte before the SUSP area (rrip.c:484-486) or the walker starts one byte into the first field; the dirent copied by the hijacked uiomove is built from the NM-chain bytes while the ISO name supplies only the OOB tail smash; first name byte must be non-0/1 (getname :652-657 treats such records as ./.. and skips the NM row); dirent header d_type/d_unused2 are stale heap bytes so the sysent alignment keeps the 16-byte header on dead fields (sstk entry).

Verified recommended fix

rewind ana->outbuf/ana->outlen to the caller's buffer base at the top of cd9660_rrip_defname() (no-op on every legitimate path): ana->outbuf -= ana->outlen; *ana->outlen = 0;

Verdict

Unprivileged uid=0 on the stock INVARIANTS kernel #0. A dangling RRIP NM-CONTINUE chain (final NM fragment in a CE continuation area, no terminating fragment) leaves ana->outbuf advanced by up to 255 bytes; cd9660_rrip_loop's default fallback then runs cd9660_rrip_defname, isofntrans-ing up to 183 attacker-chosen name bytes at base+255 -- past altname[NAME_MAX] on the cd9660_lookup stack (measured saved-RBP at OOB+22, saved-RIP at OOB+30; panic_lstat.txt) and past the dirent name in the kmalloc'd isoreaddir on the readdir path (panic in iso_uiodir+0x25 dereferencing the smashed idp->uio). The heap variant was weaponized: idp->uio redirected to a fake struct uio/iovec in the unprivileged process (no SMAP) turns iso_uiodir's uiomove into an arbitrary 152-byte kernel bcopy; one getdents writes the dirent+NM-chain bytes over &sysent[71]-16, hijacking the obsolete syscall 71 sy_call to a ring0 stub at a fixed user address (no SMEP) while restoring every adjacent live sysent entry; syscall(71) executes the stub which zeroes cr_uid/cr_ruid/cr_svuid, sets cr_ngroups=1 and cr_caps=~0 via %gs:0->gd_curthread->td_proc->p_ucred and returns through the intact syscall path. run.log: 'uid after: 0 euid: 0' as nobody plus the contents of mode-0400 /root/df3156_rootproof, guest stays up. All fragment lengths are individually valid (>=5, each accumulation <= NAME_MAX) -- distinct root cause from DF-3146 (negative-wlen underflow) and DF-0878 (entry-length OOB reads). fix.diff (2-line rewind in cd9660_rrip_defname) built as kernel #1 and validated: both triggers become clean fallback names, no panic, no escalation, uid stays 65534.