daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

commit 578fa3b5dc8ce50fd6216a3cc255e55e6b2ac9c0
parent a068c1c05c2fe92533965fd1eda87986d94ff613
Author: DAEMON <zer0sec.xp@icloud.com>
Date:   Sat,  3 Oct 2026 18:51:29 +0100

Add nine Windows-internals sheets to DFIR, from ired.team

Batch 5 of the ired.team integration, the detection-side half: ETW as a
telemetry source, walking the PEB, injected-thread hunting, handle
enumeration, kernel notification callbacks, Frida API tracing, the
kernel debugging lab, SSDT and IDT, and x64 stack frames. All filed
category dfir, subcategory 'Windows Internals'.

This is the counterpart the vault was missing. The site carried 280
sheets of offensive technique against nine DFIR sheets, so the detection
advice on an injection sheet had nothing underneath it. Kernel
notification callbacks are how an EDR sensor gets its telemetry;
Get-InjectedThread is the defensive answer to the injection sheets under
exploitation, and is credited to Jared Atkinson.

Same licence handling as the exploitation sheets: ired.team publishes no
licence, so no prose is copied, Windows structures and API names are
reproduced as the documented facts they are, and all 43 screenshots are
hotlinked at the pinned SHA with per-image credit. Every figure verified
200 image/*.

Merged where guides share one analytical payoff — the PE header parser
into the PEB sheet, since the image/memory comparison needs both halves;
VirtualBox memory acquisition into the kernel debugging lab. Each merged
guide carries its own references entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Diffstat:
Asrc/content/sheets/dfir/etw-telemetry.md | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content/sheets/dfir/frida-api-tracing.md | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content/sheets/dfir/handle-enumeration.md | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content/sheets/dfir/injected-thread-hunting.md | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content/sheets/dfir/kernel-debugging-lab.md | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content/sheets/dfir/kernel-notification-callbacks.md | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content/sheets/dfir/peb-walking.md | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content/sheets/dfir/ssdt-and-idt.md | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/content/sheets/dfir/x64-stack-frames.md | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 2269 insertions(+), 0 deletions(-)

diff --git a/src/content/sheets/dfir/etw-telemetry.md b/src/content/sheets/dfir/etw-telemetry.md @@ -0,0 +1,258 @@ +--- +title: "ETW as a Telemetry Source" +description: "Providers, keywords, sessions and consumers: how to stand up an Event Tracing for Windows session from the command line and read what the kernel already knows about processes and image loads." +category: dfir +subcategory: "Windows Internals" +tags: [etw, telemetry, windows, detection-engineering, logging] +tools: [logman, etwexplorer, event-viewer, traceevent] +difficulty: intermediate +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/etw-event-tracing-for-windows-101" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "About Event Tracing — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows/win32/etw/about-event-tracing" + relation: inspired + note: "Normative definitions of provider, controller, consumer and session." + - name: "EtwExplorer" + url: "https://github.com/zodiacon/EtwExplorer" + author: "Pavel Yosifovich" + relation: inspired + note: "Reads a provider's registered manifest, which is the only practical way to see what fields an event actually carries." + - name: "TraceEvent" + url: "https://github.com/microsoft/perfview" + author: "Microsoft" + license: MIT + relation: inspired + note: "The managed library behind the C# consumer pattern in the Walkthrough." + - name: "Tampering with Windows Event Tracing" + url: "https://medium.com/palantir/tampering-with-windows-event-tracing-background-offense-and-defense-4be7ac62ac63" + author: "Palantir" + relation: inspired + note: "The tamper-detection framing in What it tells you." +--- + +## What this is + +Most of what a Windows endpoint agent knows, it learns from Event Tracing for Windows. The +kernel and thousands of user-mode components emit structured events continuously; ETW is the +plumbing that lets a process subscribe to a subset of them without a driver, without a hook, +and without admin rights in many cases. Sysmon's process and image-load data is ETW data. +Understanding the plumbing matters for two reasons: you can build a collector against the +same events an EDR uses, and you can reason about what happens when an attacker interferes +with the session rather than with the events. + +Five terms carry the whole model: + +| Term | What it is | +|---|---| +| Provider | A component that emits events. Registered system-wide, identified by name and GUID. | +| Keyword | A bitmask flag on a provider selecting a class of events it can emit. | +| Session | A live trace. Buffers whatever its enabled providers emit, to memory or to an `.etl` file. | +| Controller | Something that creates sessions and enables or disables providers in them. `logman.exe` is one. | +| Consumer | Something that subscribes to a session and processes events as they arrive. | + +A session is independent of any consumer. It can run with no provider enabled, recording +nothing, and it can record to disk with nobody listening. + +## Prerequisites + +- `logman.exe` ships in-box, no install. +- Creating a kernel-provider session needs an elevated prompt. Querying providers does not. +- A consumer written against `Microsoft.Diagnostics.Tracing.TraceEvent` needs that NuGet + package and .NET. +- [EtwExplorer](https://github.com/zodiacon/EtwExplorer) for reading provider manifests. The + keyword list `logman` prints tells you event *classes*; the manifest tells you the field + names, which is what you need before you can write a detection against an event. + +## Walkthrough + +### Enumerate what the machine can tell you + +```powershell +logman query providers +``` + +Several thousand providers on a modern build. The interesting ones for security work are the +kernel providers, because they see things a user-mode component cannot lie about. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28532%29.png" + alt="Console output of logman query providers listing registered ETW provider names beside their GUIDs" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Every provider registered on the host, with its GUID. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Narrow to one provider, by name or by GUID, to get its keyword table: + +```powershell +logman query providers Microsoft-Windows-Kernel-Process +logman query providers "{22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}" +``` + +`Microsoft-Windows-Kernel-Process` is the one to start with. Its keywords cover process +start and exit, thread start and exit, and image load and unload — the three event families +that almost every behavioural detection is built on. + +### Read the manifest, not just the keyword names + +A keyword tells you an event class exists. It does not tell you whether the event carries a +command line, a parent PID, or an image hash. EtwExplorer parses the provider's registered +manifest and shows the per-event field layout. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28534%29.png" + alt="EtwExplorer window showing the Microsoft-Windows-Kernel-Process manifest with its event definitions and per-event field names" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The <code>Microsoft-Windows-Kernel-Process</code> manifest in EtwExplorer — event definitions and the fields each one carries. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Create a session and enable a provider in it + +A session first, with no providers: + +```powershell +logman create trace daemon-trace -ets +logman query daemon-trace -ets +``` + +The query output names the `.etl` the session writes to. At this point the session is +running and recording nothing, which is worth seeing once — it is the state an EDR session +is left in when someone removes its providers rather than stopping it. + +Keywords are a bitmask, so you OR the classes you want and pass the result. For +`Microsoft-Windows-Kernel-Process`, `WINEVENT_KEYWORD_PROCESS` is `0x10` and +`WINEVENT_KEYWORD_IMAGE` is `0x40`, giving `0x50`: + +```powershell +logman update daemon-trace -p Microsoft-Windows-Kernel-Process 0x50 -ets +logman query daemon-trace -ets +``` + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28538%29.png" + alt="logman query output for a trace session showing the Microsoft-Windows-Kernel-Process provider enabled with keyword mask 0x50" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The session now carries one provider at keywords <code>0x50</code>. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Read the .etl + +Event Viewer opens a saved `.etl` directly. From this provider you get event ID 1 for +process start, 2 for process exit, 5 for image load and 6 for image unload. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28539%29.png" + alt="Event Viewer displaying an ETW process creation event, ID 1, read out of a saved .etl trace file" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Process creation, event ID 1, read back out of the <code>.etl</code>. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Tear the session down + +Removing a provider leaves the session alive but blind. Stopping the session removes it +entirely. Both matter, because the two look different in an audit. + +```powershell +logman update trace daemon-trace --p Microsoft-Windows-Kernel-Process 0x50 -ets +logman stop daemon-trace -ets +``` + +Note the double dash on `--p`: that is the remove form, and a single dash would re-add. + +### Which providers a given process writes to + +```powershell +logman query providers -pid $pid +``` + +Useful in reverse: given a suspicious process, this says what telemetry it is itself +registered to emit. + +### Consuming live rather than from a file + +For anything continuous you want a consumer, not a file. The managed route is the +`TraceEvent` library: open a `TraceEventSession`, enable the kernel provider with the +keywords you want, attach handlers to the parsed event stream, then pump the source. + +```csharp +using var session = new TraceEventSession("daemon-live"); +session.EnableKernelProvider( + KernelTraceEventParser.Keywords.Process | + KernelTraceEventParser.Keywords.ImageLoad); + +session.Source.Kernel.ProcessStart += e => + Console.WriteLine($"{e.TimeStamp:O} pid={e.ProcessID} ppid={e.ParentID} {e.CommandLine}"); +session.Source.Kernel.ImageLoad += e => + Console.WriteLine($"{e.TimeStamp:O} pid={e.ProcessID} load {e.FileName}"); + +session.Source.Process(); +``` + +`ProcessStart` carries the command line and the parent PID in the same event, which is why +a consumer like this is better ground truth for parent-child analysis than reconstructing it +from `pslist` after the fact — the parent may already be gone. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/kernel-consumer.gif" + alt="Console application printing a live stream of colour-coded process start, process exit and image load events as they occur" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>A live consumer printing process and image-load events as they happen. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +## What it tells you + +**You can collect the primitives yourself.** Process start with a command line and a parent, +thread start, and image load are enough to build most of the behavioural detections on this +site without buying anything. The injection sheets' detection advice — +[process hollowing](/sheets/exploitation/process-hollowing), +[classic DLL injection](/sheets/exploitation/dll-injection), +[thread execution hijacking](/sheets/exploitation/thread-execution-hijacking) — leans on image +loads into processes that have no business loading that DLL, and on threads starting in a +process that nobody opened a handle to for a legitimate reason. Both are ETW events. + +**Image load is the cheapest injection tripwire you have.** Keyword `0x40` on +`Microsoft-Windows-Kernel-Process` gives you every module every process maps, with a path. +A `LoadLibrary`-based injection shows up here as the target process loading a DLL from a +writable directory. Manual mapping does not show up here at all, which is itself the point: +absence of a load event for code that is plainly running is the anomaly. + +**The session is the attack surface, not the event.** Nothing an attacker does to an +individual event is cheaper than interfering with the session carrying it. Two distinct +actions, two distinct artefacts: removing a provider from a session leaves the session +running and empty, and stopping the session removes it from `logman query -ets` entirely. So +monitor both — the set of sessions on the host, and the provider list inside each session +you care about. A session that exists but has lost its providers is the quieter of the two +and the one more likely to be missed. + +**Baseline your own sessions.** Record, per host role, which sessions exist and which +providers each one enables. The diff is the detection. Without that baseline, a missing +provider is invisible, because nothing errors and nothing logs — events simply stop. + +**`Microsoft-Windows-Threat-Intelligence` is the one you cannot have for free.** It carries +the memory-operation events that make in-memory injection visible from user mode, and it is +gated behind a protected-process-light signature. If your tooling does not have it, your +in-memory visibility comes from scanning, not from ETW — which is what the +[injected-thread hunting](/sheets/dfir/injected-thread-hunting) sheet covers. + +## References + +- [About Event Tracing — Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/etw/about-event-tracing) +- [EtwExplorer](https://github.com/zodiacon/EtwExplorer) — Pavel Yosifovich +- [TraceEvent / PerfView](https://github.com/microsoft/perfview) — Microsoft, MIT +- [Tampering with Windows Event Tracing](https://medium.com/palantir/tampering-with-windows-event-tracing-background-offense-and-defense-4be7ac62ac63) — Palantir +- [Hunting injected threads](/sheets/dfir/injected-thread-hunting) — the scanning counterpart + to ETW collection diff --git a/src/content/sheets/dfir/frida-api-tracing.md b/src/content/sheets/dfir/frida-api-tracing.md @@ -0,0 +1,242 @@ +--- +title: "API Tracing with Frida" +description: "Attaching Frida to a live Windows process, hooking an export with Interceptor, reading arguments on entry and return values on leave, and finding the right function with frida-trace." +category: dfir +subcategory: "Windows Internals" +tags: [dynamic-analysis, reverse-engineering, windows, malware-analysis, instrumentation] +tools: [frida, frida-trace, process-hacker] +difficulty: intermediate +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/instrumenting-windows-apis-with-frida" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "Frida" + url: "https://frida.re" + author: "Ole André Vadla Ravnås and the Frida project" + relation: inspired + note: "The toolkit itself. Interceptor, Module and NativePointer APIs used throughout." + - name: "Frida JavaScript API" + url: "https://frida.re/docs/javascript-api/" + author: "the Frida project" + relation: inspired + note: "Signatures for Interceptor.attach, Module.getExportByName and the pointer read methods." + - name: "CredUnPackAuthenticationBufferW — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credunpackauthenticationbufferw" + relation: inspired + note: "Parameter order for the worked example, and the documented fact that it converts an opaque buffer to plaintext strings." +--- + +## What this is + +Frida attaches to a running process and lets you run JavaScript inside it that intercepts +native function calls. For analysis work that solves a specific problem: you want to know +what arguments a binary passes to a Windows API, and you want it without building a debugger +script, without patching the binary, and with the ability to change your instrumentation +while the target keeps running. + +Two tools, two jobs. `frida-trace` answers "is this function called at all, and when" by +generating a stub per matching export. `frida` with a script answers "what exactly were the +arguments" once you know which function to look at. You almost always use them in that order. + +## Prerequisites + +- `pip install frida-tools` on the analysis host. On Windows, architecture must match the + target — a 32-bit process needs the 32-bit Frida. +- Administrator for attaching to a process you do not own. +- A disposable VM. Instrumenting a process means executing your code inside it; do this on + anything you care about and you are debugging your own instrumentation bugs in production. +- Frida cannot attach to a protected process. `lsass.exe` on a host with LSA protection or + Credential Guard enabled is out of reach, which is a constraint worth knowing before you + plan an analysis around it. + +## Walkthrough + +### Spawn or attach + +```powershell +frida C:\Windows\System32\notepad.exe +frida -p 10964 +frida -n notepad.exe +``` + +Spawning holds the process suspended until you resume it, which is how you instrument +something that does its interesting work during start-up. Attaching catches a process already +running, and anything that happened before you attached is gone. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28742%29.png" + alt="Frida REPL console attached to a freshly spawned notepad.exe process, showing the interactive prompt" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The Frida REPL inside a spawned process. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Find out which function to care about + +Pattern-match export names across the target's loaded modules and let Frida generate a +handler for each hit: + +```powershell +frida-trace -i "WriteFile" C:\Windows\System32\notepad.exe +frida-trace -i "*Cred*" -n explorer.exe +``` + +Each match produces a JavaScript file under `__handlers__/`, and editing one takes effect +immediately. Start wide with a wildcard to see which functions fire, then narrow. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/frida-trace.gif" + alt="frida-trace console printing a running count of WriteFile calls made by notepad.exe as the user saves a file" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption><code>frida-trace</code> counting <code>WriteFile</code> calls as they happen. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Wildcarding a whole family is how you find the function you did not know to look for. Tracing +`*Cred*` in `explorer.exe` and then invoking the "run as different user" prompt shows which +credential APIs the shell actually calls, in order. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/credential-popup-trace.gif" + alt="frida-trace output showing CredUIPromptForWindowsCredentialsW being called the moment the Windows credential prompt appears" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Wildcard tracing names the function the prompt goes through. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Hook one function properly + +`Interceptor.attach` takes an address and a pair of callbacks. `onEnter` receives the +argument array; `onLeave` receives the return value, and is where you read output parameters, +because on entry they are uninitialised. + +```javascript +const writeFile = Module.getExportByName(null, 'WriteFile'); + +Interceptor.attach(writeFile, { + onEnter(args) { + this.len = args[2].toInt32(); + console.log(`WriteFile handle=${args[0]} bytes=${this.len}`); + console.log(hexdump(args[1], { length: Math.min(this.len, 0x80) })); + }, + onLeave(retval) { + console.log(` -> ${retval.toInt32()}`); + } +}); +``` + +```powershell +frida C:\Windows\System32\notepad.exe -l .\hooking.js +``` + +`null` as the module in `getExportByName` searches every loaded module, which is what you +want for an API that may be exported from `kernel32` or `kernelbase` depending on build. +Pass an explicit module name when the symbol is ambiguous. `this` is shared between `onEnter` +and `onLeave` for a single call, which is how you carry a length or a pointer across. + +### Reading output parameters on leave + +The pattern that makes this technique useful: a function takes an opaque input and writes +decoded output to caller-supplied buffers. Capture the pointers on entry, read them on leave. + +`CredUnPackAuthenticationBufferW` is the textbook case, because its documented purpose is to +turn the credential blob from the Windows credential prompt into separate username, domain +and password strings. Its parameter order puts `pszUserName` at index 3 and `pszPassword` at +index 7: + +```javascript +const fn = Module.getExportByName('credui.dll', 'CredUnPackAuthenticationBufferW'); + +Interceptor.attach(fn, { + onEnter(args) { + this.user = args[3]; + this.pass = args[7]; + }, + onLeave(retval) { + if (retval.toInt32() === 0) return; + console.log(`${this.user.readUtf16String()}:${this.pass.readUtf16String()}`); + } +}); +``` + +On entry those buffers are empty; on leave they hold plaintext. That is not a flaw in Frida +or in the API — it is what the function is for, and it is the general shape of the problem: +anything that must exist in cleartext in a process's address space can be read by code +running in that process. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28744%29.png" + alt="Traced list of credential API calls made during a credential prompt, with the unpack function highlighted among them" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The credential API call sequence, with the unpack call that handles plaintext. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Useful idioms + +```javascript +Process.enumerateModules().forEach(m => console.log(m.name, m.base, m.size)); +Module.enumerateExports('ntdll.dll').filter(e => e.name.startsWith('NtCreate')); +Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join('\n'); +Process.enumerateRanges('r-x').filter(r => r.file === undefined); +``` + +The backtrace inside an `onEnter` is the one that changes an analysis: it tells you *which* +code called the API, not just that it was called. The last line enumerates executable ranges +with no backing file — the same condition the +[injected-thread](/sheets/dfir/injected-thread-hunting) check tests, reachable from a script. + +## What it tells you + +**Where a value exists in cleartext, and therefore what is worth protecting.** An API trace +turns "the password must be decrypted somewhere" into a function name and an argument index. +That is the analysis that justifies Credential Guard, LSA protection and protected processes +— and the reason those mitigations are the ones that break this technique, since Frida cannot +attach to a process it cannot open. + +**What a sample does, when static analysis stalls.** Packed or obfuscated code still has to +call the OS to do anything observable. Tracing `CreateFile`, `WriteFile`, registry and +`Nt*` memory calls gives you behaviour without ever unpacking anything. Add a backtrace in +each handler and you also get where in the unpacked image the call came from, which is where +to point a disassembler next. + +**Confirmation that a detection's assumptions hold.** The injection sheets' +detection advice names specific call sequences — +`NtUnmapViewOfSection` then `VirtualAllocEx` then `WriteProcessMemory` for +[process hollowing](/sheets/exploitation/process-hollowing), +`VirtualAllocEx` then `WriteProcessMemory` then `CreateRemoteThread` for +[DLL injection](/sheets/exploitation/dll-injection), +`SetThreadContext` for [thread execution hijacking](/sheets/exploitation/thread-execution-hijacking). +Hooking those exports in a lab and watching the order they fire in is how you verify that a +rule written against that sequence will actually match, and how you discover that a sample +reaches the syscall directly and never touches the export you hooked. + +**The limits, which are the same limits an EDR's user-mode hooks have.** Frida patches the +function prologue in the target's own address space. Code that resolves the syscall number +and issues `syscall` itself never reaches your hook. Code that maps a fresh copy of `ntdll` +and calls into that copy bypasses it. This is worth internalising, because it is exactly why +user-mode API hooking is a weak detection foundation and why the +[kernel callbacks](/sheets/dfir/kernel-notification-callbacks) and +[ETW](/sheets/dfir/etw-telemetry) routes exist: they observe from a place the target cannot +patch. + +**And the obvious one.** The same instrumentation that reads credentials for analysis reads +them for theft, from a process the attacker already controls. Treat the ability to attach to +a process as equivalent to the ability to read everything that process handles, and scope +debug privileges accordingly. + +## References + +- [Frida](https://frida.re) — the Frida project +- [Frida JavaScript API](https://frida.re/docs/javascript-api/) +- [CredUnPackAuthenticationBufferW — Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credunpackauthenticationbufferw) +- [Kernel notification callbacks](/sheets/dfir/kernel-notification-callbacks) — observation + from where the target cannot reach diff --git a/src/content/sheets/dfir/handle-enumeration.md b/src/content/sheets/dfir/handle-enumeration.md @@ -0,0 +1,234 @@ +--- +title: "Handle Enumeration and Kernel Object Addresses" +description: "NtQuerySystemInformation with SystemHandleInformation to list every handle on the host, and confirming the kernel object behind one in WinDBG with !object and !process." +category: dfir +subcategory: "Windows Internals" +tags: [windows, detection-engineering, memory-forensics, kernel, threat-hunting] +tools: [windbg, process-hacker, handle, visual-studio] +difficulty: advanced +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/get-all-open-handles-and-kernel-object-address-from-userland" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "SYSTEM_HANDLE_INFORMATION — Process Hacker" + url: "https://processhacker.sourceforge.io/doc/struct___s_y_s_t_e_m___h_a_n_d_l_e___i_n_f_o_r_m_a_t_i_o_n.html" + relation: inspired + note: "Field layout of the undocumented structures the query returns." + - name: "SystemHandleInformation — Geoff Chappell" + url: "https://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/sysinfo/handle.htm" + author: "Geoff Chappell" + relation: inspired + note: "Per-build behaviour of the information class, including which builds truncate the PID field." + - name: "NtQuerySystemInformation — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation" + relation: inspired + note: "The documented call and its STATUS_INFO_LENGTH_MISMATCH contract." +--- + +## What this is + +A single call enumerates every open handle on the machine — processes, threads, files, +registry keys, sections, mutants, tokens — and for each one gives you the kernel virtual +address of the object it refers to. No driver, and on most builds no elevation needed to get +the list itself. That makes it the cheapest way to answer "what does this process have hold +of", which is a question that distinguishes injection and credential access from normal +behaviour better than almost anything else in user mode. + +`NtQuerySystemInformation` with information class `SystemHandleInformation` (`0x10`) returns a +`SYSTEM_HANDLE_INFORMATION`: a count, followed by that many +`SYSTEM_HANDLE_TABLE_ENTRY_INFO` records. + +```cpp +typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO { + USHORT UniqueProcessId; // owner PID — truncated to 16 bits + USHORT CreatorBackTraceIndex; + UCHAR ObjectTypeIndex; // index into the object type table + UCHAR HandleAttributes; // OBJ_INHERIT, OBJ_PROTECT_CLOSE + USHORT HandleValue; // the handle as the owner sees it + PVOID Object; // kernel address of the object + ULONG GrantedAccess; // the access mask the handle was opened with +} SYSTEM_HANDLE_TABLE_ENTRY_INFO, *PSYSTEM_HANDLE_TABLE_ENTRY_INFO; + +typedef struct _SYSTEM_HANDLE_INFORMATION { + ULONG NumberOfHandles; + SYSTEM_HANDLE_TABLE_ENTRY_INFO Handles[1]; +} SYSTEM_HANDLE_INFORMATION, *PSYSTEM_HANDLE_INFORMATION; +``` + +Neither structure is documented by Microsoft. Both are stable enough to rely on and have +been for twenty years, but `UniqueProcessId` being a `USHORT` is a real limitation: PIDs +above 65535 wrap. Use `SystemExtendedHandleInformation` (`0x40`) instead when you need the +full-width PID and object-type handling. + +## Prerequisites + +- `ntdll.dll` — resolve the export at runtime with `GetProcAddress`; there is no import + library. +- A buffer sized by retry, not by guess. The call returns `STATUS_INFO_LENGTH_MISMATCH` + (`0xC0000004`) when the buffer is too small, and the handle count changes between calls, so + the only correct pattern is a loop that grows the buffer until the status is not that. +- Kernel debugging for the confirmation half — see + [the kernel debugging lab](/sheets/dfir/kernel-debugging-lab) for the transport setup. +- Process Hacker for cross-checking without a debugger. + +## Walkthrough + +### The call + +```cpp +#define SystemHandleInformation 0x10 + +using fNtQuerySystemInformation = NTSTATUS(WINAPI*)( + ULONG SystemInformationClass, PVOID SystemInformation, + ULONG SystemInformationLength, PULONG ReturnLength); + +auto NtQuerySystemInformation = (fNtQuerySystemInformation)GetProcAddress( + GetModuleHandleW(L"ntdll"), "NtQuerySystemInformation"); + +ULONG size = 0x10000, needed = 0; +PSYSTEM_HANDLE_INFORMATION info = nullptr; +NTSTATUS status; + +do { + if (info) HeapFree(GetProcessHeap(), 0, info); + info = (PSYSTEM_HANDLE_INFORMATION)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, size); + status = NtQuerySystemInformation(SystemHandleInformation, info, size, &needed); + size *= 2; +} while (status == 0xC0000004); + +for (ULONG i = 0; i < info->NumberOfHandles; i++) { + auto& h = info->Handles[i]; + if (h.UniqueProcessId != targetPid) continue; + printf("handle 0x%04x object 0x%p type %u access 0x%08x\n", + h.HandleValue, h.Object, h.ObjectTypeIndex, h.GrantedAccess); +} +``` + +Two things worth saying about the loop. The records are not grouped by PID in any guaranteed +order, so filter rather than break out on the first non-match. And `ObjectTypeIndex` is an +index into the object-type table, whose numbering changes between builds — resolve it against +`NtQueryObject(ObjectTypeInformation)` or a `SystemExtendedHandleInformation` pass rather than +hard-coding "8 means File". + +Run it against PID 4 and you get the `System` process's handles, which is a useful smoke +test because that set is large and stable. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28600%29.png" + alt="Console output listing handle values, kernel object addresses and owning PID for every handle held by the System process" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Handle value, kernel object address and owner PID for each handle held by PID 4. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Cross-check against Process Hacker + +Process Hacker's Handles tab for the same process shows the same handle values, and its +Properties pane shows the same kernel object address. Confirming one handle both ways is +worth doing before you trust a parser you just wrote, because a buffer-sizing bug produces +plausible-looking garbage rather than an error. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28601%29.png" + alt="Process Hacker handles view for the System process with handle 0x4 selected, showing its object address in kernel memory" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The same handle in Process Hacker: handle value, owning PID and object address. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Confirm the object in the kernel + +The `Object` field is a kernel virtual address. In a kernel debugging session you can ask +what lives there: + +```erlang +!object 0xffff8f077c882300 +``` + +This resolves the object header and names its type, which is the authoritative answer to +"what is handle 0x4 actually a handle to". + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28602%29.png" + alt="WinDBG !object output for a kernel address reporting a valid object header of type Process" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption><code>!object</code> confirms the address is a valid object and names its type. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +For a process object, go further and overlay `_EPROCESS` to read the identity fields: + +```erlang +!process 0xffff8f077c882300 0 +dt _eprocess 0xffff8f077c882300 UniqueProcessId ImageFileName +``` + +`UniqueProcessId` and `ImageFileName` from `_EPROCESS` are the kernel's own record of which +process this is — not the PEB's claim, the kernel's. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28605%29.png" + alt="WinDBG dt _eprocess output printing UniqueProcessId 4 and ImageFileName System for the object address" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption><code>_EPROCESS</code> overlaid on the object address: PID and image name straight from the kernel. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +## What it tells you + +**`GrantedAccess` on process handles is the highest-signal field in the whole table.** A +handle is opened with a specific access mask, and the mask tells you what the opener intended +to do. The combinations that matter: + +| Mask bits | Right | Why it is interesting | +|---|---|---| +| `0x0008` | `PROCESS_VM_OPERATION` | Needed to allocate or change protection in another process. | +| `0x0010` | `PROCESS_VM_READ` | Reading another process's memory — the credential-dumping precondition. | +| `0x0020` | `PROCESS_VM_WRITE` | Writing another process's memory. | +| `0x0002` | `PROCESS_CREATE_THREAD` | Needed for `CreateRemoteThread`. | +| `0x0040` | `PROCESS_DUP_HANDLE` | Stealing a handle someone else already holds. | +| `0x1F0FFF` | `PROCESS_ALL_ACCESS` | The lazy injector's mask. Rarely legitimate across process boundaries. | + +`0x0008 | 0x0020` together on a handle to a process the holder has no relationship with is +the handle-table shape of [classic DLL injection](/sheets/exploitation/dll-injection) and +[process hollowing](/sheets/exploitation/process-hollowing). `0x0010` against `lsass.exe` is +the shape of credential dumping. Enumerating handles gives you this *while the handle is +still open*, which a process-access event log gives you only at open time. + +**Who holds a handle to what is a graph, and it is a small one.** Build the owner-to-target +map for process-type handles across the host and the legitimate edges are few and +predictable: service hosts to their children, the debugger to its debuggee, antimalware to +everything. An edge from an unsigned binary in a user-writable directory to a system process +is an anomaly you can find without any signature. + +**Handles outlive the API call that created them.** This is the practical advantage over +telemetry. If a process opened `lsass` with `VM_READ` an hour ago and kept the handle, the +event may have rolled out of your log but the handle is still in the table. Sweeping handles +is therefore a good complement to event-based collection, not a substitute for it. + +**The same enumeration is an attack primitive, which is why it is worth watching.** Kernel +object addresses are exactly what an exploit against a vulnerable signed driver needs: given +an arbitrary kernel read or write, locating the `_EPROCESS` of a privileged process is the +step between "I can write kernel memory" and "I am SYSTEM". The artefact to look for is not +the enumeration itself — it is too common — but a non-administrative process that enumerates +handles and then opens a handle to a driver object it has no reason to touch. + +**Prefer the extended class in anything you keep.** `SystemExtendedHandleInformation` returns +a full-width `UniqueProcessId` and a wider record. The classic class is fine for a lab and +quietly wrong on a busy host with high PIDs. + +## References + +- [NtQuerySystemInformation — Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation) +- [SystemHandleInformation — Geoff Chappell](https://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/sysinfo/handle.htm) +- [SYSTEM_HANDLE_INFORMATION — Process Hacker docs](https://processhacker.sourceforge.io/doc/struct___s_y_s_t_e_m___h_a_n_d_l_e___i_n_f_o_r_m_a_t_i_o_n.html) +- [Process access rights](https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights) +- [The kernel debugging lab](/sheets/dfir/kernel-debugging-lab) — getting a session in which + `!object` works diff --git a/src/content/sheets/dfir/injected-thread-hunting.md b/src/content/sheets/dfir/injected-thread-hunting.md @@ -0,0 +1,216 @@ +--- +title: "Hunting Injected Threads" +description: "The MEM_IMAGE test behind Get-InjectedThread, how to confirm a hit in WinDBG by thread start address, and the three ways the check is evaded." +category: dfir +subcategory: "Windows Internals" +tags: [detection-engineering, process-injection, windows, memory-forensics, threat-hunting] +tools: [powershell, windbg, process-explorer, process-hacker, volatility] +difficulty: advanced +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/get-injectedthread" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "Get-InjectedThread.ps1" + url: "https://gist.github.com/jaredcatkinson/23905d34537ce4b5b1818c3e6405c1d2" + author: "Jared Atkinson" + relation: inspired + note: "Origin of the technique. The MEM_IMAGE / MEM_COMMIT test this sheet is built around is Atkinson's." + - name: "Defenders think in graphs too" + url: "https://posts.specterops.io/defenders-think-in-graphs-too-part-1-572524c71e91" + author: "Jared Atkinson" + relation: inspired + note: "The reasoning that produced the check — working backwards from what injection must do." + - name: "Understanding and evading Get-InjectedThread" + url: "https://blog.xpnsec.com/undersanding-and-evading-get-injectedthread/" + author: "Adam Chester" + relation: inspired + note: "The three evasions in the limitations section." + - name: "MEMORY_BASIC_INFORMATION" + url: "https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-memory_basic_information" + relation: inspired + note: "Field definitions for Type and State, which is what the check reads." +--- + +## What this is + +The defensive counterpart to the injection sheets. Jared Atkinson's `Get-InjectedThread` +turns a one-line property of the Windows memory manager into a system-wide scan: legitimate +code executes out of memory that is backed by a file on disk, and injected code usually does +not. Everything else here is confirming a hit and knowing where the check fails. + +The check itself: + +- Enumerate every thread of every process on the host. +- For each thread, take its start address and query the memory region containing it. +- Require `Type == MEM_IMAGE` and `State == MEM_COMMIT`. +- A thread whose start address is in committed memory that is *not* image-backed is running + code with no file behind it. Report it. + +`MEM_IMAGE` means the region came from a mapped section view of a PE — a module the loader +mapped. `MEM_PRIVATE` means the region was allocated with `VirtualAlloc`-family calls, which +is what an injector does before it writes shellcode. That one bit distinguishes the two. + +## Prerequisites + +- PowerShell 5+ and local administrator. The scan opens handles into other processes and + queries their memory; without elevation you see only your own. +- [Get-InjectedThread.ps1](https://gist.github.com/jaredcatkinson/23905d34537ce4b5b1818c3e6405c1d2). +- WinDBG with symbols, and Process Explorer or Process Hacker, for confirming a hit. +- A test injection to find. The simplest reproduction is a `CreateRemoteThread` injection + into a long-lived host — see [classic DLL injection](/sheets/exploitation/dll-injection) + for the sequence, except pointing the thread at shellcode in private memory rather than at + `LoadLibrary`. + +## Walkthrough + +### Scan + +```powershell +$hits = Get-InjectedThread +$hits +``` + +Each hit names the host process, the `ProcessId`, the `ThreadId`, the thread's +`StartAddress`, the region's `MemoryState`, `MemoryType` and `AllocatedMemoryProtection`, and +a `Bytes` array holding the first bytes at the start address. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/injected-threads-get-injected-thread.png" + alt="PowerShell output of Get-InjectedThread reporting an injected thread inside explorer.exe with its start address, memory state and protection" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>A hit: a thread in <code>explorer.exe</code> starting in non-image-backed memory. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +`AllocatedMemoryProtection` is the field to read first on a hit. `PAGE_EXECUTE_READWRITE` +on private memory is the loud case. `PAGE_EXECUTE_READ` means the injector allocated RW, +wrote, then flipped to RX — more careful, equally reportable. + +### Turn the captured bytes into something comparable + +The `Bytes` property is the actual code at the thread's entry. Render it as an escaped string +and you can diff it against known shellcode, feed it to a disassembler, or hash it: + +```powershell +($hits.Bytes | ForEach-Object tostring x2) -join "\x" +``` + +Matching those bytes against the payload from a suspected dropper is what converts "a thread +looks odd" into "this specific sample ran here". + +### Confirm the thread exists where you think it does + +Get the thread ID from the scan output, then find the same thread in Process Explorer's +Threads tab for that process. The start address column there shows the same non-module +address, usually rendered as a bare hex value rather than `module!symbol+offset` — which is +itself the tell when you are eyeballing rather than scanning. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/injected-threads-threadid.png" + alt="Process Explorer Threads tab for explorer.exe with the newly created injected thread and its numeric ID selected" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The same thread in Process Explorer. Its ID matches the scan's <code>ThreadId</code>. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Read the code in the debugger + +Attach to the host process and work with the thread directly: + +```erlang +~ +~~[0x1494]s +~. +u @$exentry +``` + +`~` lists threads with their IDs and start addresses. `~~[tid]s` switches context to a thread +by its real thread ID rather than the debugger's ordinal. `~.` prints the current thread, +including its start address — which is the value you cross-check against the scan. Then dump +and disassemble at that address: + +```erlang +db 03730000 L40 +u 03730000 L20 +``` + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/injected-threads-inspection.png" + alt="WinDBG panes showing an injected thread's ID, its StartAddress and a hex dump of the bytes at that address, with the original shellcode alongside for comparison" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Thread ID, start address and the bytes at it, against the original payload. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Confirm the region is the reason it was flagged + +The whole check reduces to two fields of `MEMORY_BASIC_INFORMATION`. Query the region in the +debugger and read them back: + +```erlang +!address 03730000 +``` + +`Type: <private>` with `State: MEM_COMMIT` is the flagged condition. `Type: MEM_IMAGE` with a +module name beside it is what every legitimate thread looks like. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/injected-threads-address.png" + alt="WinDBG address-region output for the injected thread's memory beside the matching MemoryType and MemoryState fields from Get-InjectedThread" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The region's Type and State in WinDBG, matching the scan's <code>MemoryType</code> and <code>MemoryState</code>. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +## What it tells you + +**A hit is strong evidence, and it is not the whole story.** The check answers one question +precisely: is this thread's entry point backed by a file. A hit means code is executing that +the loader never mapped from disk. Legitimate exceptions exist — JIT compilers are the big +one, so .NET, Java and JavaScript hosts will produce noise, and in those processes you need +the start address correlated against the runtime's own code heaps rather than treated as a +verdict. + +**What it is good for operationally.** Run it across a fleet and the output is small, which +is the useful property. A triage sequence that works: scan, filter out known JIT hosts, +then for every remaining hit grab `Bytes` and the `AllocatedMemoryProtection`, and pull a +full memory image of the host for anything that is executable and private. + +**Three ways it is evaded, all of which change what you should look for.** + +- *Make the thread image-backed.* Overwrite the code of a module the process legitimately + loaded, then start the thread inside it. The region is still `MEM_IMAGE`, so the check + passes. Detection moves to comparing the module's in-memory bytes against the file on disk + — see [walking the PEB](/sheets/dfir/peb-walking) for the comparison, since you need the + module list and the base addresses before you can diff anything. +- *Do not create a thread.* Hijack one that already exists, so there is no new thread whose + start address points anywhere unusual — the hijacked thread's recorded start address is + still the legitimate one, because `SetThreadContext` changes `RIP`, not the start address + the kernel recorded. This is why + [thread execution hijacking](/sheets/exploitation/thread-execution-hijacking) survives this + check, and why you want the *instruction pointer* of running threads, not just their start + addresses. +- *Point the start address at a legitimate function.* Start the thread on a real export and + pass the payload as an argument, or fix up `RIP` after the fact. The start address is clean. + +**The structural lesson.** Every evasion above works by breaking the specific correlation the +check relies on. That is the normal shape of a memory-forensics detection: it tests one +invariant, and it is defeated by whatever restores that one invariant. Run it, and run +something with a different invariant beside it — an RWX-region sweep such as Volatility's +`malfind` (see [Volatility 3](/sheets/dfir/volatility)), module-to-disk comparison, and the +API-sequence telemetry from [ETW](/sheets/dfir/etw-telemetry). + +## References + +- [Get-InjectedThread.ps1](https://gist.github.com/jaredcatkinson/23905d34537ce4b5b1818c3e6405c1d2) — Jared Atkinson +- [Defenders think in graphs too](https://posts.specterops.io/defenders-think-in-graphs-too-part-1-572524c71e91) — Jared Atkinson +- [Understanding and evading Get-InjectedThread](https://blog.xpnsec.com/undersanding-and-evading-get-injectedthread/) — Adam Chester +- [MEMORY_BASIC_INFORMATION](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-memory_basic_information) +- [Volatility 3](/sheets/dfir/volatility) — `malfind` and the memory-image route diff --git a/src/content/sheets/dfir/kernel-debugging-lab.md b/src/content/sheets/dfir/kernel-debugging-lab.md @@ -0,0 +1,270 @@ +--- +title: "Kernel Debugging Lab" +description: "Standing up a Windows kernel analysis bench: kdnet over the network to WinDBG, a minimal driver for DbgPrint output, and a raw memory image out of the VM for offline work." +category: dfir +subcategory: "Windows Internals" +tags: [windows, kernel, memory-forensics, lab, reverse-engineering] +tools: [windbg, kdnet, debugview, virtualbox, wdk, volatility] +difficulty: advanced +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/configuring-kernel-debugging-environment-with-kdnet-and-windbg-preview" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "Windows Kernel Drivers 101" + url: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/windows-kernel-drivers-101" + author: "Mantvydas Baranauskas" + license: none + relation: derived + note: "Contributed the driver-model terminology: DRIVER_OBJECT, DriverEntry, the I/O manager and IRPs, and the KMDF/WDM distinction." + - name: "Compiling a Simple Kernel Driver, DbgPrint, DbgView" + url: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/compiling-first-kernel-driver-kdprint-dbgprint-and-debugview" + author: "Mantvydas Baranauskas" + license: none + relation: derived + note: "Contributed the minimal WDM driver skeleton, the kd_default_mask value and the Debug Print Filter registry key." + - name: "Dump Virtual Box Memory" + url: "https://ired.team/miscellaneous-reversing-forensics/dump-virtual-box-memory" + author: "Mantvydas Baranauskas" + license: none + relation: derived + note: "Contributed the VBoxDbg .pgmphystofile acquisition route and the VBOX_GUI_DBG_ENABLED variable." + - name: "Setting up a network debugging connection — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/setting-up-a-network-debugging-connection-automatically" + relation: inspired + note: "The kdnet procedure and the supported-NIC constraint." +--- + +## What this is + +Everything in this subcategory that reads kernel memory assumes a bench: a machine you can +break into with a debugger, a way to get printf-style output out of kernel code, and a way to +take the whole physical memory of that machine to disk for offline analysis. Three setups, +one afternoon, and nothing else here works without them. + +The model you need in your head before starting: + +| Term | What it means | +|---|---| +| Debugger | The host running WinDBG. | +| Debuggee | The machine being debugged. It halts when the debugger breaks in. | +| `DriverEntry` | A driver's entry point, equivalent to `main`. Called once at load. | +| `DRIVER_OBJECT` | The I/O manager's record of a loaded driver, including pointers to its standard routines such as `DriverUnload`. | +| IRP | I/O Request Packet. How requests reach a driver. | +| KMDF / WDM | The framework route and the raw route. KMDF abstracts the boilerplate; WDM talks to the OS directly and is simpler to load and unload by hand. | + +A *software driver* — not bound to any hardware device — is what you want for analysis work. +It exists to run your code in kernel mode. + +## Prerequisites + +- Two machines, or one host and one VM. Snapshot the debuggee before you start; you will + bugcheck it. +- WDK and the matching Visual Studio workload on the debugger host. `kdnet.exe` and + `VerifiedNICList.xml` ship in `C:\Program Files (x86)\Windows Kits\10\Debuggers\x64`. +- A network adapter kdnet supports, which is the usual failure point on a VM. Check + `VerifiedNICList.xml` before troubleshooting anything else; an Intel or Realtek emulated + adapter generally works where a paravirtualised one does not. +- Test signing enabled on the debuggee (`bcdedit /set testsigning on`), or a loader such as + the OSR Driver Loader, since your driver will be unsigned. +- Symbols configured on the debugger: `.symfix` then `.reload`. + +## Walkthrough + +### kdnet transport + +On the **debuggee**, in an elevated prompt, pointing at the debugger's IP and a free port: + +```powershell +kdnet 192.168.2.79 50001 +``` + +It prints the exact WinDBG command line to run on the other side, including a generated key. +Copy it off the machine before rebooting — it is not recoverable afterwards without rerunning +`kdnet`, which generates a different key. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28249%29.png" + alt="kdnet console output confirming the debug transport is configured and printing the windbg -k net command with the generated connection key" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption><code>kdnet</code> prints the debugger-side command and key. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Reboot the debuggee. On the **debugger**, either run the printed command or use WinDBG's +*Attach to kernel* with the same port and key: + +```powershell +windbg -k net:port=50001,key=<the key kdnet printed> +``` + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/kerneldebuggingconnect.gif" + alt="WinDBG connecting to a remote kernel over the network transport and reaching an interactive kd prompt" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The debugger reaching a <code>kd&gt;</code> prompt on the remote kernel. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Once connected, `ctrl+break` halts the debuggee and `g` resumes it. Everything on the +debuggee stops while you are broken in — including its network stack, which is why debugging +over the same link you administer the box with is a bad idea. + +### A driver that prints + +The minimum useful WDM driver is a `DriverEntry`, an unload routine, and two `DbgPrint` +calls. It does nothing except prove the load and unload path works, which is exactly what you +want before adding anything that can fault. + +```c +#include <ntddk.h> + +void DriverUnload(PDRIVER_OBJECT driverObject) +{ + UNREFERENCED_PARAMETER(driverObject); + DbgPrint("daemon: driver unloaded\n"); +} + +NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) +{ + UNREFERENCED_PARAMETER(RegistryPath); + DriverObject->DriverUnload = DriverUnload; + DbgPrint("daemon: driver loaded\n"); + return STATUS_SUCCESS; +} +``` + +Assigning `DriverUnload` is what makes the driver stoppable. A KMDF driver built from the +*Kernel Mode Driver, Empty (KMDF)* template is the framework equivalent — it needs +`WdfDriverCreate` with an `EvtDriverDeviceAdd` callback and a `WdfDeviceCreate` inside it — +but a KMDF driver will typically refuse a stop request with "the requested control is not +valid for this service" even with an unload routine defined, which makes plain WDM the better +choice for a bench driver you will load and unload fifty times. + +### Making DbgPrint output visible + +`DbgPrint` output is filtered by default and silently discarded. Two places to fix that, +depending on where you want to read it. + +For WinDBG, raise the mask in the debugger: + +```erlang +ed kd_default_mask 0xf +``` + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28505%29.png" + alt="WinDBG command setting the kd_default_mask variable to 0xf to stop debug print output being filtered" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Raising <code>kd_default_mask</code> so <code>DbgPrint</code> output reaches the debugger. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +For DebugView on the debuggee itself — which you want when you are iterating on a driver and +do not want to be broken in — create the filter key and a `DEFAULT` DWORD of `0xf`: + +```text +HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Debug Print Filter + DEFAULT (DWORD) 0xf +``` + +Then run DebugView elevated with *Capture Kernel* enabled. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28508%29.png" + alt="DebugView window capturing kernel debug output, showing the driver loaded and driver unloaded lines from the test driver" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>DebugView capturing the driver's load and unload messages on the debuggee. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Both together is the practical arrangement: DebugView for the fast loop, the debugger for +when something faults. + +### Acquiring physical memory from the VM + +For offline analysis you want the debuggee's physical memory as a flat file. VirtualBox's own +debugger does this without an agent inside the guest, which matters — nothing is installed, +nothing is written to the guest filesystem, and the guest is paused rather than cooperating. + +List the VMs, then start the target with the debug UI enabled: + +```bash +VBoxManage list vms +virtualbox --startvm "win1002 debugee" --dbg +``` + +In the VM window, *Debug* → *Command Line*, then in the `VBoxDbg>` prompt: + +```text +.pgmphystofile 'target-memory.bin' +``` + +The file lands in the directory the VirtualBox process was started from, and it is a raw +physical memory image — the input format Volatility and MemProcFS expect. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/vbox-debug.png" + alt="VirtualBox built-in debugger console accepting the pgmphystofile command to write the guest's physical memory to a raw file" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The VirtualBox debug console writing guest physical memory to a raw file. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +To stop re-passing `--dbg`, export the variable before launching, or put it in your shell +profile: + +```bash +export VBOX_GUI_DBG_ENABLED=true +``` + +## What it tells you + +**Which questions need which tool.** The three setups answer different things and the mistake +is reaching for the wrong one. A live kernel debugger is for structure walking and +breakpoints — everything in [handle enumeration](/sheets/dfir/handle-enumeration), +[the PEB walk](/sheets/dfir/peb-walking) and +[SSDT and IDT](/sheets/dfir/ssdt-and-idt) needs it. `DbgPrint` plus DebugView is for +instrumentation you wrote yourself, which is how you watch +[kernel notification callbacks](/sheets/dfir/kernel-notification-callbacks) fire. A raw memory +image is for everything you want to analyse without the machine present, repeatably, from a +fixed point in time. + +**The acquisition method is part of the evidence.** `.pgmphystofile` captures with the guest +paused by the hypervisor and no code running inside it, so there is no smearing from the +acquisition itself and no agent footprint to explain. That is a stronger provenance story +than any in-guest acquisition tool, and it is worth preferring whenever the target is a VM +you control. The trade-off is that it only works on a VM, and only one you can reach the +hypervisor of. + +**Snapshot before, image after, and keep both.** A VM snapshot plus a raw memory image of the +same moment is a reproducible analysis target: you can rerun a question against the image +without rebuilding, and resume the snapshot when you need to watch the behaviour again. Hash +the image on creation — nothing downstream will do it for you. + +**A bugcheck is data.** The debuggee halting into the debugger with a stop code is often more +informative than the driver working, especially while you are learning the structures. `!analyze -v` +on the break, and the fact that a forgotten callback deregistration faults at the *next* +notification rather than at unload, are the two things that will cost you the most time +otherwise. + +**Test signing changes the machine you are measuring.** A debuggee with test signing on and a +kernel debugger attached is not a representative endpoint — PatchGuard behaviour differs, and +code-integrity decisions differ. Fine for understanding a structure, wrong for judging +whether a detection fires in production. + +## References + +- [Setting up a network debugging connection — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/setting-up-a-network-debugging-connection-automatically) +- [Writing a very small KMDF driver — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/gettingstarted/writing-a-very-small-kmdf--driver) +- [DRIVER_OBJECT structure](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_driver_object) +- [Reading and filtering debugging messages](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/reading-and-filtering-debugging-messages) +- [Volatility 3](/sheets/dfir/volatility) — what to do with the raw image once you have it +- [Disk imaging](/sheets/dfir/disk-imaging) — the disk-side counterpart to this acquisition diff --git a/src/content/sheets/dfir/kernel-notification-callbacks.md b/src/content/sheets/dfir/kernel-notification-callbacks.md @@ -0,0 +1,241 @@ +--- +title: "Kernel Notification Callbacks" +description: "PsSetCreateProcessNotifyRoutineEx, PsSetCreateThreadNotifyRoutine and PsSetLoadImageNotifyRoutine: the four callbacks an EDR sensor registers, what each one sees, and what it cannot see." +category: dfir +subcategory: "Windows Internals" +tags: [windows, kernel, detection-engineering, edr, telemetry] +tools: [windbg, visual-studio, debugview, wdk] +difficulty: advanced +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/subscribing-to-process-creation-thread-creation-and-image-load-notifications-from-a-kernel-driver" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "PsSetCreateProcessNotifyRoutineEx — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreateprocessnotifyroutineex" + relation: inspired + note: "Callback signature, the PS_CREATE_NOTIFY_INFO fields, and the /integritycheck requirement." + - name: "PsSetLoadImageNotifyRoutine — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetloadimagenotifyroutine" + relation: inspired + note: "Image-load callback signature and the IMAGE_INFO fields." + - name: "PsSetCreateThreadNotifyRoutine — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreatethreadnotifyroutine" + relation: inspired + note: "Thread-creation callback signature and its Create flag semantics." +--- + +## What this is + +When an endpoint product tells you "process `x` created process `y` and then created a thread +in process `z`", four kernel callbacks produced that sentence. They are documented, anyone +can register them from a signed driver, and knowing their exact semantics is how you reason +about what your sensor can and cannot have seen. + +| Routine | Fires on | Can block? | +|---|---|---| +| `PsSetCreateProcessNotifyRoutine` | process create and process exit | no | +| `PsSetCreateProcessNotifyRoutineEx` | process create and process exit | yes — set `CreationStatus` | +| `PsSetCreateThreadNotifyRoutine` | thread create and thread exit, system-wide | no | +| `PsSetLoadImageNotifyRoutine` | every image mapped into any process | no | + +These are notifications, not hooks. The kernel calls you at a defined point in its own +operation; you are not intercepting a call or patching a table. That is the whole reason they +are the sanctioned mechanism and SSDT patching is not — see +[SSDT and IDT](/sheets/dfir/ssdt-and-idt) for the historical alternative and why it died. + +## Prerequisites + +- WDK plus the matching Visual Studio workload. A WDM driver is the smaller target for this; + KMDF works too. +- A debuggee you are willing to bugcheck. A callback that dereferences a bad pointer takes + the machine down, and an unload path that forgets to deregister takes it down later. +- `DbgPrint` output visible — see [the kernel debugging lab](/sheets/dfir/kernel-debugging-lab) + for the kdnet transport, `ed kd_default_mask 0xf`, and the DebugView registry filter. +- Test signing or a loader, since the driver is unsigned. `PsSetCreateProcessNotifyRoutineEx` + additionally requires the driver image to be linked with `/integritycheck`; without it the + registration fails with `STATUS_ACCESS_DENIED` and the usual mistake is to assume the + callback is simply not firing. + +## Walkthrough + +### Process creation and exit + +```c +VOID ProcessNotify(HANDLE parentId, HANDLE processId, BOOLEAN create) +{ + if (create) + DbgPrint("process %llu created by %llu\n", (ULONG64)processId, (ULONG64)parentId); + else + DbgPrint("process %llu exited\n", (ULONG64)processId); +} + +// in DriverEntry +PsSetCreateProcessNotifyRoutine(ProcessNotify, FALSE); +// in the unload routine — the TRUE removes it +PsSetCreateProcessNotifyRoutine(ProcessNotify, TRUE); +``` + +The second argument is `Remove`, so the same call registers and deregisters. Forgetting the +deregistration on unload leaves the kernel with a pointer into freed driver memory, and the +bugcheck arrives at the next process creation rather than at unload, which makes it a +confusing one to diagnose. + +Note what this gives you: parent PID, child PID, and a create/exit flag. No image path and +no command line. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/PsSetCreateProcessNotifyRoutine.gif" + alt="Debug output showing a process creation notification naming the new PID and its parent PowerShell PID, followed by the matching exit notification" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Create and exit notifications for a child process, parent PID included. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Image loads + +```c +VOID ImageNotify(PUNICODE_STRING fullImageName, HANDLE processId, PIMAGE_INFO imageInfo) +{ + DbgPrint("pid %llu mapped %wZ at %p (%s)\n", + (ULONG64)processId, fullImageName, imageInfo->ImageBase, + imageInfo->SystemModeImage ? "kernel" : "user"); +} + +PsSetLoadImageNotifyRoutine(ImageNotify); +``` + +One callback for every image mapped anywhere, user mode and kernel mode both. `IMAGE_INFO` +carries `ImageBase`, `ImageSize`, `SystemModeImage` and the image signature level — so this +is where a sensor learns that an unsigned DLL just appeared inside a signed process. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/PsSetLoadImageNotifyRoutine.gif" + alt="Debug output listing every module notepad.exe maps as it starts, each with its image path and base address" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Every module one process maps during start-up, with path and base. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +A caveat that matters for detection: the callback fires while the image is being mapped, and +the full image name is not guaranteed to be resolvable at that point for every image. Code +that assumes a usable path for all of them produces gaps. + +### Thread creation and exit + +```c +VOID ThreadNotify(HANDLE processId, HANDLE threadId, BOOLEAN create) +{ + DbgPrint("thread %llu in process %llu %s\n", + (ULONG64)threadId, (ULONG64)processId, create ? "created" : "exited"); +} + +PsSetCreateThreadNotifyRoutine(ThreadNotify); +``` + +System-wide and high volume. The callback gives you the owning PID and the thread ID; it does +not hand you the thread's start address, so a sensor that reports "thread started outside any +module" is resolving that itself after being notified. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28529%29.png" + alt="Debug output stream of thread creation and thread exit notifications across multiple process IDs on the system" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Thread create and exit across every process on the host. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### The Ex variant, and blocking + +`PsSetCreateProcessNotifyRoutineEx` receives a `PPS_CREATE_NOTIFY_INFO` instead of a bare +parent PID, and that structure is where the useful fields live: `ImageFileName`, +`CommandLine`, `FileObject`, the creating process and thread IDs, and `CreationStatus`. +Writing a failure code into `CreationStatus` makes the creation fail. + +```c +VOID ProcessNotifyEx(PEPROCESS process, HANDLE processId, PPS_CREATE_NOTIFY_INFO info) +{ + UNREFERENCED_PARAMETER(process); + if (info == NULL) return; // NULL means exit, not create + + DbgPrint("pid %llu image %wZ cmdline %wZ\n", + (ULONG64)processId, info->ImageFileName, info->CommandLine); + + if (ShouldBlock(info->CommandLine)) + info->CreationStatus = STATUS_ACCESS_DENIED; +} + +PsSetCreateProcessNotifyRoutineEx(ProcessNotifyEx, FALSE); +``` + +Two sharp edges. `info` is `NULL` on process exit, so the early return is mandatory, not +defensive. And the block happens before the process's first instruction executes, which is +what makes this the mechanism behind "the agent prevented that from launching" rather than +"the agent killed it afterwards". + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/PsSetCreateProcessNotifyRoutineEx.gif" + alt="A process launch attempt failing with an access denied error because the driver set CreationStatus in its create-process callback" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Creation denied from the callback — the process never runs. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +## What it tells you + +**This is where your telemetry comes from, and its shape explains your telemetry's shape.** +The fields in `PS_CREATE_NOTIFY_INFO` are, almost exactly, the fields in a Sysmon event ID 1. +That is not a coincidence: a sensor can only report what the callback gave it. When an event +in your SIEM lacks a field, the first question is whether the callback carries it at all. + +**The parent PID is reported, and it is not the same as the launching process.** The +callback hands you the creating process at the moment of creation. A process created with an +explicitly reparented parent handle reports that parent, because from the kernel's view it +*is* the parent. So "unusual parent" detections built on this data are detecting what the +kernel was told, and parent spoofing shows up as a parent-child pair that is wrong in a +different way — a parent that never ran the child's binary, a parent that had already exited. +Correlate against the creating *thread* ID, which `PS_CREATE_NOTIFY_INFO` also carries and +which is harder to make consistent with a forged parent. + +**Image load is your best cross-process code-execution signal and it has a blind spot.** The +callback sees everything the loader maps. It does not see code that was never mapped by the +loader — memory allocated and written directly. So an injection that calls `LoadLibrary` in +the target generates an image-load event with a path you can act on, while one that maps a +PE manually generates nothing here at all. The techniques under +[process hollowing](/sheets/exploitation/process-hollowing) and the manual-mapping variants +of [DLL injection](/sheets/exploitation/dll-injection) are invisible to this callback +specifically, which is why the thread and memory checks in +[injected-thread hunting](/sheets/dfir/injected-thread-hunting) exist. + +**Thread creation covers cross-process thread starts, and nothing else about them.** You get +notified that a thread appeared in a process. Pairing that with the handle that was used to +create it requires an object-callback registration (`ObRegisterCallbacks`) for process-handle +opens, which is a separate mechanism — which is why "who opened a handle to lsass" and +"a thread started in lsass" are two different events from two different registrations. See +[handle enumeration](/sheets/dfir/handle-enumeration) for the user-mode way to get the same +relationship after the fact. + +**A thread that is not created is not notified.** Hijacking an existing thread produces no +thread-create callback, so this mechanism never sees +[thread execution hijacking](/sheets/exploitation/thread-execution-hijacking). The visible +artefact there is the handle access mask and the memory layout, not the notification stream. + +**Callback registration is itself a thing to inventory.** The set of drivers holding process, +thread and image callbacks on a host is small and known. A driver you do not recognise in +that set is either a product you forgot about or a problem; a *missing* callback where your +product should have one means your sensor has been unloaded or its registration removed, and +the events simply stop with no error anywhere. + +## References + +- [PsSetCreateProcessNotifyRoutineEx — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreateprocessnotifyroutineex) +- [PsSetLoadImageNotifyRoutine — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetloadimagenotifyroutine) +- [PsSetCreateThreadNotifyRoutine — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreatethreadnotifyroutine) +- [PS_CREATE_NOTIFY_INFO](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_ps_create_notify_info) +- [ETW as a telemetry source](/sheets/dfir/etw-telemetry) — the same events without a driver diff --git a/src/content/sheets/dfir/peb-walking.md b/src/content/sheets/dfir/peb-walking.md @@ -0,0 +1,274 @@ +--- +title: "Walking the PEB and Comparing Image to Disk" +description: "PEB field offsets, walking InMemoryOrderModuleList in WinDBG, and the PE header arithmetic that lets you diff a loaded module against the file it claims to come from." +category: dfir +subcategory: "Windows Internals" +tags: [windows, memory-forensics, pe-format, detection-engineering, reverse-engineering] +tools: [windbg, cff-explorer, pe-bear, process-hacker] +difficulty: advanced +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/exploring-process-environment-block" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "Process Environment Block" + url: "https://ired.team/miscellaneous-reversing-forensics/process-environment-block" + author: "Mantvydas Baranauskas" + license: none + relation: derived + note: "Second copy of the PEB lab; contributed the ProcessParameters command-line offsets and the dl/!list walking commands." + - name: "Parsing PE File Headers with C++" + url: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/pe-file-header-parser-in-c++" + author: "Mantvydas Baranauskas" + license: none + relation: derived + note: "Contributed the RVA-to-file-offset arithmetic and the import-descriptor walk in the second half." + - name: "PEB structure — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb" + relation: inspired + note: "The documented subset of the PEB; everything beyond ImageBaseAddress and Ldr is version-dependent." + - name: "PE Format — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/windows/win32/debug/pe-format" + relation: inspired + note: "Normative header and data-directory layout used in the offset arithmetic." +--- + +## What this is + +Two structures and the arithmetic that joins them. The Process Environment Block is the +user-mode record of what a process is: where its image is based, what command line it was +given, which modules it has loaded. The PE headers are the same information as it exists on +disk. Almost every memory-forensics judgement about a Windows process is a comparison +between those two views, and almost every masquerading technique works by making the +in-memory view lie while the on-disk file stays innocent. + +You need both halves to make the comparison. This sheet does the PEB walk first, then the +file-offset arithmetic you need to read the disk side. + +## Prerequisites + +- WinDBG with public symbols set (`.symfix` then `.reload`). Without `ntdll` symbols, + `dt _peb` prints nothing useful. +- A target process attached, user-mode. `cmd.exe` is a good first subject because its + command line and module list are short. +- CFF Explorer, PE-bear or any header viewer for the on-disk side. +- PEB field offsets are **version- and architecture-dependent**. The x64 offsets below hold + for current builds; re-read them with `dt` rather than hard-coding them in anything you + intend to keep. + +## Walkthrough + +### The structure + +```erlang +dt _peb +``` + +The fields that carry forensic weight on x64: + +| Offset | Field | Why it matters | +|---|---|---| +| `0x002` | `BeingDebugged` | The byte every anti-debug check reads. | +| `0x010` | `ImageBaseAddress` | Where the primary image is mapped. The anchor for every image/memory comparison. | +| `0x018` | `Ldr` | Pointer to `_PEB_LDR_DATA` — the loaded-module lists. | +| `0x020` | `ProcessParameters` | `_RTL_USER_PROCESS_PARAMETERS`: image path, command line, current directory, environment. | + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/peb-structure%20%281%29.png" + alt="WinDBG dt _peb output listing the PEB field names with their hexadecimal offsets and types" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption><code>dt _peb</code> — the field names and offsets, with no process attached to them yet. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Overlay it on a live process + +The PEB address is in a pseudo-register, so you do not have to find it: + +```erlang +r $peb +dt _peb @$peb +``` + +That second command is the whole trick for any structure in WinDBG: `dt <type> <address>` +interprets the memory at an address as that type and prints real values. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/peb-overlay%20%281%29.png" + alt="WinDBG output of dt _peb applied to the live PEB address, each field now populated with the target process's actual values" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The same structure with the live process's values in it. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Follow `ImageBaseAddress` and you are looking at the mapped PE: + +```erlang +dd @$peb+0x10 L2 +db 0000000049d40000 L100 +``` + +The `MZ` and the `This program cannot be run in DOS mode` stub confirm you are at a real +image base. Remember the dword pair is little-endian, so read it back as one qword before +using it as an address. + +For everything the debugger can summarise for you, `!peb` prints the same key fields +pre-formatted. Use it for speed; use the manual walk when you need to know exactly which +bytes you are trusting. + +### The command line, and why it is not evidence + +`ProcessParameters` holds a `_RTL_USER_PROCESS_PARAMETERS`, and the command line lives at +`+0x70` inside it as a `_UNICODE_STRING`: + +```erlang +dt _peb @$peb ProcessParameters +dt _RTL_USER_PROCESS_PARAMETERS 0x00000000002a1f40 +dt _UNICODE_STRING 0x00000000002a1f40+70 +du 00000000002a283c +``` + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/peb-cmdline2%20%281%29.png" + alt="WinDBG resolving the command line through ProcessParameters to a UNICODE_STRING buffer and printing the cmd.exe command line" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Resolving the command line down to the <code>_UNICODE_STRING</code> buffer that holds it. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +That buffer is ordinary writable process memory. A process can overwrite its own command +line after start-up with a single write — `eu <address> "something else"` in the debugger +demonstrates it in one command. Any tool that reads the command line from the PEB therefore +reports whatever the process last wrote there, not what it was launched with. + +### Walking the module list + +`Ldr` points to `_PEB_LDR_DATA`, which holds three doubly-linked lists of the same modules in +different orders. `InMemoryOrderModuleList` at `+0x20` is the usual entry point: + +```erlang +dt _peb @$peb ldr->InMemoryOrderModuleList* +dl 0x00000000002a2980 6 +!list -x "dt _LDR_DATA_TABLE_ENTRY" 0x00000000002a2980 +``` + +`dl <first-entry> <count>` prints the raw link pointers, which is how you confirm the list is +intact. `!list` does the traversal and overlays `_LDR_DATA_TABLE_ENTRY` on each node, giving +you `DllBase`, `SizeOfImage`, `FullDllName` and `BaseDllName` per module. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/peb-modulelist.png" + alt="WinDBG LDR_DATA_TABLE_ENTRY dumps for consecutive loaded modules showing DllBase, SizeOfImage and FullDllName for cmd, ntdll and kernel32" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Consecutive <code>_LDR_DATA_TABLE_ENTRY</code> nodes: base, size and full path per module. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Two things to carry away from the walk. First, the list is a doubly-linked list in writable +user memory, so a node can be unlinked from inside the process — which means "not in the +module list" is not the same as "not mapped", and you should enumerate committed regions as +well. Second, `DllBase` and `FullDllName` together are exactly the pair you need for the +disk comparison, which is the rest of this sheet. + +### The disk side: turning an RVA into a file offset + +To compare a loaded module against its file you have to read the file's headers, and every +address in a PE's data directories is a Relative Virtual Address — an offset from the image +base *as mapped*. On disk, sections sit at their `PointerToRawData`, which is not the same +as their `VirtualAddress`. So an RVA has to be translated. + +Find which section contains the RVA, then convert: + +```text +section contains rva when rva >= section.VirtualAddress + and rva < section.VirtualAddress + section.VirtualSize + +fileOffset = section.PointerToRawData + (rva - section.VirtualAddress) +``` + +Worked through with the import directory of a 32-bit `notepad.exe`: the data directory gives +an import-table RVA of `0xA0A0`; `.text` has `VirtualAddress 0x1000`, `VirtualSize 0xA6FC` +and raw offset `0x400`. `0xA0A0` falls inside `.text`, so the file offset is +`0x400 + (0xA0A0 - 0x1000) = 0x94A0`. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Screenshot%20from%202018-11-06%2020-51-04.png" + alt="CFF Explorer data directories view for notepad.exe showing the import table RVA and its size" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The import directory's RVA, read out of the optional header's data directories. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Screenshot%20from%202018-11-06%2020-51-27.png" + alt="CFF Explorer section headers table for notepad.exe listing virtual size, virtual address and raw offset per section" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Section headers: <code>VirtualAddress</code>, <code>VirtualSize</code> and the raw offset the conversion needs. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +At that offset sits an array of `IMAGE_IMPORT_DESCRIPTOR`, terminated by an all-zero entry. +Each descriptor's `Name` field is itself an RVA to an ASCII DLL name, so it needs the same +conversion again; `OriginalFirstThunk` points at the `IMAGE_THUNK_DATA` array, whose +non-ordinal entries are RVAs to `IMAGE_IMPORT_BY_NAME`. The double indirection is the part +that catches people: nothing inside a data directory is a file offset, it is RVAs all the +way down. + +The headers to read for a comparison, in order: `e_lfanew` from the DOS header gets you to +the NT headers; `FileHeader.Machine` and `NumberOfSections`; `OptionalHeader.ImageBase`, +`SizeOfImage`, `AddressOfEntryPoint` and the data directories; then the section table. + +## What it tells you + +**Image/memory mismatch is the conclusive artefact, and this is how you compute it.** For +the primary image: read `ImageBaseAddress` from the PEB, read the PE headers at that address +in memory, read the file at the path from `ProcessParameters`, and compare `SizeOfImage`, +`NumberOfSections`, the section names and `AddressOfEntryPoint`. A disagreement is not +explainable by anything legitimate. This is the decisive check for +[process hollowing](/sheets/exploitation/process-hollowing), where the mapped image is gone +or replaced while the path still names the host binary. + +**Per module, the same comparison catches overwritten modules.** Walk +`InMemoryOrderModuleList`, and for each node compare the bytes at `DllBase` against the file +at `FullDllName`. Expect benign differences — the loader applies relocations, patches the +IAT, and writable sections diverge immediately — so compare the executable sections only, +after accounting for the relocation delta. A module whose `.text` does not match its file is +a module someone wrote into, which is what makes +[classic DLL injection](/sheets/exploitation/dll-injection) variants that overwrite a loaded +module visible when the thread-start check misses them. + +**Treat the PEB as a claim, not a fact.** Everything in it is writable by the process. The +command line, the image path string, the module list links, `BeingDebugged` — all of it can +be rewritten after start-up, and a process can unlink a module node so the module stops +appearing in the list it is still mapped in. So: + +- Prefer the kernel's view where you can get it. The image path recorded at process creation + comes from the kernel, and an [ETW](/sheets/dfir/etw-telemetry) process-start event carries + the command line as it was at creation. A PEB read gives you the current value. +- Where the two disagree, the disagreement is the finding. A command line in the PEB that + does not match the one in the creation event means something rewrote it. +- Never take the module list as the complete set of mapped code. Enumerate committed regions + and look for executable memory that no list entry covers. + +**Offsets drift; structures do not.** Hard-coded PEB offsets are the most common reason a +forensic script silently returns garbage on a new build. Resolve them from symbols, or at +minimum sanity-check `ImageBaseAddress` against an `MZ` before trusting anything else you +read through it. + +## References + +- [PEB structure — Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb) +- [PE Format — Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) +- [PEB_LDR_DATA structure](https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb_ldr_data) +- [Process hollowing](/sheets/exploitation/process-hollowing) — the technique this comparison + is built to catch +- [Hunting injected threads](/sheets/dfir/injected-thread-hunting) — the complementary check + on thread start addresses diff --git a/src/content/sheets/dfir/ssdt-and-idt.md b/src/content/sheets/dfir/ssdt-and-idt.md @@ -0,0 +1,294 @@ +--- +title: "SSDT and IDT" +description: "KiServiceTable offset arithmetic, resolving a syscall number to its kernel routine, the _KIDTENTRY64 layout, and why table hooking stopped being how rootkits work on x64." +category: dfir +subcategory: "Windows Internals" +tags: [windows, kernel, rootkit, detection-engineering, reverse-engineering] +tools: [windbg, kdnet, volatility] +difficulty: advanced +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/glimpse-into-ssdt-in-windows-x64-kernel" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "Interrupt Descriptor Table - IDT" + url: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/interrupt-descriptor-table-idt" + author: "Mantvydas Baranauskas" + license: none + relation: derived + note: "Contributed the whole IDT half: the idtr read, the _KIDTENTRY64 offset layout, and the _KPCR/_KPRCB route to _KINTERRUPT." + - name: "The Quest for the SSDTs" + url: "https://www.codeproject.com/Articles/1191465/The-Quest-for-the-SSDTs" + relation: inspired + note: "The KeServiceDescriptorTable layout and the 4-bit shift in the x64 offset formula." + - name: "Interrupt dispatching — CodeMachine" + url: "https://www.codemachine.com/article_interruptdispatching.html" + relation: inspired + note: "How an interrupt reaches a driver's ISR, which the IDT walkthrough traces by hand." +--- + +## What this is + +Two dispatch tables that decide where the CPU goes next. The System Service Descriptor Table +maps a syscall number to a kernel routine; the Interrupt Descriptor Table maps an interrupt +vector to a service routine. For a decade both were the standard place to install a rootkit, +because overwriting one entry redirects every caller. On x64 that era is over — but the +tables are still how you read a stack trace that crosses the user/kernel boundary, still how +you work out which driver handles a given interrupt, and still worth checking. + +## Prerequisites + +- A live kernel debugging session. See + [the kernel debugging lab](/sheets/dfir/kernel-debugging-lab); none of this works from a + user-mode debugger. +- Symbols for `nt` and, for the syscall exercise, `ntdll`. +- The IDT values below are from a single-processor x64 VM. **Each processor has its own IDT** + and its own `IDTR`, so on a multiprocessor host you must check every one — a hook on + processor 3 only is a real technique. +- Addresses differ across boots. Re-read them rather than reusing the ones printed here. + +## Walkthrough + +### The service descriptor table + +`KeServiceDescriptorTable` is a small structure whose first member points at the dispatch +table itself: + +```cpp +typedef struct tagSERVICE_DESCRIPTOR_TABLE { + SYSTEM_SERVICE_TABLE nt; // -> KiServiceTable, the SSDT proper + SYSTEM_SERVICE_TABLE win32k; + SYSTEM_SERVICE_TABLE sst3; // -> count of routines in the table + SYSTEM_SERVICE_TABLE sst4; +} SERVICE_DESCRIPTOR_TABLE; +``` + +Read it with symbols resolved so the pointers are named: + +```erlang +dps nt!KeServiceDescriptorTable L4 +``` + +The first pointer is `nt!KiServiceTable`; the fourth is `nt!KiArgumentTable`. The third slot +holds the routine count, which you need to bound a loop over the table. + +### x64 entries are offsets, not pointers + +On x86 the SSDT held absolute addresses. On x64 it holds 32-bit values that encode an offset +from the table's own base, with the low four bits used for argument-stack information. So: + +```text +routineAddress = KiServiceTable + (entry >>> 4) +``` + +`>>>` is WinDBG's arithmetic right shift. Read the first entry and resolve it: + +```erlang +dd /c1 nt!KiServiceTable L2 +u nt!KiServiceTable + (0xfd9007c4 >>> 4) L1 +``` + +That resolves to `nt!NtAccessCheck`, which you can confirm independently: + +```erlang +u nt!NtAccessCheck L1 +``` + +Same address, same first instruction. If those two disagree, either your shift is wrong or +something has rewritten the table. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28266%29.png" + alt="Diagram showing a syscall index selecting an entry in KiServiceTable and that entry's offset being converted into the absolute address of a kernel routine" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Syscall index to table entry to absolute routine address. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28258%29.png" + alt="WinDBG output resolving an SSDT offset to nt!NtAccessCheck and the same routine disassembled directly, with matching addresses and first instructions" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The resolved offset and the symbol agree: same address, same prologue. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### From a user-mode call to its kernel routine + +The useful exercise is going end to end. A user-mode `CreateFile` reaches +`ntdll!NtCreateFile`, whose body loads a syscall number into `eax` and issues `syscall`. +Read the number out of the stub: + +```erlang +.reload /f ntdll.dll +u ntdll!NtCreateFile L2 +``` + +The `mov eax, <n>` is the index. Entries are four bytes, so index into the table and resolve: + +```erlang +dd /c1 nt!KiServiceTable + 4*0x55 L1 +u nt!KiServiceTable + (0x01fa3007 >>> 4) L1 +``` + +Which lands on `nt!NtCreateFile`. Doing this once by hand is what makes a syscall number in a +sandbox report or a disassembly legible. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28265%29.png" + alt="Diagram annotated with concrete values showing one specific syscall index resolving through KiServiceTable to its kernel routine address" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The same path with real values substituted in. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Dump the whole table with names + +Loop over every entry, resolving each to a symbol. The routine count comes from the third +slot of the descriptor table, at `+0x10`: + +```erlang +.foreach /ps 1 /pS 1 ( offset {dd /c 1 nt!KiServiceTable L poi(nt!KeServiceDescriptorTable+10)}) { r $t0 = ( offset >>> 4) + nt!KiServiceTable; .printf "%p - %y\n", $t0, $t0 } +``` + +Any entry that resolves to an address outside `nt` is the thing you are looking for. On a +clean system every one of them resolves inside the kernel image. + +### The interrupt descriptor table + +`IDTR` holds the IDT base for the current processor: + +```erlang +r idtr +!idt +``` + +`!idt` dumps the table with symbols, and its header repeats the base address so you can +confirm it matches the register. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28295%29.png" + alt="WinDBG reading the idtr register and printing the interrupt descriptor table base address for the current processor" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption><code>IDTR</code> — the IDT base for this processor. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Low vectors are CPU-defined and resolve to kernel trap handlers: `0x00` divide error, `0x03` +breakpoint, `0x0e` page fault. Higher vectors are device interrupts and resolve into drivers — +`0xa0` to the keyboard driver's interrupt service on the reference machine, with a +`KINTERRUPT` address printed beside it. + +### An IDT entry's layout + +Each x64 descriptor is 16 bytes, so entry `n` is at `IDT base + n * 0x10`: + +```erlang +dt nt!_KIDTENTRY64 +dq @idtr + (0xa0*0x10) L2 +dt nt!_KIDTENTRY64 (@idtr + (0xa0*0x10)) +``` + +```text ++0x000 OffsetLow Uint2B ++0x002 Selector Uint2B ++0x004 IstIndex 3 bits ++0x004 Type 5 bits 0xe = 64-bit interrupt gate ++0x004 Dpl 2 bits ++0x004 Present 1 bit ++0x006 OffsetMiddle Uint2B ++0x008 OffsetHigh Uint4B +``` + +`OffsetLow`, `OffsetMiddle` and `OffsetHigh` concatenate into the 64-bit address of the +interrupt service routine's entry point. That is the one field a hook has to change, and +reassembling it by hand is the only way to be sure the value `!idt` printed is the value in +memory. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28314%29.png" + alt="Diagram tracing a keyboard interrupt from the CPU through the IDT entry to the ISR entry point and on into the keyboard driver's service routine" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>Interrupt to IDT entry to ISR entry point to the driver's service routine. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### Which driver actually handles it + +The IDT entry points at a kernel stub, not at the driver. The driver's routine is in a +`_KINTERRUPT`, whose `ServiceRoutine` sits at `+0x18`: + +```erlang +dt nt!_KINTERRUPT ffffd4816353ea00 +``` + +To find that object without taking the address from `!idt`, go through the processor control +region. `_KPCR` describes a processor; `_KPRCB` at `_KPCR+0x180` describes its state; and +`InterruptObject` inside the PRCB is an array of 256 `_KINTERRUPT` pointers indexed by +vector: + +```erlang +? @$pcr +dt @$pcr nt!_KPCR Prcb.InterruptObject[a0] +``` + +The result matches what `!idt` printed, which is the point of doing it the long way. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28293%29.png" + alt="WinDBG _KINTERRUPT structure dump with the ServiceRoutine member resolving to the keyboard driver's interrupt service routine symbol" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption><code>_KINTERRUPT.ServiceRoutine</code> names the driver routine that handles the interrupt. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +## What it tells you + +**The check is one line: does every entry resolve inside a module you expect.** For the SSDT, +every routine address must fall inside the `nt` image range. For the IDT, low vectors must +resolve to `nt!Ki*` trap handlers and device vectors to a loaded driver. An entry pointing +into a non-image region, or into a driver that has no business handling that vector, is a +hook. There is no legitimate reason for either table to point outside a loaded image. + +**On x64 this is a historical check, and you should know why.** PatchGuard verifies the SSDT, +the IDT, the GDT and a list of other structures periodically, and bugchecks the machine on a +mismatch. A table hook on a modern x64 kernel is not a stealthy rootkit; it is a crash with a +delay. So the realistic finding is not an active hook — it is a bugcheck with a +`CRITICAL_STRUCTURE_CORRUPTION` stop code, which is PatchGuard telling you something modified +a protected structure. Treat that stop code as a security event, not a driver bug. + +**Where the hooking went instead.** Since the tables are defended, interception moved to the +sanctioned extension points — the registered callbacks covered in +[kernel notification callbacks](/sheets/dfir/kernel-notification-callbacks) — and to user +mode, where hooking `ntdll` stubs is unprotected and correspondingly easy to bypass by +issuing the syscall directly. That is the same limitation discussed in +[API tracing with Frida](/sheets/dfir/frida-api-tracing), and it is why direct-syscall +malware defeats user-mode hooking without touching the kernel at all. + +**Syscall numbers are the practical payoff.** They change between builds, so a sample that +hard-codes them is pinned to a build range — which is both a reliability weakness for the +attacker and a dating signal for you. Being able to resolve a hard-coded index to a routine +name on the matching build is how you read what such a sample does when it never calls an +export you can hook. + +**Per-processor, always.** `IDTR` is per-logical-processor. Any IDT verification that reads +one register has checked one processor. The same applies to anything you read through +`@$pcr`, which refers to the processor you are currently broken in on — switch with `~<n>s` +and re-read. + +## References + +- [The Quest for the SSDTs](https://www.codeproject.com/Articles/1191465/The-Quest-for-the-SSDTs) +- [Interrupt dispatching — CodeMachine](https://www.codemachine.com/article_interruptdispatching.html) +- [Interrupt Descriptor Table — OSDev](https://wiki.osdev.org/Interrupt_Descriptor_Table) +- [Kernel Patch Protection — Microsoft Learn](https://learn.microsoft.com/en-us/windows-hardware/drivers/develop/64-bit-driver-installation-issues#kernel-patch-protection) +- [The kernel debugging lab](/sheets/dfir/kernel-debugging-lab) — the session every command + here needs diff --git a/src/content/sheets/dfir/x64-stack-frames.md b/src/content/sheets/dfir/x64-stack-frames.md @@ -0,0 +1,240 @@ +--- +title: "x64 Calling Conventions and Stack Frames" +description: "Where arguments live on Windows x64 and System V: the register order, the 32-byte home space, RSP-relative addressing, and how to read an argument out of a frame you did not compile." +category: dfir +subcategory: "Windows Internals" +tags: [reverse-engineering, assembly, windows, linux, malware-analysis] +tools: [windbg, ghidra, gdb, ida] +difficulty: advanced +updated: 2026-10-04 +upstreamName: "ired.team" +upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/windows-x64-calling-convention-stack-frame" +upstreamAuthor: "Mantvydas Baranauskas" +upstreamLicense: none +upstreamRelation: derived +references: + - name: "x64 Calling Convention: Stack Frame" + url: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/x64-calling-convention-stack-frame" + author: "Mantvydas Baranauskas" + license: none + relation: derived + note: "Duplicate of the Windows guide; contributed the same register order and home-space notes, and the Ghidra example." + - name: "Linux x64 Calling Convention: Stack Frame" + url: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/linux-x64-calling-convention-stack-frame" + author: "Mantvydas Baranauskas" + license: none + relation: derived + note: "Contributed the System V register order, the rbp-relative argument offsets and the 0x10 shift per 16 bytes of locals." + - name: "x64 stack usage — Microsoft Learn" + url: "https://learn.microsoft.com/en-us/cpp/build/stack-usage" + relation: inspired + note: "Normative statement of the home space, alignment and stack-allocation rules." + - name: "System V AMD64 ABI" + url: "https://gitlab.com/x86-psABIs/x86-64-ABI" + relation: inspired + note: "Normative register order and red-zone definition for the Linux half." +--- + +## What this is + +A reference for the question you hit constantly in reversing and in reading a crash: this +function was called with some arguments, and you need argument number four. There are two +x64 conventions in common use and they disagree about almost everything — register order, +whether the caller reserves stack space for register arguments, and which register anchors +local variables. + +| | Windows x64 | System V (Linux, macOS) | +|---|---|---| +| Integer/pointer args | `RCX`, `RDX`, `R8`, `R9` | `RDI`, `RSI`, `RDX`, `RCX`, `R8`, `R9` | +| Float args | `XMM0`–`XMM3` | `XMM0`–`XMM7` | +| Further args | pushed, right to left | pushed, right to left | +| Caller-reserved space for register args | yes — 32 bytes, always | no | +| Frame anchor | `RSP` | `RBP` conventionally, `RSP` when omitted | +| Leaf red zone | none | 128 bytes below `RSP` | +| Return value | `RAX` | `RAX` | + +Note `RCX` and `RDX` appear in both lists in different positions. Reading a Linux binary with +the Windows order gives you plausible, wrong values — which is the single most common way to +misread a frame. + +## Prerequisites + +- A disassembler that shows registers at a breakpoint: WinDBG, Ghidra's decompiler, GDB, IDA. +- `.frame` and `k` in WinDBG; `bt` and `info frame` in GDB. +- Knowing which convention applies. It follows the *target platform*, not the file format — + so a Windows PE uses the Microsoft convention even when you are analysing it on Linux. + +## Walkthrough + +### Windows x64: the home space is the thing to understand + +Four integer arguments go in `RCX`, `RDX`, `R8`, `R9`. Arguments five and beyond are pushed. +The non-obvious part is that the caller must also reserve 32 bytes immediately above the +return address — the *home space* — whether or not the callee uses it, and whether or not the +function takes four arguments at all. + +Inside a function that has executed its prologue: + +```text +RSP + 0x00 return address +RSP + 0x08 home space slot for arg 1 (the RCX slot) +RSP + 0x10 home space slot for arg 2 (the RDX slot) +RSP + 0x18 home space slot for arg 3 (the R8 slot) +RSP + 0x20 home space slot for arg 4 (the R9 slot) +RSP + 0x28 argument 5 +RSP + 0x30 argument 6 +``` + +The home space exists so the callee has somewhere to spill the register arguments, which it +does whenever it needs their addresses or needs the registers back. That spill is why you can +often recover arguments one through four *after* the registers have been clobbered: look in +the home space. + +The other Windows-specific habit: `RBP` is not a frame pointer. Locals and arguments are +addressed `RSP + offset`, and `RSP` does not move through the body of the function — the +prologue subtracts the whole frame at once and the epilogue adds it back. So an `RSP`-relative +offset is stable anywhere in the body, which is what makes reading these frames mechanical. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28594%29.png" + alt="Annotated Windows x64 stack frame diagram marking the register arguments, the stacked arguments, the return address at RSP and the 32-byte home space above it" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The Windows x64 frame: register arguments, home space, return address, stacked arguments. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +In a decompiler this shows up as the first four arguments being read straight out of the +registers at the top of the function — often as 32-bit sub-registers such as `ECX` when the +parameter is an `int`, which is worth noticing because it tells you the declared width. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28595%29.png" + alt="Ghidra disassembly of a Windows library function showing its first four parameters arriving in ECX, RDX, R8 and R9" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>A real Windows function taking its first four arguments in <code>ECX</code>, <code>RDX</code>, <code>R8</code> and <code>R9</code>. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Reading a frame in WinDBG: + +```erlang +k +.frame 2 +dv /V +dq @rsp L8 +``` + +`dv /V` prints locals with the register or `RSP` offset each one lives at, which saves the +arithmetic when symbols are present. Without symbols, `dq @rsp L8` and the table above is the +whole method. + +### System V: six registers, no home space, RBP-anchored + +Six integer registers in the order `RDI`, `RSI`, `RDX`, `RCX`, `R8`, `R9`, then the stack. +Nothing is reserved for the register arguments, so the stacked arguments start directly above +the saved frame pointer. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28894%29.png" + alt="Debugger view of a nine-argument call on Linux x64 with the first six values visible in RDI, RSI, RDX, RCX, R8 and R9 and the remaining three on the stack" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>A nine-argument call: six in registers, three pushed. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +With a frame pointer established, the stacked arguments are at fixed positive offsets from +`RBP`: + +```text +RBP + 0x00 saved RBP +RBP + 0x08 return address +RBP + 0x10 argument 7 +RBP + 0x18 argument 8 +RBP + 0x20 argument 9 +``` + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28891%29.png" + alt="Annotated Linux x64 stack frame inside a callee, marking which arguments arrived in registers and which were pushed to the stack" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The frame inside the callee, with register and stacked arguments marked. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +### The spilled-argument offset that moves + +Register arguments are often spilled to negative `RBP` offsets so the function can treat them +like locals. Those offsets are not fixed — they depend on how much local space the function +allocated, because the compiler lays out locals first. + +A four-byte first argument in a function with no locals lands at `rbp - 0x4`. Add one local +and it moves to `rbp - 0x14`. Cross 16 bytes of locals and it moves to `rbp - 0x24`; cross 32 +and it is at `rbp - 0x34`. The shift is 0x10 per 16-byte block, which is the alignment the ABI +requires. + +<figure class="shot"> + <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28911%29.png" + alt="Overall Linux x64 process stack layout diagram showing the frame, the argument vector, and the environment block above it" + loading="lazy" referrerpolicy="no-referrer"> + <figcaption>The wider stack: frames below, <code>argv</code> and the environment block above. + <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> + </figcaption> +</figure> + +Two consequences. Do not carry a spilled-argument offset from one function to another, and do +not carry one across a recompile. And when you are looking for a value and the obvious offset +is wrong, count the locals before assuming you have the wrong register. + +At `main`, the same registers hold the program's own arguments: `RDI` is `argc` and `RSI` +points at `argv`, whose first element is the program path. Walk further up the stack and you +reach the environment block — which is where you read the environment of a process whose +`/proc` entry you cannot trust. + +## What it tells you + +**Which byte to read when the symbol is missing.** This is the practical payoff. A memory +image gives you a stack; a sandbox gives you a call with no prototype. The convention tables +above turn "a call happened here" into "argument three was this pointer", which is what you +need to recover a filename, a URL, a buffer length or a key. + +**How to reconstruct a call you only have the aftermath of.** On Windows, the home space +frequently still contains the first four arguments after the function has clobbered the +registers, because the callee spilled them there. On a frame that has already returned, the +values above the old `RSP` are often intact, since nothing zeroes a stack on return. Reading +a stale frame out of a memory image is a standard way to recover an argument whose caller is +gone. + +**Confidence in a hook's argument indices.** Every `Interceptor.attach` handler in +[API tracing with Frida](/sheets/dfir/frida-api-tracing) indexes `args[n]`, and that index is +only correct because of the table at the top of this sheet. When a hook prints nonsense, the +first thing to check is whether you are applying the right convention — a Windows API read +with System V ordering gives you `args[0]` and `args[1]` transposed into the wrong slots and +no error anywhere. + +**Reading the stack trace in an injection investigation.** A backtrace that crosses from a +non-image-backed region into `kernel32` or `ntdll` is the shape of injected code calling the +OS, and the frame below the boundary tells you what it asked for. That is the step between +"[injected-thread hunting](/sheets/dfir/injected-thread-hunting) flagged this thread" and +"this thread opened that file". It needs frame-walking, which needs the convention. + +**Alignment as a sanity check.** Both ABIs require `RSP` to be 16-byte aligned at a call +instruction. A frame where `RSP` is misaligned is either mid-prologue, hand-written assembly, +or you have the wrong frame — and shellcode that ignores alignment is a common source of +crashes in otherwise working injection, which is worth knowing when a payload faults +immediately on entry. + +**Varargs are special on System V.** A variadic call sets `AL` to the number of vector +registers used. If you are reading arguments to a `printf`-family function and the values are +wrong, that is usually why. + +## References + +- [x64 stack usage — Microsoft Learn](https://learn.microsoft.com/en-us/cpp/build/stack-usage) +- [x64 calling convention — Microsoft Learn](https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention) +- [System V AMD64 ABI](https://gitlab.com/x86-psABIs/x86-64-ABI) +- [API tracing with Frida](/sheets/dfir/frida-api-tracing) — where the argument indices get + used in anger