An OLLVM Survival Guide: Building, Understanding, and Defeating Control-Flow Obfuscation
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.
An OLLVM Survival Guide: Building, Understanding, and Defeating Control-Flow Obfuscation
Preface
Control-flow obfuscation is one of the most common and effective weapons in modern software protection, and it exists purely to make reverse engineering harder. OLLVM is an extension of the LLVM compiler framework that injects complex control-flow obfuscation logic, producing binaries that are painful to analyze. Indirect calls and control-flow flattening are the two most representative OLLVM techniques — by scattering a program’s control flow and introducing a dispatcher, they multiply the difficulty of reverse engineering.
These notes cover three angles:
- Building and using OLLVM — how to obtain, compile, and use OLLVM to obfuscate code.
- Analyzing OLLVM’s techniques — the inner workings of its control-flow obfuscation, especially indirect calls and control-flow flattening.
- Defeating OLLVM in practice — combining IDA Pro, the Unicorn emulator, and Keypatch to strip the obfuscation and recover the original structure.
If nothing else, this is an attack-and-defense game between top-tier players. Who can resist joining in?
Building OLLVM
Environment
| Component | Configuration |
|---|---|
| VMware | 16.1.1 build-17801498 |
| Ubuntu | 20.04.2 LTS |
| Host RAM | 32G |
| VM RAM | 16G |
| Host storage | 2T |
| VM storage | 1T |
| Target version | llvm-project-9.0 |
Dependencies
sudo apt-get install git-core gnupg flex bison build-essential zip curl zlib1g-dev gcc-multilib g++-multilib libc6-dev-i386 libncurses5 lib32ncurses5-dev x11proto-core-dev libx11-dev lib32z1-dev libgl1-mesa-dev libxml2-utils xsltproc unzip fontconfig libncurses5 cmake ninja-build
Downloading the OLLVM project
Download one of the open-source OLLVM forks. goron is the version I found most usable in practice — it ships several extra obfuscation passes of decent strength. I picked the 9.0 line because that’s the version with the most learning material available online.
git clone https://github.com/amimo/goron.git
git checkout e943a8a78325632df64988e05d66ad5fa0e0c6f6
Building clang from the command line
If you don’t have CLion, build clang from the command line. First generate a release build configuration:
cd llvm-project-9.0.1
mkdir build_release
cd build_release
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS="clang" ../llvm
Then build release:
ninja -j16
Add the built clang to your PATH:
export PATH=/home/lxz/ollvm/llvm-project/build_release/bin:$PATH
Building clang with CLion
-
Open
ollvm/llvm-project-9.0.1/llvm/CMakeLists.txtin the IDE. -
Go to File → Settings → Build, Execution, Deployment → CMake → CMake options, add a Release configuration and set the CMake options to:
-G Ninja -DLLVM_ENABLE_PROJECTS="clang" -
Switch the project target to
clang, switch the mode to Release, and hit Run to build.
Building clang with VSCode
-
Open
ollvm/llvm-project-9.0.1in VSCode. -
Create a
.vscodefolder. -
Create
.vscode/launch.json:{ "version": "0.2.0", "configurations": [ { // type: the debugger type. cppdbg is the C++ debugger "type": "cppdbg", // request: launch starts a debug session, attach attaches to a running process "request": "launch", // name: the debug session name "name": "clang_execute_release", // program: the program to debug "program": "${workspaceFolder}/build/bin/clang", // args: arguments passed to the program "args": [ "/home/lxz/demo/main.cpp", "-o", "/home/lxz/demo/main" ], // cwd: the program's working directory "cwd": "${workspaceFolder}", // preLaunchTask: a task to run before starting the debug session "preLaunchTask": "clang_ninja_release" }, ] } -
Create
.vscode/tasks.json:{ "version": "2.0.0", "tasks": [ { // label: the task name "label": "clang_ninja_release", // type: shell means the command runs in the terminal "type": "shell", // command: the command to run "command": "ninja", // args: arguments passed to the command "args": [ "-C", "${workspaceFolder}/build", "clang" ], // dependsOn: run clang_cmake_configure before clang_ninja_release "dependsOn": ["clang_cmake_configure"], // group: add the task to the build task group; isDefault marks it as the default "group": { "kind": "test", "isDefault": true } }, { "label": "clang_cmake_configure", "type": "shell", "command": "cmake", "args": [ "-S", "${workspaceFolder}/llvm", "-B", "${workspaceFolder}/build", "-G", "Ninja", "-DCMAKE_BUILD_TYPE=Release", "-DLLVM_ENABLE_PROJECTS=clang" ] } ] } -
Run and Debug → Start.
Running clang
Add clang to a temporary PATH:
export PATH=/home/lxz/ollvm/llvm-project/cmake-build-release/bin:$PATH
Test compile:
clang hello.c -o hello
Cross-compiling for arm64 requires gcc-aarch64 and g++-aarch64 first:
sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
Then cross-compile:
clang hello.c -o hello -target aarch64-linux-gnu
Using OLLVM
Compiling executables
Compile an x86 executable with instruction substitution, control-flow flattening, and bogus control flow:
clang hello.c -o hello -mllvm -sub -mllvm -bcf -mllvm -fla
Compile an arm64 executable:
clang hello.c -o hello -mllvm -sub -mllvm -bcf -mllvm -fla -target aarch64-linux-gnu
Using OLLVM in Android Studio
Download android-ndk-r21e:
wget https://dl.google.com/android/repository/android-ndk-r21e-linux-x86_64.zip
unzip android-ndk-r21e-linux-x86_64.zip
Copy the lib and bin folders from your OLLVM build into the NDK:
cp /home/lxz/ollvm/llvm-project/build_debug/lib /home/lxz/ollvm/android-ndk-r21e/toolchains/llvm/prebuilt/linux-x86_64/lib
cp /home/lxz/ollvm/llvm-project/build_debug/bin /home/lxz/ollvm/android-ndk-r21e/toolchains/llvm/prebuilt/linux-x86_64/bin
Point local.properties at your custom NDK:
ndk.dir =/home/lxz/ollvm/android-ndk-r21e
Add the OLLVM flags in CMakeLists.txt:
# control-flow flattening only
add_definitions(-mllvm -fla)
# instruction substitution + control-flow flattening + bogus control flow
add_definitions(-mllvm -sub -mllvm -bcf -mllvm -fla)
# with loop counts for flattening and bogus control flow
add_definitions(-mllvm -sub -mllvm -sub_loop=3 -mllvm -bcf -mllvm -bcf_loop=3 -mllvm -fla -mllvm -split_num=3)
OLLVM fundamentals
The compilation pipeline
.c— the front-end (Clang) converts the C source into LLVM IR (intermediate representation)..ll— LLVM Assembly, a human-readable text form of LLVM IR, roughly portable across platforms..bc— LLVM Bitcode, a compact binary form of LLVM IR that is cross-platform and cross-compiler..s— assembly language source, containing target-machine instructions. Produced at the end of the pipeline when LLVM IR is lowered to a specific architecture; not portable, since assembly is target-specific.
The toolchain
Generate an .ll file:
export PATH=~/ollvm/llvm-project-9.0.1/llvm/cmake-build-release/bin:$PATH
clang -emit-llvm -S hello.c -o hello.ll
An .ll file can be executed directly (build lli in CLion first):
lli hello.ll
Generate a .bc file (build llvm-as first):
llvm-as hello.ll -o hello.bc
Generate a .s file (build llc first):
llc hello.bc -o hello.s
Compile an executable from each:
clang hello.s -o hello_s
clang hello.bc -o hello_bc
clang hello.ll -o hello_ll
IR assembly
Beyond the basic IR syntax, you must understand the concept of blocks in IR — without it you can’t grasp control-flow flattening, the hardest of OLLVM’s tricks.
Addition
C code:
int fun1(int a, int b){
return a+b;
}
IR assembly:
; dso_local: a linkage attribute — the function is local to the current module
; i32: the function returns a 32-bit integer
; @_Z4fun1ii(i32, i32): function name and parameter list; @ marks the function name
; #0: an attribute-group reference (e.g., calling convention); see the group's definition
; parameters are assigned %0, %1, %2, %3 ... in order
define dso_local i32 @_Z4fun1ii(i32, i32) #0 {
; allocate an i32 on the stack, 4-byte aligned, label it %3
%3 = alloca i32, align 4
; allocate another i32 on the stack, 4-byte aligned, label it %4
%4 = alloca i32, align 4
; store %0 into %3
store i32 %0, i32* %3, align 4
; store %1 into %4
store i32 %1, i32* %4, align 4
; load %3 into temp %5
%5 = load i32, i32* %3, align 4
; load %4 into temp %6
%6 = load i32, i32* %4, align 4
; add %5 + %6 into %7; add does not check signed overflow
%7 = add nsw i32 %5, %6
; return the result in %7
ret i32 %7
}
Loops
C code:
int fun2(int a, int b){
int ret = 0;
for(int i = 0; i < 5; i++){
ret += a;
}
return ret+b;
}
IR assembly:
define dso_local i32 @_Z4fun2ii(i32, i32) #0 {
%3 = alloca i32, align 4 ; parameter 1
%4 = alloca i32, align 4 ; parameter 2
%5 = alloca i32, align 4 ; intermediate result
%6 = alloca i32, align 4 ; loop counter
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
store i32 0, i32* %5, align 4
store i32 0, i32* %6, align 4
; unconditional jump to label %7, start the loop
br label %7
; this part corresponds to i < 5
7: ; preds = %14, %2
%8 = load i32, i32* %6, align 4
; compare %8 < 5, store the bool in %9
; %9 = (bool)(%8 < 5)
%9 = icmp slt i32 %8, 5
; branch on %9 to %10 or %17
br i1 %9, label %10, label %17
; this part corresponds to ret += a
10: ; preds = %7
; load the first parameter
%11 = load i32, i32* %3, align 4
; load the intermediate result
%12 = load i32, i32* %5, align 4
; add %12 + %11 into %13
%13 = add nsw i32 %12, %11
; store the new intermediate result back to %5
store i32 %13, i32* %5, align 4
; jump to %14, continue the loop
br label %14
; this part corresponds to i++
14: ; preds = %10
; load the loop counter
%15 = load i32, i32* %6, align 4
; increment the counter
%16 = add nsw i32 %15, 1
; store the new counter back to %6
store i32 %16, i32* %6, align 4
; jump back to %7
br label %7
; this part corresponds to return ret+b
17: ; preds = %7
; load the final intermediate result
%18 = load i32, i32* %5, align 4
; load the second parameter
%19 = load i32, i32* %4, align 4
; add %18 + %19 into %20
%20 = add nsw i32 %18, %19
; return %20
ret i32 %20
}
Calls
C code:
int fun3(int a, int b){
return fun2(a, b);
}
IR assembly:
define dso_local i32 @_Z4fun3ii(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4
%6 = load i32, i32* %4, align 4
; call _Z4fun2ii with %5 and %6, store the return value in %7
%7 = call i32 @_Z4fun2ii(i32 %5, i32 %6)
ret i32 %7
}
Branches
C code:
int fun4(int a, int b){
if(a > 5){
return fun2(a, b);
}else{
return fun3(a, b);
}
}
IR assembly:
define dso_local i32 @_Z4fun4ii(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
store i32 %0, i32* %4, align 4
store i32 %1, i32* %5, align 4
%6 = load i32, i32* %4, align 4
%7 = icmp sgt i32 %6, 5
br i1 %7, label %8, label %12
8: ; preds = %2
%9 = load i32, i32* %4, align 4
%10 = load i32, i32* %5, align 4
%11 = call i32 @_Z4fun2ii(i32 %9, i32 %10)
store i32 %11, i32* %3, align 4
br label %16
12: ; preds = %2
%13 = load i32, i32* %4, align 4
%14 = load i32, i32* %5, align 4
%15 = call i32 @_Z4fun3ii(i32 %13, i32 %14)
store i32 %15, i32* %3, align 4
br label %16
16: ; preds = %12, %8
%17 = load i32, i32* %3, align 4
ret i32 %17
}
OLLVM source analysis
Below I’ve kept only the snippets I consider important. Many of the comments were produced with AI assistance (a genuinely great way to understand a complex codebase), plus a few LOG statements I inserted while analyzing. My main takeaway: insert plenty of logging, print the IR before and after the key points, and compare. Once you’ve fully worked through any one of these passes, you’ll be fluent in the LLVM IR-manipulation APIs, and the rest of the source becomes progressively easier.
IndirectCall.cpp
Core function: encrypt call-target addresses, turning bl label into blr reg to defeat static analysis by decompilers.
bool runOnFunction(Function &Fn) override {
if (!toObfuscate(flag, &Fn, "icall")) { // if no obfuscation is needed, return false
return false;
}
if (Options && Options->skipFunction(Fn.getName())) { // if the options skip this function, return false
return false;
}
LLVMContext &Ctx = Fn.getContext(); // get the context
CalleeNumbering.clear(); // clear the callee numbering map
Callees.clear(); // clear the callee set
CallSites.clear(); // clear the call-site set
NumberCallees(Fn); // number the function's callees
if (Callees.empty()) { // if there are no callees, return false
return false;
}
uint32_t V = RandomEngine.get_uint32_t() & ~3; // generate a random number
ConstantInt *EncKey = ConstantInt::get(Type::getInt32Ty(Ctx), V, false); // create the encryption-key constant
const IPObfuscationContext::IPOInfo *SecretInfo = nullptr; // obfuscation info pointer
if (IPO) { // if there is an IPO context
SecretInfo = IPO->getIPOInfo(&Fn); // get the IPO info
}
Value *MySecret;
if (SecretInfo) { // if there is obfuscation info
MySecret = SecretInfo->SecretLI; // use the obfuscation info's value
} else {
MySecret = ConstantInt::get(Type::getInt32Ty(Ctx), 0, true); // otherwise use 0
}
ConstantInt *Zero = ConstantInt::get(Type::getInt32Ty(Ctx), 0); // create the zero constant
GlobalVariable *Targets = getIndirectCallees(Fn, EncKey); // get the indirect-callee global
for (auto CI : CallSites) { // iterate the call sites
SmallVector<Value *, 8> Args; // argument list
SmallVector<AttributeSet, 8> ArgAttrVec; // argument-attribute list
CallSite CS(CI); // create the call site
Instruction *Call = CS.getInstruction(); // get the call instruction
Function *Callee = CS.getCalledFunction(); // get the called function
FunctionType *FTy = CS.getFunctionType(); // get the function type
IRBuilder<> IRB(Call); // create the IR builder
Args.clear(); // clear the argument list
ArgAttrVec.clear(); // clear the attribute list
Value *Idx = ConstantInt::get(Type::getInt32Ty(Ctx), CalleeNumbering[CS.getCalledFunction()]); // callee number
Value *GEP = IRB.CreateGEP(Targets, {Zero, Idx}); // element-pointer GEP
LoadInst *EncDestAddr = IRB.CreateLoad(GEP, CI->getName()); // load the encrypted destination
Constant *X;
if (SecretInfo) { // if there is obfuscation info
X = ConstantExpr::getSub(SecretInfo->SecretCI, EncKey); // compute the expression
} else {
X = ConstantExpr::getSub(Zero, EncKey); // compute the expression
}
const AttributeList &CallPAL = CS.getAttributes(); // get the call attributes
CallSite::arg_iterator I = CS.arg_begin(); // argument iterator
unsigned i = 0;
for (unsigned e = FTy->getNumParams(); i != e; ++I, ++i) { // iterate the params
Args.push_back(*I); // add the argument
AttributeSet Attrs = CallPAL.getParamAttributes(i); // get the param attributes
ArgAttrVec.push_back(Attrs); // add the attributes
}
for (CallSite::arg_iterator E = CS.arg_end(); I != E; ++I, ++i) { // iterate remaining args
Args.push_back(*I); // add the argument
ArgAttrVec.push_back(CallPAL.getParamAttributes(i)); // add the attributes
}
AttributeList NewCallPAL = AttributeList::get( // build the new call attribute list
IRB.getContext(), CallPAL.getFnAttributes(), CallPAL.getRetAttributes(), ArgAttrVec);
Value *Secret = IRB.CreateSub(X, MySecret); // create the subtraction
Value *DestAddr = IRB.CreateGEP(EncDestAddr, Secret); // element-pointer GEP
// add: further increase the arithmetic complexity of icall
// a series of complex operations on MySecret
Value *Value1 = IRB.CreateAdd(MySecret, ConstantInt::get(Type::getInt32Ty(Fn.getContext()), V));
Value *Value2 = IRB.CreateSub(MySecret, ConstantInt::get(Type::getInt32Ty(Fn.getContext()), V));
// increase the complexity further
Value *Value3 = IRB.CreateMul(Value1, ConstantInt::get(Type::getInt32Ty(Fn.getContext()), V));
Value *Value4 = IRB.CreateMul(Value2, ConstantInt::get(Type::getInt32Ty(Fn.getContext()), V));
// bitwise operations
Value *Value5 = IRB.CreateShl(Value3, ConstantInt::get(Type::getInt32Ty(Fn.getContext()), 2)); // shift left 2
Value *Value6 = IRB.CreateLShr(Value4, ConstantInt::get(Type::getInt32Ty(Fn.getContext()), 3)); // logical shift right 3
// final results
Value *FinalValue1 = IRB.CreateAdd(Value5, ConstantInt::get(Type::getInt32Ty(Fn.getContext()), V));
Value *FinalValue2 = IRB.CreateSub(Value6, ConstantInt::get(Type::getInt32Ty(Fn.getContext()), V));
// compare FinalValue1 and FinalValue2 for equality
Value *CmpResult = IRB.CreateICmpEQ(FinalValue1, FinalValue2);
// convert FinalValue1 to an i8* pointer
Value *FinalValue1AsPtr = IRB.CreateIntToPtr(FinalValue1, Type::getInt8PtrTy(Fn.getContext()));
// use CreateSelect so TrueValue and FalseValue have matching types
Value *Result = IRB.CreateSelect(CmpResult, FinalValue1AsPtr, DestAddr);
DestAddr = Result;
// add
Value *FnPtr = IRB.CreateBitCast(DestAddr, FTy->getPointerTo()); // bitcast to function pointer
FnPtr->setName("Call_" + Callee->getName()); // set the name
CallInst *NewCall = IRB.CreateCall(FTy, FnPtr, Args, Call->getName()); // create the new call
NewCall->setAttributes(NewCallPAL); // set attributes
Call->replaceAllUsesWith(NewCall); // replace all uses
Call->eraseFromParent(); // remove from the parent basic block
}
return true; // return true
}
IndirectBranch.cpp
Core function: encrypt inter-block branch addresses, turning b label into br reg to defeat static analysis.
// rewrite runOnFunction to process each function
bool runOnFunction(Function &Fn) override {
// decide whether obfuscation is needed
if (!toObfuscate(flag, &Fn, "indbr")) {
return false;
}
// if the options skip this function, return
if (Options && Options->skipFunction(Fn.getName())) {
return false;
}
// skip empty functions, linkonce-linkage functions, or startup-section code
if (Fn.getBasicBlockList().empty() || Fn.hasLinkOnceLinkage() || Fn.getSection() == ".text.startup") {
return false;
}
LLVMContext &Ctx = Fn.getContext(); // get the LLVM context
// initialize member variables
BBNumbering.clear();
BBTargets.clear();
// split all critical edges so indirect branches have no edge cases
SplitAllCriticalEdges(Fn, CriticalEdgeSplittingOptions(nullptr, nullptr));
NumberBasicBlock(Fn); // number the basic blocks
// if there are no basic blocks to process, return
if (BBNumbering.empty()) {
return false;
}
// generate a random encryption key
uint32_t V = RandomEngine.get_uint32_t() & ~3;
ConstantInt *EncKey = ConstantInt::get(Type::getInt32Ty(Ctx), V, false);
// get the IPO obfuscation info
const IPObfuscationContext::IPOInfo *SecretInfo = nullptr;
if (IPO) {
SecretInfo = IPO->getIPOInfo(&Fn);
}
// get the obfuscation value
Value *MySecret;
if (SecretInfo) {
MySecret = SecretInfo->SecretLI;
} else {
MySecret = ConstantInt::get(Type::getInt32Ty(Ctx), 0, true);
}
// create the indirect-branch target global
ConstantInt *Zero = ConstantInt::get(Type::getInt32Ty(Ctx), 0);
GlobalVariable *DestBBs = getIndirectTargets(Fn, EncKey);
// iterate every basic block in the function
for (auto &BB : Fn) {
auto *BI = dyn_cast<BranchInst>(BB.getTerminator()); // get the branch instruction
if (BI && BI->isConditional()) { // if it is a conditional branch
IRBuilder<> IRB(BI); // create the IR builder
Value *Cond = BI->getCondition(); // get the branch condition
Value *Idx; // the indirect-branch index
Value *TIdx, *FIdx; // true and false branch indices
// get the numbers of the true and false successors
TIdx = ConstantInt::get(Type::getInt32Ty(Ctx), BBNumbering[BI->getSuccessor(0)]);
FIdx = ConstantInt::get(Type::getInt32Ty(Ctx), BBNumbering[BI->getSuccessor(1)]);
// select the index by the condition
Idx = IRB.CreateSelect(Cond, TIdx, FIdx);
// compute the target address
Value *GEP = IRB.CreateGEP(DestBBs, {Zero, Idx});
LoadInst *EncDestAddr = IRB.CreateLoad(GEP, "EncDestAddr");
// compute the decryption key
Constant *X;
if (SecretInfo) {
X = ConstantExpr::getSub(SecretInfo->SecretCI, EncKey);
} else {
X = ConstantExpr::getSub(Zero, EncKey);
}
Value *DecKey = IRB.CreateSub(X, MySecret);
// decrypt the target address
Value *DestAddr = IRB.CreateGEP(EncDestAddr, DecKey);
// create the indirect branch and replace the original
IndirectBrInst *IBI = IndirectBrInst::Create(DestAddr, 2);
IBI->addDestination(BI->getSuccessor(0));
IBI->addDestination(BI->getSuccessor(1));
ReplaceInstWithInst(BI, IBI);
}
}
return true; // the function was modified
}
Flattening.cpp
Core function: control-flow flattening — OLLVM’s most important protection. It numbers every basic block and dispatches them through one giant while + switch, defeating decompiler analysis.
bool Flattening::flatten(Function *f) {
vector<BasicBlock *> origBB; // store the original basic blocks
BasicBlock *loopEntry; // the loop-entry block
BasicBlock *loopEnd; // the loop-exit block
LoadInst *load; // the switch-variable load
SwitchInst *switchI; // the switch instruction
AllocaInst *switchVar; // the switch variable's storage
// generate a random scrambling key
char scrambling_key[16];
llvm::cryptoutils->get_bytes(scrambling_key, 16);
// lower the switch for different LLVM versions
#if LLVM_VERSION_MAJOR * 10 + LLVM_VERSION_MINOR >= 90
FunctionPass *lower = createLegacyLowerSwitchPass();
#else
FunctionPass *lower = createLowerSwitchPass();
#endif
lower->runOnFunction(*f);
// iterate all basic blocks into origBB
for (Function::iterator i = f->begin(); i != f->end(); ++i) {
BasicBlock *tmp = &*i;
origBB.push_back(tmp);
if (isa<InvokeInst>(tmp->getTerminator())) {
return false; // bail out on invoke instructions
}
}
// nothing to flatten if there is at most one block
if (origBB.size() <= 1) {
return false;
}
LLVMContext &Ctx = f->getContext(); // get the LLVM context
const IPObfuscationContext::IPOInfo *SecretInfo = nullptr;
if (IPO) {
SecretInfo = IPO->getIPOInfo(f); // get IPO info
}
Value *MySecret = SecretInfo ? SecretInfo->SecretLI : ConstantInt::get(Type::getInt32Ty(Ctx), 0);
// remove the first basic block
origBB.erase(origBB.begin());
// handle the first block so it can be switch-controlled
Function::iterator tmp = f->begin();
BasicBlock *insert = &*tmp;
if (isa<BranchInst>(insert->getTerminator()) && insert->getTerminator()->getNumSuccessors() > 1) {
BasicBlock::iterator i = insert->end();
--i;
if (insert->size() > 1) {
--i;
}
BasicBlock *tmpBB = insert->splitBasicBlock(i, "first");
origBB.insert(origBB.begin(), tmpBB);
}
insert->getTerminator()->eraseFromParent();
// create and initialize the switch variable
switchVar = new AllocaInst(Type::getInt32Ty(f->getContext()), 0, "switchVar", insert);
new StoreInst(ConstantInt::get(Type::getInt32Ty(f->getContext()), llvm::cryptoutils->scramble32(0, scrambling_key)), switchVar, insert);
// build the flattening loop structure
loopEntry = BasicBlock::Create(f->getContext(), "loopEntry", f, insert);
loopEnd = BasicBlock::Create(f->getContext(), "loopEnd", f, insert);
load = new LoadInst(switchVar, "switchVar", loopEntry);
insert->moveBefore(loopEntry);
BranchInst::Create(loopEntry, insert);
BranchInst::Create(loopEntry, loopEnd);
// create the switch statement
BasicBlock *swDefault = BasicBlock::Create(f->getContext(), "switchDefault", f, loopEnd);
BranchInst::Create(loopEnd, swDefault);
switchI = SwitchInst::Create(load, swDefault, 0, loopEntry);
// handle the block jumps
for (BasicBlock *bb : origBB) {
bb->moveBefore(loopEnd);
ConstantInt *numCase = ConstantInt::get(switchI->getCondition()->getType(), llvm::cryptoutils->scramble32(switchI->getNumCases(), scrambling_key));
switchI->addCase(numCase, bb);
}
fixStack(f); // fix the stack structure
lower->runOnFunction(*f);
delete lower;
return true;
}
Defeating OLLVM
Environment
| Component | Configuration |
|---|---|
| OS | Windows 10 |
| IDA | 7.7 |
| Python | 3.10.7 |
| unicorn | 2.1.1 |
| keystone-engine | 0.9.2 |
Unicorn
Unicorn is a CPU-emulation framework. In OLLVM de-obfuscation, its role is to compute the value of the BR register and the SWITCH ID register used by control-flow flattening. Here’s a snippet I used while learning Unicorn; it should help clarify the API.
import unicorn # import Unicorn
import capstone # import Capstone
import binascii # import binascii
# print register values
def print_regs(mu):
for i in range(unicorn.arm64_const.UC_ARM64_REG_X0, unicorn.arm64_const.UC_ARM64_REG_X30 + 1): # X0-X30
print('X%d: 0x%x' % (i - unicorn.arm64_const.UC_ARM64_REG_X0, mu.reg_read(i)))
# code-execution callback
def hook_code(mu, addr, size, user_data):
print('-------hook code start-------')
code = mu.mem_read(addr, size) # read the code at addr
cp = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM) # init Capstone
for i in cp.disasm(code, addr): # disassemble
print('[addr:0x%x]: %s %s' % (i.address, i.mnemonic, i.op_str))
print_regs(mu) # print registers
# memory-write callback
def hook_mem_write(mu, type, addr, size, value, user_data):
print('-------hook mem write-------')
if type == unicorn.UC_MEM_WRITE: # memory-write event
print('memory write addr:0x%x size:%x value:0x%x' % (addr, size, value))
# interrupt callback
def hook_intr(mu, intno, user_data):
print('-------hook intr start-------')
print_regs(mu) # print registers
def test_arm64():
# an ARM64 instruction
code = b'\xe0\x03\x1f\xaa' # mov x0, xzr (example, replaceable)
# init Unicorn
mu = unicorn.Uc(unicorn.UC_ARCH_ARM64, unicorn.UC_MODE_ARM)
# memory address and size
addr = 0x1000
size = 0x1000
# map memory and write the code
mu.mem_map(addr, size)
mu.mem_write(addr, code)
# read back and print the code
code_bytes = mu.mem_read(addr, len(code))
print('addr:0x%x, content:%s' % (addr, binascii.b2a_hex(code_bytes)))
# set register values
mu.reg_write(unicorn.arm64_const.UC_ARM64_REG_X0, 0x100)
mu.reg_write(unicorn.arm64_const.UC_ARM64_REG_X1, 0x200)
mu.reg_write(unicorn.arm64_const.UC_ARM64_REG_X2, 0x300)
mu.reg_write(unicorn.arm64_const.UC_ARM64_REG_X3, 0x400)
# hook code execution
mu.hook_add(unicorn.UC_HOOK_CODE, hook_code)
# hook memory writes
mu.hook_add(unicorn.UC_HOOK_MEM_WRITE, hook_mem_write)
# hook interrupts
mu.hook_add(unicorn.UC_HOOK_INTR, hook_intr)
try:
# emulate
mu.emu_start(addr, addr + len(code))
except unicorn.UcError as e:
print(e)
# read back memory
stack_bytes = mu.mem_read(addr, 4)
print("mem:0x%x, value:%s" % (addr, binascii.b2a_hex(stack_bytes)))
if __name__ == '__main__':
test_arm64()
flare-emu
flare-emu brings Unicorn’s emulation power to IDA-based binary analysis. It supports several architectures (x86, x86_64, ARM, ARM64) and offers five main emulation interfaces plus a set of helpers:
emulateRange— emulate instructions or functions in a range; supports user-defined instruction hooks and call hooks, register/stack argument setup, byte-string injection into emulator memory (with the pointer placed in a register or stack variable), and utilities to read from emulated memory or registers.iterate— force emulation down a specific branch to reach a target address; accepts a list of target addresses or derives them from function cross-references; supports user hooks.iterateAllPaths— find and emulate all paths through a target function, for coverage of every basic block.emulateBytes— emulate a standalone blob (e.g., shellcode); the bytes are not added to the IDB.emulateFrom— emulate from an address until no instructions remain or a hook stops it; supports dynamic code discovery, useful for unclear function boundaries or obfuscated code.
Building a test case
C code:
#include <stdio.h>
int add(int a, int b){
return a + b;
}
int add5(int a, int b){
int ret = 0;
for (int i = 0; i < 5; i++){
if(b > 10){
ret -= add(a, b);
}else{
ret += add(a, b);
}
}
return ret;
}
int main() {
int ret = add5(1, 2);
printf("add5 ret %d", ret);
return 0;
}
Compile three obfuscated binaries:
clang main.cpp -o main_icall -target aarch64-linux-gnu -mllvm -irobf-icall
clang main.cpp -o main_indbr -target aarch64-linux-gnu -mllvm -irobf-indbr
clang main.cpp -o main_cff -target aarch64-linux-gnu -mllvm -irobf-cff
Defeating encrypted call addresses (indirect call)
The obfuscation
Opening main in main_icall, the jump to add5 is encrypted.

The approach
The original BL was replaced with BLR. Here we can just use flare_emu to emulate the function, force execution to the target address with iterate, and read the target register’s value. Then patch the BLR back to BL.

The jump target is easy to compute via emulation. My approach:
- Use flare-emu’s
iterateto emulate from a given address range. - Record each indirect-call target address in the
targetCallback. - Fix the indirect calls.
Example
import flare_emu # import flare-emu for emulation
import idc # import idc for IDA interaction
import idaapi # import idaapi for function info, etc.
import keypatch # import keypatch for instruction patching
# patch one instruction at an address
def patch_one(address: int, new_instruction: str):
"""
Use Keypatch to patch the instruction at the given address.
Args:
address (int): the instruction address to patch.
new_instruction (str): the new assembly instruction.
Returns:
bool: True on success, else False.
"""
kp_asm = keypatch.Keypatch_Asm() # init the Keypatch assembler
if kp_asm.arch is None: # check architecture support
print("ERROR: Keypatch cannot handle this architecture")
return False
# resolve the new instruction
assembly = kp_asm.ida_resolve(new_instruction, address)
(encoding, count) = kp_asm.assemble(assembly, address) # assemble to machine code
if encoding is None: # nothing to patch
print("Keypatch: nothing to patch")
return False
# convert machine code to bytes and patch
patch_data = ''.join(chr(c) for c in encoding)
patch_len = len(patch_data)
kp_asm.patch(address, patch_data, patch_len)
print(f"patched: {assembly}")
def myTargetCallBack(emu, address, argv, userData):
"""
Emulation callback to record indirect-call target addresses.
Args:
emu (flare_emu.EmuHelper): the emulator object.
address (int): the current instruction address.
argv (list): the current instruction's arguments.
userData (dict): user data for the indirect-call map.
"""
# get the disassembly string
code_str = idc.GetDisasm(address)
# extract the register name used by the BR instruction
register_name = code_str.split(" ")[-1]
# print the address and register value
print(f"address = {hex(address)}, X* = {hex(emu.getRegVal(register_name))}")
# store the address -> register value mapping
userData["br_map"][address] = emu.getRegVal(register_name)
def anti_icall():
"""
Main routine to fix indirect calls.
"""
br_addr_list = [0x4006A0, 0x400724] # the indirect-call address list
eh = flare_emu.EmuHelper() # init the flare-emu emulator
# emulate the range and record indirect-call targets
eh.iterate(br_addr_list, targetCallback=myTargetCallBack, hookData={"br_map": {}})
# patch each recorded indirect call back to a direct call
for br_addr in eh.hookData["br_map"]:
print(f"br_addr = {hex(br_addr)} {hex(eh.hookData['br_map'][br_addr])}")
patch_one(br_addr, f"bl {hex(eh.hookData['br_map'][br_addr])}")
Verification
IDA can now resolve the address of add.

Defeating encrypted inter-block branches (indirect branch)
The obfuscation
Opening add5 in main_indbr, the function body can’t be resolved.

The approach
Again BL became BLR, but unlike icall we can’t just brute-force the address, because here there can be multiple branches.

From observation there are usually only two branches, so my recovery approach is:
- Use flare-emu’s
emulateFromto emulate from the function start up to theBRinstruction. - During emulation, intercept instructions with
my_instruction_hookand recordCSEL/CSETaddresses, keeping in mind there are two branches. - Based on the
CSEL/CSETtype, patch into a conditional jump.
Example
import flare_emu # import flare-emu for emulation
import idc # import idc for IDA interaction
import idaapi # import idaapi for function info, etc.
import keypatch # import keypatch for instruction patching
# patch one instruction at an address
def patch_one(address: int, new_instruction: str):
kp_asm = keypatch.Keypatch_Asm() # init the Keypatch assembler
if kp_asm.arch is None: # check architecture support
print("ERROR: Keypatch cannot handle this architecture")
return False
# resolve the new instruction
assembly = kp_asm.ida_resolve(new_instruction, address)
(encoding, count) = kp_asm.assemble(assembly, address) # assemble to machine code
if encoding is None: # nothing to patch
print("Keypatch: nothing to patch")
return False
# convert machine code to bytes and patch
patch_data = ''.join(chr(c) for c in encoding)
patch_len = len(patch_data)
kp_asm.patch(address, patch_data, patch_len)
print(f"patched: {assembly}")
def my_instruction_hook(uc, address, size, userData):
import unicorn, re
# match CSEL and register names
match = re.search(r'CSEL\s+W(\w+),\s+W(\w+),\s+W(\w+),\s+LT', idc.GetDisasm(address))
if match:
print('CSEL LT --> ', match.groups()[0], match.groups()[1], match.groups()[2])
userData["cse_addr"] = address
# print the register names and values for the CSEL operands
reg_id1 = int(match.groups()[0])
reg_id2 = int(match.groups()[1])
reg_id3 = int(match.groups()[2])
reg_id1_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0 + reg_id1)
reg_id2_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0 + reg_id2)
reg_id3_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0 + reg_id3)
print(f"x{reg_id1} = {hex(reg_id1_value)}, x{reg_id2} = {hex(reg_id2_value)}, x{reg_id3} = {hex(reg_id3_value)}")
# read the current PSTATE
pstate_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_PSTATE)
if userData["branch"] == 1:
# branch 1, clear the Z flag
new_pstate_value = (pstate_value & ~(1 << 30))
else:
# branch 2, set the Z flag
new_pstate_value = (pstate_value & (1 << 30))
# write back the modified PSTATE
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PSTATE, new_pstate_value)
# match CSEL GT
match = re.search(r'CSEL\s+W(\w+),\s+W(\w+),\s+W(\w+),\s+GT', idc.GetDisasm(address))
if match:
print('CSEL GT --> ', match.groups()[0], match.groups()[1], match.groups()[2])
userData["cse_addr"] = address
reg_id1 = int(match.groups()[0])
reg_id2 = int(match.groups()[1])
reg_id3 = int(match.groups()[2])
reg_id1_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0 + reg_id1)
reg_id2_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0 + reg_id2)
reg_id3_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0 + reg_id3)
print(f"x{reg_id1} = {hex(reg_id1_value)}, x{reg_id2} = {hex(reg_id2_value)}, x{reg_id3} = {hex(reg_id3_value)}")
pstate_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_PSTATE)
if userData["branch"] == 1:
new_pstate_value = (pstate_value & ~(1 << 30))
else:
new_pstate_value = (pstate_value & (1 << 30))
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PSTATE, new_pstate_value)
# match CSET LT
match = re.search(r'CSET\s+W(\w+),\s+LT', idc.GetDisasm(address))
if match:
print('CSET --> ', match.groups()[0])
userData["cse_addr"] = address
pstate_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_PSTATE)
if userData["branch"] == 1:
# branch 1, clear the N flag
new_pstate_value = (pstate_value & ~(1 << 31))
else:
# branch 2, set the N flag
new_pstate_value = (pstate_value & (1 << 31))
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PSTATE, new_pstate_value)
# match CSET GT
match = re.search(r'CSET\s+W(\w+),\s+GT', idc.GetDisasm(address))
if match:
print('CSGT --> ', match.groups()[0])
userData["cse_addr"] = address
pstate_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_PSTATE)
if userData["branch"] == 1:
new_pstate_value = (pstate_value & ~(1 << 31))
else:
new_pstate_value = (pstate_value & (1 << 31))
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PSTATE, new_pstate_value)
def branch(start_addr, br_addr, branch_id):
br_reg_name = idc.GetDisasm(br_addr).split(" ")[-1] # extract the BR register name
eh = flare_emu.EmuHelper()
eh.emulateFrom(start_addr, instructionHook=my_instruction_hook, hookData={"branch":branch_id}, count=int((br_addr-start_addr)/4)) # emulate from start
br_reg_value = eh.getRegVal(br_reg_name)
cse_addr = eh.getHookData()["cse_addr"]
print(f"branch_{branch_id} {br_reg_name} = {hex(br_reg_value)}")
return br_reg_name, br_reg_value, cse_addr
def fun_to_block(addr):
import idc
import ida_funcs
import ida_bytes
# get the function object
func = ida_funcs.get_func(addr)
if func:
# delete the function definition
ida_funcs.del_func(addr)
print(f"deleted function: {hex(addr)}")
else:
print(f"no function at {hex(addr)}")
# ensure the bytes are marked as code
ida_bytes.del_items(addr, ida_bytes.DELIT_SIMPLE, 0)
# disassemble as a code block
idc.create_insn(addr)
print(f"{hex(addr)} is now a code block")
def anti_indbr():
br_addr_list = [0x400658, 0x4006AC] # the indirect-branch address list
start_addr = idaapi.get_func(br_addr_list[0]).start_ea
for br_addr in br_addr_list:
if not idc.GetDisasm(br_addr).startswith("BR"):
return
print(f"br_addr = {hex(br_addr)}")
# find the cse instructions and emulate both branches to get the BR register values
br_reg_name1, br_reg_value1, cse_addr1 = branch(start_addr, br_addr, 1)
br_reg_name2, br_reg_value2, cse_addr2 = branch(start_addr, br_addr, 2)
print(f"branch_1 {br_reg_name1} = {hex(br_reg_value1)}, cse_addr1 = {hex(cse_addr1)}")
print(f"branch_2 {br_reg_name2} = {hex(br_reg_value2)}, cse_addr2 = {hex(cse_addr2)}")
# convert the target functions into code blocks to improve IDA's F5 output
fun_to_block(br_reg_value1)
fun_to_block(br_reg_value2)
# a few of the handled cases
# CSET W9, LT
if idc.GetDisasm(cse_addr1).startswith("CSET") and idc.GetDisasm(cse_addr1).endswith("LT"):
patch_one(cse_addr1, f"B.GE {hex(br_reg_value1)}")
patch_one(cse_addr1+4, f"B {hex(br_reg_value2)}")
# CSET W9, GT
elif idc.GetDisasm(cse_addr1).startswith("CSET") and idc.GetDisasm(cse_addr1).endswith("GT"):
patch_one(cse_addr1, f"B.GT {hex(br_reg_value1)}")
patch_one(cse_addr1+4, f"B {hex(br_reg_value2)}")
# CSEL W9, W10, W11, LT
elif idc.GetDisasm(cse_addr1).startswith("CSEL") and idc.GetDisasm(cse_addr1).endswith("LT"):
patch_one(cse_addr1, f"B.LT {hex(br_reg_value1)}")
patch_one(cse_addr1+4, f"B {hex(br_reg_value2)}")
# CSEL W9, W10, W11, GT
elif idc.GetDisasm(cse_addr1).startswith("CSEL") and idc.GetDisasm(cse_addr1).endswith("GT"):
patch_one(cse_addr1, f"B.GT {hex(br_reg_value1)}")
patch_one(cse_addr1+4, f"B {hex(br_reg_value2)}")
Verification
IDA can now resolve the body of add5.

Defeating control-flow flattening
The obfuscation
Opening add5 in main_cff, the logic is hard to follow.

The approach
Control-flow flattening is OLLVM’s most important pass and the hardest to recover. My approach:
1. Identify the basic blocks
- Use IDA’s
FlowChartAPI to iterate every basic block. - Decide whether each block is real based on its terminating instruction pattern.
- The block containing the function entry is real (it sets up the stack).
- Blocks ending in
RETare real. - The remaining real blocks, by observation, end in one of two instruction patterns —
[cmp, csel, str, b]or[mov, subs, str, b]— and all jump to the same block. - Store real and fake blocks in
real_blocks/fake_blockslists respectively.

2. Emulate
- Use
emulateRangeto emulate the function. - During emulation, intercept branch instructions (
CSEL,CSET) and record the jump state and whether a branch exists based on the status register. - The tricky part is forcing execution into blocks that are unreachable by default — done by modifying the status register at branch instructions.
- Record each basic block’s successor links, adding the current block to the previous real block’s
behind_blocklist.
3. Fix the control flow
- For real blocks:
- One successor → patch to an unconditional
B. - Two successors → patch to a conditional jump (
B.GE,B.GT, etc.).
- One successor → patch to an unconditional
- For fake blocks: patch all instructions to
NOP.
Example
import flare_emu
import idaapi
import idc
import unicorn.arm64_const
# a class to record basic-block info
class BlockInfo:
def __init__(self, start_addr, end_addr):
self.start_addr = start_addr # block start address
self.end_addr = end_addr # block end address
self.size = end_addr - start_addr # block size
self.behind_block = {0: None, 1: None} # successor map: 0 = false, 1 = true
self.csel_state = -1 # CSEL state: -1 = none, 0 = false, 1 = true
def get_behind_block_num(self):
"""number of successors"""
num = 0
if self.behind_block[0] is not None and self.behind_block[0] is not True:
num += 1
if self.behind_block[1] is not None and self.behind_block[1] is not True:
num += 1
return num
def to_string(self):
"""string representation of the block info"""
behind_block0 = hex(self.behind_block[0].start_addr) if self.behind_block[0] else "None"
behind_block1 = hex(self.behind_block[1].start_addr) if self.behind_block[1] else "None"
return f"addr:{hex(self.start_addr)} behind_block0:{behind_block0} behind_block1:{behind_block1}"
# a class to strip OLLVM control-flow obfuscation
class OLLVMDeobfuscator:
def __init__(self, func_start):
self.func_start = func_start # function start address
self.func_end = idc.find_func_end(func_start) # function end address
self.fake_blocks = [] # fake blocks
self.real_blocks = [] # real blocks
self.block_links = [] # block link relationships
self.branch_tasks = {} # pending branch tasks
# get the function's control-flow graph
self.flowchart = idaapi.FlowChart(idaapi.get_func(func_start))
def analyze_blocks(self):
"""analyze basic blocks, split real vs fake"""
for block in self.flowchart:
block_info = BlockInfo(block.start_ea, block.end_ea - 4) # block info object
if block_info.size == 0:
continue
# decide whether the block is real
if self.is_real_block(block_info):
self.real_blocks.append(block_info)
else:
self.fake_blocks.append(block_info)
# print and color the real/fake blocks
for block_info in self.real_blocks:
print(f"real block: {hex(block_info.start_addr)} - {hex(block_info.end_addr)}")
self.set_block_color(block_info.start_addr, block_info.end_addr + 4, 0x00ff00) # green
for block_info in self.fake_blocks:
print(f"fake block: {hex(block_info.start_addr)} - {hex(block_info.end_addr)}")
self.set_block_color(block_info.start_addr, block_info.end_addr + 4, 0x00ffff) # yellow
def is_real_block(self, block_info):
"""decide whether a block is real"""
# the entry block is real
if idaapi.get_func(block_info.start_addr).start_ea == block_info.start_addr:
return True
# a block ending in RET is real
if idc.GetDisasm(block_info.end_addr).startswith("RET"):
return True
# decide by instruction pattern
disasm1 = idc.GetDisasm(block_info.end_addr - 12)
disasm2 = idc.GetDisasm(block_info.end_addr - 8)
disasm3 = idc.GetDisasm(block_info.end_addr - 4)
disasm4 = idc.GetDisasm(block_info.end_addr)
if disasm1.startswith("CMP") and disasm2.startswith("CSEL") and disasm3.startswith("STR") and disasm4.startswith("B"):
return True
if disasm1.startswith("MOV") and disasm2.startswith("SUBS") and disasm3.startswith("STR") and disasm4.startswith("B"):
return True
return False
def set_block_color(self, start_ea, end_ea, color):
"""set block color"""
for ea in range(start_ea, end_ea):
idc.set_color(ea, idc.CIC_ITEM, color)
def get_NZCV(self, uc):
"""get NZCV flags"""
pstate_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_PSTATE)
N = (pstate_value >> 31) & 1
Z = (pstate_value >> 30) & 1
C = (pstate_value >> 29) & 1
V = (pstate_value >> 28) & 1
return N, Z, C, V
def set_NZCV(self, uc, N=None, Z=None, C=None, V=None):
"""set NZCV flags"""
pstate_value = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_PSTATE)
if N is not None:
pstate_value = (pstate_value & ~(1 << 31)) | (N << 31)
if Z is not None:
pstate_value = (pstate_value & ~(1 << 30)) | (Z << 30)
if C is not None:
pstate_value = (pstate_value & ~(1 << 29)) | (C << 29)
if V is not None:
pstate_value = (pstate_value & ~(1 << 28)) | (V << 28)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PSTATE, pstate_value)
def is_real_block_start(self, address):
"""is this a real block's start address?"""
for block in self.real_blocks:
if address == block.start_addr:
return block
return None
def is_real_block_end(self, address):
"""is this a real block's end address?"""
for block in self.real_blocks:
if address == block.end_addr:
return block
return None
def find_real_block_by_address(self, address):
"""find the real block containing an address"""
for block in self.real_blocks:
if block.start_addr <= address <= block.end_addr:
return block
return None
def handle_real_block_start(self, uc, address, block_info):
"""handle a real block's start address"""
if self.is_real_block_start(address):
print(f"real block start: {hex(address)}")
self.block_links.append(block_info)
def handle_csel_lt(self, uc, address, block_info):
"""handle a CSEL LT instruction"""
if idc.GetDisasm(address).startswith("CSEL") and idc.GetDisasm(address).endswith("LT"):
print(f"handle CSEL LT: {hex(block_info.start_addr)}")
if self.branch_tasks.get(address):
if self.branch_tasks[address]["branch_true"] == False:
bl_addr = int(idc.GetDisasm(block_info.end_addr).split("loc_")[1], 16)
print(f"force condition true, jump {hex(bl_addr)}")
# set N != V
self.set_NZCV(uc, N=1, V=0)
block_info.csel_state = 1
self.branch_tasks[address]["branch_true"] = True
elif self.branch_tasks[address]["branch_false"] == False:
bl_addr = block_info.start_addr
print(f"force condition false, no jump {idc.GetDisasm(bl_addr)}")
# set N == V
self.set_NZCV(uc, N=1, V=1)
block_info.csel_state = 0
self.branch_tasks[address]["branch_false"] = True
else:
N, Z, C, V = self.get_NZCV(uc)
if N != V:
# N != V means the condition holds
bl_addr = int(idc.GetDisasm(block_info.end_addr).split("loc_")[1], 16)
print(f"natural condition true, jump {hex(bl_addr)}")
block_info.csel_state = 1
self.block_links[-1].behind_block[1] = True
self.branch_tasks[address] = {"branch_true":True, "branch_false":False}
else:
# N == V means the condition does not hold
bl_addr = block_info.start_addr
print(f"natural condition false, no jump {idc.GetDisasm(bl_addr)}")
block_info.csel_state = 0
self.block_links[-1].behind_block[0] = True
self.branch_tasks[address] = {"branch_true":False, "branch_false":True}
def handle_csel_gt(self, uc, address, block_info):
"""handle a CSEL GT instruction"""
if idc.GetDisasm(address).startswith("CSEL") and idc.GetDisasm(address).endswith("GT"):
print(f"handle CSEL GT: {hex(block_info.start_addr)}")
if self.branch_tasks.get(address):
if self.branch_tasks[address]["branch_true"] == False:
bl_addr = int(idc.GetDisasm(block_info.end_addr).split("loc_")[1], 16)
print(f"force condition true, jump {hex(bl_addr)}")
# set Z = 0 and N == V
self.set_NZCV(uc, Z=0, N=1, V=1)
block_info.csel_state = 1
self.branch_tasks[address]["branch_true"] = True
elif self.branch_tasks[address]["branch_false"] == False:
bl_addr = block_info.start_addr
print(f"force condition false, no jump {idc.GetDisasm(bl_addr)}")
# set Z = 1
self.set_NZCV(uc, Z=1)
block_info.csel_state = 0
self.branch_tasks[address]["branch_false"] = True
else:
N, Z, C, V = self.get_NZCV(uc)
if Z == 0 and N == V:
# condition holds
bl_addr = int(idc.GetDisasm(block_info.end_addr).split("loc_")[1], 16)
print(f"natural condition true, jump {hex(bl_addr)}")
block_info.csel_state = 1
self.block_links[-1].behind_block[1] = True
self.branch_tasks[address] = {"branch_true":True, "branch_false":False}
else:
# condition does not hold
bl_addr = block_info.start_addr
print(f"natural condition false, no jump {idc.GetDisasm(bl_addr)}")
block_info.csel_state = 0
self.block_links[-1].behind_block[0] = True
self.branch_tasks[address] = {"branch_true":False, "branch_false":True}
def handle_real_block_end(self, uc, address, block_info):
"""handle a real block's end address"""
if idc.GetDisasm(address + 4).startswith("RET"):
print(f"early return handling: {hex(address)}")
if len(self.block_links) > 1:
# add the current block to the previous real block's behind_block
before_block = self.block_links[-2]
if before_block.csel_state == 1:
print(f"current block CSEL:{hex(address)} {before_block.csel_state} {hex(before_block.start_addr)} --> {hex(block_info.start_addr)}")
if before_block.behind_block[1] == None or before_block.behind_block[1] == True:
before_block.behind_block[1] = block_info
elif before_block.csel_state == 0:
print(f"current block CSEL:{hex(address)} {before_block.csel_state} {hex(before_block.start_addr)} --> {hex(block_info.start_addr)}")
if before_block.behind_block[0] == None or before_block.behind_block[0] == True:
before_block.behind_block[0] = block_info
elif before_block.csel_state == -1:
print(f"current block CSEL:{hex(address)} {before_block.csel_state} {hex(before_block.start_addr)} --> {hex(block_info.start_addr)}")
before_block.behind_block[1] = block_info
# if this is a real block's end address, fetch that block
if self.is_real_block_end(address):
print(f"real block end: {hex(address)}")
if len(self.block_links) > 1:
before_block = self.block_links[-2]
if before_block.csel_state == 1:
print(f"current block CSEL:{hex(address)} {before_block.csel_state} {hex(before_block.start_addr)} --> {hex(block_info.start_addr)}")
if before_block.behind_block[1] == None or before_block.behind_block[1] == True:
before_block.behind_block[1] = block_info
elif before_block.csel_state == 0:
print(f"current block CSEL:{hex(address)} {before_block.csel_state} {hex(before_block.start_addr)} --> {hex(block_info.start_addr)}")
if before_block.behind_block[0] == None or before_block.behind_block[0] == True:
before_block.behind_block[0] = block_info
elif before_block.csel_state == -1:
print(f"current block CSEL:{hex(address)} {before_block.csel_state} {hex(before_block.start_addr)} --> {hex(block_info.start_addr)}")
before_block.behind_block[1] = block_info
def my_instruction_hook(self, uc, address, size, userData):
"""instruction hook"""
block_info = self.find_real_block_by_address(address)
if block_info is None:
return
# handle the real block's start address
self.handle_real_block_start(uc, address, block_info)
# handle CSEL instructions
self.handle_csel_lt(uc, address, block_info)
self.handle_csel_gt(uc, address, block_info)
# handle the real block's end address
self.handle_real_block_end(uc, address, block_info)
def simulate_start(self):
"""emulate the function start"""
# run several times to avoid missing blocks
eh = flare_emu.EmuHelper()
for i in range(0, 3):
eh.emulateRange(self.func_start, self.func_end, skipCalls=True, instructionHook=self.my_instruction_hook)
for block_info in self.real_blocks:
print(block_info.to_string())
def patch_blocks(self):
"""fix the fake and real block jumps"""
for block_info in self.real_blocks:
# skip the return block
if idc.GetDisasm(block_info.end_addr).startswith("RET"):
continue
# one successor: jump directly
if block_info.get_behind_block_num() == 1:
patch_addr = block_info.end_addr
jump_instruction = f"B {hex(block_info.behind_block[1].start_addr)}"
self.patch_one(patch_addr, jump_instruction)
# two successors: conditional jump
elif block_info.get_behind_block_num() == 2:
patch_addr = block_info.end_addr - 8
if idc.GetDisasm(patch_addr).startswith("CSEL") and idc.GetDisasm(patch_addr).endswith("LT"):
jump_instruction = f"B.GE {hex(block_info.behind_block[0].start_addr)}"
elif idc.GetDisasm(patch_addr).startswith("CSEL") and idc.GetDisasm(patch_addr).endswith("GT"):
jump_instruction = f"B.GT {hex(block_info.behind_block[0].start_addr)}"
self.patch_one(patch_addr, jump_instruction)
patch_addr = block_info.end_addr - 4
jump_instruction = f"B {hex(block_info.behind_block[1].start_addr)}"
self.patch_one(patch_addr, jump_instruction)
# patch fake blocks to NOP
for block_info in self.fake_blocks:
for patch_addr in range(block_info.start_addr, block_info.end_addr, 4):
self.patch_one(patch_addr, "nop")
def patch_one(self, address: int, new_instruction: str):
"""patch one instruction"""
import keypatch
kp_asm = keypatch.Keypatch_Asm()
if kp_asm.arch is None:
print("ERROR: Keypatch cannot handle this architecture")
return False
# resolve the new instruction
assembly = kp_asm.ida_resolve(new_instruction, address)
encoding, count = kp_asm.assemble(assembly, address)
if encoding is None:
print("Keypatch: nothing to patch")
return False
# apply the patch
patch_data = ''.join(chr(c) for c in encoding)
kp_asm.patch(address, patch_data, len(patch_data))
print(f"{hex(address)} patched: {assembly}")
def run(self):
"""run the de-obfuscation flow"""
print("analyzing basic blocks...")
self.analyze_blocks()
print("emulating...")
self.simulate_start()
print("patching instructions...")
self.patch_blocks()
print("OLLVM control-flow obfuscation removed!")
def deobfuscate_ollvm():
"""entry function"""
func_start = 0x04005D8 # the function start address
deobfuscator = OLLVMDeobfuscator(func_start)
deobfuscator.run()
Verification
IDA can now resolve the logic of add5.

Summary
Through this study I now have a much deeper grasp of OLLVM’s build process, its usage, and its obfuscation techniques:
- Build and use — compiling OLLVM and obfuscating code, and verifying the obfuscation actually takes effect.
- Technique analysis — indirect calls and control-flow flattening, and how they use dispatchers to inflate complexity.
- De-obfuscation practice — combining IDA Pro and Unicorn to strip the obfuscation and recover readable code.
The biggest lesson is that when facing complex obfuscation, tool selection and clarity of approach matter more than anything. Obfuscation is strong, but it is not unbreakable.
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