dbgscope 0.1.0

Typed WinDbg/DbgEng debug sessions, with kernel pool and user heap walkers built on them.
Documentation

dbgscope

Build Status Miri codecov crates.io docs.rs Dependency status License: MIT

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.

What it provides

  • Session drivers for every target DbgEng opens: attach_kernel, attach_local_kernel, launch_process, attach_process, open_dump, open_trace, plus connect for an existing debugging server. open_dump and open_trace commit the session and leave the pump to the caller — the engine has no current process or thread until wait_for_event has run.
  • Two-step opening on the four live openers, each a thin x_begin()?.wait(). x_begin does only the side effect that creates or claims the target and hands back a PendingTarget; wait completes 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 tagged RegisterValue, not a string), register_descriptions, modules / unloaded_modules / module_at, module_pdb, stack_frames carrying the module each frame belongs to, bug_check as fields, breakpoints, disassemble as Instruction records.
  • Bounded execution control. execute_command_bounded, execute_and_wait, settle and run_to_address are waits with a condition-variable watchdog behind them. A break at the bound comes back as Interruption::Deadline, distinct from a host's own Interruption::OnRequest.
  • InterruptHandle — a Send + Sync handle that Ctrl+Breaks an engine from another thread, built on SetInterrupt, the one DbgEng call documented as safe there.
  • Per-process teardown. DbgEng holds several user-mode targets at once and EndSession takes 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 -v discards a frame or .ecxr context the caller had selected — without the session ending up somewhere else. A Scope carries the target it was read from and is refused rather than applied to a later one.
  • Kernel pool walking (pool::query): find_tag, chunk_at with 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 design rule

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.

Example

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");
}

Walking the pool

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, 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.

Naming a tag

find_tag accepts one to four ASCII bytes ("Tgsm"), or the raw form0x 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.

Requirements

  • 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 nextest as 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.

The !dbgscope.poolmap extension

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 --lib

Run 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.

Documentation

  • 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 changing dbgeng.

Layout

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.

Building

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 run

Run examples/session_fuzz.rs after touching anything in the wait/settle/guard seam. It is seeded, so a failing sequence replays.

Scope

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.

CI

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.

Contributing

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.

License

MIT — see LICENSE.