Skip to content

ntkrnlmp/svmtrace

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SvmTrace

Windows CI C++20 Platform Capture Trace Driver

SvmTrace is an AMD64 instruction tracer built around AMD SVM. An NPT execute fault starts capture; the VM-exit handler sets Trap Flag, intercepts exit 0x41, verifies DR6.BS, records the resulting RIP, RFLAGS, CR3, and 16 GPRs, rearms Trap Flag, and resumes the guest. The trace encoder stores these post-instruction states as compact, replayable anchors and deltas.

Contents

Why SvmTrace is different

Most trace visualizations start with edges: a branch was taken, a call reached a destination, or execution returned. SvmTrace starts with CPU state at consecutive instruction boundaries.

flowchart LR
    subgraph Branch[Branch-oriented view]
        B1[Source address] --> B2[Branch event]
        B2 --> B3[Destination address]
    end

    subgraph SVM[SvmTrace view]
        S1[Execute gate NPF] --> S2[Set guest TF]
        S2 --> S3[Execute one instruction]
        S3 --> S4[Intercept #DB VM exit]
        S4 --> S5[Capture RIP, RFLAGS, CR3, GPRs]
        S5 --> S6[Set TF and resume]
        S6 --> S3
    end
Loading
Question Branch-only event stream Current SvmTrace record model
Where did control go? Source and destination around control-flow events RIP at every captured instruction boundary
What did an instruction change? Requires reconstruction from other data Consecutive records expose GPR and selected RFLAGS changes
Which address space was active? Depends on the producer CR3 is part of the reconstructed state
Can straight-line code be followed? No event is required between branches Each intercepted single-step debug exit can produce a record
How is state stored? Producer-specific packets Full anchors plus compact deltas in per-CPU v5 streams

The live capture path stamps each accepted raw slot with:

  • the instruction-boundary RIP;
  • RFLAGS and CR3;
  • RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, and R8 through R15;
  • a global sequence value and TSC for the raw state;
  • a session-start marker when the first post-gate debug exit arrives.

The v5 wire format and TraceReader also model 16 YMM registers and an xstate_bv field. The current VM-exit hot path deliberately zeroes those fields, so live captures from this source revision provide GPR-rich state but not live YMM values. This distinction is visible directly in driver/svmtrace_driver.c.

What one instruction step means

The debug exception occurs after the processor executes an instruction. The captured RIP therefore points to the next instruction, while the registers contain the state produced by the instruction that just completed.

sequenceDiagram
    participant Guest as Target thread
    participant CPU as AMD CPU
    participant VMM as SvmTrace VM-exit handler
    participant Ring as Per-CPU raw ring

    VMM->>CPU: Set guest RFLAGS.TF
    VMM->>Guest: Resume
    Guest->>CPU: Execute one instruction
    CPU->>VMM: #DB VM exit, exit code 0x41
    VMM->>VMM: Require DR6.BS and WeSetTf
    VMM->>Ring: Store resulting RIP, RFLAGS, CR3, and GPRs
    VMM->>CPU: Set guest RFLAGS.TF again
Loading

This is continuous single-step observation, not an interactive debugger command. The public client can arm, query, stop, and shut down capture, but it does not expose step, pause, or register-edit commands.

The handler also checks that a debug exit belongs to SvmTrace. It requires both the single-step status bit in DR6 and a per-CPU marker showing that SvmTrace set Trap Flag. Kernel-mode RIPs clear Trap Flag, while a later tracked execute fault can re-engage stepping when execution returns to tracked user-mode code.

Note

A record is produced only when trace writing is enabled and the raw ring accepts the slot. If the ring reaches its backpressure threshold, the handler keeps stepping but drops the slot. A later full anchor can expose the sequence gap.

Quick start

The default build contains the C++20 library, CLI, and synthetic parser tests. It does not build or load the driver.

git clone https://github.com/ntkrnlmp/svmtrace.git
cd svmtrace
cmake -S . -B build -A x64
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failure

Inspect one per-CPU v5 stream:

build\Release\svmtrace.exe inspect C:\svmtrace_trace_cpu0.bin

Dump decoded records as CSV:

build\Release\svmtrace.exe dump C:\svmtrace_trace_cpu0.bin
build\Release\svmtrace.exe dump C:\svmtrace_trace_cpu0.bin 100

The optional final argument stops output after that many decoded rows. The CLI prints this schema:

cpu,sequence,kind,rip,rflags,cr3,reason

The decoder visits records as it reads frames, so callers do not need to load the whole trace into memory.

Concrete synthetic walkthrough

Note

Everything in this section is synthetic. The addresses and register values illustrate the record semantics. They are not a hardware capture, benchmark, or claim about a specific machine.

Assume the target entry page is the execute gate, RFLAGS begins at 0x202, and the target begins with these instructions:

0x0000000140001000  mov eax, 7
0x0000000140001005  add eax, 1
0x0000000140001008  test eax, eax

The gate page first causes a nested page fault. SvmTrace clears the gate's NX bit, starts a trace session, and sets Trap Flag. The next two intercepted debug exits can then be interpreted like this:

Instruction that completed RIP reported by #DB Resulting RAX Resulting RFLAGS Record kind
mov eax, 7 at 0x140001000 0x140001005 0x7 0x202 session_start
add eax, 1 at 0x140001005 0x140001008 0x8 0x202 instruction

Illustrative CLI output for that synthetic sequence:

cpu,sequence,kind,rip,rflags,cr3,reason
3,10,session_start,0x140001005,0x202,0x12345000,0
3,11,instruction,0x140001008,0x202,0x12345000,0

The key point is visible in the first row: RIP=0x140001005 describes the boundary after the mov, and RAX=7 is available through the C++ record even though the CLI's compact CSV view does not print every GPR.

How the same state becomes an anchor and delta
flowchart LR
    A[Session start state<br/>RIP 0x140001005<br/>RAX 7] -->|full 704-byte record| B[Anchor baseline]
    B --> C[Next state<br/>RIP +3<br/>RAX +1]
    C -->|RIP varint + GPR mask + RAX delta| D[Compact delta]
    D --> E[TraceReader reconstructs<br/>RIP 0x140001008<br/>RAX 8]
Loading

The encoder compares each raw state with the preceding state. It writes changed GPRs behind a 16-bit mask, represents nearby RIPs and GPR changes as signed variable-length integers, and periodically emits a full anchor again.

C++ API

TraceReader calls a visitor once for every reconstructed record. Return false to stop early.

#include <svmtrace/trace.hpp>

#include <cinttypes>
#include <cstdio>
#include <optional>

int main() {
    const svmtrace::TraceReader trace("svmtrace_trace_cpu0.bin");
    std::optional<svmtrace::CpuState> previous;

    const auto summary = trace.read([&](const svmtrace::Record& record) {
        if (previous && previous->gpr[0] != record.state.gpr[0]) {
            std::printf("seq=%" PRIu64 " rip=0x%" PRIx64
                        " rax: 0x%" PRIx64 " -> 0x%" PRIx64 "\n",
                        record.sequence,
                        record.state.rip,
                        previous->gpr[0],
                        record.state.gpr[0]);
        }

        previous = record.state;
        return true;
    });

    std::printf("decoded %" PRIu64 " instruction records\n",
                summary.instructions);
}

The public model is typed and independent of the kernel driver:

svmtrace::Record
  .kind             // instruction, session_start, or session_end
  .cpu              // logical processor that owns this stream
  .sequence         // reconstructed record order
  .tsc              // present on anchors and session-end records
  .reason           // gate id, exit reason, or session-end reason
  .state.rip
  .state.rflags
  .state.cr3
  .state.gpr[16]
  .state.ymm[16][4] // represented by v5; current capture path writes zeros
  .state.xstate_bv  // represented by v5; current capture path writes zero

Driver control is a separate API:

#include <svmtrace/client.hpp>

svmtrace::DriverClient driver;
driver.connect();
driver.set_target(pid);
driver.arm();

const auto status = driver.status();
driver.stop();

Analysis applications can therefore use TraceReader without connecting to the \\.\svmtrace device.

Architecture

flowchart LR
    A[Target entry page] -->|execute NPF| B[SVM VM-exit handler]
    B -->|clear NX and set TF| C[Target instruction]
    C -->|intercepted #DB| B
    B -->|full raw state| D[Per-CPU ring]
    D --> E[Per-CPU worker]
    E --> F[Anchor and delta encoder]
    F --> G[LZ4 or raw frames]
    G --> H[(Per-CPU v5 file)]
    H --> I[TraceReader]
    I --> J[C++ visitor]
    I --> K[inspect]
    I --> L[dump]
Loading
Layer Source Responsibility
SVM capture driver/svmtrace_driver.c, driver/vmexit.asm Configure VMCBs and NPT, handle execute faults, intercept debug exits, capture guest state
Per-CPU buffering driver/svmtrace_driver.c Keep encoding, compression, and file I/O out of the VM-exit hot path
v5 encoding driver/svmtrace_driver.c Convert full raw slots into anchors and deltas, then write framed per-CPU files
Decoder src/trace.cpp, src/lz4_block.cpp Validate frames, decode LZ4 blocks, and reconstruct typed records
CLI and client cli/main.cpp, src/client.cpp Inspect streams and issue explicit driver IOCTLs

The capture and analysis halves share a file contract, not a runtime dependency. That keeps offline decoding usable when the experimental driver is not loaded.

Capture lifecycle

The standalone gate definition contains one target gate. During arm, the driver reads the selected process's PE AddressOfEntryPoint, resolves that page to a guest physical address, and marks the corresponding nested page-table entry NX.

sequenceDiagram
    participant CLI as svmtrace CLI
    participant Driver as SVM driver
    participant NPT as Nested page table
    participant CPU as Target CPU
    participant Worker as Per-CPU worker

    CLI->>Driver: set-target PID
    Driver->>Driver: resolve image and PE entry point
    CLI->>Driver: arm
    Driver->>NPT: mark entry page NX
    Driver->>CPU: request translation flush
    CPU->>Driver: execute NPF at entry page
    Driver->>NPT: clear NX on gate page
    Driver->>CPU: set guest RFLAGS.TF
    loop while the target session remains active
        CPU->>Driver: intercepted single-step #DB
        Driver->>Driver: verify DR6.BS and WeSetTf
        Driver->>Worker: publish full state to per-CPU ring
        Driver->>CPU: set RFLAGS.TF and resume
    end
    Worker->>Worker: encode anchors and deltas
    Worker->>Worker: write raw or LZ4 frames
Loading
Capture state transitions
stateDiagram-v2
    [*] --> Idle
    Idle --> TargetSelected: set-target PID
    TargetSelected --> Armed: arm
    Armed --> Recording: entry-page execute NPF
    Recording --> Recording: accepted #DB step
    Recording --> Armed: target CR3 changes
    Recording --> TargetSelected: stop
    Armed --> TargetSelected: stop
    TargetSelected --> Idle: select another target
    Idle --> [*]: shutdown SVM
Loading

The VM-exit assembly switches to a dedicated host stack and uses VMSAVE and VMLOAD around the C handler. The C hot path copies state into a fixed raw slot. A worker later performs delta encoding, LZ4 compression, and file I/O.

Trace format

Each logical processor writes C:\svmtrace_trace_cpu{N}.bin. TraceReader recognizes version 5, a framed little-endian stream.

+-------------------------------+
| 16-byte file header           |
+-------------------------------+
| decoded_size : u32            |  frame header
| stored_size  : u32            |
+-------------------------------+
| raw or LZ4 block payload      |
+-------------------------------+
| next frame ...                |
+-------------------------------+

File header

Offset Size Field
0x00 4 Magic bytes FTRC
0x04 4 Format version 5
0x08 4 Logical CPU identifier
0x0c 4 Flags, with bit 0 marking a framed body

A decoded frame is at most 64 KiB. When decoded_size == stored_size, the payload is raw. Otherwise the payload is one LZ4 block that must decode to exactly the declared size. The worker starts a new frame before a record could cross a frame boundary.

Record stream

flowchart LR
    A[Session start<br/>full state] --> D1[Delta<br/>changed fields]
    D1 --> D2[Delta<br/>changed fields]
    D2 --> AN[Anchor<br/>full state]
    AN --> D3[Delta<br/>changed fields]
    D3 --> Z[Session end<br/>RIP, TSC, reason]
Loading
Record Wire type Decoder result
Anchor 01 in the low header bits One instruction record with a full baseline
Session start 10 in the low header bits One session_start record with full baseline and gate id
Delta 00 in the low header bits One reconstructed instruction record
Session end 11 in the low header bits One session_end record with final RIP and reason

Full anchors carry sequence, TSC, exit reason, RIP, RFLAGS, CR3, 16 GPRs, the v5 YMM area, and xstate_bv. The encoder emits a full anchor at session start, for the first state after a reset, and when 64 records have elapsed since the previous anchor.

Delta header bits announce whether the record contains changed GPRs, RFLAGS, YMM registers, or a full RIP. Otherwise RIP and GPR changes use signed variable-length integers. TraceReader applies each delta to the previous state and increments the sequence value.

Defensive decoding

The decoder checks data before exposing a record to a visitor:

  • file magic, version, and framed-body flag;
  • complete frame headers and payloads;
  • decoded and stored frame sizes;
  • exact LZ4 output size, offsets, and match lengths;
  • complete fixed records and variable-length integers;
  • availability of every field selected by a delta mask;
  • the requirement that a delta follows an initialized anchor.

SvmTrace and branch-only tracing

SvmTrace is not a branch-packet decoder. Its source implements a deliberately different observation path.

Property SvmTrace implementation Branch-only model
Trigger after session start Trap Flag produces a debug exception after an instruction A control-flow event produces a packet or event
Straight-line instructions Visible through consecutive debug exits No branch event occurs between them
State in this repository RIP, RFLAGS, CR3, and 16 GPRs in accepted live slots State must come from another source or be inferred
Storage Full anchors plus changed-field deltas Control-flow-oriented encoding
Runtime effect Every intercepted step enters the VM-exit handler Depends on the branch tracing mechanism
Intended use Narrow research sessions where state transitions matter Broader control-flow reconstruction
flowchart TB
    Q{What must the trace answer?}
    Q -->|Which edge executed?| B[Branch-oriented trace]
    Q -->|What state followed each instruction?| S[SvmTrace]
    S --> R[Compare consecutive GPR and flag values]
    S --> C[Follow straight-line execution]
    S --> A[Associate state with CR3 and sequence]
Loading

No performance comparison is presented here. The repository contains no checked-in hardware captures or benchmark harness that could support one.

Build and test

User-mode components

Requirements:

  • Windows 10 or Windows 11
  • Visual Studio 2022 with Desktop C++ tools
  • CMake 3.22 or newer
cmake -S . -B build -A x64
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failure

The default configuration builds:

Target Output Purpose
svmtrace Static or import library selected by CMake Reader, LZ4 decoder, and driver client
svmtrace_cli svmtrace.exe inspect, dump, and driver commands
svmtrace_tests Test executable Synthetic raw and LZ4 v5 parser checks

Experimental driver

The optional driver build requires a matching Windows Driver Kit:

cmake -S . -B build-driver -A x64 `
  -DSVMTRACE_BUILD_DRIVER=ON `
  -DSVMTRACE_WDK_VERSION=10.0.26100.0
cmake --build build-driver --config Release

The repository does not ship a driver binary or loader. Use the normal Windows signed or test-signed driver path on an isolated machine.

Driver workflow

From an elevated shell after the driver is installed and started:

svmtrace driver set-target 1234
svmtrace driver arm
svmtrace driver status
svmtrace driver stop
svmtrace driver shutdown

status reports:

bootstrap: complete|pending
watchdog: clear|triggered
armed: yes|no
active CPUs: <count>
target PID: <pid>
target image: <base>..<end>
NX pages: <count>
NPF exits: <count>
debug exits: <count>

The status fields map directly to DriverStatus and the driver's SVMTRACE_STATUS_RESPONSE.

Validation

The checked-in tests use synthetic files, which makes their evidence precise and repeatable without implying that CI exercised AMD SVM hardware.

Check Evidence in this repository
Library, CLI, and tests compile on Windows .github/workflows/ci.yml configures and builds the default CMake targets
Raw framed v5 decoding parser_round_trip(false) constructs and reads a synthetic raw frame
LZ4 framed v5 decoding parser_round_trip(true) wraps the same fixture in a literal-only LZ4 block
Anchor reconstruction Tests assert CPU id, initial RIP, and initial RAX
Delta reconstruction Tests assert sequence, RIP delta, RAX delta, and RFLAGS change
Session-end decoding Tests assert record kind and end reason
Invalid magic rejection bad_magic_is_rejected() verifies a TraceError
Live SVM capture Not exercised by the current CI workflow

Run the same validation locally:

ctest --test-dir build -C Release --output-on-failure

The diagrams and walkthrough in this README are explanatory renderings of the source and synthetic record semantics. No fabricated hardware screenshot or capture result is presented as validation.

Project status

SvmTrace is a research prototype, not a production tracing facility. The default build exercises the offline reader and CLI. Driver compilation is opt-in, and live capture requires independent testing on suitable AMD hardware.

Each CPU writes an independent stream. TraceReader preserves that per-CPU order but does not merge streams or normalize TSC values. The current live hot path zeros the v5 YMM and xstate_bv fields. Ring backpressure can create sequence gaps, and single-step VM exits materially perturb target execution.

Those properties matter when interpreting a trace. A successful build and synthetic parser test establish that the checked-in user-mode components agree on the v5 file contract. They do not establish live capture correctness on a particular processor, firmware revision, or Windows build.

Provenance

The AMD SVM driver began as an uncommitted prototype in a private research workspace and was extracted on 21 July 2026. The source directory had no commit history, so a stronger provenance claim is not possible. Target-specific binaries, traces, microcode, loader code, and surrounding research data were not copied.

driver/sflz4.c and driver/sflz4.h are adapted from SFLZ4 code by Nigel Tao. The prototype identifies that code as Apache License 2.0, but the exact upstream revision was not recorded. The adaptation accepts a caller-owned hash table and uses Windows kernel memory routines. The original format article is available at https://nigeltao.github.io/blog/2022/lz4-format.html. The C++ decoder in src/lz4_block.cpp is a separate implementation of the documented LZ4 block format.

No project-wide license has been added. Choose one only after confirming authorship and rights for the original driver prototype.

About

Experimental AMD SVM control-flow tracer with a C++ API, CLI, and offline decoder

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors