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

RRIP NM length underflow in cd9660_rrip_altname: u_short wrap rewinds the name output pointer by 65536*N β†’ controlled OOB write at a wild kernel address (heap via readdir, kstack via lookup) or bcopy((size_t)-1) panic

Summary

cd9660_readdir's RRIP path calls cd9660_rrip_getname(ep, idp->current.de.d_name,...) (vnops.c:542) and cd9660_lookup calls it with a kernel-stack altname[NAME_MAX] (lookup.c:261). Inside, an NM SUSP field whose on-disk length byte is < 5 (the walk's only plausibility check requires >= 4) makes wlen = length - 5 negative. With *outlen==0 the u_short accumulator wraps to 65531..65535, the '> maxlen' branch fires, and outbuf -= *outlen - wlen rewinds the output pointer by exactly 65536 bytes per such field; with *outlen>=1 the negative wlen reaches bcopy() as (size_t)-1..-5. VERIFIED guest unpriv getdents: warm variant = deterministic 'Fatal trap 12 / memmove+0x24f repe movsq' panic; cold variants = silent writes of 16 fully attacker-chosen bytes at (isoreaddir kmalloc)-65536*N (N=1, 20 survived, shipped dirent disclosed 16 stale heap bytes as the name); CE-chained N=16140 (-1.02GB) panicked in vm_object_hold_shared with isofntrans() writing at the rewound pointer. N scales without bound via bounded CE continuations; content up to ~250 bytes per chunk from the image; repeatable. Crafted RRIP ISO image (mount = setup, same family as DF-0878/0879/DF-3124); after the mount ANY unpriv user with read access triggers via getdents/ls/stat. Impact: reliable unpriv panic (DoS) plus an arbitrary-content write at any 64KB-grid address below the output buffer - a kernel-memory-corruption primitive (uid0 escalation analyzed: blocked in-session by the intrinsic 64KB rewind residue + missing anchor-address leak; documented). Fix validated (reject NM < 5 bytes before arithmetic): warm/cold/ce variants all clean.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3146 Β· 20 files
FileTypeDescriptionSize
craft_img.py β€” 5.5 KB view raw
craft_ce.py β€” 2.5 KB view raw
trigger.c β€” 3.1 KB view raw
build.sh β€” 236 B view raw
run.sh β€” 217 B view raw
df3146_warm.iso β€” 80.0 KB ↓ download
df3146_cold1.iso β€” 80.0 KB ↓ download
df3146_cold20.iso β€” 80.0 KB ↓ download
df3146_ce64.iso β€” 228.0 KB ↓ download
df3146_ce65.iso β€” 230.0 KB ↓ download
df3146_pay.iso β€” 80.0 KB ↓ download
df3146_none.iso β€” 80.0 KB ↓ download
run.log β€” 3.4 KB view raw
panic.txt β€” 673 B view raw
panic2.txt β€” 783 B view raw
env.txt β€” 835 B view raw
fix.diff β€” 970 B view raw
fix_build.log β€” 5.7 MB ↓ download
fix_run.log β€” 1.9 KB view raw
VERDICT.md β€” 6.3 KB ↓ raw
VERDICT.md
↓ download raw

DF-3146 β€” VERDICT

reproduced β€” unprivileged post-mount trigger, two independent panic signatures plus a silent controlled wild write, on the stock INVARIANTS kernel (DragonFly dfbsd 6.5-DEVELOPMENT #0, 2026-07-02).

How it was proven

1. Warm variant β€” deterministic panic (bcopy with (size_t)-1)

Entry W SUSP area: NM(len=21,payload) then NM(len=4) then filler. Second NM: wlen = 4-5 = -1 (cd9660_rrip.c:258), *outlen = 16-1 = 15 (≀ maxlen) β†’ bcopy(in, out, (size_t)-1) (rrip.c:276).

Observed (serial console, guest down in DDB):

Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xfffff8005d685ff9
fault code             = supervisor read data, page not present
Stopped at  memmove+0x24f:  repe movsq (%rsi),%es:(%rdi)

(bcopy β†’ memmove on amd64; the copy walks forward until the unmapped page after the source buffer-cache page.) β†’ panic.txt.

2. Cold variant β€” silent OOB write at d_name βˆ’ 65536Β·N + heap disclosure

Entry C SUSP area: [NM(len=4)+filler]Γ—N + NM(len=21,payload). First NM: *outlen = 0-1 wraps u_short β†’ 65535 > NAME_MAX β†’ outbuf -= 65535-(-1) = 65536 exactly (rrip.c:268-271). After N such entries the output pointer sits 65536Β·N below idp->current.de.d_name; the payload NM then bcopys 16 fully attacker-chosen bytes there.

  • N=1 and N=20: guest survived (the target pages are mapped kernel heap) and the trigger entry shipped with namlen=16 and a name of stale idp heap bytes (2e 2e 00 00 … β€” leftovers of the previous entry's defname strcpy into the same buffer) while ABCDEFGHIJKLMNOP went to the wild address. Direct evidence of both the write and a corroborating 16-byte stale-heap disclosure in the same dirent.
  • N=16140 (CE-chained, βˆ’1.02 GB, image df3146_ce64.iso): the write reached far below the malloc arena and killed the VM subsystem:
panic: assertion "obj != NULL" failed in vm_object_hold_shared at vm/vm_object.c:330
vm_fault() at vm_fault+0x408
trap_pfault() at trap_pfault+0x9a
--- trap 000000000000000c, rip = ... isofntrans() at isofntrans+0xe1 ---

The faulting isofntrans is the defname fallback writing at the rewound pointer (same mechanism as cold1, scaled to βˆ’1 GB) β†’ panic2.txt.

3. Address-control analysis (what the attacker gets)

  • Write address: (output buffer) βˆ’ 65536Β·N, N β‰₯ 1 arbitrary (CE continuations scale N without bound β€” each 2048-byte CE block carries 252 rewind pairs = βˆ’16 MB per block; ce64 used 64 chained blocks).
  • Write content: arbitrary bytes from the image (payload NM up to 250 bytes; defname fallback writes the entry's ISO name, up to ~222 bytes).
  • Repeatable: every readdir allocates a fresh isoreaddir; the objcache recycles the same chunk, so the grid anchor is stable within a boot.
  • The 65536 residue is intrinsic: every "epoch" (outlen reset) rewinds by exactly 65536 + (bytes advanced since last reset), i.e. the pointer always returns to start βˆ’ 65536Β·k. No byte-granular placement within the grid is possible from this bug alone.

4. uid0 escalation β€” attempted, blocked in-session (documented honestly)

Primitive: arbitrary-content write at any address ≑ idp (mod 65536) below the idp heap allocation, repeatable, unprivileged post-mount. To reach uid=0 one needs a juicy victim object (ucred/struct file/kstack of another thread β€” the lookup call site writes below a kernel stack altname[]) whose address is (a) below the anchor and (b) on the anchor's 64KB residue, plus knowledge of the anchor address (heap leak).

In-session blockers, rigorously:

  1. Residue constraint (intrinsic, proven above): the victim must lie on the anchor's 64KB grid. Real victims (ucred ~96 B, file ~144 B) occupy 16-byte-aligned addresses; for a given boot only ~1/4096 of sprayed victims land on the required residue, and their addresses must then be known to pick N.
  2. No anchor-address leak in this pack: the stale-name disclosure (cold runs) reads bytes of the previous dirent content, not pointers; the DF-3147 leak reads the stale uio_off slot (offsets, not pointers). A pointer-class leak of the idp/anchor address was not available without pulling in other findings (DF-0878's read primitive would be the natural complement, and on this no-KASLR guest kernel text is fixed β€” the missing piece is the heap address).
  3. Combined (1)+(2) make a reliable single-session chain infeasible; a realistic weaponization needs a heap-address leak plus long-run grooming (fork-spray ucreds until one lands on the residue, or pivot off the kstack-relative variant in cd9660_lookup). The primitive itself β€” write-what-at-chosen-64KB-grid-address, arbitrary content, repeatable β€” is fully demonstrated above.

Impact recorded as panic (both deterministic warm panic and deep-write panic reproduced); the write primitive is the memcorrupt escalation vector, not completed to uid0 this session.

Distinction from known findings

  • DF-0878 = rrip_loop walk upper-bound OOB reads (large lengths β†’ read past buffer). This finding = lower-bound violation (length < 5) β†’ u_short wrap β†’ write at a rewound pointer + negative-length bcopy. Different direction, different primitive class, separate fix site.
  • DF-0866 = dirent pad disclosure. Different sink (here the stale name bytes are corroboration, not the finding).
  • DF-0879 = CE infinite loop. The CE chain here is bounded (validated, forward-progress); it is used only to scale N.

Fix validation

fix.diff (NM h.length < 5 β†’ ignore field; plus the DF-3147 uio_off init) applied to the guest's /usr/src, make -j8 nativekernel, make installkernel, reboot. Re-runs of all three triggers on the patched kernel: no panic, no stale-name entries β€” the crafted W/C entries now parse as their payload names (ABCDEFGHIJKLMNOP), i.e. the NM walk behaves as if the bogus 4-byte NMs did not exist. See fix_run.log.

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

fix.diff (altname NM length>=5 guard, plus the DF-3147 uio_off init in the same build) applied to the guest /usr/src; make -j8 nativekernel && make installkernel; reboot into #1. All three baseline triggers re-run as nobody: warm/cold1/cold20/ce64 walk cleanly (payload names parse normally, no rewind artifacts, no panic) and the guest stayed up - baseline bad behaviors eliminated. fix_build.log holds the full kernel build.

fix_run.log (side-by-side baseline vs patched outputs); fix_build.log (full nativekernel build, ends '>>> Kernel install for X86_64_GENERIC completed'); uname #1 in the run transcript
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Mon Sep 7 04:20:56 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/usermount setup) -> unprivileged getdents on the mount -> per-entry cd9660_rrip_getname -> [NM len=4 + filler] x N rewinds outbuf to d_name - 65536*N -> payload NM (or defname fallback) bcopy's up to ~250 attacker bytes at that wild address -> repeatable write-what-at-64KB-grid-below-heap-object. Warm variant instead lands bcopy((size_t)-1) = deterministic panic. Escalation to uid=0 requires (a) victim on the anchor's 64KB residue (ucred/file/kstack) and (b) a heap-address leak of the anchor; both are hard blockers in-session (no pointer-class leak available from this bug alone; residue collision odds ~250/65536 per candidate) - see VERDICT.md section 4.

Evidence (decisive lines)

panic.txt (memmove+0x24f repe movsq, Fatal trap 12), panic2.txt (vm_object_hold_shared assert, isofntrans at the rewound pointer, N=16140), run.log RUN 3 (cold1: namlen=16 with stale-heap name 2e 2e 00... = payload diverted to d_name-65536), fix_run.log (patched kernel #1: same images parse as ABCDEFGHIJKLMNOP, no panic, guest stays up)

PoC changes

No seed pack existed; authored from scratch: craft_img.py (minimal RRIP ISO with SP/ER/PX so the image mounts as ISO_FTYPE_RRIP, per-variant trigger entries), craft_ce.py (CE-chained deep rewind), trigger.c (unprivileged getdents/lseek prober). One iteration was needed on the filler bytes after a len-4 NM (the walk's plausibility check reads the pseudo-field length at +2, not +1); the cold primitive fired even through the first attempt via the defname fallback writing at the rewound pointer.

Verified recommended fix

Reject NM entries shorter than 5 bytes in cd9660_rrip_altname() before any flags/wlen arithmetic (fix.diff).

Verdict

Reproduced on the stock INVARIANTS guest: an unprivileged getdents(2) on a mounted crafted RRIP ISO drives cd9660_rrip_altname() with an NM SUSP field of length < 5; the unguarded wlen = length-5 (cd9660_rrip.c:258) is negative, the u_short outlen accumulator wraps (:268) and the 'treat as no name' rewind (:271) moves the output pointer down by exactly 65536 bytes per bad NM. Warm variant (payload NM then len-4 NM) reaches bcopy() with (size_t)-1 -> Fatal trap 12 in memmove+0x24f repe movsq (panic.txt). Cold variants write fully attacker-controlled bytes at (isoreaddir kmalloc) - 65536*N: N=1 and N=20 land silently on mapped heap while the shipped dirent discloses 16 stale heap bytes as the name (run.log), and a CE-chained N=16140 (-1.02GB) panics in vm_object_hold_shared with isofntrans() writing at the rewound pointer (panic2.txt). Arbitrary-content write at any 64KB-grid address below the output buffer, repeatable, from cd9660_readdir (heap anchor, cd9660_vnops.c:542) and cd9660_lookup (kernel-stack anchor, cd9660_lookup.c:261). uid0 escalation not completed this session: the 65536-byte rewind granularity is intrinsic (proven: each epoch rewinds by exactly 65536 plus bytes advanced since the last reset), so a victim object must lie on the anchor's 64KB residue and the anchor address must be leaked; neither a matching victim nor a pointer-class leak of the anchor was achievable in-session (documented in VERDICT.md). Fix validated: with the NM length guard, all three trigger images walk cleanly on the patched kernel and none of the baseline behaviors remain (fix_run.log).