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)
PoC verification
Evidence pack
findings/poc/DF-3146 Β· 20 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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=16and a name of stale idp heap bytes (2e 2e 00 00 β¦β leftovers of the previous entry'sdefnamestrcpy into the same buffer) whileABCDEFGHIJKLMNOPwent 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;
defnamefallback 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 tostart β 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:
- 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.
- 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_offslot (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). - 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
fixedfix.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
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).
No comments yet.