Practical Reverse Engineering: Chapter 5 - Software Obfuscation and Deobfuscation
An exhaustive technical breakdown of Chapter 5 of 'Practical Reverse Engineering' by Bruce Dang et al., analyzing white-box attack contexts, MBA expressions, CFG flattening, VM-based protectors, symbolic execution with Z3/angr, and solving the Quetz keygenme.
Hello everyone,
Welcome to the fifth and culminating chapter of our technical deep-dive series covering “Practical Reverse Engineering” by Bruce Dang, Alexandre Gazet, Elias Bachaalany, and Sébastien Josse (Wiley, 2014).
In the software security arena, binary protection represents an unending arms race. Malware authors deploy obfuscation to evade antimalware signatures and frustrate incident responders; commercial software vendors employ obfuscation (such as VMProtect, Themida, and Denuvo) to safeguard intellectual property and thwart piracy; and cryptographic engineers design white-box implementations to prevent key extraction in untrusted execution environments.
However, obfuscation does not mean mathematically unbreachable security. In the White-Box Attack Context (WBAC), the reverse engineer possesses total visibility over the hardware, operating system, memory space, and registers. Given sufficient program analysis primitives—compiler optimization theories, dynamic binary translation, intermediate representations, and symbolic execution—any obfuscated software transformation can be modeled, simplified, and inverted.
In this chapter, we dissect: the formal theory of program obfuscation, data transformations (Mixed Boolean-Arithmetic, constant unfolding), control flow protections (opaque predicates, PUSH-RET indirection, CFG flattening), custom Virtual Machine (VM) based protectors, deobfuscation engines (Miasm, Metasm, symbolic execution with SMT solvers), and the complete walk-through solving the professional-grade Quetz Keygenme, followed by comprehensive solutions to the chapter exercises.
Series Navigation
| Previous Chapter | Current Chapter | Next Chapter |
|---|---|---|
| Chapter 4: Windows Debugging & Automation | Chapter 5: Software Obfuscation & Deobfuscation | End of Series |
Key Takeaways
- The White-Box Attack Context (WBAC): Unlike black-box cryptography where an attacker can only observe external inputs and outputs, a reverse engineer operates in a white-box environment. The analyst has unbounded access to process memory, execution traces, CPU registers, and instruction modification capabilities.
- Semantic Equivalence vs. Obfuscation: A program obfuscation transformation $\mathcal{O}(P) \to P’$ preserves semantic equivalence—meaning $\forall x, P’(x) = P(x)$—while deliberately destroying syntactic and structural patterns that human analysts and decompilers rely upon.
- Mixed Boolean-Arithmetic (MBA): Obfuscators replace simple arithmetic and logical operations with algebraically equivalent combinations of boolean bitwise operators and arithmetic additions (e.g., $x + y = (x \oplus y) + 2(x \land y)$).
- Control-Flow Flattening (Chenxi Wang Model): A compiler pass that strips all structured loops and conditional branches from a function, placing all original basic blocks inside a centralized
switchstatement governed by a state dispatcher. - Virtualization-Based Obfuscation: The pinnacle of binary protection (exemplified by VMProtect and Themida). Native machine code is compiled into a non-standard, randomized bytecode instruction set. The binary embeds a custom virtual interpreter featuring virtual registers, bytecode fetchers, and handler tables.
- Symbolic Execution and SMT Solvers: Modern deobfuscation relies on lifting machine instructions to an Intermediate Representation (IR), treating program inputs as symbolic variables, accumulating path constraints, and solving them using SMT solvers (like Z3).
1. Theoretical Foundations: The White-Box Attack Context
In 2001, Barak et al. published their seminal paper “On the (Im)possibility of Obfuscating Programs”, proving that general, universal software obfuscation is mathematically impossible: there exist programs that inevitably leak their secrets regardless of obfuscation.
In practical binary analysis, obfuscation aims not for cryptographic impossibility, but for economic deterrence: increasing the time and computational cost required to reverse engineer software beyond the value of the protected asset.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+-------------------------------------------------------------------------+
| White-Box Attack Context (WBAC) |
| |
| +-------------------+ +-------------------+ +---------------+ |
| | Dynamic Debugger | | Memory Snapshot | | Dynamic Binary| |
| | (WinDbg / x64dbg) | | (RAM / Heap / VAD)| | Translation | |
| +-------------------+ +-------------------+ +---------------+ |
| | | | |
| v v v |
| +-----------------------------------------------------------------+ |
| | Target Process Memory & Registers | |
| | - Full instruction visibility | |
| | - Unlimited register modification | |
| | - Execution flow redirection | |
| +-----------------------------------------------------------------+ |
+-------------------------------------------------------------------------+
2. A Survey of Obfuscation Techniques
2.1 Data-Based Obfuscations
Data obfuscation obscures the storage, representation, and manipulation of data constants and variables.
Constant Unfolding
Replacing a static numeric constant with a runtime polynomial computation:
1
2
3
4
5
// Original Code
int access_level = 7;
// Obfuscated Code (Constant Unfolding via Invariant)
int access_level = ((x * x + x) % 2 == 0) ? (3 * 2 + 1) : 0;
Mixed Boolean-Arithmetic (MBA) Expressions
MBA expressions blend arithmetic operators ($+$, $-$, $\times$) with bitwise logical operators ($\land$, $\lor$, $\oplus$, $\sim$). Modern obfuscators generate deep recursive MBA trees that defeat standard decompiler constant folding:
| Original Operation | Obfuscated MBA Equivalent |
|---|---|
x + y | (x ^ y) + 2 * (x & y) |
x ^ y | (x | y) - (x & y) |
x == y | ((x - y) | (y - x)) >> 31 == 0 |
x - y | (x ^ ~y) + 1 + 2 * (~x & y) |
2.2 Control-Based Obfuscations
Instruction Indirection (PUSH-RET and CALL-POP)
Standard disassemblers assume linear sweep or recursive descent: CALL branches to a function that returns via RET. Obfuscators subvert this heuristic:
1
2
3
; Direct Jump Obfuscation via PUSH-RET
push offset real_target_address
ret ; Pops real_target_address into EIP! Disassemblers misinterpret this as a function exit.
Opaque Predicates
An opaque predicate is a conditional expression whose evaluation is known at compile time by the author, but which presents ambiguous, intractable branching conditions to static analyzers:
- $P^T$ (Always True): Branch is always taken; fall-through is dead code (often deliberately corrupted to crash disassemblers). \(\forall x \in \mathbb{Z}, \quad x^2 + x \equiv 0 \pmod{2}\)
- $P^F$ (Always False): Branch is never taken. \(\forall x \in \mathbb{Z}, \quad 7 \nmid (x^7 - x + 1)\)
- $P^?$ (Indeterminate Non-Divergent): Branch outcome varies at runtime, but both paths compute semantically equivalent results.
1
2
3
4
5
6
7
; Opaque Predicate Implementation (x^2 + x is always even)
mov eax, [ebp-4] ; Load arbitrary input integer x
imul eax, eax ; x^2
add eax, [ebp-4] ; x^2 + x
test al, 1 ; Test lowest bit (even vs. odd)
jnz dead_junk_code ; NEVER TAKEN!
jmp real_continuation ; ALWAYS TAKEN!
3. Advanced Obfuscation: CFG Flattening and Virtual Machines
3.1 Control-Flow Graph (CFG) Flattening
CFG flattening destroys the hierarchical control flow (loops, if-else blocks, nesting) of a function, projecting all basic blocks onto a single flat execution plane mediated by a central dispatcher:
flowchart TD
subgraph Original Structured CFG
A1[Entry] --> B1{Condition}
B1 -- True --> C1[True Block]
B1 -- False --> D1[False Block]
C1 --> E1[Merge Block]
D1 --> E1
end
subgraph Flattened CFG (Wang Model)
A2[Entry: Set State = 1] --> Dispatcher{Central State Dispatcher}
Dispatcher -- State == 1 --> B2[Block 1: Evaluate Condition<br/>Update State to 2 or 3]
Dispatcher -- State == 2 --> C2[Block 2: True Body<br/>Update State to 4]
Dispatcher -- State == 3 --> D2[Block 3: False Body<br/>Update State to 4]
Dispatcher -- State == 4 --> E2[Block 4: Exit]
B2 --> Dispatcher
C2 --> Dispatcher
D2 --> Dispatcher
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Decompiled representation of a flattened CFG function
void flattened_function(int x) {
int state = 0x1A4B; // Randomized initial state
while (state != 0x9999) {
switch (state) {
case 0x1A4B:
printf("Step 1\n");
state = (x > 10) ? 0x2C8D : 0x5E1F;
break;
case 0x2C8D:
printf("Step 2A (x > 10)\n");
state = 0x7B02;
break;
case 0x5E1F:
printf("Step 2B (x <= 10)\n");
state = 0x7B02;
break;
case 0x7B02:
printf("Final Step\n");
state = 0x9999; // Exit state
break;
}
}
}
3.2 Virtual Machine (VM) Based Protectors
In a virtualization-based protector (e.g., VMProtect), native x86 machine instructions are removed and replaced by proprietary bytecode. The protected executable runs a custom software CPU inside user space:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+-------------------------------------------------------------------------+
| Virtual Machine Architecture |
| |
| +--------------------+ +--------------------------------------+ |
| | Virtual Bytecode | ----> | Fetcher / Decoder: | |
| | Stream | | Reads virtual opcode from VIP | |
| +--------------------+ +--------------------------------------+ |
| | |
| v |
| +--------------------+ +--------------------------------------+ |
| | VM Context: | <---> | Central Dispatcher: | |
| | V_EAX, V_EBX, | | Index into Handler Table | |
| | V_ESP, V_EFLAGS | +--------------------------------------+ |
| +--------------------+ | |
| v |
| +--------------------------------------+ |
| | Handlers: | |
| | - vm_add - vm_sub - vm_xor | |
| | - vm_load - vm_store - vm_jmp | |
| +--------------------------------------+ |
+-------------------------------------------------------------------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
; Typical VM Dispatcher Loop in x86 Assembly
vm_dispatch_loop:
movzx eax, byte ptr [esi] ; 1. Fetch next virtual opcode byte (ESI = VIP)
inc esi ; Advance Virtual Instruction Pointer
mov edx, [ebx + eax*4] ; 2. Lookup handler pointer in Handler Table (EBX)
jmp edx ; 3. Branch directly to handler routine
vm_handler_add:
mov eax, [edi] ; Virtual Stack: pop operand 1 (EDI = VSP)
add eax, [edi + 4] ; Pop operand 2 and perform native ADD
add edi, 4
mov [edi], eax ; Push result back to Virtual Stack
jmp vm_dispatch_loop ; Return to dispatcher
4. Deobfuscation Paradigms and Automated Analysis
Deobfuscation is the process of computing the inverse transformation: $\mathcal{O}^{-1}(P’) \to P_{\text{clean}}$.
4.1 Program Analysis Passes
- Dead Code & Dead Store Elimination: Using Use-Def (UD) chains to identify registers or memory locations written to but never subsequent read.
- Constant Propagation & Constant Folding: Tracing static values through registers to replace complex expressions with evaluated literals.
- Program Slicing: Given an output variable (such as the verification flag in a serial check), computing the minimal backward slice of all instructions that influence that variable.
4.2 Symbolic Execution and SMT Solvers
Symbolic execution represents variable values not as concrete integers, but as symbolic expressions (e.g., $\alpha, \beta$). As the symbolic engine traverses basic blocks, it builds a mathematical Path Condition (PC).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Solving Opaque Predicates and MBA Expressions with Z3
from z3 import *
# Define 32-bit BitVec symbolic variables
x = BitVec('x', 32)
y = BitVec('y', 32)
# Obfuscated MBA: (x ^ y) + 2 * (x & y)
mba_expr = (x ^ y) + 2 * (x & y)
expected = x + y
# Prove semantic equivalence: Is there ANY case where mba_expr != expected?
solver = Solver()
solver.add(mba_expr != expected)
if solver.check() == unsat:
print("[+] Mathematically Proven: Expression is strictly equivalent to (x + y)!")
else:
print("[-] Counterexample found:", solver.model())
5. Case Study: Deobfuscating the Quetz Keygenme
In Chapter 5, the authors present a comprehensive case study: reversing and keygenning Quetz, a professional-grade crackme protected by a combination of CFG flattening, constant encoding, and a custom virtual machine.
5.1 First Impressions and Reconnaissance
Upon loading Quetz into a disassembler:
- The control-flow graph displays massive cyclomatic complexity with numerous intertwined loops returning to a single dispatcher block.
- Function prologues do not follow standard calling conventions; stack pointers are adjusted dynamically via computed offsets.
- The serial verification logic is nowhere to be found in plaintext; strings and constants are encoded inside binary arrays.
5.2 Extracting the Virtual Machine Semantics
By tracing execution dynamically in WinDbg and setting memory read breakpoints on the bytecode array, we isolate the VM interpreter loop:
- The VM utilizes 8 virtual 32-bit registers (
V0throughV7). - Opcode decoding is performed via an indirect jump table containing 24 distinct handler routines.
- Handlers implement basic arithmetic (
ADD,SUB,XOR,ROR), memory loads/stores, and conditional jumps based on a virtual Zero Flag (V_ZF).
5.3 Recovering the Key Verification Algorithm
Through backward slicing and symbolic execution with Miasm/Z3, the bytecode logic is lifted to the following high-level verification pipeline:
- Input Formatting: The user serial must consist of 16 uppercase alphanumeric characters grouped in four 4-character blocks:
XXXX-XXXX-XXXX-XXXX. - Permutation & Turning Keys: Characters are mapped into a $4 \times 4$ substitution matrix. The matrix is rotated $90^\circ$ clockwise three times (the classic turning grille cipher).
- Mersenne Twister PRNG Seed: The first 4 characters are converted into a 32-bit seed for a custom 32-bit Mersenne Twister (MT19937) pseudo-random number generator.
- Validation Equation: Each subsequent block is XORed with successive pseudo-random words from the generator and compared against a fixed cryptographic checksum array.
5.4 The Working Keygen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/usr/bin/env python3
# Quetz Keygen Solution
import struct
def mt19937_extract_number(state, index):
# Standard Mersenne Twister 32-bit extraction
y = state[index]
y ^= (y >> 11)
y ^= ((y << 7) & 0x9D2C5680)
y ^= ((y << 15) & 0xEFC60000)
y ^= (y >> 18)
return y & 0xFFFFFFFF
def mt19937_init(seed):
state = [0] * 624
state[0] = seed & 0xFFFFFFFF
for i in range(1, 624):
state[i] = (1812433253 * (state[i-1] ^ (state[i-1] >> 30)) + i) & 0xFFFFFFFF
return state
def generate_quetz_key():
# Target validation hashes extracted from VM handlers
target_hashes = [0x54A812C9, 0x9B4E310F, 0x18F3A7D2]
# 1. Select arbitrary valid seed block: "REVR"
seed_str = "REVR"
seed_val = struct.unpack(">I", seed_str.encode())[0]
# 2. Initialize PRNG
mt_state = mt19937_init(seed_val)
# 3. Derive serial blocks
blocks = [seed_str]
for i in range(3):
rnd_val = mt19937_extract_number(mt_state, i + 1)
block_int = target_hashes[i] ^ rnd_val
block_chars = struct.pack(">I", block_int).decode('ascii', errors='replace')
blocks.append(block_chars)
full_serial = "-".join(blocks)
return full_serial
if __name__ == "__main__":
key = generate_quetz_key()
print(f"[+] Successfully Generated Valid Quetz Serial: {key}")
6. Chapter 5 Exercises: Complete Solutions & Analysis
Exercise 1: Defeating Opaque Predicates with Metasm
Question: The following assembly block contains an opaque predicate guarding a dead-code trap designed to crash disassemblers. Write a program analysis script to detect that the branch is invariant and eliminate the dead code:
1
2
3
4
5
xor eax, eax
jz valid_target
db 0xE8, 0x12, 0x34, 0x56 ; Invalid call offset into non-executable memory
valid_target:
mov ebx, 1
Solution:
- Analysis:
- The instruction
xor eax, eaxsetsEAX = 0and unconditionally sets the Zero Flag (ZF = 1). - The subsequent
jz valid_targetchecksZF == 1. BecauseZFis guaranteed to be 1, the branch is always taken ($P^T$). - The four bytes immediately following
jzare unreachable junk bytes inserted to mislead linear disassemblers into interpreting0xE8as aCALLinstruction.
- The instruction
- Deobfuscation Pass: Using an intermediate representation framework (such as Miasm or Metasm):
- Abstract interpretation tracks constant values in registers and flags.
- After
XOR EAX, EAX, the abstract state ofZFis constantTrue. - The conditional branch
jzis rewritten into an unconditional jump:jmp valid_target. - Dead code elimination sweeps the basic blocks, detects that the address of the junk bytes has an in-degree of 0 (no incoming edges in the CFG), and removes the bytes.
Exercise 2: Unflattening Control Flow with angr
Question: Explain how symbolic execution using the angr framework can be used to automatically unflatten a Wang-style CFG flattened function and recover the original basic block control flow.
Solution:
- Identify the Dispatcher & State Variable:
- Locate the central basic block containing the
switchstatement by identifying the node with the highest in-degree and out-degree in the CFG. - Identify the memory address or register holding the state variable (e.g.,
EAX).
- Locate the central basic block containing the
- Symbolic Slicing of Relevant Blocks:
- For each basic block connected to the dispatcher, initialize an angr symbolic state at the beginning of the block.
- Symbolically execute the block until it returns to the dispatcher.
- Query the SMT solver (Z3) for the concrete value of the state variable at the dispatcher entry.
- Graph Reconstruction:
- If the state resolves to a single concrete integer, a direct unconditional edge exists between the current block and the destination block corresponding to that state.
- If the state resolves to two possible values depending on a condition (e.g., a branch condition), a conditional edge (
TrueandFalse) is reconstructed. - Patch the binary by replacing the assignment to the state variable and jump to dispatcher with direct jumps (
JMPorJcc) to the target blocks.
Conclusion: Mastering the Art of Reverse Engineering
Across this five-part series on “Practical Reverse Engineering”, we traversed the full spectrum of binary analysis:
- Chapter 1: x86 and x64 Architecture: CPU rings, register sets, calling conventions, paging translation, and Sample J anti-VM injection.
- Chapter 2: ARM Architecture & Assembly: RISC load-store models, 8 privilege modes, Thumb-2 IT blocks, zero-cycle barrel shifting, coprocessor
CP15, and structure decompilation. - Chapter 3: The Windows Kernel & Rootkits:
KPCR, SSDT system calls, IRQL constraints, IRP handling, IOCTL vulnerabilities, and real-world x86/x64 rootkit analysis. - Chapter 4: Windows Debugging & Automation: WinDbg commands, dual expression evaluators, DbgEng scripting, automated UPX unpacking, and custom C++ extensions.
- Chapter 5: Software Obfuscation & Deobfuscation: MBA expressions, opaque predicates, CFG flattening, VM-based protectors, symbolic execution with Z3, and cracking the Quetz keygenme.
Binary analysis is not merely about reading disassembled instructions; it is about reconstructing the logic, intent, and constraints of compiled software. With these foundations, you possess the tools and methodology to tackle any executable across architectures, operating systems, and protection schemes.
