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

DDB trap funnel defeats the db_nofault guard: recoverable DDB memory probes β€” including show registers' unconditional disasm of the DDB-writable $rip at db_print.c:68 β€” are routed through trap_fatal(), producing a spurious 'Fatal trap' crash banner with kernel state disclosure, a leaked gd_trap_nesting_level, and corrupted session-resume state

Summary

db_show_regs ends with an unconditional disasm of tf_rip - a raw DDB-settable register: disassembling a noncanonical rip faults inside db_read_bytes under the db_nofault guard, but trap()'s db_active funnel sends every such trap to trap_fatal() (full crash banner + register disclosure) BEFORE kdb_trap's nofault longjmp recovers the prompt - and the longjmp abandons the funnel's ++gd_trap_nesting_level and corrupts session resume. Reproduced live 2x (set $rip=0xdead... + show registers; x/i 0xdead...): trap 9 banner at the db_read_bytes loop then recovered db> prompt. Operator at a crashed system (the exact population DDB serves - tf_rip may already be noncanonical) gets a second crash dump instead of quiet recovery. honor-nofault-before-the-funnel fix (exported pending/take helpers, both platforms). File otherwise clean (the prompt's db_print_cmd map was stale; modifier/format paths killed with citations).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5732 Β· 17 files
FileTypeDescriptionSize
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 6.0 KB ↓ raw
drive.py β€” 2.2 KB view raw
serial_vm.sh β€” 1.8 KB view raw
run.sh β€” 312 B view raw
env.txt β€” 460 B view raw
run4-decisive-setrip-showregisters.log β€” 1.2 KB view raw
panic.txt β€” 480 B view raw
session0-sweep.log β€” 6.4 KB view raw
run1-control-valid-xi.log β€” 117 B view raw
run3-control-held-connection.log β€” 117 B view raw
run3-sshpoll.log β€” 249 B view raw
serial-full.log β€” 14.2 KB view raw
fix.diff β€” 3.7 KB view raw
verdict.json β€” 5.7 KB view raw
manifest.json β€” 1.7 KB view raw
code_hashes.txt β€” 148 B view raw

DF-5732 β€” DDB trap funnel defeats db_nofault on recoverable memory probes (trigger: show registers via db_print.c:68)

What

sys/ddb/db_print.c:68 β€” db_show_regs() ends with db_print_loc_and_inst(PC_REGS(DDB_REGS), DDB_REGS) β†’ db_disasm() dereferences ddb_regs.tf_rip. $rip is a DDB-settable variable (db_regs[] entry { "rip", &ddb_regs.tf_rip, NULL }, sys/platform/pc64/x86_64/db_trace.c:107; db_set_cmd writes it raw, sys/ddb/db_variables.c:120-129).

When tf_rip (or, for the same class, any address x/i probes) is noncanonical, db_disasm β†’ db_get_value β†’ db_read_bytes faults under the db_nofault guard (sys/platform/pc64/x86_64/db_interface.c:242). But trap()'s DDB funnel (sys/platform/pc64/x86_64/trap.c:414-427) routes every trap taken while db_active straight into trap_fatal() β€” full "Fatal trap" crash banner + register/segment dump β€” before kdb_trap() finally takes the db_nofault longjmp (db_interface.c:140-144) back to the db> prompt.

Side effects beyond the noise: the funnel's ++gd_trap_nesting_level (trap.c:422) is skipped by the longjmp and leaks; and the abandoned nested dispatch corrupts session-resume state β€” the DF-5727 evidence pack captured panic: trap: critical section count mismatch! -1892583040/-1 on cont after exactly this funnel (findings/poc/DF-5727/baseline-up.serial.log:547-549).

Reproduce (decisive run β€” this file's trigger)

# 1. boot guest with socket serial console
findings/poc/DF-5732/serial_vm.sh up

# 2. enter DDB as root (blocks; run in background)
ssh -F dfbsd-qemu/config root@dfbsd 'sysctl debug.enter_debugger=ddb' &

# 3. at the db> prompt on the serial socket:
set $rip = 0xdead000000000000
show registers

# Expected (bug): full register dump, then from db_print.c:68's disasm of $rip:
#   -0x2153000000000000:
#   Fatal trap 9: general protection fault while in kernel mode
#   ... full trap_fatal register/segment dump ...
#   kernel: type 9 trap, code=0
#   db>            <- recovered via db_nofault longjmp (the "quiet" recovery
#                     the guard exists for was NOT quiet)

Automated: python3 findings/poc/DF-5732/drive.py 'set $rip = 0xdead000000000000' 3 'show registers' 6 (with DF5732_SOCK=/tmp/opencode/df-5732-ser.sock).

Sibling trigger, same funnel: x/i 0xdead000000000000 (see session0-sweep.log).

Files

  • run4-decisive-setrip-showregisters.log β€” the decisive run (banner reproduced)
  • session0-sweep.log β€” full modifier/format sweep + x/i noncanonical trigger
  • run1-control-valid-xi.log, run3-control-held-connection.log (+ ssh poll) β€” controls
  • serial-full.log β€” complete serial console capture
  • env.txt β€” guest identity
  • fix.diff β€” funnel fix (authored post-verification, never applied to sys/)
  • verdict.json / manifest.json β€” machine verdict
VERDICT.md
↓ download raw

DF-5732 VERDICT β€” reproduced (Low, dos)

Bottom line

sys/ddb/db_print.c in this tree contains only db_show_regs() (69 LOC); the "print/p modifier dispatch" the task described lives in db_examine.c (db_print_cmd, db_examine.c:204) and db_hex_value does not exist (its role is db_format_radix/db_format_hex, db_output.c:260/286, and db_num_to_str, db_lex.c:127). The one live defect found on this file's own code path is at db_print.c:68: the unconditional db_print_loc_and_inst(PC_REGS(DDB_REGS), DDB_REGS) disassembles ddb_regs.tf_rip, which any DDB console user can set to an arbitrary 64-bit value via set $rip = ... (db_variables.c:120-129 writes the raw db_expr_t). Disassembling a noncanonical (or unmapped) rip faults inside db_read_bytes under the db_nofault guard (db_interface.c:242) β€” but trap()'s db_active funnel (trap.c:414-427) sends every trap (except T_DNA) to trap_fatal() first: a full "Fatal trap 9: general protection fault" crash banner with register/segment disclosure, then the quiet db_nofault longjmp recovery (db_interface.c:140-144) back to db>. The longjmp also abandons the funnel's ++gd_trap_nesting_level (trap.c:422), leaking it, and the discarded nested trap dispatch leaves the session-resume path corrupted β€” independently corroborated by the DF-5727 pack, which captured panic: trap: critical section count mismatch! -1892583040/-1 on cont immediately after this exact funnel (findings/poc/DF-5727/baseline-up.serial.log:525-549: c β†’ Fatal trap 12 at the same 0xffffffff80bccb70 (db_read_bytes loop) β†’ c β†’ crit-count panic).

Reproduction (all on the stock snapshot kernel, live guest)

Run 4 (decisive, this file's trigger β€” run4-decisive-setrip-showregisters.log): sysctl debug.enter_debugger=ddb β†’ set $rip = 0xdead000000000000 β†’ show registers β†’ all 25 registers print cleanly (the value-formatting path is fine) β†’ -0x2153000000000000: printed by db_printsym β†’ Fatal trap 9 banner (cpuid 3, RIP 0xffffffff80bccb70 = the db_read_bytes read loop) β†’ db> recovers via the nofault longjmp. Banner reproduced, twice total:

  • run 4: set $rip + show registers (db_print.c:68 trigger)
  • run 0 (session0-sweep.log): x/i 0xdead000000000000 (sibling trigger, same funnel, trap 9 at the same RIP)

Honest caveats (why Low and not higher)

  1. Trigger privilege: DDB console access is already root-equivalent (serial console / kbd break / debug.enter_debugger). The bug degrades an already-privileged recovery tool; it is not an escalation.
  2. The post-cont wedge is NOT cleanly attributable to the fault: my controls show that on this snapshot even a perfectly clean debug.enter_debugger DDB session (x/i on a valid address, then cont, driver connected or not) leaves the guest dead (silent console, ssh down, no panic output) β€” see run1-control-valid-xi.log and run3-control-held-connection.log (+run3-sshpoll.log: DOWN for 110 s with the connection held). Prior packs' "control" logs end identically at "restarted" (e.g. DF-5728 run3-controls.serial.log), so post-resume liveness was never actually established there either. Therefore I claim only: banner + register disclosure (deterministic, reproduced), trap-nesting leak (static, trap.c:422 vs longjmp), and resume-state corruption (captured in DF-5727's pack; my runs' resume failures are consistent with it but confounded by the snapshot-level resume problem).

Pass-2 checklist results (negative results, with weight)

  • (a) modifier dispatch: db_show_regs ignores modif entirely (dummy4, db_print.c:46); show registers/x, /99 verified harmless live (session0-sweep.log). The stale-modifier default path (p/16 1) is consumer-level harmless: db_print_cmd's switch (db_examine.c:211-250) has no default β€” prints a bare newline. DF-5720 territory, not re-reported.
  • (b) number formatting: db_num_to_str (static buf[25], ksnprintf-bounded, worst case "-%llo" of INT64_MIN = 24 bytes β€” fits); db_format_radix/_hex ksnprintf-bounded (db_output.c:279/296); db_print.c:60-63 tbuf[24] can only see offset ∈ [0,2^32) because db_search_symbol stores diff into an unsigned int (db_sym.c:210,221,225) β€” worst octal "01000000000000000000000" = 23 chars + NUL fits. val = -val on INT64_MIN (db_output.c:274-275, 291-292) is UB-in-theory, wraps in practice, cosmetic only.
  • (c) console reachability: show registers = db_show_cmds entry (db_command.c:88). $maxoff=-1 (DF-5730's unvalidated write) on db_print.c:56's (unsigned long)db_maxoff cast produces only cosmetic garbage attribution (kernphys+0xffdf0ff0, session0-sweep.log) β€” DF-5730's blast radius, not re-reported. db_find_xtrn_sym_and_offset never leaves name/offset uninitialized (db_sym.c:225, 237-239) and never dereferences the probe address. Register table type-safety verified: all tf_* fields are register_t (cpu/x86_64/include/frame.h:79-83) == db_expr_t-sized.
  • Killed candidates: stale-modifier strcpy OOB (unreachable β€” every dispatch re-lexes a tIDENT first, db_command.c:308,318; "String too long" path longjmps via db_error, db_command.c:481-487, before any strcpy); db_num_to_str static-buffer aliasing (single use per db_printf); unmapped-rip disasm hang (guarded by db_nofault longjmp β€” just noisily, which IS the filed bug); offset != value heuristic (upstream FreeBSD heritage, cosmetic).

Fix

Honor db_nofault before the trap_fatal funnel β€” see fix.diff (trap.c + db_interface.c + ddb.h). Not kernel-build-validated (Low/Info-class robustness finding; fix-validation kernel builds are reserved for memory-corruption findings per contract).

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

fix.diff authored post-verification against the read-only sys/ tree (trap.c funnel pre-check + db_interface.c accessors + ddb.h decls, pc64 and vkernel64). Not kernel-build-validated: Low-severity dos/robustness finding, and fix-validation kernel builds are contract-reserved for memory-corruption findings. Baseline reproduction is deterministic (banner on both triggers).

fix.diff; baseline = run4-decisive-setrip-showregisters.log / panic.txt
↓ fix.diffper-fix-DF-5732

Confirmed kernel references

Detail

Exploit chain

root/serial-console DDB session -> set $rip = 0xdead000000000000 (raw db_expr_t write, no validation) -> show registers -> db_print.c:68 db_print_loc_and_inst -> db_disasm dereferences noncanonical rip under db_nofault -> #GP -> trap.c:420 funnel -> trap_fatal banner + register disclosure + leaked gd_trap_nesting_level -> kdb_trap nofault longjmp recovers db> with corrupted resume state (crit-count panic on cont per DF-5727 capture). No unprivileged path; console access is root-equivalent.

Evidence (decisive lines)

run4-decisive-setrip-showregisters.log (register dump then 'Fatal trap 9' banner, recovered db>); panic.txt (banner extract); session0-sweep.log (x/i noncanonical sibling trigger at same RIP + full modifier/format sweep incl. harmless /x /99 modifiers, $maxoff=-1 cosmetic attribution, radix 8/10 formatting); run1-control-valid-xi.log + run3-control-held-connection.log + run3-sshpoll.log (honest controls: clean DDB enter/cont also fails to resume on this snapshot, so the post-cont wedge is not claimed as fault-attributed); serial-full.log (complete console capture); VERDICT.md (full narrative + killed candidates); fix.diff (funnel fix, never applied to sys/)

PoC changes

No seed PoC existed (pass-2 discovery). Built the rig fresh: socket-serial VM (serial_vm.sh, adapted from proven DF-5728 rig) + paced serial driver (drive.py); DDB entry via backgrounded 'sysctl debug.enter_debugger=ddb'.

Verified recommended fix

In trap()'s DDB funnel, honor db_nofault BEFORE trap_fatal(): export db_nofault_pending()/db_nofault_take() from db_interface.c and longjmp quietly for guarded probes instead of running the crash banner and abandoning the nesting bookkeeping (fix.diff).

Verdict

Reproduced live on the stock snapshot kernel: db_show_regs (sys/ddb/db_print.c:46-68) ends with db_print_loc_and_inst(PC_REGS(DDB_REGS)) which disassembles ddb_regs.tf_rip; tf_rip is DDB-writable via set $rip (db_trace.c:107 + db_variables.c:120-129). After set $rip = 0xdead000000000000, show registers prints the register dump then hits 'Fatal trap 9: general protection fault' - trap()'s db_active funnel (trap.c:414-427) routes every trap taken under the db_nofault guard (db_interface.c:242) into trap_fatal() (full crash banner + register disclosure) before kdb_trap's db_nofault longjmp (db_interface.c:140-144) quietly recovers to db>. The longjmp also skips the funnel's --gd_trap_nesting_level (leak, trap.c:422-424) and the abandoned nested dispatch corrupts session-resume state (DF-5727's pack independently captured 'panic: trap: critical section count mismatch!' on cont after this exact funnel). Same funnel hit via x/i 0xdead000000000000 (trap 9 at the identical RIP 0xffffffff80bccb70 = db_read_bytes read loop). Rated Low: trigger requires the already-privileged DDB console; the defect is a debugger-robustness/information-noise bug in the kernel's primary crash-response tool, not an escalation.