DF-2882 / verdict.json
{ "finding_id": "DF-2882", "status": "reproduced", "reproduced": 1, "impact": "leak", "confidence": "certain", "verdict": "kvsscanf's CT_CHAR suppress short-input branch (sys/kern/subr_scanf.c:292-298) advances inp by n=inr without zeroing inr, so after a %*Nc with N greater than the remaining input the engine believes inr characters are still unread while inp sits on the string's NUL terminator; every subsequent conversion then violates the read bound: %s (subr_scanf.c:375-382) copies the NUL and keeps copying adjacent kernel memory into the caller buffer, %c (:308) bcopy-reads width bytes starting at the terminator, negated %[ (:324) walks likewise. Proven in-kernel on the stock INVARIANTS kernel with the kernel's own ksscanf: canary bytes past the terminator copied (E1); 'Q' bytes from beyond an exact-size kmalloc(32) input -- i.e. out of the adjacent LIVE heap chunk at in+32 -- copied into the caller buffer (E2, delta199=32); a live kernel pointer 0xfffff8008d941680 plus heap bytes leaked on the %c path (E3), fresh pointer again on a second load (run.2.log). Negative control %*8c (N==strlen) clean. No in-tree caller uses %*Nc with N>1 today (21 sites surveyed; vinumio.c:711 uses the balanced %*d/%*[...] paths, if_ath.c:576 uses width-1 %*c), so there is no unprivileged path in the shipping base system -- the defect is live for the exported KPI (any KLD or future in-tree caller) and was filed Medium/kernleak for that reason.", "exploit_chain": "no in-tree trigger exists (kernel-supplied format strings; no %*Nc caller). For any caller with user-influenced input: exact-size heap input string + %*(len+1)c + %s copies up to len-1 bytes past the allocation (adjacent kernel heap objects, slab freelist pointers -- observed 0xfffff8008d941680) into the caller's destination buffer, which such callers typically copy back to userland; walk can also fault into an unmapped page (panic). No write primitive: writes stay inside caller-supplied buffers.", "evidence": [ "run.log: 'dfpoc: E1 %*16c%s ... ret=1' + 'out:00 43 41 4e 41 52 59 21 00' -- NUL + 7 canary bytes from beyond the terminator", "run.log: 'dfpoc: E2 in=0xfffff8008d942e60 ... live v[199]=0xfffff8008d942e80 delta199=32 ret=1' + 'out:00 51 51 ...' -- 'Q' bytes from beyond the 32-byte allocation (adjacent live chunk): CROSS-OBJECT HEAP DISCLOSURE", "run.log: 'dfpoc: E3 ... out:00 80 16 94 8d 00 f8 ff ff 51 ...' -- live kernel pointer 0xfffff8008d941680 leaked into caller buffer", "run.2.log: second module load, E2 reproduces (delta199=32), E3 leaks 0xfffff8004f102660", "run_patched.log: kernel #1 with fix.diff hunk 2 -- E1/E2/E3 ret=0, out untouched (0xee sentinels intact), equal to libc", "VERDICT.md: full path:line chain and caller survey" ], "kernel_refs": [ "sys/kern/subr_scanf.c:123", "sys/kern/subr_scanf.c:258", "sys/kern/subr_scanf.c:292", "sys/kern/subr_scanf.c:295", "sys/kern/subr_scanf.c:308", "sys/kern/subr_scanf.c:324", "sys/kern/subr_scanf.c:375", "sys/sys/systm.h:240" ], "poc_changes": "authored fresh (no seed): KLD harness dfpoc.ko drives the kernel's own ksscanf/kvsscanf; heap groom reuses the DF-2862-proven technique (200x32-byte M_TEMP chunks filled 'Q', evens freed LIFO so the input kmalloc lands directly before a live 'Q' chunk). E5 initially used ksscanf directly but cc's __scanflike(2,3) annotation rejects '%f' at compile time (itself confirming %f is not a supported kernel conversion) -- rerouted through an unannotated kvsscanf shim.", "attempts": 3, "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64", "runtime_sec": 2400, "guest_dirty": 0, "build_cmd": "cd /root/dfscanf && make (fix validation: cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel KERNCONF=X86_64_GENERIC)", "run_cmd": "kldload /root/dfscanf/dfpoc.ko && sleep 1 && dmesg | tail -22 (then kldunload/kldload for run.2.log)", "code_hash": "0c3b123fc9ff001261befd22c3cdba2bce64a21495883ef913e4ab55413b5b3e", "notes": "distinct from known DF-0111 (%c bcopy reading width>inr bytes at :308 -- a caller width-vs-remaining contract issue) and from DF-0099/DF-2862 (caller-side width/buffer and termination bugs in vfs_conf.c): this is the engine corrupting its OWN inr accounting so even callers whose widths equal the reported remaining input read out of bounds. Panic potential (walk into unmapped page) noted but not the demonstrated impact; leak demonstrated twice with distinct live kernel pointers.", "recommended_fix": "zero the remaining-count in the short-input branch: add 'inr = 0;' after 'inp += n;' at sys/kern/subr_scanf.c:295 (fix.diff hunk 2)", "fix_status": "fixed", "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 21:17:17 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "fix.diff hunk 2 (inr = 0 in the CT_CHAR suppress short-input branch) built into kernel #1 via in-guest make nativekernel + installkernel; the identical dfpoc.ko re-run shows E1/E2/E3 returning 0 with the caller buffer untouched (0xee sentinels intact) and E6 unchanged -- no byte past the terminator is consulted, behavior now identical to libc; baseline (stock kernel #0) reproduced the cross-object disclosure in the same session.", "fix_evidence": [ "run_patched.log: 'dfpoc: E2 in=... delta199=32 ret=0' + 'out:ee ee ee ...' (groom still adjacent, nothing copied)", "run.log (baseline, kernel #0): same experiment copied 15 'Q' bytes from beyond the allocation", "fix.diff hunk 2" ] } |