Control Flow Obfuscation
Overview
Static analysis tools — disassemblers, decompilers, YARA scanners — work by recognising patterns in a binary’s structure: function prologues, loop constructs, API call sequences, string literals. Control flow obfuscation (CFO) disrupts these patterns by transforming the binary’s control flow graph (CFG) into a form that is semantically equivalent but structurally unrecognisable.
The three primary transforms are:
-
Control flow flattening (CFF): Decomposes every function into a set of basic blocks dispatched by a switch statement in an infinite loop. The CFG changes from a structured tree to a flat star. Decompilers produce incomprehensible output; YARA patterns that rely on sequential API call order break.
-
Bogus control flow (BCF): Inserts fake branches that are taken consistently at runtime but cannot be resolved by static analysis alone.
-
Opaque predicates: Adds conditions based on mathematical identities (e.g.,
(x*x + x) % 2 == 0is always true) that a symbolic executor cannot simplify without expensive constraint solving.
Control flow obfuscation significantly increases binary size and reduces runtime performance. CFF in particular can increase code size 3–10× and introduce significant overhead in tight loops. Apply selectively — obfuscate only the sensitive code paths (key derivation, anti-analysis checks, shellcode decryption), not the entire binary.
The call chain
- 1control flow flatteningReplace structured if/else and loops with a dispatcher switch() in a top-level loop; the CFG becomes a flat star topology.
- 2bogus control flow insertionInsert fake conditional branches that are always taken the same way at runtime but confuse static analysis tools.
- 3instruction substitutionReplace simple operations (ADD, XOR) with semantically equivalent but more complex instruction sequences.
- 4opaque predicatesInsert always-true or always-false conditions based on mathematical identities that a static analyser cannot determine at compile time.
Reference implementation
Manual control flow flattening (C)
#include <windows.h>
// Original function (unobfuscated):
// if (check_a()) { do_b(); do_c(); }
// else { do_d(); }
//
// After CFF, the same function becomes:
BOOL obfuscated_fn(void) {
int state = 0x3F1A; // initial dispatch state (obfuscated initial value)
BOOL result = FALSE;
while (1) {
switch (state) {
case 0x3F1A: // entry
state = check_a() ? 0xB2C4 : 0x9D7E;
break;
case 0xB2C4: // path A: do_b
do_b();
state = 0x4A11;
break;
case 0x4A11: // path A continued: do_c
do_c();
state = 0xFFFF;
result = TRUE;
break;
case 0x9D7E: // path B: do_d
do_d();
state = 0xFFFF;
result = FALSE;
break;
case 0xFFFF: // exit
return result;
default: // unreachable — confuses decompilers
__assume(0);
}
}
}
// A static analyser sees a function that jumps between states in a loop.
// The original if/else structure is invisible. The CFG is a flat star
// with the switch as the hub, not a tree with branches.#include <windows.h>
// Original function (unobfuscated):
// if (check_a()) { do_b(); do_c(); }
// else { do_d(); }
//
// After CFF, the same function becomes:
BOOL obfuscated_fn(void) {
int state = 0x3F1A; // initial dispatch state (obfuscated initial value)
BOOL result = FALSE;
while (1) {
switch (state) {
case 0x3F1A: // entry
state = check_a() ? 0xB2C4 : 0x9D7E;
break;
case 0xB2C4: // path A: do_b
do_b();
state = 0x4A11;
break;
case 0x4A11: // path A continued: do_c
do_c();
state = 0xFFFF;
result = TRUE;
break;
case 0x9D7E: // path B: do_d
do_d();
state = 0xFFFF;
result = FALSE;
break;
case 0xFFFF: // exit
return result;
default: // unreachable — confuses decompilers
__assume(0);
}
}
}
// A static analyser sees a function that jumps between states in a loop.
// The original if/else structure is invisible. The CFG is a flat star
// with the switch as the hub, not a tree with branches.Opaque predicate insertion
#include <windows.h>
// Opaque predicate: (n * (n + 1)) % 2 is always 0 (n*(n+1) is always even)
// A static analyser cannot determine this without symbolic execution.
static BOOL always_true(UINT n) {
return (n * (n + 1)) % 2 == 0;
}
// Opaque predicate: 7 * x^2 - 1 is never divisible by 3 for any x
// (proven by checking x mod 3 = 0,1,2 all yield remainder 2 when divided by 3)
static BOOL never_divisible_by_3(UINT x) {
return (7 * x * x - 1) % 3 != 0;
}
// Usage: insert dead code branches that appear conditional but are not
void sensitive_function(void) {
UINT r = GetTickCount(); // runtime value — prevents constant folding
if (always_true(r)) {
// Real code
decrypt_payload();
} else {
// Dead code — inserted to confuse static analysis
// Can also hold a second payload that only runs if the predicate were false
ExitProcess(0);
}
}#include <windows.h>
// Opaque predicate: (n * (n + 1)) % 2 is always 0 (n*(n+1) is always even)
// A static analyser cannot determine this without symbolic execution.
static BOOL always_true(UINT n) {
return (n * (n + 1)) % 2 == 0;
}
// Opaque predicate: 7 * x^2 - 1 is never divisible by 3 for any x
// (proven by checking x mod 3 = 0,1,2 all yield remainder 2 when divided by 3)
static BOOL never_divisible_by_3(UINT x) {
return (7 * x * x - 1) % 3 != 0;
}
// Usage: insert dead code branches that appear conditional but are not
void sensitive_function(void) {
UINT r = GetTickCount(); // runtime value — prevents constant folding
if (always_true(r)) {
// Real code
decrypt_payload();
} else {
// Dead code — inserted to confuse static analysis
// Can also hold a second payload that only runs if the predicate were false
ExitProcess(0);
}
}LLVM obfuscation pass usage (OLLVM / Hikari)
# Build with OLLVM (https://github.com/obfuscator-llvm/obfuscator) # Hikari is the maintained fork for modern LLVM versions # (https://github.com/HikariObfuscator/Hikari) # Flags: # -mllvm -enable-cff : control flow flattening # -mllvm -enable-bcf : bogus control flow # -mllvm -enable-sub : instruction substitution # -mllvm -enable-indibran: indirect branching clang -mllvm -enable-cff -mllvm -enable-bcf -mllvm -bcf_prob=50 -mllvm -enable-sub -O1 -o implant_obf implant.c # Note: -O0 can cause issues with OLLVM; use -O1 minimum # Higher optimisation levels may undo some obfuscation transforms
# Build with OLLVM (https://github.com/obfuscator-llvm/obfuscator)
# Hikari is the maintained fork for modern LLVM versions
# (https://github.com/HikariObfuscator/Hikari)
# Flags:
# -mllvm -enable-cff : control flow flattening
# -mllvm -enable-bcf : bogus control flow
# -mllvm -enable-sub : instruction substitution
# -mllvm -enable-indibran: indirect branching
clang -mllvm -enable-cff -mllvm -enable-bcf -mllvm -bcf_prob=50 -mllvm -enable-sub -O1 -o implant_obf implant.c
# Note: -O0 can cause issues with OLLVM; use -O1 minimum
# Higher optimisation levels may undo some obfuscation transformsEntropy measurement (before/after comparison)
import math, struct
def entropy(data):
if not data: return 0
freq = [0]*256
for b in data: freq[b] += 1
n = len(data)
return -sum((f/n)*math.log2(f/n) for f in freq if f)
def section_entropy(pe_path):
import pefile
pe = pefile.PE(pe_path)
for s in pe.sections:
name = s.Name.decode().strip('