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

ddb '/'-modifier parsing copies stale db_tok_string for numeric modifier tokens β€” the typed modifier is silently substituted with the previously lexed identifier (deterministically the command's own name)

Summary

tNUMBER is accepted as a modifier but db_tok_string is only populated for tIDENT, so modif[] receives the previous identifier (the command's own name on a plain line): 'x/4 addr' silently behaves as 'x/x', 'examine/4 addr' becomes a per-character format walk of the literal string 'examine' (hex dump + symbolized pointer read + a DISASSEMBLY step), 'print/4' prints empty, 'write/4' refuses 'Unknown size'. Live-proven on the stock guest via the DF-5561 socket-serial rig with clean controls. Bounded at Info (lexer-bounded modif[120], single-char consumer switches, fail-safe write path, console/root surface). ksnprintf-from-db_tok_number fix. db_fncall fault cascade owned by DF-5561 (its pack cites the :515 trigger); breakpoint-recursive-session scenario disproven (db_stop_at_pc clears breakpoints+watchpoints on every entry).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5720 Β· 18 files
FileTypeDescriptionSize
README.md β€” 3.5 KB ↓ raw
VERDICT.md β€” 6.1 KB ↓ raw
drive_ddb.py β€” 2.7 KB view raw
experiment.py β€” 1.9 KB view raw
ab_cycle.sh β€” 1.4 KB view raw
run.sh β€” 179 B view raw
build.sh β€” 125 B view raw
build.log β€” 133 B view raw
run.log β€” 456 B view raw
run.wedge-session1.log β€” 456 B view raw
serial.full.txt β€” 13.5 KB view raw
serial.wedge-session1.txt β€” 13.5 KB view raw
exp.A-short-none.drain.log β€” 59 B view raw
env.txt β€” 214 B view raw
fix.diff β€” 587 B view raw
hashes.txt β€” 159 B view raw
verdict.json β€” 3.4 KB view raw
manifest.json β€” 1.3 KB view raw

DF-5720 β€” ddb /-modifier parsing copies a stale token string (numeric modifiers silently substituted with the previous identifier)

File: sys/ddb/db_command.c (lines 361–370) Severity: Info (debugger parser correctness / stale-state injection; no memory-safety impact) Status: reproduced live on the QEMU guest (deterministic).

The bug

db_command() accepts a numeric token after the command's / but copies db_tok_string into modif[] β€” and the lexer only populates db_tok_string for tIDENT tokens (numbers go into db_tok_number, sys/ddb/db_lex.c:190-216). The string therefore copied is whatever identifier was lexed last β€” on a plain line that is the command's own name (x/4 β†’ modif "x", examine/4 β†’ modif "examine", write/4 β†’ modif "write", print/4 β†’ modif "print").

Consumers then switch on the letters of that stale string:

input modif used observed behavior (stock kernel)
x/4 addr "x" silently behaves as x/x (4 ignored)
examine/4 addr "examine" per-char walk: x hex + a pointer/symbol read + i disassembly
print/4 0x41 "print" 'p' has no case in db_print_cmd β†’ empty line
write/4 a v "write" 'w' β†’ db_error("Unknown size") β€” write refused

Controls in the same session prove letter modifiers work (x/x, print/x, write/l).

Build / run

No compilation β€” this is a serial-console ddb session:

  1. ./serial_vm.sh up β€” relaunch the guest with a writable socket serial console (adapted from the proven DF-5561 rig).
  2. ssh -F ../../../dfbsd-qemu/config dfbsd 'sysctl debug.enter_debugger=ddb' (root vehicle; kernel stops at db> on the serial console).
  3. python3 drive_ddb.py β€” sends the battery above with proper UART pacing.
  4. Transcript lands in /tmp/opencode/df-5720-drain.log (copied to run.log).

./run.sh automates 1–4.

Expected output (see run.log)

db> x/x db_active
db_active:      1
db> x/4 db_active
db_active:      1                       <-- "4" silently ignored (same as x/x)
db> examine/4 db_active
db_active:      1               0x100000001addb %al,(%rax)
db_global_jmpbuf_valid+0x6:             <-- modif="examine": x + a + i walk (!)
db> print 0x41
      41
db> print/4 0x41
                                        <-- empty line: modif="print" -> 'p'
db> print/x 0x41
      41
db> write/4 db_active 1
Unknown size                            <-- modif="write" -> 'w' -> error
db> write/l db_active 1
db_active               1 = 1           <-- control: letter modifier works
db> x/x db_active
db_active:      1
db> c
CPU0 restarting CPUs: 0x000000000000003e
 restarted

Success criterion: the three markers (mixed a/i walk on examine/4, empty output on print/4, Unknown size on write/4) with all controls behaving normally.

Note β€” post-continue guest state (NOT part of this finding)

After c, this snapshot's guest does not resume userland (no serial echo, no ssh; vCPUs ~5%). A controlled experiment (ab_cycle.sh 5 none, i.e. enter ddb, send no commands, c) reproduces the same starvation β€” the behavior is a property of the debug.enter_debugger/kdb_trap/restart_cpus resume path (adjacent to DF-2246/DF-5561 territory), not of anything in sys/ddb/db_command.c. Documented as a cross-file lead in VERDICT.md; the modifier-bug evidence above was fully captured before the continue.

VERDICT.md
↓ download raw

DF-5720 β€” VERDICT

Status: reproduced (deterministic, first clean attempt on the stock X86_64_GENERIC snapshot guest, DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 x86_64). Impact: none (Info-class debugger parser correctness bug β€” no memory unsafety, no privilege boundary crossed; DDB requires console/root access). Confidence: certain β€” the wrong-modifier behavior is directly visible in the serial transcript (run.log) with in-line letter-modifier controls, and the root cause is line-verified in the lexer.

Root cause (line-accurate)

sys/ddb/db_command.c:361-370, standard command syntax:

361:  t = db_read_token();
362:  if (t == tSLASH) {
363:      t = db_read_token();
364:      if (t != tIDENT && t != tNUMBER) {   /* tNUMBER is accepted … */
367:          return;
368:      }
369:      db_strcpy(modif, db_tok_string);     /* … but the copied buffer is
                                                 only filled for tIDENT */

The lexer never writes db_tok_string for numbers β€” sys/ddb/db_lex.c:170-216 stores digits in db_tok_number only; db_tok_string retains the previous identifier token, which on a plain command line is the command's own name (lexed at db_command.c:295). modif therefore becomes, deterministically:

  • x/4 β†’ "x" (coincidentally a valid hex-format letter β€” the 4 is silently ignored)
  • examine/4 β†’ "examine" (per-char format walk in db_examine, sys/ddb/db_examine.c:78-193)
  • print/4 β†’ "print" (db_print_cmd switches on modif[0]; 'p' has no case β†’ prints only \n, sys/ddb/db_examine.c:204-248)
  • write/4 β†’ "write" ('w' hits default: β†’ db_error("Unknown size\n"), sys/ddb/db_write_cmd.c:56-67)

Why Info (impact bounded)

  • No memory unsafety: modif[TOK_STRING_SIZE=120] (db_command.c:289) is filled from db_tok_string[120], which the lexer bounds at ≀119 chars + NUL and NUL-terminates on every tIDENT success path (sys/ddb/db_lex.c:225-256; the over-long path at :245-249 longjmps out via db_error before any consumer runs).
  • Every consumer switches on single characters with bounded defaults (db_examine.c:58-59,208-209, db_write_cmd.c:56-67, db_run.c:310, db_watch.c:281-297). The worst observed effect is a format change (a read command silently disassembling via the 'i' letter) and a refused write (write/4 β†’ "Unknown size", fail-safe: no size expansion is possible β€” the stale string always begins with the command's own first letter, and no write-relevant command name begins with b/h).
  • DDB itself is console/root-privileged (unprivileged writes to debug.enter_debugger are EPERM β€” DF-2246 verified that gate).

Live evidence (run.log, from /tmp/opencode/df-5720-drain.log)

db> x/x db_active            <- control
db_active:      1
db> x/4 db_active            <- numeric modifier silently ignored
db_active:      1
db> examine/4 db_active      <- modif="examine": hex + 'a' pointer + 'i' disasm
db_active:      1               0x100000001addb %al,(%rax)
db_global_jmpbuf_valid+0x6:
db> print 0x41               <- control
      41
db> print/4 0x41             <- modif="print" -> 'p': no case -> EMPTY LINE
db> print/x 0x41             <- control (letter modifier works)
      41
db> write/4 db_active 1      <- modif="write" -> 'w' -> "Unknown size"
Unknown size
db> write/l db_active 1      <- control
db_active               1 = 1

All three bug markers present, all four controls normal. db_active value verified unchanged (1) across the session; the write/l control wrote the same value it read.

Deliberate non-goals / delimitation

  • The db_fncall raw-jump β†’ nested-fault β†’ db_active leak cascade is DF-5561 (filed against sys/platform/pc64/x86_64/db_interface.c:152-195; its pack cites db_command.c:515 as the trigger). Not re-verified here.
  • Verified in-bounds: the MAXARGS=11 fence (db_command.c:537) checks before the write at :542, so the last write is args[10]; nargs==11 hits the default: arm (:602-605) and never calls. retval is assigned on every switch arm.
  • Verified no breakpoint-driven recursive session: db_stop_at_pc clears software breakpoints and watchpoints on every debugger entry (sys/ddb/db_run.c:78-80), so a break db_printf-style trap at the db> prompt cannot re-enter kdb_trap recursively; in-command faults are caught by the db_nofault / db_global_jmpbuf nets (db_interface.c:141-153).

Cross-file lead observed during verification (NOT this finding)

After c, the guest does not resume userland: no serial getty echo, no sshd banner (QEMU slirp fakes the host-side handshake, so that is not evidence of a live stack), vCPU threads at ~5%. The controlled cycle ab_cycle.sh 5 none (enter ddb via root sysctl, send zero commands, c) reproduces the identical starvation β€” proving it is a property of the debug.enter_debugger / kdb_trap / stop_cpus/restart_cpus resume path (sys/ddb/db_sysctl.c:56-76, sys/platform/pc64/x86_64/db_interface.c:160-200), not of any db_command.c behavior. DF-5561's baseline transcript shows userland running immediately after c only because its leaked db_active rerouted the first user-mode trap into a fresh kdb_trap; its patched run (like this one) shows a silent " restarted" tail. Recommended follow-up for the db_interface.c / mp owners; evidence in exp.A-short-none.drain.log.

Fix

fix.diff β€” populate modif from db_tok_number for numeric modifier tokens (alternative: strictly reject tNUMBER as a modifier; no consumer defines a numeric meaning). Not kernel-rebuild-validated: Info-class correctness fix, behavior-verified at the parser level (the patched branch is unreachable-before/ reachable-after reasoning is trivial and no panic or corruption is involved).

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

not_testable

Info-class parser correctness fix; kernel-rebuild validation not warranted (no panic/corruption involved). fix.diff is a minimal, line-accurate unified diff against sys/ddb/db_command.c (never applied to sys/).

findings/poc/DF-5720/fix.diff
↓ fix.diffper-fix-DF-5720

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (aka /tmp/opencode/df-5720-drain.log snapshot): 'examine/4 db_active' -> 'db_active: 1  0x100000001addb %al,(%rax)' + second address header 'db_global_jmpbuf_valid+0x6:' (modif='examine' per-char walk); 'print/4 0x41' -> empty line; 'write/4 db_active 1' -> 'Unknown size'; controls 'x/x db_active' -> 'db_active: 1', 'print/x 0x41' -> '41', 'write/l db_active 1' -> 'db_active 1 = 1'. VERDICT.md has full delimitation vs DF-5561 and the post-continue cross-file lead with its battery=none control.

PoC changes

Authored directly (no seed pack existed): socket-serial rig adapted from the proven DF-5561 pattern with per-queue pacing; QEMU monitor 'server=nowait' modernized to 'server=on,wait=off'.

Verified recommended fix

In db_command's '/'-modifier parsing, format db_tok_number into modif for tNUMBER tokens (or reject tNUMBER outright) instead of copying the tIDENT-only db_tok_string buffer.

Verdict

Reproduced deterministically on the stock snapshot guest: db_command.c:364 accepts a tNUMBER token as a '/'-modifier but :369 copies db_tok_string, which the lexer fills only for tIDENT tokens (db_lex.c:170-216), so modif[] receives the previously lexed identifier β€” deterministically the command's own name. Live serial transcript shows examine/4 silently switching to a mixed hex/pointer/disassembly format walk, print/4 printing an empty line ('p' has no case in db_print_cmd), and write/4 refused with 'Unknown size', while letter-modifier controls (x/x, print/x, write/l) behave normally. Impact is none: modif is lexer-bounded (<=119+NUL into modif[120]) and every consumer switches on single characters with bounded defaults, so this is a debugger parser correctness/stale-state bug (console-privileged), not memory unsafety.