Reversing OLLVM's Control-Flow Flattening With IDA Python
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.
Reversing OLLVM’s Control-Flow Flattening With IDA Python
Setup
I’ve been studying OLLVM de-obfuscation lately, and this sample happened to land in front of me at the right time. I’m using it to walk through a control-flow-flattening (FLA) de-obfuscation, and along the way share some of the IDA Python APIs that come in handy for the job.
Target: a sample protected by Naga — libgeiri.so, function init_proc.
Tools: IDA 7.7, plus PyCharm for writing the IDAPython (you need to import the IDA libraries from your IDA install directory).

A quick OLLVM FLA primer
Here’s a standard flattened CFG. After OLLVM FLA, code that was a handful of lines balloons into a hundred or more. Each rectangle is a basic block; in this graph we only care about three kinds of blocks — the prologue, the return block, and the relevant blocks (the “real” blocks). Everything else was injected by OLLVM, and I’ll call those fake blocks.

A few definitions:
- Basic block — a run of instructions ending in a jump or
ret. - Main dispatcher — a
switch-like structure that reads a state variable and jumps to the matching target block. - Pre-dispatcher — an abstraction: the blocks that converge on the pre-dispatcher are the real blocks.
Six rules describe how the pieces relate:
- The function’s entry address is the prologue’s address.
- The prologue’s successor is the main dispatcher.
- Blocks whose successor is the main dispatcher are the pre-dispatcher.
- Blocks whose successor is the pre-dispatcher are the relevant blocks.
- Blocks with no successor are the return block.
- Everything left over is useless blocks and sub-dispatchers.
Since “predecessor” and “successor” come up constantly, here’s the picture. Execution flows A → B → C, so B is the middle block: A is B’s predecessor, and C is B’s successor.

The de-obfuscation strategy
De-obfuscation boils down to one core idea: find the real blocks (the prologue, all blocks that converge on the pre-dispatcher, and the return block). Once you have all the real blocks, you reconstruct the edges between them, and finally rewrite each block’s jump instruction to patch directly to the correct target. That undoes the flattening.
Analysis
Open the SO in IDA and jump into init_proc.

F5 into the pseudocode:

The flow is a mess — impossible to reason about directly. Let’s map the structure piece by piece.
Prologue: 0x43058

Main dispatcher: 0x43120

Calling block.preds() on the dispatcher gives all of its predecessors — i.e., the real blocks.
Return block: 0x43500

At this point we have all three categories — prologue, the blocks converging on the main dispatcher, and the return block. Is that enough to de-obfuscate? Not yet. We’ve collected the blocks, but we don’t know how they link to each other, so the edges still have to be recovered.

Click the main dispatcher’s address and IDA shows every block that references it. One caveat: the six rules above are general — they hold “in spirit”, but individual cases contradict them. Take this block: its successor is also the main dispatcher (this function has no pre-dispatcher; real blocks link straight to the dispatcher). Is it a real block? No — it only resets the state variable w8 for the next switch jump. The disassembly shows no real logic at all.

Look at the B.NE loc_43120 here. If w8 != w9, execution goes to the main dispatcher; otherwise it falls through to the next block — whose last instruction is also a direct jump to the dispatcher. Conclusion: this B.NE block linking to the dispatcher is not a real block, it’s just a state-variable reset for the switch. So the real blocks are the ones that both converge on the dispatcher and end with a B instruction.
With the dispatcher’s predecessors sorted out, let’s recover the link relationships. I’ll use the block at 0x43490 as the reference; every other block follows the same logic.
loc_43490
ADRP X8, #off_97FB8@PAGE
LDR X8, [X8,#off_97FB8@PAGEOFF]
LDR W8, [X8]
STR W8, [X19,#0x20]
MOV W8, #0x39649A15
B loc_43120
The MOV W8, #0x39649A15 resets the state variable. When we loop back into the switch, the current w8 is matched, and the matching value dispatches to the corresponding location. So 0x39649A15 is this block’s successor index. But an index isn’t an address yet — we still need to map it to the block it points to.

Following 0x39649A15, we get this block:
MOV W9, #0x39649A15
CMP W8, W9
B.NE loc_43120
When w8 == w9, execution goes the other way (not the dispatcher). That block’s address is 0x431D8. So 0x39649A15 → 0x431D8, and transitively 0x43490 → 0x431D8.

To visualize with 0x43490 as the reference: its successor is the index it stores, and its predecessor is whoever stores its index. Looking at the predecessors, one stores index 0x3455F111; searching who references 0x3455F111 shows 0x43314’s successor index is 0x3455F111. In other words, a real block stores its successor’s index, and its predecessor stores its index.
Now we can script the whole thing.
Scripting it
Finding the main dispatcher
def findDispatchers(func_start, num=10):
func = idaapi.get_func(func_start)
blocks = idaapi.FlowChart(func)
patchers = []
for block in blocks:
preds = block.preds()
preds_list = list(preds)
if len(preds_list) > num:
patchers.append(block)
return patchers
This returns the main dispatcher’s address — correct on this sample.
Collecting the blocks that converge on the dispatcher
This also lets us inspect the characteristics of the real blocks.
def findLoopEntryBlockAllPreds(loop_end_ea):
block = getBlockByAddress(loop_end_ea)
for pred in block.preds():
ea = idc.prev_head(pred.end_ea)
print("dispatcher predecessor block:", hex(ea), idc.GetDisasm(ea))
Storing all real blocks

Here I first check that the real block’s last instruction is B and its operand is the dispatcher address 0x43120. Then I take the instruction above each real block’s B and match the successor index by the MOV/MOVK pattern.
MOV W8, #0xA6FB
STR W0, [X19,#0x24]
MOVK W8, #0x7986,LSL#16
This pair shifts 0x7986 left by 16 bits while keeping the low bits intact (w8 = 0xA6FB). Shifting 16 bits gives 0x79860000, and OR-ing with the low bits yields 0x7986A6FB. In arithmetic: w8 = (0x7986 << 16) | 0xA6FB.
CSEL W8, W24, W8, EQ
Watch for CSEL — it marks a branching real block. Real blocks either link sequentially (one successor) or branch (two successors, i.e., an if/else). In this sample the CSELs all have four operands with EQ as the condition; when EQ holds, w24 is assigned to w8, otherwise w8 stays. In general: condition true → second operand (w24), false → third operand (w8).
For the CSEL blocks I just added the relevant indices by hand since there were only a few; with a larger count you’d want to match by pattern.


Paste it into IDA and run — we get every real block’s predecessor and successor index.

Verifying the link relationships
A natural question: how do we know these links are all correct? I wrote a verification routine that walks the object and confirms every connection resolves.

It produces a call chain starting at the function entry and ending at the return block, and every stored real block resolves without errors. If any step fails, it prints an error — for example, the last element 0x434ac stores index 0xbd9fbba, which has no matching successor block. On that error you can either check IDA for the corresponding info, or just patch the successor for 0x434ac into the map directly. It’s the same “fill in whatever’s missing” spirit as unidbg.

Rebuilding the links
Finally, this function rebuilds the edges.
def rebuildControlFlow(state_map):
for block in state_map:
block_ea = int(block, 16) # string -> int
# the real block's stored predecessor/successor link indices
value = state_map[block]
# find the block-end jump instruction
endEa = getBlockByAddress(block_ea).end_ea
last_insn_ea = idc.prev_head(endEa)
if idc.print_insn_mnem(last_insn_ea) == "B":
# unconditional branch (B)
if len(value) == 2:
succ_index = value[0] # this real block's successor index
if succ_index == None: # return block has no successor; skip it
continue
jmp_addr = getSuccBlockAddrFromMap(state_map, succ_index)
patchBranch(last_insn_ea, jmp_addr)
# conditional branch (CSEL)
elif len(value) == 3:
succ_0 = value[0]
jmp_addr_0 = getSuccBlockAddrFromMap(state_map, succ_0)
patchBranch(last_insn_ea, jmp_addr_0, 1)
succ_1 = value[1]
jmp_addr_1 = getSuccBlockAddrFromMap(state_map, succ_1)
patchBranch(last_insn_ea, jmp_addr_1)
if idc.print_insn_mnem(last_insn_ea) == "MOV":
succ_index = value[0] # this real block's successor index
jmp_addr = getSuccBlockAddrFromMap(state_map, succ_index)
patchBranch(last_insn_ea, jmp_addr)
Run it in IDA:

The de-obfuscated code now reads top-to-bottom — the actual execution logic is directly visible.
Wrapping up
The workflow is mechanical once you internalize the block taxonomy: identify the prologue, main dispatcher, and return block; filter the real blocks from the state-reset fakes; recover the successor index stored in each real block and map it back to an address; then patch the trailing branches to jump straight to their true targets. The trickiest part isn’t the patching — it’s correctly classifying which “blocks that point at the dispatcher” are real logic versus state-variable resets.
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