ntoseye

Windows kernel debugger for Linux hosts running Windows under KVM/QEMU. Essentially, WinDbg for Linux.
Features
- Command line interface
- WinDbg style commands
- Kernel debugging
- PDB fetching & parsing for offsets
- Breakpointing (kernel, usermode)
- Bugcheck analysis (decodes the bug check code, parameters, and faulting site on a guest crash)
- Three backends: Windows KD over a serial pipe (KDCOM, default), QEMU's
gdbstub, and passive memory introspection (see Choosing a backend)
Supported Windows
ntoseye currently only supports Windows 10 and 11 guests.
Disclaimer
ntoseye needs to download symbols and images to initialize required offsets, it will only download symbols from Microsoft's official symbol server. All files which will be read/written to will be located in $XDG_CONFIG_HOME/ntoseye, which includes the following subfolders:
commands/for custom scripted commandsimages/for binaries downloaded from the VMsymbols/for PDBs
Preview

Installation
Install via shell script
|
Install via cargo
Building
Usage
Quickstart
The default and recommended backend is kd (KDCOM), which runs Windows KD over a QEMU serial socket. For a libvirt/virt-manager guest, the fastest path is:
- Configure the VM transport with
ntoseye virsh: pick the domain, choose configure debug transports, thenkd. (Prefer editing the XML yourself? See VM configuration.) - In the guest, enable kernel debugging and reboot (Administrator PowerShell):
bcdedit /debug on bcdedit /dbgsettings serial debugport:1 baudrate:115200 Restart-Computer - On the host, relax ptrace scope so
ntoseyecan attach to QEMU (resets on reboot):| - Start the VM, then run
ntoseye.
For guests that aren't configured for KD, see Choosing a backend for the gdb and memory alternatives.
The debugger is self-documented: run ntoseye --help for command-line arguments, and press tab in the REPL for completions and descriptions of commands, symbols, and types.
Choosing a backend
ntoseye can talk to the guest three ways. Pick with --backend kd (default), --backend gdb, or --backend memory.
kd (default) |
gdb |
memory |
|
|---|---|---|---|
| Transport | Windows KD over a serial pipe (KDCOM) | QEMU's gdbstub |
None; /dev/kvm memory introspection only |
| Requires in-guest configuration | Yes (bcdedit /debug on; anti-debug code, PatchGuard, and some Windows behaviour change once enabled) |
No (guest is unaware it's being debugged) | No |
| Requires host VM configuration | Yes (serial socket) | Yes (-s -S) |
No |
| Execution control | Yes | Yes | No |
| Kernel breakpoints | Yes | Yes | No |
| Usermode breakpoints | Yes | No | No |
| Kernel breakpoint mechanism | DbgKdWriteBreakPointApi |
gdb Z0 packets |
No |
See VM configuration for the host-side setup of each backend.
VM configuration
Manual host-side setup for each backend. libvirt/virt-manager users can do most of this automatically with ntoseye virsh (see Quickstart); ntoseye virsh can also remove ntoseye-managed debug transports later.
GDBSTUB
Fallback backend for guests that are not configured for Windows KD. Expose QEMU's gdbstub on 127.0.0.1:1234 by passing -s -S, then run with --backend gdb.
[!NOTE] Do not enable kernel debug mode (
bcdedit /debug on) in the guest when using thegdbbackend. That setting is only for thekdbackend, and thegdbbackend's whole advantage is that the guest is unaware it's being debugged. With debug mode on, the kernel changes behaviour (anti-debug code, PatchGuard) and expects a KD debugger to service breaks, while nothing on thegdbside answers the KD transport, so the guest can hang onDbgBreakPoint/exceptions. Leave debug mode off.
QEMU
Append -s -S to the qemu command.
virt-manager
Add the following to the XML configuration:
...
KDCOM
Default backend. In the guest, enable kernel debugging (run as Administrator, then reboot):
bcdedit /debug on
bcdedit /dbgsettings serial debugport:1 baudrate:115200
Use debugport:2 instead of :1 if the KD chardev ends up as COM2 (see the virt-manager subsection below).
QEMU
Add a Unix-socket chardev and route a serial port to it:
-chardev socket,id=kd,path=/tmp/ntoseye-kd.sock,server=on,wait=off -serial chardev:kd
Then connect: ntoseye.
The initial KD handshake timeout is 8 seconds by default. For unusually slow guests, override it with NTOSEYE_KD_TIMEOUT=<seconds>.
virt-manager
[!WARNING] virt-manager auto-adds a
<serial>console device on every VM, which claims COM1. Either replace that device with one pointing at the KD socket (KD becomes COM1, usedebugport:1), or leave it and add the KD chardev viaqemu:commandline(KD becomes COM2, usedebugport:2).
Option A (recommended): replace the auto-added serial. KD is COM1, debugport:1 is correct.
Option B: keep the auto-added serial and append the KD chardev via qemu:commandline. If KD is COM2, use debugport:2.
...
Memory
Passive backend for guests where you only want /dev/kvm memory introspection. It requires no guest or VM debug transport configuration:
Execution control, registers, execution-context selection, breakpoints, debug output, bugcheck stops, and reload detection are unavailable in this mode. Run capabilities in the REPL for the exact backend feature matrix.
Recommended guest tweaks
Although not required, disabling memory paging and compression in the guest avoids memory-related issues. This only needs to be done once per Windows installation (Administrator PowerShell):
Get-CimInstance Win32_ComputerSystem | Set-CimInstance -Property @{ AutomaticManagedPagefile = $false }
Get-CimInstance Win32_PageFileSetting | Remove-CimInstance
Disable-MMAgent -MemoryCompression
Restart-Computer
Scripting
ntoseye auto-loads any *.lua file in $XDG_CONFIG_HOME/ntoseye/commands/ at REPL startup. Scripts can register new commands that appear in tab completion and dispatch alongside the builtins. Run reload in the REPL to pick up script edits without restarting.
Bundled scripts can be installed/updated with:
ntoseye scripts install <source> also accepts:
- a local
.luafile - a local directory of
.luafiles - single HTTPS URL ending in
.lua
Local and remote installs print a trust warning and prompt before copying; use --yes for non-interactive installs and --force to overwrite existing scripts. Remote installs are limited to one .lua file and print the downloaded content's SHA-256.
register_command
-- with per-argument tab-completion hints:
register_command
Completion strategies: "none", "symbol", "type", "process", "vcpu", "breakpoint", "driver". The strategies table is positional; arg 1 uses the first entry, arg 2 the second, etc. Missing positions fall back to "none". Pass {} for an empty table if you want completion turned off explicitly.
Scripts run with a constrained Lua standard library: base globals plus table, string, math, and utf8. Host filesystem/process/module access through Lua's io, os, and package libraries is not available; debugger interaction should go through ntos.
Host API is exposed under a global ntos table:
| function | returns |
|---|---|
ntos.ps([filter]) |
array of {pid, name, eprocess} |
ntos.process(target) / ntos.try_process(target) |
one {pid, name, eprocess}; strict form raises with a candidate list when ambiguous, try_ form collapses both no-match and ambiguous into nil (call process() if you need to surface the ambiguity to the user); numeric targets require exact PID |
ntos.command_usage() |
nil (prints the current command's registered help) |
ntos.eval(expr) / ntos.try_eval(expr) |
Address / Address or nil |
ntos.read_byte/word/dword/qword(addr) |
integer / integer / integer / Address |
ntos.try_read_byte/word/dword/qword(addr) |
value or nil |
ntos.read_bytes(addr, len) / ntos.try_read_bytes(addr, len) |
Lua string / Lua string or nil |
ntos.read_struct(type, addr) / ntos.try_read_struct(type, addr) |
table / table or nil (raises if type is unknown, that always indicates a script bug, not a runtime condition); only top-level pointer/primitive/bitfield/enum fields decode (nested struct/union/array fields come back as raw byte strings; read those explicitly with try_read_field_* using offset_of) |
ntos.try_read_unicode_string(addr) |
string or nil (addr points to a _UNICODE_STRING) |
ntos.write_byte/word/dword/qword(addr, v) |
nil |
ntos.write_bytes(addr, str) |
nil |
ntos.loaded_module_list() |
Address (value printed in the startup banner) |
ntos.driver_objects() / ntos.try_find_driver_object(name) |
array of driver tables / driver table or nil |
ntos.kernel_modules() / ntos.try_find_kernel_module(name) |
array of {name, short_name, base, size, end} / one module or nil (match is exact on short_name, substring on name, case-insensitive) |
ntos.search(addr, len, pattern) / ntos.search_first(addr, len, pattern) |
array of Addresses / Address or nil (pattern is a raw Lua byte string, e.g. "\x48\x83...") |
ntos.offset_of(type, field) / ntos.try_offset_of(type, field) |
integer / integer or nil |
ntos.type_size(type) / ntos.try_type_size(type) |
integer / integer or nil |
ntos.fields_of(type) / ntos.try_fields_of(type) |
array of field tables / array or nil |
ntos.try_read_field_byte/word/dword/qword(type, field, addr) |
value or nil (raises if type/field is unknown, that always indicates a script bug, not a runtime condition) |
ntos.containing_record(entry, type, field) |
Address |
ntos.can_read(addr, len) |
boolean |
ntos.is_kernel_address(addr) |
boolean (true if addr is in the canonical kernel half) |
ntos.try_closest_symbol(addr) / ntos.try_closest_symbol_any(addr) |
symbol table or nil |
ntos.format_symbol(addr) |
"module!name+0x<offset>" (no offset suffix when zero); falls back to hex if no symbol resolves |
ntos.read_register(name) |
Address (read-only; returned as Address so high-half values render correctly and compose with pointer math, use :to_int() for bit-tests on small values) |
ntos.addr(n) |
Address (constructor for literals) |
Addresses are an opaque userdata supporting + - & | ^ << >> < == and tostring (renders as hex).
Credits
Functionality regarding initialization of guest information was written with the help of the following sources: