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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user