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

seq_puts() shim passes its data string as the ksnprintf format β€” kernel format-string trap with live %n write conversion (latent for ported code)

Summary

The shim maps Linux's data-only seq_puts onto ksnprintf(buf,size,str), putting DATA in the FORMAT slot while the kernel printf core keeps %n as a live write-through-pointer conversion and %s as an unbounded strlen: any dynamic string acquires a kernel format-string primitive. Proven live three ways - shipped drm.ko disassembly (mov %rsi,%rdx into the fmt register), a %d-in-data kmod leaking a never-passed vararg slot, and a %s-in-data rig that PANICKED the kernel ('Stopped at strlen'). Latent today (the whole seq_file graph is dead on DFly: CONFIG_DEBUG_FS undefined, no struct seq_file instantiated) but upstream drivers routinely seq_puts() EDID/connector-derived strings - a format-string trap for every future port. data-via-%s fix validated live (verbatim, guest alive); %n-removal in kvcprintf noted as defense-in-depth. DF-2224's call graph also proven dead.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5779 Β· 17 files
FileTypeDescriptionSize
seqputs_rig.c β€” 2.2 KB view raw
seqputs_rigc.c β€” 1.5 KB view raw
seqputs_rigfix.c β€” 1.5 KB view raw
build.sh β€” 153 B view raw
run.sh β€” 199 B view raw
build.log β€” 2.6 KB view raw
run.log β€” 414 B view raw
run_c.log β€” 766 B view raw
fix_run.log β€” 250 B view raw
panic.txt β€” 1.6 KB view raw
disasm.txt β€” 1.5 KB view raw
env.txt β€” 373 B view raw
fix.diff β€” 704 B view raw
VERDICT.md β€” 4.6 KB ↓ raw
README.md β€” 3.3 KB ↓ raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 5.4 KB view raw

DF-5779 β€” seq_puts() shim passes its data string as the kernel printf FORMAT

What / where

#define seq_puts(m, str)    ksnprintf((m)->buf, (m)->size, str)

str β€” which the Linux seq_puts() API defines as pure DATA (never interpreted) β€” sits in ksnprintf's __printflike(3,4) format parameter.

Build

On the host, from this directory:

../../../dfbsd-qemu/vm.sh up          # if down
./build.sh                           # transfers sources (base64 via run_root),
                                     # builds seqputs_rig.ko + seqputs_rigc.ko in-guest

Manual equivalent (in guest /tmp/rig): make SYSDIR=/usr/src/sys WERROR="" with KMOD=seqputs_rig SRCS=seqputs_rig.c (DragonFly bsd.kmod.mk).

Run

./run.sh
  1. kldload /tmp/rig/seqputs_rig.ko β€” rigs A+B (safe): * A: controlled %n β†’ seqputs_rig A: %n wrote 2 (write conversion LIVE) * B: seq_puts(&m, "conn=DP-1 link=%d ok=100%%\n") β†’ link=0 β€” %d interpreted against a NEVER-passed vararg
  2. kldload /tmp/rig/seqputs_rigc.ko β€” rig C: same macro, data with nine %s β†’ kernel panic (wild strlen), see panic.txt.

Expected output (baseline, vulnerable shim)

seqputs_rig: loaded
seqputs_rig A: %n wrote 2 (2 == LIVE write conv)
seqputs_rig B: seq_puts(m,data) produced: 'conn=DP-1 link=0 ok=100%
'
seqputs_rig B: Linux contract would print the literal data string verbatim (no interpretation)
seqputs_rigc: firing %s-walk on unset varargs...
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x1a640
...
Stopped at      strlen: cmpb $0,(%rdi)

Fix validation

seqputs_rigfix.c compiles the FIXED shim (ksnprintf(buf, size, "%s", str)) and replays the same inputs:

rigfix B: 'conn=DP-1 link=%d ok=100%%
'                      <- verbatim, no interpretation
rigfix C: 'm=%s s=%s x=%s y=%s z=%s w=%s v=%s u=%s t=%s
'                      <- the string that PANICKED baseline: now verbatim
rigfix N: 'w=%d write=%n addr=%p
'                      <- %n/%d/%p in data stay literal
rigfix: DONE verbatim, guest alive

Reachability (honest scope)

In-tree TODAY: every seq_puts/seq_printf call site is a debugfs show callback, and DragonFly defines neither CONFIG_DEBUG_FS nor any debugfs dispatch (sys/dev/drm/drm_internal.h:139-143 stub; drm_debugfs.c not in sys/conf/files; no struct seq_file instantiation exists anywhere). All in-tree seq_puts arguments are %-free literals. The bug is therefore LATENT: it converts any future ported caller that passes a dynamic string (upstream Linux drivers routinely pass EDID/connector-derived names β€” attacker-influenced bytes from a malicious monitor) into a kernel format-string primitive: %n = write-through-pointer (live, subr_prf.c:688), %s = wild read (panic, proven), %d/%x/%p = kernel slot leak into the seq buffer.

Severity Low (latent hardening / contract divergence). Proof is nonetheless complete: disassembly of the shipped drm.ko (disasm.txt) + live-kernel differential (run.log, run_c.log, panic.txt, fix_run.log).

VERDICT.md
↓ download raw

DF-5779 VERDICT β€” reproduced (latent primitive, proven live)

Finding

seq_puts() in the DRM Linux-compat shim passes its DATA argument through ksnprintf's FORMAT parameter:

Linux's seq_puts(m, s) never interprets s. DragonFly's kernel printf core, however, supports conversions that make a data-as-format bug a memory corruption primitive:

Verification (guest DragonFly 6.5-DEVELOPMENT, X86_64_GENERIC)

Guest /usr/src md5-verified identical to the audited tree for both linux_printf.c and include/linux/seq_file.h.

  1. Disassembly (shipped /boot/kernel/drm.ko) β€” __drm_puts_seq_file: mov %rsi,%rdx places the caller's str in %rdx, the third parameter of ksnprintf(rdi=buf, rsi=size, rdx=fmt, ...) (disasm.txt). Contract divergence proven at machine-code level in the artifact users boot.

  2. Differential rig (live kernel) β€” seqputs_rig.c replicates the struct + macro verbatim: * Rig A: ksnprintf(buf, size, "AA%nBB\n", &written) β†’ seqputs_rig A: %n wrote 2 β€” the %n write conversion is live in the running kernel. * Rig B: seq_puts(&m, "conn=DP-1 link=%d ok=100%%\n") (no format args β€” the seq_puts contract) β†’ output conn=DP-1 link=0 ok=100%: the %d consumed a never-passed vararg slot and its value landed in the seq buffer. Under the Linux contract this string must print verbatim.

  3. Rig C (impact ceiling) β€” same macro, data containing nine %s: Fatal trap 12: page fault while in kernel mode, fault virtual address 0x1a640 … Stopped at strlen: cmpb $0,(%rdi) (panic.txt). Wild kernel read from an unset vararg slot; guest down (reset afterwards).

Fix validation

seqputs_rigfix.c compiles the fixed shim (ksnprintf(buf, size, "%s", str), fix.diff) and replays the same inputs: rig B verbatim, the rig-C panic string verbatim, %d/%n/%p inside data literal, guest alive, module unloaded cleanly (fix_run.log). The fix is a header macro; semantic validation via a consumer compiled against the exact fixed macro is equivalent to a full drm.ko rebuild for this contract (a full rebuild would recompile the identical expansion in every consumer).

baseline: interpretation + leak + panic. patched: verbatim, no panic. => fixed.

Honest reachability scope (why Low, not High)

  • CONFIG_DEBUG_FS is not defined anywhere in the DragonFly DRM build (sys/dev/drm/kconfig.h β€” no match, verified in tree and in guest /usr/src).
  • drm_debugfs.c / amdgpu_debugfs.c are not in sys/conf/files; drm_debugfs_init is the no-op inline (sys/dev/drm/drm_internal.h:139-143).
  • No struct seq_file is ever instantiated in the tree (grep for allocation: zero hits) β€” m->buf/m->size never get set by anything.
  • Every in-tree seq_printf/seq_puts format/argument today is a %-free or arg-matched literal (scanned all call sites; the only non-literal seq_puts argument is drm_print.c:128 itself).

So no in-tree caller currently executes the primitive: the defect is a latent format-string trap for ported code. Upstream Linux drivers pass dynamic strings to seq_puts (device/connector names, EDID-derived strings β€” bytes an attacker with a crafted monitor controls), and this shim silently turns that pattern into kernel %n/%s interpretation. The adjacent seq_printf va_list ABI issue is DF-2224 (known; not re-reported).

Files

  • disasm.txt β€” shipped drm.ko disassembly (the %rdx proof)
  • run.log β€” rigs A+B output (live %n, %d slot leak)
  • run_c.log + panic.txt β€” rig C kernel panic
  • fix_run.log β€” fixed-shim replay (verbatim, alive)
  • build.log β€” full untrimmed guest builds (includes the honest first empty-module transfer failure)
  • env.txt β€” guest uname/toolchain/module state

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

Fixed shim compiled into seqputs_rigfix.ko and replayed on the live guest: the exact rig-C string that panicked the baseline kernel now prints verbatim, %d/%n/%p inside data remain literal, guest stays up, module unloads cleanly (fix_run.log). The fix unit is a header macro; a consumer compiled against the exact fixed macro is semantically equivalent to rebuilding every drm kmod. Baseline bad behavior (interpretation, slot leak, wild-read panic) is GONE with the fix.

findings/poc/DF-5779/fix_run.log (rigfix B/C/N all verbatim, 'DONE verbatim, guest alive', UNLOADED_CLEAN); baseline in run.log/run_c.log/panic.txt; diff in fix.diff
↓ fix.diffper-fix-DF-5779

Confirmed kernel references

Detail

Exploit chain

Simulated-caller chain (proven): dynamic string reaches seq_puts(m, s) -> ksnprintf(buf, size, s-as-fmt) -> kvcprintf interprets s: '%d/%x/%p' leak unset vararg slots into the seq buffer (rig B: link=0); '%n' writes retval through a wild pointer (conversion live, rig A: wrote 2 to &written); '%s' strlen()s a wild pointer -> kernel page fault + panic (rig C, 0x1a640). In-tree activation requires a future port passing attacker-influenced data (e.g. EDID strings) to seq_puts/drm_puts via drm_seq_file_printer; today that dispatch (debugfs) does not exist on DragonFly, so no unprivileged-user-to-kernel path is currently wired.

Evidence (decisive lines)

disasm.txt (mov %rsi,%rdx -> ksnprintf fmt slot in shipped drm.ko); run.log (rig A '%n wrote 2'; rig B 'link=0'); run_c.log + panic.txt (Fatal trap 12, Stopped at strlen, guest down); fix_run.log (fixed shim: same inputs verbatim, guest alive); env.txt (uname, md5 match of guest /usr/src vs audited tree, no CONFIG_DEBUG_FS)

PoC changes

New pass-2 finding (no seed sketch). Built the rig set from scratch; first transfer attempt produced empty object files (vm.sh run_root consumes ssh stdin for the command itself) β€” re-transferred via base64-in-command; widened rig C to nine %s so the va_arg walk leaves the register-save area and reaches stack slots, making the wild read deterministic instead of a lucky (null).

Verified recommended fix

Replace the seq_puts macro with an inline function that passes the data string through "%s": static inline void seq_puts(struct seq_file m, const char str) { ksnprintf(m->buf, m->size, "%s", str); } (fix.diff in this pack)

Verdict

The seq_puts() shim (sys/dev/drm/include/linux/seq_file.h:44) places its DATA argument in ksnprintf's format parameter. Proven three ways on the live guest: (1) shipped /boot/kernel/drm.ko disassembly of __drm_puts_seq_file shows mov %rsi,%rdx putting str into ksnprintf's fmt register; (2) a kmod replicating the exact macro shows %d inside data interpreted against a never-passed vararg (slot value leaked into the seq buffer) while the Linux contract requires verbatim output; (3) data containing %s walks unset vararg slots and strlen() faults on a wild pointer -> Fatal trap 12, guest down (panic.txt). The kernel printf core's %n is a live write-through-pointer conversion (subr_prf.c:688-701), demonstrated controlled (wrote 2). LATENT in-tree today: CONFIG_DEBUG_FS is undefined, drm_debugfs.c is not built, no struct seq_file is ever instantiated, and all current call sites pass literals β€” so the finding is filed Low (hardening/contract divergence), but any ported caller passing a dynamic string (EDID/connector-derived bytes, as upstream drivers do) silently acquires a kernel format-string primitive with %n write and %s wild-read. DF-2224 (seq_printf va_list forwarding) known and not re-reported.