#Dynamic analysis that terminates at the user-mode boundary is not dynamic analysis of the threat — it is dynamic analysis of what the threat chose to expose.
Nanga Resources
#The Monitoring Gap in Dynamic Analysis
Standard sandbox output for a typical PE sample contains a predictable set of observations: files created, registry keys modified, network connections established, process children spawned. This information is produced by running the sample in an instrumented environment and correlating what the instrumentation layer can see.
The problem is not that this output is wrong. It is that it is bounded by where the instrumentation sits. A sandbox built on user-mode hooks — or even kernel callbacks that only fire when the standard dispatch path is taken — will miss anything that bypasses that path. Direct syscall invocation is now the baseline for commodity injectors, not an advanced technique. SysWhispers-style stubs are included by default in most public injection frameworks. The result is that the most analytically interesting portion of a sample's behavior — the injection chain, the evasion path, the memory manipulation sequence — is frequently invisible to the tools used to analyze it.
The gap is structural, not a tooling deficiency that can be patched in user space. Closing it requires moving the interception point below the layer at which evasion occurs.
#Limitations of the Standard Toolkit
The existing tools are well-engineered for their design goals. Those goals, however, do not include visibility into what happens when a sample deliberately avoids the code paths those tools monitor.
Process Monitor operates as a minifilter registered with the filter manager at a configurable altitude. It surfaces file and registry activity accurately for most workloads, but has two fixed constraints: it sees only what the filter manager dispatches after the I/O stack completes, and it has no visibility into memory operations. NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx — the entire injection primitive set — produces no ProcMon output.
API Monitor instruments at the API boundary. Against samples that issue syscalls directly, bypassing ntdll.dll, it observes nothing. In 2025, direct syscall invocation is present in the majority of public injection PoCs and a significant fraction of commodity malware.
x64dbg is the appropriate tool for controlled, interactive analysis of specific code paths. It is not suitable for behavioral capture under normal execution conditions: breakpoints introduce timing perturbation, and any sample that checks elapsed time, queries debug state via NtQueryInformationProcess, or uses watchdog threads will behave differently — or abort — under a debugger.
ETW is the most powerful user-accessible telemetry source on Windows, and is substantially underutilized in most analysis workflows. Its structural limitation for this work is that it is a consumer of events that the kernel chooses to emit via registered providers. Syscalls that bypass the standard dispatch path may not generate provider events. A sample that patches EtwEventWrite in its own process removes itself from any user-mode ETW consumer's view.
The pattern is consistent: every instrumentation layer that operates in user mode can be evaded from user mode. The specific mechanism varies; the outcome does not.
#Direct Syscall Invocation — The Threat Model
The architectural reason kernel-level interception changes the analysis is worth making explicit.
A conventional Windows API call traverses the following path:
[Process] → VirtualAllocEx() → kernel32.dll → ntdll.dll → NtAllocateVirtualMemory stub → syscall → kernel
Each transition in this chain is a potential hook point, and each is accessible from user mode. EDRs hook at the kernel32 boundary. API monitors hook at the ntdll stub. Both can be bypassed by a process that resolves the system call number (SSN) independently and issues the syscall instruction from its own code section:
// SysWhispers-style direct syscall
// SSN resolved from ntdll.dll on disk or from another process's memory.
// The syscall instruction is issued from the malware's own .text section,
// never touching the monitored ntdll stubs.
EXTERN_C NTSTATUS NtAllocateVirtualMemory_Syscall(
HANDLE ProcessHandle,
PVOID* BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
);
// In asm:
// mov r10, rcx
// mov eax, <SSN> ; hardcoded or dynamically resolved syscall number
// syscall ; enters kernel directly, bypassing ntdllWhen this executes, the user-mode RIP captured in the trap frame is not within ntdll.dll. It is within the calling process's own memory. This is the observable artifact Nanga uses for detection.
At the SSDT hook, every syscall that enters the kernel is intercepted regardless of call origin. Nanga checks the trap frame RIP against the cached ntdll.dll address range:
// From driver.c — direct syscall detection
static BOOLEAN IsDirectSyscall(ULONG_PTR userRip)
{
// g_NtdllBase and g_NtdllSize resolved at first hook invocation
// via PEB walking: KeStackAttachProcess + PEB traversal
if (g_NtdllBase == 0) return FALSE;
return (userRip < g_NtdllBase || userRip >= g_NtdllBase + g_NtdllSize);
}Direct syscall origin is signaled by setting bit 31 in the logged SSN field:
#define SSN_FLAG_DIRECT_SYSCALL 0x80000000UL
// In the hook handler:
entry->SSN = ssn;
if (IsDirectSyscall(trapRip))
entry->SSN |= SSN_FLAG_DIRECT_SYSCALL;The resulting JSONL event:
{
"event": "NtAllocateVirtualMemory",
"ssn": 2147483672, // 0x80000018 — bit 31 set = direct syscall origin
"pid": 4812,
"image": "inject.exe",
"params": {
"ProcessHandle": "0xffffffffffffffff (self)",
"AllocationType": "MEM_COMMIT|MEM_RESERVE",
"Protect": "PAGE_EXECUTE_READWRITE"
}
}PAGE_EXECUTE_READWRITE allocation via direct syscall in a single event. The combination identifies shellcode staging without additional analysis.
#Architecture
Nanga operates on a single architectural principle: intercepting at the syscall dispatch table places the hook below every user-mode evasion technique. There is no mechanism available to a user-mode process to bypass an SSDT hook without itself being a kernel-mode component — and a kernel-mode attacker is a separate threat category with separate tooling requirements.
The driver implements three interception layers, all feeding a single shared-memory ring buffer consumed by a user-mode reader process.
Layer 1: SSDT Hooks (34 syscalls)
The System Service Descriptor Table maps syscall numbers to kernel function addresses. Nanga patches it directly using CR0 write-protect bypass to modify the read-only kernel memory:
// From driver.c — CR0 write-protect manipulation around SSDT patching
static void DisableWP(void) {
__writecr0(__readcr0() & ~0x10000ULL); // Clear WP bit
}
static void EnableWP(void) {
__writecr0(__readcr0() | 0x10000ULL); // Restore WP bit
}
// Redirect an SSDT entry to the hook function
static NTSTATUS InstallSSDTHook(ULONG ssn, PVOID hookFn, PVOID* original) {
LONG_PTR* ssdt = (LONG_PTR*)g_SsdtBase;
*original = (PVOID)((ULONG_PTR)g_SsdtBase + (ssdt[ssn] >> 4));
DisableWP();
ssdt[ssn] = ((LONG_PTR)hookFn - (LONG_PTR)g_SsdtBase) << 4;
EnableWP();
return STATUS_SUCCESS;
}All 34 hooks fire post-call, after the original syscall completes. Both input arguments and output results — resolved handles, allocated addresses, return status — are captured in each log entry.
Layer 2: File System Minifilter (9 IRP_MJ operations)
Registered at altitude 420000, the minifilter intercepts file system I/O at the I/O manager layer. Pre-operation callbacks acquire the ring buffer slot; post-operation callbacks backfill the I/O result:
// From minifilter.c
FLT_PREOP_CALLBACK_STATUS MfPreCreate(
PFLT_CALLBACK_DATA Data,
PCFLT_RELATED_OBJECTS FltObjects,
PVOID* CompletionContext)
{
PSYSCALL_LOG_ENTRY entry = AcquireRingSlot(&slotIndex);
entry->SSN = 0xF01; // NtCreateFile synthetic SSN
RtlCopyMemory(entry->DataPayload,
nameInfo->Name.Buffer,
min(nameInfo->Name.Length, MAX_PAYLOAD - 1));
*CompletionContext = (PVOID)(ULONG_PTR)slotIndex;
return FLT_PREOP_SUCCESS_WITH_CALLBACK;
}File paths are recorded from kernel-resolved names, not user-mode paths — symlink-based path spoofing has no effect on what appears in the log.
Layer 3: WFP Network Callouts (14 ALE callouts)
Windows Filtering Platform callouts at the ALE layer provide network visibility from within the kernel routing stack, covering IPv4 and IPv6 TCP/UDP connect and accept operations:
// WFP classify callback — outbound TCP connect (IPv4)
static void WfpConnectV4Classify(
const FWPS_INCOMING_VALUES0* inFixedValues,
const FWPS_INCOMING_METADATA_VALUES0* inMetaValues,
...)
{
UINT32 remoteIp = inFixedValues->incomingValue[
FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_ADDRESS].value.uint32;
UINT16 remotePort = inFixedValues->incomingValue[
FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_PORT].value.uint16;
UINT64 pid = inMetaValues->processId;
LogNetworkEvent(0x4E01, pid, remoteIp, remotePort);
}The resulting log entry:
{
"event": "ConnectV4",
"ssn": 19969,
"pid": 4812,
"image": "inject.exe",
"params": {
"RemoteAddress": "185.220.101.47",
"RemotePort": "443",
"Protocol": "TCP"
}
}The connection is logged at the kernel routing layer, before the TLS stack in user mode processes the socket.
#Transitive Process Tracking
Most monitoring tools require the analyst to specify a target process at capture start. When that process injects into another, monitoring coverage terminates at the injection boundary — the victim process's subsequent behavior is unobserved.
Nanga resolves this through automatic tracking expansion. Every time a tracked process opens a handle to another process, the target PID is added to the tracked set:
// From driver.c — AUTO_TRACK_TARGET macro
#define AUTO_TRACK_TARGET(targetPid) \
do { \
if ((targetPid) != 0 && (targetPid) != 4 \
&& IsTrackedPid(callerPid)) { \
AddTrackedPid(targetPid); \
} \
} while (0)In practice: reader.exe -s malware.exe starts capture on the initial process. When that process calls NtOpenProcess against explorer.exe, Nanga resolves the handle to its PID and adds explorer.exe to the tracked set. From that point, all syscalls from explorer.exe are also logged.
// Handle open — tracking expansion fires here
{
"event": "NtOpenProcess",
"pid": 4812,
"image": "malware.exe",
"target_pid": 9204,
"target_image": "explorer.exe",
"params": {
"DesiredAccess": "PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_CREATE_THREAD",
"ProcessHandle": "0x3c4 -> explorer.exe(pid=9204)"
}
}
// Subsequent events from the injection target appear automatically
{
"event": "NtCreateThreadEx",
"pid": 9204,
"image": "explorer.exe",
"params": {
"StartAddress": "0x7ff8a3c10000",
"CreateFlags": "0"
}
}The complete injection chain — injector and victim — is captured in a single JSONL stream with coherent timestamps and PID context, without requiring the analyst to anticipate the injection target in advance.
#Injection Chain Analysis: A Worked Example
The following walkthrough uses a generic shellcode injector. The sequence is representative of the majority of commodity injection implementations.
Setup:
reader.exe -s suspicious.exeNanga spawns the target suspended, registers its PID in the tracked set, and resumes execution. Capture begins at the first instruction.
Reconnaissance phase:
// Process enumeration
{ "event": "NtQuerySystemInformation",
"params": { "SystemInformationClass": "SystemProcessInformation" } }
// Target selection and handle acquisition
{ "event": "NtOpenProcess",
"target_image": "explorer.exe",
"params": {
"DesiredAccess": "PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_CREATE_THREAD|PROCESS_QUERY_INFORMATION"
}
}The access mask specifies the complete injection primitive set. The target is identified without running the sample under a debugger.
Memory allocation:
{
"event": "NtAllocateVirtualMemory",
"ssn": 2147483672, // bit 31 set — direct syscall invocation
"target_pid": 9204,
"target_image": "explorer.exe",
"params": {
"AllocationType": "MEM_COMMIT|MEM_RESERVE",
"Protect": "PAGE_EXECUTE_READWRITE",
"RegionSize": "0x1000"
},
"returns": {
"status_name": "STATUS_SUCCESS",
"BaseAddress": "0x1e3a0000000"
}
}PAGE_EXECUTE_READWRITE in a remote process via direct syscall. The SSN field identifies the bypass technique before the protection flags are even examined.
Payload write with automatic dump:
{
"event": "NtWriteVirtualMemory",
"target_pid": 9204,
"target_image": "explorer.exe",
"params": {
"BaseAddress": "0x1e3a0000000",
"NumberOfBytesToWrite": "0x1c8"
},
"returns": {
"status_name": "STATUS_SUCCESS",
"NumberOfBytesWritten": "0x1c8"
},
"dump_file": "9204_0x1e3a0000000.bin"
}The reader process calls ReadProcessMemory on the written region immediately after the syscall returns and writes the contents to disk. The .bin file is available for static analysis in any disassembler without manual intervention.
Thread creation:
{
"event": "NtCreateThreadEx",
"pid": 4812,
"image": "suspicious.exe",
"target_pid": 9204,
"target_image": "explorer.exe",
"params": {
"StartAddress": "0x1e3a0000000",
"CreateFlags": "0"
}
}Start address matches the allocation from the previous step. The injection chain is complete and unambiguous across five log entries.
#Vulnerability Research Applications
Nanga's utility extends beyond malware behavioral analysis to privilege escalation research and attack surface enumeration.
Exploit chain tracing:
When analyzing a privilege escalation PoC, the relevant observable is the precise syscall sequence that transitions the exploit from normal to elevated execution. Running the PoC under reader.exe -s exploit.exe produces a timestamped, ordered log of every kernel interaction:
// Pre-exploit token access pattern
{ "event": "NtOpenProcessToken",
"pid": 5120, "image": "exploit.exe",
"params": { "DesiredAccess": "TOKEN_QUERY" } }
// Escalation attempt — full token write access on self
{ "event": "NtOpenProcessTokenEx",
"pid": 5120, "image": "exploit.exe",
"params": {
"DesiredAccess": "TOKEN_ALL_ACCESS",
"ProcessHandle": "0xffffffffffffffff"
},
"returns": { "status_name": "STATUS_SUCCESS" }
}
// Privilege modification follows
{ "event": "NtSetInformationProcess",
"pid": 5120, "image": "exploit.exe",
"params": {
"ProcessInformationClass": "ProcessAccessToken"
}
}The log identifies the exploitation mechanism independent of CVE documentation or advisory analysis.
Attack surface enumeration:
Exercising specific functionality in a target application while Nanga is attached produces a complete map of kernel objects, file paths, registry keys, and network endpoints touched during that operation — covering handles, memory allocations, loaded modules, and file accesses in a single pass. Attack surface enumeration that requires days of static analysis can be completed in the time taken to exercise the feature.
Parser differential analysis:
Attaching Nanga to two different applications processing the same input file and diffing the JSONL output surfaces behavioral divergences: allocation sizes, syscall sequences, file seek patterns. Differences in execution path between parsers processing identical input are frequently correlated with the location of format handling vulnerabilities.
#Registry Monitoring
Registry monitoring is frequently treated as secondary in dynamic analysis workflows. For persistence analysis, it is a primary signal source.
Nanga's CmRegisterCallbackEx callback intercepts every RegNt* operation system-wide. Persistence mechanisms writing to Run keys, configuration data embedded in obscure registry paths, service installations — all appear in the stream with the kernel-resolved full key path in DataPayload:
{
"event": "RegNtSetValueKey",
"ssn": 2457,
"pid": 4812,
"image": "malware.exe",
"payload": "\\REGISTRY\\MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Image File Execution Options\\svchost.exe",
"params": {
"ValueName": "Debugger",
"Type": "REG_SZ"
}
}Image File Execution Options debugger hijacking via a single JSONL event. The technique is identified without behavioral correlation across multiple log sources.
#Querying the Output
The JSONL format is a deliberate design choice. One event per line, each line valid JSON, the complete analysis toolchain a standard Unix pipe:
Direct syscall events:
jq 'select(.ssn > 2147483648)' capture.jsonlRWX allocations:
jq 'select(.params.Protect? == "PAGE_EXECUTE_READWRITE")' capture.jsonlCross-process operations (injection chain):
jq 'select(.pid != .target_pid and .target_pid != null and .target_pid != 0)' capture.jsonlOutbound connections:
jq 'select(.event == "ConnectV4" or .event == "ConnectV6")' capture.jsonlFile operation timeline for a specific process:
jq 'select(.pid == 4812 and .category == "FILE_SYSTEM") | {t: .timestamp, op: .event, path: .payload}' capture.jsonlComplete injection sequence targeting a specific PID:
jq 'select(.target_pid == 9204)' capture.jsonl | jq -s 'sort_by(.timestamp)'No proprietary viewer. No custom query language. The output format is the interface.
#Implementation Techniques
The techniques Nanga uses are identical to those employed by kernel-mode rootkits. The difference is instrumentation intent, not mechanism:
- SSDT hooking — direct modification of the system call dispatch table, the same approach used by kernel rootkits from the early 2000s onward
- CR0 write-protect bypass — clearing the WP bit in CR0 to write to read-only kernel memory
- Out-of-range hook trampolines — allocating code caves in existing kernel modules when the hook target is beyond 32-bit relative jump range
- PEB walking via
KeStackAttachProcess— attaching to a target process's address space to traverse its PEB and loaded module list - NULL DACL shared memory section — creating a kernel-mode memory section accessible from user mode without access control checks
Understanding these mechanisms in a controlled instrumentation context is directly applicable to recognizing the same techniques when they appear in malicious drivers during incident response or reverse engineering.
#Known Limitations
WoW64 (32-bit processes on 64-bit Windows): Nanga hooks the 64-bit SSDT. 32-bit processes route through Wow64SystemServiceEx, a separate dispatch path that is not instrumented. 32-bit samples and shellcode are not captured.
Kernel-mode malware: Drivers that issue syscalls or manipulate the SSDT directly bypass the hook entirely. A kernel-mode attacker can also unhook Nanga's modifications. Hypervisor-based monitoring is the appropriate tool for that threat model.
PatchGuard: Kernel Patch Protection will BSOD the system on detection of SSDT modification on a production Windows installation. Nanga requires test-signing mode and is explicitly scoped to isolated analysis VMs. This is a design constraint, not a deficiency — analysis environments should be isolated regardless.
Ring buffer capacity: The ring buffer holds 4096 entries. Under sustained high-syscall-rate workloads, the writer can outpace the reader and drop events silently. For targeted malware analysis this is not a practical constraint; for system-wide profiling it is.
#Getting Started
# 1. Enable test signing (requires reboot)
bcdedit /set testsigning on
# Reboot the VM
# 2. Build from x64 Native Tools Command Prompt
.\Nanga_Manager.ps1 Build
.\Nanga_Manager.ps1 Sign
# 3. Install and start the drivers
.\Nanga_Manager.ps1 Install
.\Nanga_Manager.ps1 Start
# 4. Run the target (elevated prompt)
reader.exe -s malware.exe > capture.jsonl
# 5. Query the output
jq 'select(.ssn > 2147483648)' capture.jsonl # direct syscalls
jq 'select(.params.Protect? == "PAGE_EXECUTE_READWRITE")' capture.jsonl # RWX
jq 'select(.event == "ConnectV4")' capture.jsonl # outbound connections#Closing Notes
The instrumentation arms race between security tooling and malware has been running at the user-mode layer for over a decade. Each new API hook has a corresponding unhook. Each new ETW provider has a corresponding patch. Each sandbox fingerprint has a corresponding detection. The attack surface of user-mode instrumentation is the instrumentation mechanism itself.
An SSDT hook does not participate in this dynamic. The syscall instruction must enter the kernel. The kernel must dispatch it through the table. The hook fires. The sample cannot prevent this without itself being a kernel-mode component — at which point the threat category changes and different tooling is appropriate.
Nanga is scoped to exactly the problem it solves: moving the analysis interception point below the layer at which current malware operates. For samples that use direct syscalls, reflective loading, or ETW patching, this is the analysis layer that produces complete output.
Source code is available on GitHub. Run it in an isolated VM. The disclaimer is there for a reason — kernel hooks on a primary machine are not recoverable with a simple reboot.