Fill the stub-authoring, import-mechanism and unwind gaps

injection-and-hooking.md gains where a stub can live, the jump encodings and the
64-bit distance problem, what the stub must preserve, the order the writes land in,
and how the bytes are produced and checked before use.

recon.md gains an import-cluster table: which API sets name which mechanism, and
which reference continues each one.

static-analysis.md reads the x64 exception directory as a function table, with the
three validity checks, the unwind record's contents, and the three limits. compiler-
fingerprints.md points at it.

dotnet-decompilation.md loses a product-name-with-version literal that the leak scan
reports as a strong finding once the file is tracked.
This commit is contained in:
root
2026-10-24 15:35:36 +08:00
parent 8e50676eba
commit 9fd8422d8f
5 changed files with 112 additions and 3 deletions
@@ -54,7 +54,8 @@ and the versioned C++ runtime (both present on this host as `vcruntime140.dll` a
`observed` by path existence) indicates a C/C++ build whose exceptions and STL use the matching
runtime. Symbol names in `.pdb`-style C++ mangling, RTTI records, and a `.pdata` array covering every
non-leaf function give you function extents for free — read the exception directory before
disassembling, because it bounds functions the disassembler will otherwise join.
disassembling (`references/static-analysis.md` reads it as a function table), because it bounds
functions the disassembler will otherwise join.
**The timestamp caution.** A `/Brepro`-style deterministic build writes a hash into date/time fields, and
the file declares that with a debug entry of type 16. The specification's wording is explicit that such
@@ -435,9 +435,10 @@ the feature observed directly.
`unknown`, and it is worth stating precisely: **no .NET tool was installed on the host that wrote
this file**, so every command above is quoted from its project's or vendor's documentation and
none was executed here. `observed` in this file therefore means "the named document says so",
not "a run reproduced it". The versions behind the claims are the ILSpyCmd 11.0.0 help text, the
not "a run reproduced it". The versions behind the claims are the ILSpyCmd command-line help, the
Microsoft Learn pages for `ildasm` and `ilasm`, the de4dot README at the 2015-era source range,
and the dnSpyEx README, read on 2026-10-24.
and the dnSpyEx README, read on 2026-10-24; the exact revision of each is recorded with its
source in `skills/exe-reverse/evidence/tool-sources.md`.
Also not covered:
@@ -118,6 +118,50 @@ whole instructions. Concretely:
be suspended while you write (`inferred`; the failure it produces is a crash inside the target that
disappears under a debugger).
## Building the stub: where it lives, how it reaches, and what it clobbers
The trampoline above is the reading problem. This is the authoring problem, and it has four
independent decisions: where the bytes live, which jump encoding can reach them, what the stub must
preserve, and in what order the writes land.
**Placement.**
| Where | Reached by | Cost |
|---|---|---|
| a run of padding inside an executable section — a "cave" | a relative jump, if the distance is in range | no structural change; the cave may sit in a page the target hashes |
| a new section | a relative jump, because the RVA is chosen with it | structural: section table, alignment and size fields all change, and the file grows (`references/patching.md`) |
| memory allocated in the target | the jump is written into the target's code and points at the allocation | memory patch only; the allocation must be made executable, and a private executable region is visible to a memory scan |
| an existing routine that is close enough | a routine-pointer or call-site edit | no stub at all — the smallest edit, when such a routine exists |
**Reach.** The jump has to encode the distance, and on x64 the distance is the problem: a relative
displacement is 32-bit, so it covers ±2 GB, while an image and a separately allocated region are
commonly farther apart than that (`inferred` from the encoding width).
| Encoding | Bytes | Range | Notes |
|---|---|---|---|
| `EB rel8` | 2 | ±127 | cheapest and the most fragile; a rebuild moves the target |
| `E9 rel32` | 5 | ±2 GB | the default hook; needs five bytes of whole instructions at the site |
| `FF 25 00000000` + an 8-byte address | 14 | the full 64-bit space | reads the address out of the instruction stream; needs 14 bytes at the site, which a function head rarely has |
| `mov rax, imm64; jmp rax` | 12 | the full 64-bit space | clobbers RAX, which the callee may be about to use |
| a relay: the 14-byte form parked in a cave, reached by `E9 rel32` | 5 at the site | ±2 GB to the cave | the usual answer when the stub is far away and the head is short |
**What the stub must preserve.** The non-volatile registers it touches, and the flags if it branches
before restoring them (`pushfq`/`popfq`). A stub that calls anything needs the x64 frame — shadow
space and 16-byte alignment — described in `references/assembly-idioms.md`. Position independence is
not optional: the image base moves between runs, so the stub resolves what it needs at run time,
either from data placed beside it or through the export tables.
**Order of writes.** The stub body goes in first and the hook bytes last, so the site never points at
a half-written routine. A five-byte write is not atomic against a thread executing there: suspend the
other threads, or make the site's page non-executable for the duration. Where the site is 8-byte
aligned, one aligned 64-bit store installs the five-byte jump plus three filler bytes as a single
atomic write, which is why that alignment is worth checking before the site is chosen.
**Producing the bytes.** Assemble the stub to an object, take the `.text` raw bytes, and disassemble
those bytes back before using them: an assembler that emitted a relocation, or a section extracted at
the wrong file offset, still produces something that assembles. Then give the hex to the patcher's
`--bytes` on the file route, or write it into the allocation on the memory route.
## Managed methods are a different target
A managed method's code may not exist yet when you want to hook it: it is produced at run time, can be
+32
View File
@@ -143,6 +143,38 @@ The import list is the cheapest structural evidence in the file, and the most mi
usually a library the request cares about; a stripped export table on a `DLL` subsystem image is
itself a finding.
### The import list names the mechanism, read in clusters
One API proves nothing; three that only make sense together are the mechanism. `GetTickCount` is in
every program, and `GetTickCount` read twice around `Sleep` and compared to a constant is a timing
check. Name the cluster, then find one call site before believing it — mechanism without a site is a
hypothesis (`inferred`).
| Cluster in the import table | Mechanism it points at | Route that continues |
|---|---|---|
| `OpenProcess` + `VirtualAllocEx` + `WriteProcessMemory` + `CreateRemoteThread`, or `NtCreateThreadEx`, `QueueUserAPC`, `SetThreadContext` | injection into another process: a loader, a launcher, or the program's own agent | `references/injection-and-hooking.md` |
| `CreateToolhelp32Snapshot` + `Process32First/Next`, `EnumProcesses`, `OpenProcess` | enumerating processes, usually to find the analysis tool or the game before it | `references/anti-analysis.md` |
| `IsDebuggerPresent`, `CheckRemoteDebuggerPresent`, `NtQueryInformationProcess`, `OutputDebugString` | anti-debug reads, direct or through the PEB | `references/anti-analysis.md` |
| `LoadLibrary` + `GetProcAddress`, or little else | a stub that resolves the rest at run time | `references/protectors-and-packers.md` |
| `RegOpenKeyEx`, `RegQueryValueEx`, `RegCreateKeyEx`, `RegSetValueEx` | local configuration or licence state — in the redirected 32-bit key when the image is 32-bit | `references/license-mechanisms.md` and `references/assembly-idioms.md` |
| `CryptAcquireContext`, `CryptHashData`, `BCrypt*` | hashing or encryption of something local: a stored licence, an integrity digest, a challenge | `references/license-mechanisms.md` and `references/integrity-checks.md` |
| `WinHttpOpen`, `InternetOpen`, `HttpSendRequest`, `WSAStartup` with `socket`/`send`/`recv` | a network-activated decision, or an update check | `references/network-activated-licensing.md` |
| `GetVolumeInformation`, `GetAdaptersInfo`, `GetSystemFirmwareTable`, `WbemLocator` | machine binding: a fingerprint that the licence is scoped to | `references/license-mechanisms.md` |
| `GetTickCount`, `QueryPerformanceCounter`, `timeGetTime` around `Sleep` or `NtDelayExecution` | timing checks: anti-debug or a trial clock | `references/anti-analysis.md` |
| `FindWindow`, `GetForegroundWindow`, `GetWindowText`, `EnumWindows` | watching for a debugger, a monitor, or another product's window | `references/anti-analysis.md` |
| `CreateFileMapping`, `MapViewOfFile`, `CreateMutex`, `CreateEvent` with a fixed name | one-instance enforcement, a local IPC handshake, or state shared with a helper process | `references/injection-and-hooking.md` |
| `GetModuleFileName`, `GetTempPath`, `SHGetFolderPath`, `GetEnvironmentVariable` | where its state actually lives — resolve it before searching, and take the 32-bit view into account | `references/assembly-idioms.md` |
| `CreateProcess`, `ShellExecuteEx`, `WinExec` | a relaunch, an elevation, an updater, or the launcher that extracts a payload | `references/installers-and-packaging.md` |
| `SetWindowsHookEx`, `SetWinEventHook`, `RegisterHotKey` | system-wide observation or input capture | `references/injection-and-hooking.md` |
| `OpenProcessToken` + `AdjustTokenPrivileges`, `IsUserAnAdmin` | privilege manipulation, or an elevation check with a second code path behind it | `references/environment.md` |
| `VirtualProtect`, `FlushInstructionCache`, `VirtualAlloc` with an execute protection | code written at run time: a JIT, a trampoline, or a decryptor | `references/protectors-and-packers.md` |
Two cautions. The same cluster appears in legitimate software — an installer injects, a security
product enumerates processes — so the cluster is a direction, not a verdict. And an unresolved name is
not an absent capability: delay-loaded and manually resolved imports never appear in this table, so a
cluster that should be present and is not is evidence about the resolver. The search you ran is the
finding, not the absence.
## What the program talks to
Do this from the file, before any execution, because a first execution of an unknown binary should
@@ -78,6 +78,37 @@ Consequences for your search:
- Record which search you ran and over what. A negative result is `unknown` with a named search, never
"the program does not contain it".
## The exception directory is a function table (x64)
Every x64 function the toolchain gave unwind data to has a **runtime function entry** in `.pdata`, and
data directory 3 (the exception directory) points at the array. Each entry is three 32-bit RVAs — begin
address, end address, unwind-info address — and the array is sorted by begin address (`inferred` from
the x64 unwind convention; `observed` that the shipped reader reports the directory and its size).
That makes it the only **exhaustive** function table in a stripped image, and it is worth reading before
any disassembly:
- It gives real boundaries, so the tail-call merge and the shared epilogue described above stop being
guesses.
- It bounds a function the disassembler would otherwise run past, because the end address is recorded.
- It gives the frame without executing it: the unwind record carries the prologue size, the frame
register — zero when the frame pointer was omitted — and one code per non-volatile register that was
saved, with where.
Read it before disassembling. `python scripts/peutil.py APP.exe --summary` reports whether directory 3
is populated and how large it is, and `--json` gives the section table so the RVAs can be mapped to
file offsets. Three checks say whether what you parsed is really a table: begin below end, end inside an
executable section, and entries in ascending order. A record that fails one of them means a different
record layout or a packed image, not a table to be repaired.
Three limits. **Leaf functions may have no entry**, because there is nothing to unwind; a call target
outside every range is therefore a leaf, a hand-written routine assembled without unwind data, or a
splice, and which one it is needs a second observation. **x86 has no such table** — the directory is
normally empty and the exception mechanism is stack-linked instead, so on 32-bit the boundaries come
from exports, symbols, call sites and the frame shapes in `references/assembly-idioms.md`. And **the
record layout is architecture-specific**: an ARM64 image populates the same directory with a packed
encoding, so parse the 12-byte x64 record only when the machine field says `0x8664`.
## Call graph and caller counting
The call graph is the cheapest structure in the file, and it is the one that prevents the single most