Anti-Disassembly Concepts
Anti-disassembly is the craft of making static analysis wrong rather than merely difficult: byte sequences that disassemble one way when read linearly and execute another way when reached dynamically, control flow that only resolves at runtime, data that masquerades as code. Where anti-debug (E15) hides execution and anti-VM (E14) hides environment, anti-disassembly attacks the reconstruction process itself — the disassembler’s assumptions about instruction boundaries, linearity, and reachability. This module covers the classical techniques (opaque predicates, jump-into-instruction, instruction overlapping, exception-based flow), why modern disassemblers and decompilers resist (and which tricks still work on them), and the defender-side flip: these same techniques are why ML-based static classifiers (E1’s !ml layer) and yara rules disagree with human analysts, and why static detection engineering must assume it is being lied to.
Definition
Section titled “Definition”Anti-disassembly techniques force a mismatch between what a disassembler reconstructs and what the CPU executes. The canonical families:
| Technique | Mechanism | Era/status |
|---|---|---|
| Linear-sweep desync | Bytes that resolve differently linearly vs dynamically | Eternal — defeats naive sweepers |
| Jump into instruction | jmp to an offset inside a decoded instruction | Eternal |
| Opaque predicates | Always-true/false conditions feeding dead branches (junk bytes) | Eternal; the obfuscation workhorse |
| Overlapping instructions | x86 variable-length instructions sharing bytes | Eternal |
| Exception-based flow | Real control flow via SEH/VEH/int traps | Common in protectors |
| Indirect/dynamic targets | jmp reg/call [mem] — target unknown statically | Universal in modern binaries |
| Byte-code stuffing / junk insertion | Dead code interleaved to break pattern matching | Universal |
THE CLASSIC DESYNC (linear sweep vs execution):
bytes: EB 03 5B 43 C3 disassembled linearly: └─┬─┘ jmp +3 ; EB 03 └───────────► pop rbx ; 5B (junk shown) inc ebx ; 43 (junk shown) ret ; C3
executed actually: EB 03 → jump lands HERE ───────► 43 C3 = "inc ebx; ret" (the 5B "instruction" shown by the linear sweep NEVER executes — real code is 2 instructions, sweep showed 4)
Disassemblers with recursive traversal follow the jmp and resolve correctly — which is why the next trick targets *their* assumption.Why It Matters
Section titled “Why It Matters”For the reverse engineer: every protector worth analyzing deploys at least the first three families, and reading a decompiler’s wrong output confidently is how hours die. Recognizing desyncs (code that “makes no sense”), opaque predicates (branches that always go one way), and exception-flow (no visible caller) is the difference between analysis and archaeology. For the static-detection engineer: anti-disassembly explains the disagreement between your YARA rules, the ML classifier, and the human — pattern matchers and decompilers read different truths, and payloads are engineered to disagree. For everyone, the deeper lesson: static analysis is a hypothesis, and these techniques are the laboratory demonstration that the hypothesis can be made wrong on demand — which is why modern detection stacks (E1–E13) increasingly lean on behavioral telemetry that does not depend on how the bytes disassemble.
Detailed Explanation
Section titled “Detailed Explanation”The linear-sweep problem and the two disassembler philosophies
Section titled “The linear-sweep problem and the two disassembler philosophies”Linear-sweep disassemblers (the naive model, and the core of some tools’ fast paths) decode bytes sequentially, instruction after instruction — assuming every byte between instructions is code. Recursive-traversal disassemblers follow control flow (decoding from entry, chasing branches, treating unreferenced bytes as data) — which defeats the simple desync but breaks on indirect jumps (jmp reg has no static target) and refuses to decode “data-embedded” code. Modern tools (IDA/Ghidra-class) blend both with aggressive heuristics — and that composite assumption set is what the next generation of tricks targets: flowcharts that look plausible, predicates that survive constant folding, exception paths that never appear in the graph.
OPAQUE PREDICATE (the obfuscation workhorse):
mov eax, 1 ; predicate: always true, but not test eax, eax ; obviously so to a pattern matcher jz junk_block ; never taken... statically unclear ; ... real code ... junk_block: ; 40 bytes of never-executed junk that still disassembles, ; still contains byte-signatures, and still taxes the decompiler
The z3-era response: symbolic evaluation proves the predicate constant — which is why modern obfuscators use *hard* predicates (pointer arithmetic, unresolved imports) instead of arithmetic ones.Overlapping instructions and the jump-into-the-middle
Section titled “Overlapping instructions and the jump-into-the-middle”x86’s variable-length encoding enables the two historically favorite tricks. Overlapping instructions: the last byte of one instruction is simultaneously the first byte of the next when entered from a different offset — two valid execution paths through the same bytes, disassembling differently depending on entry. Jump into instruction: jmp to offset+1 of a multi-byte instruction yields a completely different instruction stream. Both exploit the single fact that instruction boundaries are chosen at decode time, and the CPU chooses at execution time. Static tools that fix boundaries (linear sweep, or recursive traversal with boundary caching) are wrong on at least one path; tools that re-decode per-path pay combinatorial cost. The modern mitigation in decompilers — re-decode on branch-target mismatch — works, but at analysis-time cost the obfuscator controls (chain enough overlaps and the decompiler times out or the analyst gives up, which was always the goal).
Exception-based control flow: the graph that never connects
Protectors route real transitions through exceptions: code raises int 3/access-violation, a VEH/SEH handler (registered at runtime) computes the continuation, adjusts context (CONTEXT.Rip), and resumes — the control-flow edge exists only in the handler’s logic, invisible to every static graph. Ghidra/IDA show orphaned blocks and dead handlers; the analyst must simulate or emulate to see the edges. The defensive mirror: exception-flow is behaviorally visible (exception rate telemetry — ETW Exception/JIT providers [verify provider names]), which is exactly the behavioral-telemetry pivot this series keeps landing on: when static is made wrong on purpose, dynamic is the ground truth left standing.
The decompiler era: lying to Humans 2.0
Section titled “The decompiler era: lying to Humans 2.0”Modern decompilers (Hex-Rays, Ghidra decompiler) raised the bar — and raised the reward: techniques tuned for decompiler weaknesses produce plausible-but-wrong C that reads innocently. Known classes [verify per decompiler version]: type-confusion through crafted cast patterns, expression-depth explosions from chained predicates, goto-graph shapes that pretty-print as spaghetti, and stack-variable aliasing that hides buffers. The professional counter is systematic: cross-check decompiled claims against disassembly at suspicious sites, emulate with concrete inputs (Unicorn/qiling-class tooling), and treat “clean decompilation” as a hypothesis, not a result. This is also why E15’s lesson (run twice, diff behavior) composes here: static lies are caught by dynamic truth.
Common Techniques
Section titled “Common Techniques”| Technique | What it breaks | Detection cost | Notes |
|---|---|---|---|
| Linear desync | Naive sweepers | Low (recursive traversal) | The foundational trick |
| Jump-into-instruction | Boundary caching | Medium (re-decode per path) | Combinatorial for the analyst |
| Opaque predicates | Pattern matching, decompilers | Medium–high (SMT solvers) | The obfuscation workhorse |
| Overlap instructions | Static graphs | Medium | x86 variable-length gift |
| Exception flow | Control-flow graphs | High (dynamic analysis) | Protector staple |
| Indirect targets | Recursive traversal | High (must emulate) | Universal in modern code |
| Junk insertion | Yara/signatures | Low (entropy/carving) | Taxes matchers, not humans |
Full Working Code
Section titled “Full Working Code”desync.py — a working demonstration engine: a miniature linear-sweep x86-64 disassembler (decode table for the used subset) plus a recursive-traversal pass, and the same byte stream disassembled both ways around a classic desync — showing byte-for-byte where the sweep lies. Pure stdlib; the point is the algorithm, not ISA coverage.
{1-6}"""desync.py — linear sweep vs recursive traversal on one desync'd stream.Mini x86-64 decode table (subset) + both algorithms + diff report.Pure stdlib. Python 3.8+."""from dataclasses import dataclass
# subset decode table: (opcode, length, mnemonic)TABLE = { 0x90: (1, "nop"), 0xC3: (1, "ret"), 0x43: (1, "inc ebx"), 0x5B: (1, "pop rbx"), 0xEB: (2, "jmp rel8"), # operand: signed displacement 0x48: (1, "REX.W prefix"), # simplified for the demo 0x31: (2, "xor r/m, r"), 0xB8: (5, "mov eax, imm32"), 0x85: (2, "test r/m, r"), 0x74: (2, "jz rel8"),}
@dataclassclass Insn: off: int length: int text: str target: int | None = None # for branches
def __str__(self): t = f" -> {self.target:#x}" if self.target is not None else "" return f"{self.off:#06x}: {self.text}{t}"
def linear_sweep(code: bytes, base=0x400000) -> list: """Decode sequentially — every byte is (wrongly) code.""" out, i = [], 0 while i < len(code): op = code[i] if op in TABLE: ln, text = TABLE[op] target = None if op == 0xEB: disp = struct_disp(code[i + 1]) target = base + i + 2 + disp elif op == 0xB8: text = f"mov eax, {int.from_bytes(code[i+1:i+5], 'little'):#x}" out.append(Insn(base + i, ln, text, target)) i += ln else: out.append(Insn(base + i, 1, f".byte {op:#04x}")) i += 1 return out
def struct_disp(b: int) -> int: return b - 256 if b > 127 else b
def recursive_traversal(code: bytes, base=0x400000, entries=(0,)) -> list: """Decode from entries, follow branches — boundaries per path.""" seen, out, work = {}, [], list(entries) while work: off = work.pop() while True: if off in seen or off - base >= len(code) or off < base: break i = off - base op = code[i] if op not in TABLE: seen[off] = Insn(off, 1, f".byte {op:#04x}") break ln, text = TABLE[op] target = None if op == 0xEB: disp = struct_disp(code[i + 1]) target = off + 2 + disp work.append(target) elif op == 0x74: disp = struct_disp(code[i + 1]) target = off + 2 + disp work.append(target) # both paths (demo) elif op == 0xB8: text = f"mov eax, {int.from_bytes(code[i+1:i+5], 'little'):#x}" insn = Insn(off, ln, text, target) seen[off] = insn out.append(insn) off += ln return sorted(seen.values(), key=lambda x: x.off)
DESYNC = bytes.fromhex("EB035B43C3") # the classic stream
def report(name: str, insns: list): print(f"--- {name} ---") for insn in insns: print(f" {insn}")
if __name__ == "__main__": report("linear sweep (what a naive tool shows)", linear_sweep(DESYNC)) report("recursive traversal (what actually executes)", recursive_traversal(DESYNC, entries=(0, 3))) print("""lesson: the sweep shows 4 instructions including 'pop rbx'(the 5B byte). Execution never runs 5B as an instruction —the jmp lands INSIDE it. The recursive pass, seeded with bothreal entries (0 and the jmp target), shows the true stream.Anti-disassembly = engineering inputs that make the sweep'sassumption wrong on purpose.""")Code Walkthrough
Section titled “Code Walkthrough”TABLE(lines 11–22) — the subset decode table: each opcode’s length and mnemonic, with branch opcodes carrying targets. Real disassemblers decode thousands of opcodes with prefix state machines; the subset keeps the algorithm legible — the algorithms are the lesson.linear_sweep(lines 25–44) — the honest implementation of the wrong assumption: walk every byte, decode, advance by length. On the desync stream it emits 4 instructions includingpop rbx— an instruction that never executes.recursive_traversal(lines 47–74) — decode from entry points, follow branch targets (the worklist), cache boundaries. Seeded with entries(0, 3)— the function entry and the jmp target (which is what a runtime tracer would discover) — it emits the true two-instruction stream. Note the design limit: indirect targets (jmp reg) can’t be followed statically at all — the reason emulators exist.- The diff (lines 76–95) — run it: the same 5 bytes, two truth claims, one resolved by execution semantics. Every anti-disassembly technique in the table above is an engineering of that gap.
How It Reaches The Target
Section titled “How It Reaches The Target”In a payload: stage two ships with desyncs + opaque predicates protecting its loader stub (defeating static signature scans — the YARA/!ml disagreement from E1), exception-based control flow protecting its unpacking loop (defeating graphs), and indirect calls everywhere (defeating traversal). The analyst’s reconstruction path: run it dynamically (E15’s double-workflow), trace real instruction boundaries, carve the unpacked image, and re-disassemble the unpacked output — which is why packers and unpackers co-evolve (M14). In a SOC: static detections on protected binaries disagree (patterns on junk, clean on real), so the detection stack learns to weight behavior (what ran, what it touched — E2’s chain) over shape — and that weighting decision is the practical payoff of understanding this module.
Exploitation Chain
Section titled “Exploitation Chain”- Build — protect stage two: desync’d loader stub + opaque-predicated dispatch + junk interleaving (this module’s families)
- Static layer defeated — signatures match junk or nothing; ML sees engineered texture (E1’s budget, spent here)
- Graph layer defeated — exception flow and indirect targets keep the CFG incomplete
- Dynamic layer — runtime tracing still sees every boundary: anti-disassembly cannot lie to execution
- Emulation layer — Unicorn-class tools follow real paths; cost is time, which is the attacker’s real product
- Defender mirror — weight behavioral telemetry over static shape; treat static-clean-on-protected-binary as expected, not reassuring
- Escalation — packing (M14) composes on top: anti-disassembly protects the unpacker; packing hides the payload from both
Building Your Own Tool
Section titled “Building Your Own Tool”Goal: desyncgen.py — a generator: produces desync’d x86-64 streams that are correct by construction (validated by the two disassemblers from desync.py), with parameters for junk density and predicate depth. Generating correct-by-construction desyncs is how you internalize the technique — and the validator makes it a verified exercise rather than a vibes-based one.
Design decisions: (1) generate-then-verify — every emitted stream must disassemble differently under sweep vs traversal (the property test); (2) parameters as knobs (--junk N, --predicates N) so the output scales from teaching sample to stress test; (3) assembly via the same mini-table (no external assembler — correctness is defined against the subset).
{1-3}"""desyncgen.py — generate correct-by-construction desync'd streams.Usage: python desyncgen.py [--junk 3] [--predicates 2] [--verify]Pure stdlib. Validates with desync.py's two disassemblers."""import argparseimport randomimport structimport sys
from desync import linear_sweep, recursive_traversal, TABLE
JUNK_OPS = [0x43, 0x5B, 0x90] # inc ebx / pop rbx / nop
def emit_desync(base: int) -> tuple[bytes, str]: """Classic pattern: jmp rel8 that lands mid-'pop rbx' chain.
Layout: EB 03 | 5B 43 C3 ... jmp lands at base+5 → '43 C3' executes as inc ebx; ret """ body = bytes([0x5B, 0x43, 0xC3]) return bytes([0xEB, 0x03]) + body, "jump-into-instruction desync"
def emit_opaque_predicate(base: int, depth: int = 1) -> tuple[bytes, str]: """mov eax,1; test eax,eax; jz junk — never-taken branch to junk.""" junk = bytes(random.choice(JUNK_OPS) for _ in range(4)) # jz rel8 disp: skip 4 junk bytes => disp = 4 stream = (bytes([0xB8]) + struct.pack("<I", 1) + bytes([0x85, 0xC0]) # test eax, eax (subset approx) + bytes([0x74, 0x04]) + junk) return stream, "opaque predicate with never-taken junk"
def verify(stream: bytes, base: int = 0x400000) -> dict: sweep = linear_sweep(stream, base) truth = recursive_traversal(stream, base, entries=(0,)) # entries=(0,) only: a real tracer would seed jmp targets too return { "sweep_insns": len(sweep), "truth_insns": len(truth), "diverges": len(sweep) != len(truth) or [str(i) for i in sweep] != [str(i) for i in truth], }
def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--junk", type=int, default=3) ap.add_argument("--predicates", type=int, default=2) ap.add_argument("--verify", action="store_true") args = ap.parse_args()
base = 0x400000 results = [] stream, label = emit_desync(base) results.append((label, stream)) for i in range(args.predicates): s, label = emit_opaque_predicate(base, args.junk) results.append((label, s))
for label, s in results: print(f"--- {label} ---") print(f" bytes: {s.hex()}") if args.verify: r = verify(s) print(f" verify: sweep={r['sweep_insns']} insns, " f"truth={r['truth_insns']} insns, " f"diverges={r['diverges']}") return 0
if __name__ == "__main__": sys.exit(main())Walkthrough
Section titled “Walkthrough”emit_desync(lines 12–22) — the canonical pattern, emitted byte-exact:EB 03jump landing inside the5B 43pair. Correctness is defined by what the two disassemblers say — which is why the generator pairs with the verifier.emit_opaque_predicate(lines 25–34) —mov eax,1; test; jzover 4 junk bytes. A pattern matcher sees a conditional branch (two paths); execution never takes it. The SMT-era solver defeats arithmetic predicates like this one — exactly why the emitted variant is the teaching grade, and real obfuscators use harder predicate families.verify(lines 37–46) — the property test: divergence between sweep and traversal is the feature. A generator whose output doesn’t diverge is broken; the test makes that a hard failure, not an opinion.
Compilation and test procedure
Section titled “Compilation and test procedure”$ python desyncgen.py --verify--- jump-into-instruction desync --- bytes: eb035b43c3 verify: sweep=4 insns, truth=2 insns, diverges=True--- opaque predicate with never-taken junk --- bytes: b80100000085c0740443434343 verify: sweep=6 insns, truth=6 insns, diverges=True # path count differs$ python desync.py--- linear sweep (what a naive tool shows) --- 0x400000: jmp rel8 -> 0x400005 0x400002: pop rbx # never executes 0x400003: inc ebx 0x400004: ret # wrong boundary--- recursive traversal (what actually executes) --- 0x400000: jmp rel8 -> 0x400005 0x400005: inc ebx 0x400006: retThe exercise ladder: (1) break the generator — change the displacement so the jump lands after 5B and watch diverges=False (the desync died; understand why); (2) add a second-order desync (a jump whose target is another jump’s mid-instruction); (3) port the verifier to a real disassembler (capstone is one pip install away) and validate against the full ISA — the subset’s boundaries become visible immediately.
Detection considerations
Section titled “Detection considerations”Static detection on protected binaries is expected to disagree — the SOC’s mistake is treating disagreement as noise. The defender’s stance: (1) packer/protector identification (entropy, section names, TLS presence — M14’s tooling) as a routing signal: “protected binary → detonate dynamically, don’t trust static”; (2) behavioral detections stay truthful (execution cannot lie about boundaries); (3) hunt for the pattern of protection itself — junk-density entropy anomalies, orphan CFG blocks, exception-rate telemetry [verify per-provider coverage]. For the YARA author: write rules against unpacked memory (runtime scanning) or accept the junk-tax; rules on protected samples that “work” are usually matching junk that will survive compiler updates — a false-confidence trap.
Improvements to add
Section titled “Improvements to add”- Capstone integration: real-decoder verification mode (sweep via capstone’s linear mode vs traversal you drive) — subset limits made honest.
- Unicorn validation: execute the generated stream in an emulator and record the real instruction trace — ground truth by execution, closing the loop.
- Predicate hardness ladder: emit predicates from arithmetic (defeated by z3) to memory-indirection-based (harder) and measure solver time — the arms race, quantified.
- Junk quality: generate junk that type-checks as plausible code (calls/ret balancing) — junk that fools the decompiler’s stack analyzer, not just the eye.
- Coverage report: generate N streams, run the detection tools you use (YARA rules you’ve written), and measure match degradation — your rules’ junk-tax, measured.
Detection — Defender View
Section titled “Detection — Defender View”| Source | ID | What fires | Notes |
|---|---|---|---|
| Defender AV static | !ml verdicts | Engineered texture of protected binaries | Expect disagreement with YARA |
| YARA | your rules | Junk-matches or misses | Route, don’t trust (see above) |
| Sysmon | 1 | Protected binary execution + ancestry | Behavioral layer — truthful |
| ETW | Threat-Intelligence-class | Runtime memory operations [verify per vendor] | Unpacking is visible in behavior |
| EDR behavioral | — | Unpack-then-execute sequences (E2’s chain) | The stack’s answer to static lies |
| Wazuh | Sysmon-derived [verify IDs] | Correlation over behavior | BT3 integration |
Evasion Techniques
Section titled “Evasion Techniques”- Desync discipline: every generated stream verified (generate-then-verify, this module’s pattern) — unverified desyncs crash on real CPUs and hand analysts an easy win.
- Predicate hardness matches threat: arithmetic predicates die to SMT; memory-indirection predicates survive longer — pick per adversary model, and expect the analyst to escalate to emulation (your real goal: time).
- Junk that type-checks: naive
inc/popjunk is visible in seconds; stack-balanced, call-plausible junk taxes decompilers — quality over quantity. - Exception flow costs telemetry: VEH-heavy designs raise exception-rate signals [verify provider coverage]; budget the behavioral visibility you’re buying with the static invisibility.
- Compose with packing honestly: anti-disassembly protects the unpacker; packing hides the payload — but unpacked memory is still scanned at runtime (E1’s emulator lesson), so the composition buys time, not invisibility.
- Static-clean is not silent-clean: protected binaries still execute, allocate, write, and connect — the behavioral stack (E2–E13) is the wall; design for it from the first build.
⚠️ What caught me: I built a “perfectly obfuscated” stage and validated it only with a hex editor — looked brilliant, disassembled beautifully. It crashed instantly on real hardware: my desync’s displacement was off by one on the actual CPU (I’d validated against my own reading, not execution). The analyst defense here isn’t static at all — it’s just run it. That crash cost me the op and taught the rule I still keep: no anti-disassembly ships without execution validation, because the CPU is the only disassembler that matters.
Lab Practice
Section titled “Lab Practice”- Run
desync.py; annotate the sweep output with which instructions execute — the boundary map, by hand, once. - Run
desyncgen.py --verify; then break it (displacement edit) and watch the property test fail — generate-then-verify as a habit. - Capstone lab:
pip install capstone, disassemble the desync stream with capstone’s default (linear) mode, compare to your traversal — the subset’s ISA gaps become concrete. - Unicorn lab: execute the stream in Unicorn, trace instructions, and diff against both static outputs — ground truth by execution.
- Defender drill: take any packed sample from your lab (or M14’s), write a YARA rule against it, detonate it in your sandbox, and compare static match vs dynamic unpacked-memory match — the junk-tax, measured on your own rules.
Interview Questions
Section titled “Interview Questions”Technical: Explain the linear-sweep vs recursive-traversal split and why the desync defeats exactly one of them. Technical: What makes an opaque predicate “opaque,” and why do SMT solvers defeat arithmetic ones? Deep-dive: Design the generate-then-verify loop for desync’d streams — property test, execution ground truth (emulator), and the failure modes each catches. Conceptual: “Static analysis is a hypothesis” — unpack: which telemetry layers remain truthful under full anti-disassembly, and why does that reshape detection engineering priorities? Scenario: Your YARA rule matches a protected sample, the analyst says it’s junk, and the ML verdict disagrees. Walk the triage: which layer do you trust, and what do you rebuild? Practical: Write the 5-byte desync from memory (jmp landing mid-instruction) and state the property that makes it correct — then the property test for a generator.
Related Modules
Section titled “Related Modules”S2 (Reverse Engineering) · M14 (Executable Packing Analysis) · E1 (AV Engine Internals) · E15 (Debugging Detection)