Removing OLLVM Bogus Control Flow with angr Symbolic Execution
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
Background
OLLVM supports “Bogus Control Flow” (BCF). When you compile source with BCF enabled, the resulting binary, when disassembled in IDA, shows many basic blocks that are never reachable at runtime. These redundant unreachable blocks interfere with IDA’s decompiler — you can’t see the original code logic by eye — yet they don’t affect actual execution. BCF’s purpose is to defeat static analysis.
OLLVM’s own diagram of this:
Original flow
entry
|
______v______
| original |
|_____________|
|
v
return
Flow after enabling BCF
entry
|
____v_____
|condition*| (false)
|__________|----+
(true)| |
| |
______v______ |
+-->| original* | |
| |_____________| (true)
| (false)| !-----------> return
| ______v______ |
| | altered |<--!
| |_____________|
|__________|
In the diagram, those condition checks are actually always true, so execution always proceeds along the true path, identical to the original logic. But during static analysis, the false branch is a distraction — you can’t tell that the false path is unreachable.
A previous writeup on the same topic used angr symbolic execution to identify unreachable blocks and statically patch the target by NOP-ing them out. IDA’s disassembly graph still looked messy, but the decompiler became smart enough to cut through the noise and show the original logic:
Flow after NOP-ing
entry
|
____v_____
|condition*| (false)
|__________|----+
(true)| |
| |
______v______ |
+-->| original* | |
| |_____________| (true)
| (false)| !-----------> return
| ______v______ |
| | nop |<--!
| |_____________|
|__________|
Assuming angr symbolic execution can precisely identify the “junk” conditional jumps, I chose to statically patch every junk conditional jump into an unconditional jump that goes directly to the corresponding reachable block. This approach is essentially equivalent to NOP-ing the unreachable blocks.
Flow after JMP-ing
entry
|
____v_____
|condition*|
|__________|
(jmp)|
|
______v______
+-->| original* |
| |_____________| (jmp)
| !-----------> return
| ______ ______
| | altered |
| |_____________|
|__________|
This article’s goal is to learn advanced angr usage, using BCF de-obfuscation as the target to demonstrate the JMP-ification approach.
$ pip3 show angr | grep Version
Version: 9.2.125.dev0
hello.c
#include <stdio.h>
#include <stdlib.h>
static unsigned int foo ( unsigned int n )
{
unsigned int mod = n % 4;
unsigned int ret = 0;
if ( mod == 0 )
{
ret = ( n | 0xbaaad0bf ) * ( 2 ^ n );
}
else if ( mod == 1 )
{
ret = ( n & 0xbaaad0bf ) * ( 3 + n );
}
else if ( mod == 2 )
{
ret = ( n ^ 0xbaaad0bf ) * ( 4 | n );
}
else
{
ret = ( n + 0xbaaad0bf ) * ( 5 & n );
}
return ret;
}
int main ( int argc, char * argv[] )
{
unsigned int n,
ret;
if ( argc < 2 )
{
fprintf( stderr, "Usage: %s <num>\n", argv[0] );
return -1;
}
n = (unsigned int)strtoul( argv[1], NULL, 0 );
ret = foo( n );
fprintf( stdout, "n=%#x ret=%#x\n", n, ret );
return 0;
}
clang -pipe -O0 -s -mllvm -passes=bcf -o hello_bcf hello.c
Compile with a build of OLLVM that has BCF enabled, producing hello_bcf.
hello_bcf
$ file -b hello_bcf
ELF 64-bit LSB executable, x86-64, ..., stripped
Disassembling hello_bcf in IDA64:
__int64 __fastcall main(int a1, char **a2, char **a3)
{
...
v24 = a1;
v25 = a2;
//
// unk_4040?? lives in .bss and is initialized to 0 at runtime. The following
// boolean is always false.
//
if ( ((unk_404098 * (unk_404098 + 1)) & 1) != 0 && unk_4040F8 >= 10 )
//
// this jump never happens
//
goto LABEL_11;
while ( 1 )
{
v3 = v25;
v19 = (unsigned int *)(&v17 - 2);
v20 = (const char ***)(&v17 - 2);
v21 = (unsigned int *)(&v17 - 2);
v22 = (unsigned int *)(&v17 - 2);
*((_DWORD *)&v17 - 4) = 0;
v17 = v3;
v23 = (int)v3 < 2;
//
// the following boolean is always true
//
if ( ((unk_404120 * (unk_404120 + 1)) & 1) == 0 || unk_4040C4 < 10 )
//
// this break always happens
//
break;
LABEL_11:
//
// never-reachable basic block
//
LODWORD(v17) = v24;
*(&v17 - 2) = v25;
}
if ( v23 )
{
//
// the following boolean is always false
//
if ( ((unk_40411C * (unk_40411C + 1)) & 1) != 0 && unk_4040C0 >= 10 )
//
// this jump never happens
//
goto LABEL_12;
while ( 1 )
{
fprintf(stderr, "Usage: %s <num>\n", **v20);
*v19 = -1;
//
// the following boolean is always true
//
if ( ((unk_404110 * (unk_404110 + 1)) & 1) == 0 || unk_4040B8 < 10 )
//
// this break always happens
//
break;
LABEL_12:
//
// never-reachable basic block
//
fprintf(stderr, "Usage: %s <num>\n", **v20);
*v19 = -1;
}
}
else
{
...
}
do
v18 = *v19;
//
// the following boolean is always false
//
while ( ((unk_4040E8 * (unk_4040E8 + 1)) & 1) != 0 && unk_404138 >= 10 );
return v18;
}
Don’t over-analyze the decompiler’s pseudocode — just get the gist.
hello_bcf_patch.py
This is the complete code for handling hello_bcf. It’s a demonstration, not a general-purpose implementation.
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
import sys, logging
import angr
def get_func_from_addr ( proj, addr ) :
try :
return proj.kb.functions.get_by_addr( addr )
except KeyError :
return proj.kb.functions.floor_func( addr )
class PrivateHelper ( angr.ExplorationTechnique ) :
def __init__ ( self, cond_jmp_set, hooksub, hooksubset ) :
super().__init__()
self.cond_jmp_set = cond_jmp_set
self.hooksub = hooksub
self.hooksubset = hooksubset
def successors ( self, sm, state, **kwargs ) :
if self.hooksub and 0 != state.callstack.ret_addr :
proj = state.project
target = state.addr
if target not in self.hooksubset :
proj.hook( target, angr.SIM_PROCEDURES['stubs']['ReturnUnconstrained'](), replace=True )
self.hooksubset.add( target )
ret = sm.successors( state, **kwargs )
if not self.hooksub or 0 == state.callstack.ret_addr :
i = len( ret.all_successors )
j = len( ret.successors )
k = len( ret.unsat_successors )
if i == 2 and j == 1 and k == 1 :
j_from = ret.successors[0].history.jump_source
j_to = ret.successors[0].addr
self.cond_jmp_set.add( ( j_from, j_to ) )
return ret
def get_cond_jmp_list ( proj, func, hooksub, hooksubset ) :
cond_jmp_set = set()
init_state = proj.factory.blank_state(
addr = func.addr,
add_options = {
angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS,
angr.options.BYPASS_UNSUPPORTED_SYSCALL,
}
)
sm = proj.factory.simulation_manager( init_state )
sm.use_technique( angr.exploration_techniques.DFS() )
sm.use_technique( PrivateHelper( cond_jmp_set, hooksub=hooksub, hooksubset=hooksubset ) )
sm.run()
return sorted( cond_jmp_set )
def patch_buf ( proj, buf, cond_jmp_list ) :
#
# for this example, assume every patch fits in an EB short jump
#
for j_from, j_to in cond_jmp_list :
j_off = j_to - ( j_from + 2 )
assert j_off <= 0x7f and j_off >= -0x80
off = proj.loader.main_object.addr_to_offset( j_from )
buf[off:off+2] \
= bytes( [0xeb, j_off] )
def dosth ( proj, buf, addr, hooksub, hooksubset ) :
func = get_func_from_addr( proj, addr )
cond_jmp_list = get_cond_jmp_list( proj, func, hooksub, hooksubset )
print( f'cond_jmp_list[{len(cond_jmp_list)}]' )
for j in cond_jmp_list :
print( f'cond_jmp {j[0]:#x} -> {j[1]:#x}' )
patch_buf( proj, buf, cond_jmp_list )
def main ( argv ) :
logging.getLogger( 'angr.engines.successors' ).setLevel( logging.ERROR )
logging.getLogger( 'angr.analyses.cfg.cfg_base' ).setLevel( logging.ERROR )
base_addr = 0x400000
proj = angr.Project(
argv[1],
load_options = {
'auto_load_libs' : False,
'main_opts' : {
'base_addr' : base_addr
}
}
)
cfg = proj.analyses.CFG(
force_smart_scan = False,
force_complete_scan = True,
normalize = True,
resolve_indirect_jumps \
= True,
fail_fast = True
)
with open( argv[1], 'rb' ) as f :
buf = bytearray( f.read() )
hooksub = int( argv[3], 0 )
#
# the functions that need to be patched
#
if hooksub :
addrlist = ( 0x401140, 0x4014e0, )
hooksubset = set()
else :
#
# don't specify 0x4014e0 separately — it's a sub-function of 0x401140
#
addrlist = ( 0x401140, )
hooksubset = None
for addr in addrlist :
if hooksub and addr in hooksubset :
proj.unhook( addr )
hooksubset.discard( addr )
dosth( proj, buf, addr, hooksub, hooksubset )
with open( argv[2], 'wb' ) as f :
f.write( buf )
if "__main__" == __name__ :
main( sys.argv )
python3 hello_bcf_patch.py hello_bcf hello_bcf_new_0 0
python3 hello_bcf_patch.py hello_bcf hello_bcf_new_1 1
Disassemble hello_bcf_new_* in IDA64 and decompile main and sub_4014E0 — the logic from hello.c is now visible.
The technique behind get_cond_jmp_list
The core of hello_bcf_patch.py is obtaining those conditional jumps that always take the same branch, which depends on:
sm.use_technique( PrivateHelper( cond_jmp_set, hooksub=hooksub, hooksubset=hooksubset ) )
More precisely, the core is:
class PrivateHelper ( angr.ExplorationTechnique ) :
#
# override successors()
#
def successors ( self, sm, state, **kwargs ) :
#
# if the CLI asks to hook sub-functions, walk the call stack; when the
# current state is at a sub-function entry, hook it.
#
if self.hooksub and 0 != state.callstack.ret_addr :
proj = state.project
target = state.addr
if target not in self.hooksubset :
proj.hook( target, angr.SIM_PROCEDURES['stubs']['ReturnUnconstrained'](), replace=True )
self.hooksubset.add( target )
#
# get the successors of the current state
#
ret = sm.successors( state, **kwargs )
#
# if the CLI doesn't ask to hook sub-functions, record conditional jumps
# in the tree rooted at the current function; otherwise record only the
# current function's conditional jumps, not those of sub-functions.
#
if not self.hooksub or 0 == state.callstack.ret_addr :
i = len( ret.all_successors )
j = len( ret.successors )
k = len( ret.unsat_successors )
#
# found a conditional jump that always takes only one branch
#
if i == 2 and j == 1 and k == 1 :
j_from = ret.successors[0].history.jump_source
j_to = ret.successors[0].addr
self.cond_jmp_set.add( ( j_from, j_to ) )
return ret
Whether to hook sub-functions depends on how a sub-function’s return value affects the parent’s flow. For hello_bcf alone, whether or not you hook sub-functions doesn’t change the result — hello_bcf_new_* is the same either way.
If you need to hook a sub-function, do it inside successors() — you don’t need to walk the “active stash” or touch block.capstone.insns.
Once you find a conditional jump that always takes only one branch, record (from, to); later, patch the conditional jump at from (e.g. jnz) into a jmp. In the demo I assume every patch fits in an eb xx short jump.
The successors() override is very hacky — it’s only been tested on hello_bcf and is a PoC, not a robust general implementation. No matter how the target sample varies, the angr technique shown here is always useful; only the de-obfuscation logic needs to be adjusted case by case.
I recommend following along with a dynamic debugger to deepen your understanding of angr’s flow.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch