Typed access to a WinDbg/DbgEng debug session from Rust, and kernel-pool and user-heap walkers
built on one. It opens a target — a live kernel over KD, a local kernel, a launched or attached
user-mode process, a crash dump, a TTD trace — and answers in values rather than in the text
r, lm and bl print, so a host does not parse a debugger's output to find out what the
machine is doing.
The organising idea is smaller than the API and worth stating first: every answer carries what the answering cost. A pool walk against a live kernel is normally incomplete, a bounded command may have been Ctrl+Broken at its deadline, and a four-byte pool tag has renderings that name no particular tag. Each of those is reported as itself rather than passed off as a clean result — see Unknown, not absent.
- Session drivers for every target DbgEng opens:
attach_kernel,attach_local_kernel,launch_process,attach_process,open_dump,open_trace, plusconnectfor an existing debugging server.open_dumpandopen_tracecommit the session and leave the pump to the caller — the engine has no current process or thread untilwait_for_eventhas run. - Two-step opening on the four live openers, each a thin
x_begin()?.wait().x_begindoes only the side effect that creates or claims the target and hands back aPendingTarget;waitcompletes the initial break. The split lets a caller tell "nothing happened, retry is clean" from "the target exists and only the wait failed" — opposite recoveries, since re-running the second spawns a second process or re-dials a live KD link. - Typed reads of session state:
register_values(a taggedRegisterValue, not a string),register_descriptions,modules/unloaded_modules/module_at,module_pdb,stack_framescarrying the module each frame belongs to,bug_checkas fields,breakpoints,disassembleasInstructionrecords. - Bounded execution control.
execute_command_bounded,execute_and_wait,settleandrun_to_addressare waits with a condition-variable watchdog behind them. A break at the bound comes back asInterruption::Deadline, distinct from a host's ownInterruption::OnRequest. InterruptHandle— aSend + Synchandle that Ctrl+Breaks an engine from another thread, built onSetInterrupt, the one DbgEng call documented as safe there.- Per-process teardown. DbgEng holds several user-mode targets at once and
EndSessiontakes one flag for the whole session, so provenance is tracked per pid: a process this engine attached to is detached individually before the session ends, or a passive end kills somebody else's service. A live kernel is resumed and actively detached, or it stays frozen at its last break. - Scope save/restore (
scope,set_scope,scope_guard) for running a command that moves the debugger's scope — measured:!analyze -vdiscards a frame or.ecxrcontext the caller had selected — without the session ending up somewhere else. AScopecarries the target it was read from and is refused rather than applied to a later one. - Kernel pool walking (
pool::query):find_tag,chunk_atwith neighbours,tag_census,snapshot_report— every answer paired with the coverage of the walk it came from. - User-mode Segment Heap walking (
heap): version-aware queries over the same page-segment, LFH, VS and backend decoding, because the two allocators are the same machinery either side of the ring boundary. - A WinDbg extension,
!dbgscope.poolmap, over the same walker and the same caches, so the interactive and programmatic entry points cannot drift apart.
The usual Rust advice is to make illegal states unrepresentable, which assumes you know which states are legal. A debugger reads a machine bigger than any one read of it, so the harder problem is the inverse: when the ground truth is genuinely unknowable, the type has to carry its own incompleteness. Three places where that shows up in the public API:
Coverage. Paged pool is partly on disk, and a page the memory manager has paged out cannot
be read through the debugger either. So an incomplete walk is the normal case, not a fault.
Those ranges come back as sparse virtual range diagnostics, the chunks inside them are absent
from the snapshot, and the report says which of three things happened:
pub enum WalkCoverage { Complete, BudgetExpired, Partial }Not a bool, because the two ways of falling short need opposite responses: BudgetExpired
reaches more of the pool if given more time, Partial reports the same gaps however long it
runs. Running out of the wall-clock budget is therefore not an error — the walk returns what it
reached, with coverage saying so.
Forced breaks. A bounded command that was Ctrl+Broken at its deadline comes back as a
CommandRun whose cut_short is Some(Interruption::Deadline { after_ms }), keeping the
output captured up to the break. Not an Err — that would discard the output, which is the
whole reason to interrupt rather than end the session — and not a bare String, because a
search cut short prints the hits it reached and nothing to say there were more.
Tags. A pool tag is four bytes; its printed form is a lossy rendering, since every
unprintable byte becomes . and so does a literal .. The tag stays raw internally, and every
output site prints through tag_label, which shows the raw form wherever the rendering would
not survive being handed back.
The part that took the work is making that bearable — PoolAnswer<T> pairs an answer with its
walk so the two cannot come from different walks, impl From<bool> for PoolWalk leaves the
ordinary call site unchanged, and WalkCoverage::complete() is the one-word escape hatch.
Unknown, not absent is the long form, with the wrong answer each
simpler shape produced.
Open a dump and read its modules as values:
use dbgscope::dbgeng::DebugEngine;
let engine = DebugEngine::new();
engine.open_dump(r"C:\dumps\MEMORY.DMP")?;
// `open_dump` only commits the session. The engine has no current process or thread until
// it has been pumped, so every read below fails without this.
engine.wait_for_event(60_000)?;
for module in engine.modules()? {
println!("{:#018x} {:<24} {:?}", module.base, module.name, module.symbols);
}
if let Some(bug_check) = engine.bug_check()? {
println!("bug check {:#x} {:x?}", bug_check.code, bug_check.parameters);
}Run a command under a deadline, and report the deadline as one:
use dbgscope::dbgeng::Interruption;
let run = engine.execute_command_bounded("!process 0 f", 30_000)?;
print!("{}", run.output); // real output either way
match run.cut_short {
None => {}
Some(Interruption::Deadline { after_ms }) =>
eprintln!("cut short after {after_ms}ms — scope the command and retry"),
Some(Interruption::OnRequest) => eprintln!("interrupted on request"),
}
if run.target_gone {
eprintln!("the target is gone; retire this engine");
}use dbgscope::pool::{query, tag_label};
// `false` converts into a PoolWalk meaning "reuse any snapshot cached for this target";
// `true` rebuilds. Either picks up DEFAULT_WALK_BUDGET.
let answer = query::find_tag(&engine, "Pipe", None, None, false)?;
for span in &answer.found {
println!("{:#018x} {:>8} {}", span.usable_address, span.size, tag_label(span.raw_tag));
}
// The count and the coverage come from the same walk, by construction.
if !answer.walk.coverage.complete() {
eprintln!(
"{} spans is a floor, not a total ({:?}); {} diagnostics across {} shapes",
answer.found.len(),
answer.walk.coverage,
answer.walk.diagnostics.emitted(),
answer.walk.diagnostics.shapes().len(),
);
}Ask for a specific deadline instead of the default, and take the census:
use std::time::Duration;
use dbgscope::pool::query::{self, PoolWalk};
let census = query::tag_census(&engine, PoolWalk::refreshed().within(Duration::from_secs(30)))?;
for row in census.found.iter().take(20) {
println!("{:<10} {:>8} allocations {:>12} bytes", tag_label(row.raw_tag), row.allocations, row.total_bytes);
}Read answer.walk.diagnostics by shape rather than by kept-line count: a live walk carries
thousands of messages, one stale list pointer or one paged-out region yields one message per
node, and PoolDiagnostics keeps eight verbatim examples per shape with the totals as numbers
beside them. emitted() describes the walk; examples().len() describes the struct, and on a
busy target the two differ by two orders of magnitude.
find_tag accepts one to four ASCII bytes ("Tgsm"), or the raw form — 0x and the four
bytes as hex in memory order, so it reads in the same direction as the printed tag and as the
debugger's own output (Tgsm is 0x5467736d, not 0x6d736754).
use dbgscope::pool::{display_is_ambiguous, raw_tag_hex, tag_label};
let binary = u32::from_le_bytes([0x00, 0x01, 0x80, 0xff]);
let dots = u32::from_le_bytes(*b"....");
// Both render as "....", so the rendering names neither of them.
assert!(display_is_ambiguous(binary) && display_is_ambiguous(dots));
assert_ne!(raw_tag_hex(binary), raw_tag_hex(dots));
// tag_label is what every output site prints: the readable form where it can be handed
// back, the raw form where it cannot.
assert_eq!(tag_label(u32::from_le_bytes(*b"Tgsm")), "Tgsm");
assert_eq!(tag_label(binary), "0x000180ff");The two forms cannot collide, which is what makes accepting both safe rather than a guess: the
raw form is exactly ten characters and a printed tag is at most four, so "0x2e" is still the
ordinary four-byte tag 0x2e.
- Windows x86_64 or Windows ARM64. The crate calls Windows APIs directly with no
#[cfg]gating and is not designed to build elsewhere; pool walking is x64-only, because the allocator encodings it consults are. - Rust 1.88 or later (let-chains, used in the pool walker); nightly for Miri.
- MSVC build tools.
- Optional:
cargo nextestas the local test runner.
Symbols must be on the debugger host — PDBs are never fetched from a target over KD — and
resolving them at all needs msdia140.dll beside the engine (symsrv.dll finds a PDB, that one
parses it). Without it every module reports Symbol Type: EXPORT - PDB not found, and on a
kernel dump that presents not as missing symbols but as a memory read failing, since virtual
addresses are translated through structures the engine locates with nt's symbols.
Building the library for the native x64 MSVC target produces both the rlib and
target\debug\dbgscope.dll — that is what crate-type = ["rlib", "cdylib"] is for:
rustup target add x86_64-pc-windows-msvc
cargo build --libRun that from an x64 MSVC Rust host. If an explicit --target x86_64-pc-windows-msvc is
supplied instead, the DLL lands in target\x86_64-pc-windows-msvc\debug\ and the .load path
must use that target-qualified directory.
The walker assumes Windows 10 19H1 or later allocator algorithms and needs full private type
information for nt. Configure a symbol server, break in, and force-load kernel symbols first:
.symfix
.reload /f nt
.load C:\path\to\dbgscope\target\debug\dbgscope.dll
!dbgscope.help
!dbgscope.poolmap -tag Pipe
!dbgscope.poolmap -tag ABC -paged
!dbgscope.poolmap -tag Test -nonpaged -refresh
!dbgscope.poolmap -tag 0x5467736d
!dbgscope.poolmap ffff800012345678
-tag takes either tag form, and a tag only ever arrives through it: a bare positional
argument is always parsed as an address, so !dbgscope.poolmap 0x5467736d asks about that
address rather than about Tgsm. -paged and -nonpaged filter exact pool identities and
cannot be combined; the map retains nearby unrelated allocations and holes. An address argument
prints detail for the allocation or hole containing it. -refresh discards a complete cached snapshot
and walks again. Where WinDbg accepts DML, map cells have colours and clickable address links;
the same rows use ASCII glyphs and carry a legend when DML is stripped or the output is
captured as plain text.
"Snapshot" is literal: the extension examines current allocations, reusable frees, and cached/delay-free spans while the target is stopped. It does not install allocation breakpoints or reconstruct history. The cache is invalidated when execution resumes or the session changes, and incomplete or Ctrl+C-interrupted walks are never cached.
A walk is thousands of debugger reads plus every committed pool page, so over a live KDNET link
it can run for minutes. The extension lets it run to completion, because there is an operator at
a prompt who can Ctrl+Break. pool::query cannot assume anyone is watching the clock, so its
walks carry a wall-clock budget (DEFAULT_WALK_BUDGET, 120s), and a walk that runs out of it
returns what it reached rather than failing.
A host that is watching can stop one itself. engine.interrupt_handle() hands back a
Send + Sync handle whose interrupt() sets the same flag Ctrl+Break does, the walk polls it,
and the query comes back as PoolQueryError::Interrupted — a variant of its own, because an
interrupt somebody asked for is not a walk that failed. That is what makes
PoolWalk::unbounded() usable away from an interactive prompt.
Per-session paged heaps are outside the initial pool-map scope, and the command says so.
- Unknown, not absent — the long-form design guide: coverage, budgets, forced breaks, tag rendering, and what carrying incompleteness in the types costs.
- API documentation on docs.rs — built for the MSVC targets, since the crate does not compile on docs.rs' Linux default.
- CHANGELOG.md — what each release contains, and the standing limitations.
CLAUDE.md/AGENTS.md— the load-bearing invariants, for anyone changingdbgeng.
| Path | Purpose |
|---|---|
src/lib.rs |
Crate root; declares the four public modules. |
src/dbgeng.rs |
Sessions, execution control, breakpoints, symbols, modules, scopes, dumps, traces. |
src/pool.rs, src/pool/ |
Kernel pool walking: decode, index, layout, query, render, snapshot. |
src/heap.rs |
Version-aware queries over user-mode Segment Heaps. |
src/allocator.rs |
Layout provenance and VS semantic families shared by both walkers. |
src/pool_extension.rs |
The !dbgscope.poolmap exports. |
examples/kdtest.rs |
Live-kernel exercise of the opener split over KDNET. |
examples/session_fuzz.rs |
Seeded randomised command sequences checking the session invariant after every step. |
examples/scope_restore.rs |
Re-validates scope save/restore against a dump. |
examples/split_open.rs |
Re-validates the two-step openers, including a guard dropped before the engine is pumped. |
examples/typed_context.rs |
Typed reads next to the debugger's own text for the same state. |
examples/user_heap_smoke.rs |
Launches a child, allocates across size regimes, walks its Segment Heap. |
examples/register_description.rs |
The full register description, not one flag of it. |
cargo build --verbose # build for the active Windows target
cargo fmt --all -- --check # the CI formatter gate
cargo nextest run --verbose # preferred test runner
cargo test # fallback
cargo miri nextest run # nightly unsafe-code check, as miri.yml runs it
cargo miri test --doc # the doctests nextest cannot runRun examples/session_fuzz.rs after touching anything in the wait/settle/guard seam. It is
seeded, so a failing sequence replays.
This crate reads and drives a debug session; it does not exploit one. Everything here is analysis — walking allocator metadata, resolving symbols, stepping a target. Corrupt or unreadable allocator metadata is reported conservatively rather than treated as an exploitable condition. The exploitation half — shellcode, ROP, process injection, win32k wrappers, pool spraying — left for a separate crate, nothing here depends on it, and nothing here should grow back toward it.
Windows runners only.
| Workflow | What it does |
|---|---|
ci.yml |
cargo fmt --check, cargo build, cargo nextest run on windows-latest (x64) and windows-11-arm (ARM64), stable. |
coverage.yml |
Instrumented build, LCOV via grcov, upload to Codecov. |
miri.yml |
cargo miri nextest run on nightly plus cargo miri test --doc. Not on pull requests — it is the longest job by a wide margin, so it runs on merge to main, weekly for toolchain drift, and on workflow_dispatch. A green PR says nothing about Miri; run it by hand when a change is one Miri would have something to say about. |
rustfmt defaults, small and localised unsafe blocks at the FFI boundary, focused
#[cfg(test)] tests next to the code (test_*). Commit subjects use lowercase prefixes:
fix:, feat:, perf:, docs:, style:, refactor:, test:, chore:.
When a change makes an answer less complete than it looks, say so in the type. That is the one rule this crate has.
MIT — see LICENSE.