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

DDB db_set_breakpoints() fault-abort orphans live INT3s and poisons the saved original byte β€” permanent kernel-text corruption at an unrelated address plus unrecoverable crit-section panic

Summary

A single breakpoint at an unmapped address aborts the resume-time insert pass mid-list via nested trap: earlier (LIFO) breakpoints keep live INT3s while the inserted flag stays FALSE so clear never restores them, and any later insert re-reads the patched 0xCC as the 'original' - permanently corrupting the first byte of an instruction the operator did NOT type. Live-proven twice on stock: db_invltlb_cmd 0x0f->0xcc with flag FALSE, then crit-count-mismatch panic + dump. Save-byte-at-set-time + never-re-read fix validated on a rebuilt kernel.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5725 Β· 16 files
FileTypeDescriptionSize
README.md β€” 3.9 KB ↓ raw
VERDICT.md β€” 6.1 KB ↓ raw
serial_vm.sh β€” 1.9 KB view raw
serial_vm1.sh β€” 1.8 KB view raw
drive_ddb.py β€” 2.2 KB view raw
enter_ddb.sh β€” 867 B view raw
build.sh β€” 163 B view raw
run.sh β€” 275 B view raw
run1-orphan-panic.serial.log β€” 29.3 KB view raw
run2-orphan-panic.serial.log β€” 36.0 KB view raw
control-run.serial.log β€” 45.0 KB view raw
env.txt β€” 350 B view raw
fix.diff β€” 1.5 KB view raw
fix_build.log β€” 5.7 MB ↓ download
fix_run.log β€” 15.2 KB view raw
verdict.json β€” 5.4 KB view raw

DF-5725 β€” DDB db_set_breakpoints() fault-abort orphans live INT3s / poisons the saved original byte β†’ kernel-text corruption + unrecoverable panic

Severity: Low (DDB console is root-gated), but the primitive is real kernel-text memory corruption (byte β†’ 0xCC) caused by state-machine desync, plus a deterministic panic: trap: critical section count mismatch! with crash dump β€” live-proven twice on the stock X86_64_GENERIC kernel.

What breaks

db_set_breakpoints() (sys/ddb/db_break.c:161-180) reads the original byte and writes the INT3 for every listed breakpoint without any per-breakpoint fault isolation, and without initializing bkpt_inst at breakpoint-set time (db_set_breakpoint(), db_break.c:88-111, never touches bkpt_inst). When one breakpoint in the list sits at an unmapped address (an operator typo β€” b 0xffffffff85000000), the resume path (c β†’ db_restart_at_pc() β†’ db_set_breakpoints()) aborts mid-list via the kernel page-fault path:

  1. Breakpoints earlier in the list (list is LIFO: the last-set bp is inserted first) already have INT3 written β€” but db_breakpoints_inserted stays FALSE (the flag store at db_break.c:178 is never reached). The INT3 is now orphaned: db_clear_breakpoints() (db_break.c:187-196) restores only if (db_breakpoints_inserted), so no future code path ever restores that byte while the flag is desynced.
  2. Any later db_set_breakpoints() pass re-reads the patched byte (db_break.c:171) as the "original instruction" β€” bkpt_inst is poisoned with 0xCC β€” and every subsequent clear restores 0xCC, permanently replacing the first byte of the instruction at that address.
  3. Empirically (UP guest, stock kernel) the abort route is not the quiet db_nofault longjmp but Fatal trap 12 β†’ trap_fatal() β†’ nested DDB entry from a CRIT-priority thread; the nested session is a hair-trigger: the next trap on that corrupted-context thread panics with panic: trap: critical section count mismatch! β†’ crash dump β†’ reboot.

Live proof (twice, deterministic; stock kernel, UP rig)

run1-orphan-panic.serial.log / run2-orphan-panic.serial.log:

db> x/bx db_invltlb_cmd          # pristine first byte (a never-executed DDB cmd handler)
db_invltlb_cmd: f                # 0x0F
db> b 0xffffffff85000000         # BAD breakpoint set FIRST (list tail)
db> b db_invltlb_cmd             # GOOD breakpoint set SECOND (list head)
db> c                            # insert pass: INT3->db_invltlb_cmd, then read fault
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xffffffff85000000
current thread          = pri 6 (CRIT)
db> x/bx db_invltlb_cmd
db_invltlb_cmd: cc               # <-- ORPHANED INT3; flag db_breakpoints_inserted==FALSE
panic: trap: critical section count mismatch! -1892583040/-1
... dump ... Rebooting...

Control (control-run.serial.log): the same GOOD breakpoint without the bad one behaves correctly β€” byte restored to 0x0F at DDB entry, machine resumes clean. The defect is specifically the fault-aborted insert pass.

Fix

Save the original byte at breakpoint-set time (validating the address while the console can still react) and never re-read patched memory in db_set_breakpoints() β€” see fix.diff (validated: patched kernel refuses to add the bad breakpoint, insert pass no longer faults at resume, no orphan, no panic; byte restored cleanly).

Reproduce

./serial_vm1.sh up                       # UP guest, socket serial (SMP wedges after DDB resume)
./enter_ddb.sh                           # root sysctl debug.enter_debugger=ddb
DF5725_SOCK=/tmp/opencode/df-5725-ser-up.sock ./drive_ddb.py \
   "x/bx db_invltlb_cmd" 3 \
   "b 0xffffffff85000000" 2 "b db_invltlb_cmd" 2 "show breaks" 3 "c" 8 \
   "x/bx db_invltlb_cmd" 6
# expect: 0x0f ... Fatal trap 12 ... db_invltlb_cmd: cc ... panic + dump + reboot
VERDICT.md
↓ download raw

DF-5725 β€” VERDICT

reproduced (deterministic, twice) β€” impact: panic (plus a real, demonstrated kernel-text corruption primitive: first byte of an instruction at a valid, unrelated address permanently replaced with 0xCC while DDB believes no breakpoints are inserted; the run then ends in panic: trap: critical section count mismatch!, crash dump, reboot).

Environment

  • DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 x86_64, stock X86_64_GENERIC kernel (guest /sys/ddb/db_break.c MD5 00d1f40d09dd788691f6224b391d3e26 = audited tree).
  • QEMU/KVM, 1 vCPU (UP), 4 GB, socket-backed serial console driven from the host (serial_vm1.sh + drive_ddb.py, paced 8-byte chunks).
  • DDB entry: root-gated sysctl debug.enter_debugger=ddb (unprivileged write β†’ EPERM, verified by DF-5721's probes); debug.debugger_on_panic=1.

What was run (exact)

Run 1 and Run 2 (identical, run1-orphan-panic.serial.log, run2-orphan-panic.serial.log):

db> x/bx db_invltlb_cmd                 # read pristine byte: 0x0f
db> b 0xffffffff85000000                # BAD bp set FIRST  (tail of list)
db> b db_invltlb_cmd                    # GOOD bp set SECOND (head of list)
db> show breaks                         # both listed
db> c                                   # resume: insert pass head-first
Fatal trap 12: page fault while in kernel mode
  fault virtual address = 0xffffffff85000000
  current thread = pri 6 (CRIT)
db> x/bx db_invltlb_cmd
db_invltlb_cmd: cc                      # ORPHANED INT3 (flag says NOT inserted)
panic: trap: critical section count mismatch! -1892583040/-1
  (dump, reboot)

Control (control-run.serial.log): GOOD bp alone β†’ c β†’ machine runs; re-enter DDB β†’ byte already restored to 0x0f at entry (db_stop_at_pc β†’ db_clear_breakpoints with flag TRUE); delete; c; machine alive. The defect is specifically the fault-aborted insert pass.

Why (line-accurate)

  1. db_set_breakpoint() (sys/ddb/db_break.c:88-111) never initializes bkpt_inst and never validates the address.
  2. db_set_breakpoints() (db_break.c:161-180) walks the list head-first: for each bp it reads the original byte (db_break.c:171, db_get_value) and writes BKPT_SET (db_break.c:174-176). On x86_64 db_read_bytes (sys/platform/pc64/x86_64/db_interface.c:241-251) accesses the raw address; an unmapped address faults. The fault is not fielded by db_nofault on this path β€” it routes to trap_fatal() β†’ nested DDB entry (observed; the trap dump is in both run logs, current thread = pri 6 (CRIT) β€” the resume path runs in a critical section).
  3. The abort leaves earlier-listed breakpoints patched with INT3 while db_breakpoints_inserted remains FALSE (db_break.c:178 unreached) β€” proven by db_invltlb_cmd: cc at the nested prompt.
  4. From that point every recovery path is broken: - db_clear_breakpoints() (db_break.c:187-196) restores only if (db_breakpoints_inserted) β€” the desynced flag makes it a no-op, so nothing ever restores the byte; - a later insert pass re-reads the patched byte as the original (db_break.c:171) β€” bkpt_inst poisoned with 0xCC β€” and the eventual clear writes back 0xCC: permanent first-byte corruption of the instruction at that address; - empirically the nested CRIT-context trap state is a hair-trigger: the next trap on that thread trips panic: trap: critical section count mismatch! (observed in both runs; panic trace runs through Debugger() ← sysctl_debug_enter_debugger ← userland_sysctl β€” the original entry frame β€” confirming the abort happened mid-resume).
  5. Because a stop requires --bkpt->count == 0 exactly (db_run.c:100), the corrupted state also silently swallows the skip-count bookkeeping.

With debug.debugger_on_panic=0 the fatal trap would panic-reboot directly at step 2; with the default (1) the machine survives only until the first nested trap, which panics anyway (both runs).

Threat model

DDB requires the root-gated console (serial break, panic, or debug.enter_debugger). The corrupted byte and the panic are caused by an operator typo (b at an unmapped address) β€” the corruption lands at an address the operator did NOT type and persists after deleting every breakpoint. Severity: Low (privileged surface), but it is genuine kernel-text corruption (wrong-code execution primitive if the target address is chosen) caused by a debugger state-machine defect, not by the by-design debugger memory-write capability.

Fix validation

fix.diff: initialize+save bkpt_inst at db_set_breakpoint() time (before linking; a bad address faults at the prompt β€” the pre-existing, recoverable behavior for bad DDB reads β€” and nothing is added to the list), and use the saved byte in db_set_breakpoints() (never re-read patched memory).

Validated on a single combined-fix kernel (this fix + DF-5726 + DF-5727, fix_build.log / fix_run.log): b 0xffffffff85000000 is rejected at set time, show breaks lists only the good breakpoint, c resumes with no fatal trap, and db_invltlb_cmd reads 0x0f (never 0xcc) at every observation point; no panic; machine stays up. Residual (documented): a page that is valid at set time but unmapped later (module unload) can still abort the insert pass mid-resume β€” the poisoning leg is gone by construction, the orphan leg would need per-bp fault isolation in the MD layer.

Rig notes

  • The SMP rig (serial_vm.sh, 6 vCPU) wedged unrecoverably after a DDB resume (same failure DF-5721 recorded in smp-E1-wedge.serial.log); all clean sessions ran on the UP rig (serial_vm1.sh).
  • Crash dumps written by the two baseline panics were discarded with vm.sh reset with-src before the fix build.

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

Baseline reproduced twice on stock #0 kernel (orphan 0xcc + crit-mismatch panic + dump + reboot). Patched kernel (#1, combined db_break/db_watch fixes): 'b 0xffffffff85000000' faults at SET time at the prompt (pre-existing recoverable bad-read behavior) and adds nothing ('No breakpoints set'); good bp insert/clear cycle restores 0x0f at entry; c resumes with no fatal trap; machine stays up. Bad behavior gone.

findings/poc/DF-5725/fix.diff; fix_build.log; fix_run.log (patched session: 'No breakpoints set' / 'db_invltlb_cmd: f' / clean resume); baseline: run1-orphan-panic.serial.log, run2-orphan-panic.serial.log; control-run.serial.log
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Oct 10 18:42:38 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

Root at DDB console: (1) b then b (LIFO: valid bp inserted first), (2) c -> insert pass writes INT3 to the valid address, faults on the bad one, flag desync, (3) orphaned INT3 persists (x/bx shows 0xcc) and poisons bkpt_inst on any re-insert; deleting all breakpoints leaves the 0xcc permanently in kernel text; (4) the next nested trap panics with crit-section mismatch -> dump+reboot. No unprivileged path (debug.enter_debugger is root-gated, verified EPERM as uid 1002 by DF-5721 probes); escalation not applicable - corruption target is chosen by the operator's own valid breakpoint, so an operator can corrupt any text byte deliberately, which DDB already permits by design via 'w'; the defect is that it happens unintentionally and irreversibly from a typo plus panics the box.

Evidence (decisive lines)

run1-orphan-panic.serial.log + run2-orphan-panic.serial.log: 'db_invltlb_cmd: f' (0x0f pristine) -> 'b 0xffffffff85000000' + 'b db_invltlb_cmd' + 'c' -> 'Fatal trap 12 ... 0xffffffff85000000 ... pri 6 (CRIT)' -> 'db_invltlb_cmd: cc' (orphaned INT3) -> 'panic: trap: critical section count mismatch! -1892583040/-1' -> dump -> reboot. control-run.serial.log: same GOOD bp without the bad one restores 0x0f cleanly and resumes. fix_run.log (patched kernel): bad bp rejected at set time ('No breakpoints set'), c resumes with no trap, byte stays 0x0f, machine up.

PoC changes

No seed PoC. Built the socket-serial DDB rig (serial_vm.sh SMP / serial_vm1.sh UP + drive_ddb.py, adapted from the proven DF-5721 rig) plus enter_ddb.sh; discovered x/b alone is modifier-only (must use x/bx) and that the SMP instance wedges after DDB resume (same as DF-5721), so all clean runs are UP.

Verified recommended fix

Save and validate the original instruction byte in db_set_breakpoint() at set time (before linking) and make db_set_breakpoints() write BKPT_SET(saved) without re-reading patched memory (fix.diff).

Verdict

db_set_breakpoints() (sys/ddb/db_break.c:161-180) has no per-breakpoint fault isolation and defers reading the original instruction byte to resume time: a single breakpoint at an unmapped address (operator typo) aborts the insert pass mid-list via Fatal trap 12 -> nested DDB entry, leaving breakpoints earlier in the list patched with INT3 while db_breakpoints_inserted stays FALSE (proven live: db_invltlb_cmd byte 0x0f -> 0xcc at the nested prompt, twice, run1/run2 logs), after which no code path ever restores the byte (db_clear_breakpoints restores only when the flag says inserted) and any later insert pass re-reads the patched INT3 as the original (bkpt_inst poisoned at db_break.c:171) making the corruption permanent on clear. The nested CRIT-context trap state then panics deterministically ('panic: trap: critical section count mismatch!', crash dump, reboot) in both runs. Root-gated DDB console surface -> Low severity, but the demonstrated primitive is real kernel-text corruption at an address the operator did not type plus an unrecoverable panic.