Python SDK¶
The ntoseye package exposes debugger introspection and run control to standalone Python programs. The native wheel is self-contained: it needs no separately installed ntoseye, and installs the ntoseye command itself. The type stubs ship with the package, generated from the extension itself; editor completion, type checkers, and docstrings describe the API.
Install¶
pip install ntoseye
To build and install the extension from a checkout:
cd python
python3 -m venv .venv
. .venv/bin/activate
pip install maturin
maturin develop --release
Attach and inspect¶
attach() defaults to the kd backend. Choose kdnet, gdb, memory, or dmp as appropriate; connect supplies the transport endpoint (or dump path), and key is required for kdnet. memory is passive and cannot halt or resume the guest; use kd or gdb for run control. dmp reads an offline dump.
import ntoseye
with ntoseye.attach(backend="kd") as dbg:
proc = dbg.processes[1234] # Process, keyed by PID
print(proc.name, hex(proc.eprocess))
print(proc.memory.read(proc.peb.addr, 16).hex() if proc.peb else "no PEB")
Leaving the with block (or calling dbg.close()) removes the debugger’s breakpoints, resumes the guest, and ends the session: the connection and the target’s single-instance lock are released, so the target can be attached again, and the debugger and its handles raise NtoseyeError afterwards.
A Debugger exposes namespaces rather than flat inspect_* methods:
Namespace |
Contents |
|---|---|
|
Kernel virtual and guest-physical memory |
|
Kernel symbols, PDB types, and loaded modules |
|
Keyed processes, threads, and processors |
|
Breakpoint handles and exception policies |
|
System-wide reports/decoders and driver objects |
|
The VBS secure kernel and its trustlets (VTL1), read-only |
Processes are keyed by PID: dbg.processes[pid] raises KeyError if absent, while .get(pid) returns None. dbg.processes.find(name) returns exact, case-insensitive image-name matches as a list. A process owns address-space-bound views: proc.memory, proc.symbols, proc.types, proc.modules, proc.threads, proc.regions, and proc.heaps. Use these views directly instead of selecting or attaching a global process.
Handles such as Process, Module, Thread, Frame, and struct cursors are stamped with the target generation. After a reboot (Stop.Reboot), discard and re-query old handles; accessing one raises ntoseye.StaleHandleError (they stay hashable and printable, so a set or dict holding them keeps working). dbg.generation lets code that caches raw addresses detect a rebuild. Breakpoint handles are not stamped: breakpoints survive a reboot, and symbolic ones re-resolve in the new kernel.
Memory, symbols, and types¶
Memory operations are explicit about address space: dbg.memory is kernel virtual memory, proc.memory is that process’s virtual memory, and dbg.physical is untranslated physical memory.
kernel_fn = dbg.symbols["nt!KeBugCheckEx"]
proc_module = proc.modules["ntdll"]
print(hex(kernel_fn), proc_module.name, hex(proc_module.base))
print(dbg.memory.read(kernel_fn, 16).hex())
Types are looked up with dbg.types[name] (or proc.types[name]). Type.fields maps names to Field layouts in offset order, and an enum’s Type.values maps member names to values; .at(address) creates a live cursor, and .read() returns a snapshot dictionary. Cursor fields support attribute access, while pointer fields remain integer addresses and follow("Field") explicitly dereferences a typed pointer. address_of("Field") is a field’s address (C’s &cursor->Field), for a watchpoint or a raw read. Type.walk(head, "Links") (or cursor.walk("HeadField", "_RECORD", "Links")) follows an intrusive _LIST_ENTRY list to cursors of its records.
entry = proc.object # live _EPROCESS cursor
print(entry.Pcb.DirectoryTableBase) # nested typed field access
links = entry["ActiveProcessLinks"] # works even if a field name collides
params = proc.peb.follow("ProcessParameters") if proc.peb else None
if params is not None:
print(params.CommandLine)
PDB enum fields return cached IntEnum members when the value is defined; other values remain plain int. Use cursor["Field"] for collision-proof field access (and assignment); ordinary cursor.Field can resolve a cursor member first. Struct cursors are live reads tied to their original address space.
Secure kernel (VTL1)¶
Important
VTL1 inspection is experimental; see Secure kernel (VTL1) for how it works and where it has been tested.
With VBS running, dbg.secure_kernel is the secure kernel, discovered from host memory on first use (the memory and gdb backends, or kd/kdnet reading host memory). It raises NtoseyeError when VBS is not running or the backend cannot reach VTL1 memory. Its memory, symbols, types, and modules are bound to the secure kernel’s system address space, as proc.memory is to a process’s; trustlets lists the secure kernel’s processes, each with the same views bound to its own address space and process naming its NT side.
sk = dbg.secure_kernel
head = sk.symbols["securekernel!SkpsProcessList"]
print(sk.memory.read_u64(head), [m.name for m in sk.modules])
for trustlet in sk.trustlets: # LsaIso.exe, trustlet_id 1, ...
print(trustlet.pid, trustlet.name, trustlet.trustlet_id, hex(trustlet.dtb))
These views are read-only: writes, and operations that read NT’s own state about an address (describe, page_in, ptov), raise NtoseyeError. A memory view does not own CPU registers, so sk.eval("@rip") always raises, even at a VTL1 stop. Secure-kernel symbols resolve in these views and at live VTL1 stops, not in NT address spaces. The public securekernel.pdb has no types; name NT’s explicitly (sk.types["nt!_LIST_ENTRY"]). A trustlet’s own user-mode modules are not enumerated.
With backend="gdb" on AMD64 QEMU/KVM, use a hardware execution breakpoint to stop inside a loaded secure-kernel module without modifying its code:
dbg.interrupt()
sk = dbg.secure_kernel
address = sk.symbols["securekernel!SkeSelectProcessAddressSpace"]
bp = dbg.breakpoints.add(address, hardware=True)
try:
stop = dbg.run(timeout=10.0)
if isinstance(stop, ntoseye.Stop.Breakpoint) and bp in stop.breakpoints:
print(stop.symbol, stop.cpu.registers["rip"], stop.cpu.registers["cr3"])
print(dbg.command("k"))
finally:
dbg.interrupt()
bp.delete()
stop.cpu.registers describes the real halted CPU; at VTL1 stops it is read-only, and stop.thread/stop.process are None rather than the suspended NT identities. run() continues normally. Hardware execution sites support conditions, when=, pass counts, one-shot operation, and processor filters; they share the hardware slots, resolve once, and must be recreated after reboot. step(), step_over(), step_out(), run_to(), and trace_calls() work at VTL1 stops: their temporary sites in secure-kernel code are debug-register breakpoints in free slots, never code patches. NT process/thread filters, software breakpoints, and data watches in secure modules are refused. See the VTL1 limits and tested configuration.
A vCPU halted in the Windows hypervisor itself, as idle vCPUs under VBS usually are, reports where its VTLs left off in cpu.saved_vtl, read from the hypervisor’s saved state: ["VTL0 nt!HalProcessorIdle+0xf"], plus VTL1 when the hypervisor was entered from it. It needs the VM’s hv-evmcs enlightenment and is empty otherwise; see where NT left off under the hypervisor. To walk NT’s stack from there, select the saved context with .vtlcxr in the same command() line as what uses it, since the next call starts from the live registers again:
for cpu in dbg.cpus:
print(cpu.id, cpu.symbol, cpu.saved_vtl) # p01.01 hvix64+0x3a6bde ['VTL0 nt!HalProcessorIdle+0xf']
print(dbg.command(".vtlcxr; k 5"))
Run control and breakpoints¶
run(timeout=None) resumes and waits; wait(timeout=None) waits without resuming. Both return a Stop when one is observed, or None when the timeout expires; timeouts are in seconds, and None waits indefinitely. cont() resumes without waiting; interrupt() breaks in and returns a Stop. step(), step_over(), step_out(), and run_to() provide synchronous run control; step(until="call") (and "ret", "branch") steps to the next such instruction, and run_to(addr, step="over") single-steps to an address instead of running there. trace_calls() records the call tree up to the current function’s return (wt).
While the target is halted, the stop it is halted at stays current until it moves again: dbg.stop, wait(), and interrupt() all return it, and reading it consumes nothing.
Stop kinds are ntoseye.Stop.Breakpoint, .Exception, .Interrupt, .Step, .Bugcheck, and .Reboot. Inspect the specific stop with isinstance (available on Python 3.9+); shared fields include rip, symbol, thread, process, cpu, and breakpoints. stop.breakpoints is empty for other stop kinds, so if bp in stop.breakpoints: works without dispatching on the stop type. A crash dump opened with backend="dmp" is halted at its bugcheck, so its dbg.stop is a Stop.Bugcheck.
bp = dbg.breakpoints.add("nt!NtCreateFile")
stop = dbg.run(timeout=10.0)
if stop is None:
print("still running")
elif isinstance(stop, ntoseye.Stop.Breakpoint) and bp in stop.breakpoints:
print("hit", stop.symbol, stop.thread)
dbg.breakpoints is a live iterable/ID-keyed collection. A breakpoint handle owns its state: set bp.enabled, bp.condition, or bp.pass_count; remove it with bp.delete().
add() accepts debugger-expression conditions with condition= or a Python predicate with when=:
bp = dbg.breakpoints.add(
"nt!NtCreateFile",
when=lambda stop: stop.process is not None and stop.process.pid == 1234,
)
A when callback runs on the thread waiting in run(), wait(), run_to(), step(), step_over(), or step_out() and receives the Stop; a false result resumes past the hit (or, for a step or run_to(step=...), keeps going), and a callback that raises surfaces the hit with the error in stop.condition_error. It must not resume or step the target, or add/delete breakpoints; violating these restrictions raises an error. If the breakpoint hits when nobody is waiting, the target remains parked until a later wait. command() keeps the REPL’s semantics and does not call when predicates: a hit during a resuming command stops there. The separate condition= argument is a debugger expression, not a Python callback; a breakpoint has one or the other, so assigning bp.condition while a when callback is attached raises ValueError.
The session lives on its own thread. A Debugger can be used from any Python thread; calls are serialized there, and waiting calls (run(), wait(), a resuming command()) release the GIL, so other threads keep running. Ctrl+C (KeyboardInterrupt) ends a wait, step, or trace early and is raised; an interrupted run() or wait() leaves the target running, so call interrupt() to halt it. During a resuming command() Ctrl+C breaks in, as in the REPL, then raises with the target halted. Between calls that thread keeps servicing the guest: wrong-process and false-condition= hits are resumed right away instead of leaving the guest frozen until your next call.
Commands, errors, and build identity¶
dbg.command(line, timeout=None) runs a REPL command and returns text. REPL state (aliases, radix, and $vars) persists between calls. If a command resumes the target, timeout is its stop budget and the resulting stop is available as dbg.stop. Prefer typed SDK methods for structured results; for example, use dbg.inspect.triage() rather than parsing dbg.command("!analyze -v").
All SDK exceptions derive from ntoseye.NtoseyeError. MemoryAccessError reports an unreadable/partially readable guest range; TargetRunningError means an operation requires a halted target; SymbolNotFoundError also derives from LookupError; StaleHandleError identifies a handle from before a reboot. An argument outside its fixed choices (backend="windbg", until="calls") or an invalid combination (backend="kdnet" without key) raises ValueError before anything touches the target.
to_dict() on a process, thread, module, CPU, driver, or breakpoint returns the same fields the MCP server reports for it; on a value object (a Field, Symbol, MemoryRegion, …) it returns the object’s attributes.
ntoseye.build is the commit stamp compiled into the extension (<commit>, <commit>-dirty, or unknown). Compare it after rebuilding if a long-lived Python interpreter may still have an older native module loaded.
REPL custom commands¶
Scripts can also add commands to the REPL itself, using the same API through a borrowed Debugger; see custom REPL commands.