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:

  1. Building and using OLLVM — how to obtain, compile, and use OLLVM to obfuscate code.
  2. Analyzing OLLVM’s techniques — the inner workings of its control-flow obfuscation, especially indirect calls and control-flow flattening.
  3. 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.txt in 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.1 in VSCode.

  • Create a .vscode folder.

  • 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 iterate to 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 emulateFrom to emulate from the function start up to the BR instruction.
  • During emulation, intercept instructions with my_instruction_hook and record CSEL/CSET addresses, keeping in mind there are two branches.
  • Based on the CSEL/CSET type, 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 FlowChart API 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 RET are 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_blocks lists respectively.

2. Emulate
  • Use emulateRange to 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_block list.
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.).
  • 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:

  1. Build and use — compiling OLLVM and obfuscating code, and verifying the obfuscation actually takes effect.
  2. Technique analysis — indirect calls and control-flow flattening, and how they use dispatchers to inflate complexity.
  3. 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