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

DDB software watchpoints (watch) are inert on x86_64 β€” pmap protection silently evaporates, watchpoint trap classification is dead code

Summary

The watch command RO-protects the page, but a watched kernel write takes a plain T_PAGEFLT that vm_fault silently rewires writable - after the first hit the watch is permanently inert while still listed as armed. Live-proven: watch ticks + c left the machine running with the counter advancing through thousands of writes to the exact watched address, zero stops. Root cause chain: IS_WATCHPOINT_TRAP hardwired 0, the Mach classifier #ifdef notused dead, db_watchpoints_inserted desynced from the vanished protection. Operational risk only (operator believes a tripwire is armed when none exists); hwatch is the working flavor. refuse-until-classifier-exists fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5721 Β· 15 files
FileTypeDescriptionSize
README.md β€” 4.3 KB ↓ raw
VERDICT.md β€” 4.3 KB ↓ raw
watch-desync.serial.log β€” 687 B view raw
unpriv-probes.log β€” 2.0 KB view raw
probe_int3.c β€” 377 B view raw
probe_step.c β€” 1.4 KB view raw
probe_dbregs.c β€” 2.8 KB view raw
drive_ddb.py β€” 2.0 KB view raw
serial_vm.sh β€” 1.7 KB view raw
serial_vm1.sh β€” 1.7 KB view raw
smp-E1-wedge.serial.log β€” 13.9 KB view raw
run.watch-panic.serial.log β€” 30.2 KB view raw
env.txt β€” 626 B view raw
fix.diff β€” 1.2 KB view raw
verdict.json β€” 3.3 KB view raw

DF-5721 β€” DDB software watchpoints (watch) are inert on x86_64

Severity: Low (debugger correctness / operational; DDB console is root-gated)

The watch command (software watchpoint, sys/ddb/db_watch.c) silently arms nothing that can ever fire on x86_64, and the pmap-based protection it does install evaporates on the first write fault. A kernel debugging session that relies on watch during incident response is debugging with a dead instrument: writes to the watched address proceed, no stop occurs, and the watchpoint list still shows it as armed.

Live proof (UP guest, stock X86_64_GENERIC kernel)

Session transcript (watch-desync.serial.log, from the socket-serial console):

db> dwatch ticks
db> watch ticks
db> c
... machine continues running normally ...
(36 s later, second DDB session)
db> x/x db_watchpoints_inserted
db_watchpoints_inserted:        0
db> x/x ticks
ticks:  b342                       <-- thousands of writes HAPPENED to the
db> show watches                     exact watched 4-byte address
*ffffffff8159eea0  ffffffff810529c8  4    <-- watch still listed as armed

ticks (a hot kernel global written by the clock code on every tick) advanced freely while a 4-byte software watchpoint sat on it. Zero watchpoint stops. Machine alive throughout.

Root cause (line-accurate)

  1. sys/ddb/db_watch.c:218-233 β€” db_set_watchpoints() arms software watchpoints with pmap_protect(map->pmap, ..., VM_PROT_READ) β€” a RO downgrade of the watched kernel page. A kernel write then takes a plain T_PAGEFLT, not a debug trap.
  2. The Mach-era classifier that would turn that page fault into a watchpoint event β€” db_find_watchpoint() (sys/ddb/db_watch.c:241-273) β€” is #ifdef notused: dead code. Nothing in the x86_64 trap path calls it.
  3. sys/cpu/x86_64/include/db_machdep.h:67 β€” #define IS_WATCHPOINT_TRAP(type, code) 0 (comment: "Watchpoints are not supported. The debug exception type is in %dr6 and not yet in the args to this macro"). Therefore db_trap() (sys/ddb/db_trap.c:54) always computes watchpt = FALSE, the "Watchpoint at" print (db_trap.c:63-64) is unreachable, and the step-over choreography in db_restart_at_pc() (sys/ddb/db_run.c:199-206, the watchpt || arm) can never engage.
  4. The faulting write instead flows trap.c:629-631 β†’ trap_pfault(); vm_fault() on the kernel_map entry (which is writable) re-installs the PTE writable, the write completes, and the watchpoint has silently evaporated. db_watchpoints_inserted was set TRUE at resume (db_run.c:208 β†’ db_watch.c:231), so no later resume re-arms it β€” the flag is desynced from the (vanished) pmap protection while running.
  5. Hardware watchpoints (hwatch, db_watch.c:296 β†’ db_md_set_watchpoint, DR0-3/DR7) are the only working flavor; they are unaffected.

Related (same family, same session, kept out of this finding's scope): when a watch-armed continue happened in a boot that also had DDB sessions in interrupt context, the kernel panicked β€” but a dedicated bisect (DF-5722) proved the watch is not the panic's cause.

Threat model

DDB is reachable only from the console or root-gated sysctl (debug.enter_debugger β€” DF-2246 verified the EPERM gate; re-verified live this run: an unprivileged sysctl debug.enter_debugger=ddb gets "Operation not permitted"). So this is not an unprivileged attack surface; the risk is operational: a root operator debugging a kernel-data-write bug with watch gets a silently dead instrument. No memory-safety impact, no info leak, no privilege boundary.

Files

  • watch-desync.serial.log β€” decisive transcript
  • up-panics.fullserial.log (in DF-5722 pack) β€” full boot's serial history
  • serial_vm.sh / serial_vm1.sh / drive_ddb.py β€” reproducible rig
  • probe_*.c + unpriv-probes.log β€” unprivileged reachability probes (all negative: user int3 β†’ SIGTRAP; PT_STEP over syscall β†’ SIGTRAP; PT_SETDBREGS with kernel addr β†’ EINVAL)
  • fix.diff β€” refuse software watchpoints on x86_64
  • verdict.json / manifest.json

Reproduce

  1. ./serial_vm1.sh up (UP guest, socket serial)
  2. ssh -F dfbsd-qemu/config dfbsd 'sysctl debug.enter_debugger=ddb' &
  3. On the serial socket: watch ticks, then c
  4. Watch the machine keep running (no stop), re-enter DDB, x/x ticks (advancing), show watches (still listed).
VERDICT.md
↓ download raw

DF-5721 β€” VERDICT

Status: reproduced (deterministic; single attempt on the stock X86_64_GENERIC snapshot kernel, UP guest). Impact: none (debugger correctness defect β€” no memory unsafety, no privilege boundary; DDB entry is console/root-gated, verified live). Confidence: certain β€” the inert-watch behavior is directly visible in the serial transcript and the root cause is line-verified in three files.

What was claimed / what reproduced

Claim: DDB software watchpoints (watch) never fire on x86_64 and the protection silently evaporates after the first watched write.

Reproduced exactly: watch ticks + c β†’ machine continues; ~36 s later (second DDB session) ticks = 0xb342 (the counter advances on every clock tick β€” i.e. thousands of kernel writes to the exact watched address occurred), show watches still lists the watchpoint, zero watchpoint stops, machine alive (watch-desync.serial.log).

Root cause chain (line-accurate)

  1. sys/ddb/db_watch.c:226-229 β€” watchpoints are armed by RO-protecting the page (pmap_protect(..., VM_PROT_READ)), so a watched write takes T_PAGEFLT (14), not a debug exception.
  2. sys/ddb/db_watch.c:241-273 — db_find_watchpoint(), the page-fault→ watchpoint classifier this design requires, is #ifdef notused (dead).
  3. sys/cpu/x86_64/include/db_machdep.h:63-67 β€” IS_WATCHPOINT_TRAP() is hardwired 0, so db_trap.c:54 always passes watchpt=FALSE: the "Watchpoint at" print (db_trap.c:63-64) and the watchpoint step-over arm in db_restart_at_pc (sys/ddb/db_run.c:199-206) are unreachable on x86_64.
  4. The page fault instead flows sys/platform/pc64/x86_64/trap.c:629-631 β†’ trap_pfault() β†’ vm_fault(kernel_map, ...); the map entry is writable, so the PTE is rewired RW and the write completes. The pmap protection is gone; db_watchpoints_inserted remains TRUE while running (db_run.c:208 β†’ db_watch.c:231), so db_set_watchpoints() never re-protects on later resumes. The feature is inert.

Why Low / impact none

  • DDB entry paths are all privileged: console (physical/serial access), debug.enter_debugger sysctl (root; unprivileged write β†’ EPERM, re-verified live this run), panic with debug.debugger_on_panic=1, NMI+ddb_on_nmi. The three unprivileged probes (probe_int3.c, probe_step.c, probe_dbregs.c β€” see pack) confirm no unprivileged route into db_stop_at_pc on this kernel: user int3 β†’ SIGTRAP (trap.c:490-495), PT_STEP-over-syscall artifact β†’ filtered (trap.c:714-718), PT_SETDBREGS with kernel DR0 β†’ EINVAL (machdep.c set_dbregs address validation).
  • No memory unsafety: the watch list is bounded (db_watch.c:51, NWATCHPOINTS=100, static table), sizes are capped by command parsing.
  • Operational risk only: an operator debugging a data-corruption incident with watch believes a tripwire is armed when none exists.

Delimitation

  • The kernel panic that co-occurred in one watch-armed session (run with b hardclock history) is DF-5722 and was proven watch-independent by bisect: identical panic with the watch removed (DF-5722 run #3/#4), no panic with the watch alone (this finding's run).
  • hwatch/dhwatch (hardware DR watchpoints) work and are not implicated.
  • The SMP-guest resume starvation (DF-5720 lead) was cross-checked from db_run.c's side this run and is NOT in db_run.c (see audit notes): identical c-resume code on a UP guest is clean.

Fix

fix.diff: make watch/dwatch refuse on x86_64 (the platform defines DB_NO_SOFTWARE_WATCHPOINTS in db_machdep.h; db_watchpoint_cmd prints "software watchpoints not supported on this platform (use hwatch)" and returns). Not kernel-rebuild-validated: Low-severity feature-disable fix, no panic/corruption involved, and a kernel rebuild was not feasible in-run (UP guest). Full implementation (resurrect db_find_watchpoint in the kernel page-fault path + a real IS_WATCHPOINT_TRAP) is the upstream-quality fix but is a feature restoration, not a one-line repair.

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
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored post-verification against the read-only sys/ tree; not applied/rebuilt (kernel rebuild infeasible in-run on the 1-vCPU validation guest; Low-severity feature-disable, not memory corruption, so rebuild validation is not mandatory per contract).

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

Confirmed kernel references

Detail

Evidence (decisive lines)

watch-desync.serial.log (db> watch ticks / c / machine alive / second session: ticks: b342 advancing, show watches lists it, zero stops); README.md root-cause chain with path:line; unpriv-probes.log (all reachability probes negative); fix.diff refuses the feature on x86_64

PoC changes

No seed PoC existed. Built the rig from scratch: socket-serial QEMU guests (serial_vm.sh SMP / serial_vm1.sh UP), a paced serial-line DDB driver (drive_ddb.py), and three unprivileged reachability probes (probe_int3.c, probe_step.c, probe_dbregs.c) compiled on the guest.

Verified recommended fix

Refuse software watchpoints on x86_64 (define DB_NO_SOFTWARE_WATCHPOINTS in db_machdep.h, reject in db_watchpoint_cmd) until a real watchpoint trap classifier exists; point users at hwatch.

Verdict

DDB software watchpoints (watch) are inert on x86_64: live-verified that a 4-byte watch on the every-tick-written kernel global ticks never stops the kernel (ticks advanced to 0xb342 with the watch listed as armed, machine alive), because the pmap RO protection (db_watch.c:226-229) faults as a plain page fault that vm_fault() silently rewires writable, while the watchpoint classifier the resume choreography needs is dead code (db_find_watchpoint #ifdef notused, db_watch.c:241-273) and IS_WATCHPOINT_TRAP is hardwired 0 (db_machdep.h:67), making the watchpt arm of db_restart_at_pc (db_run.c:199-206) unreachable. Root-console-only surface (unprivileged probes all negative), no memory unsafety: Low severity, debugger-correctness defect that leaves an operator debugging with a dead instrument.