Analyzing and Deobfuscating Polaris's BogusControlFlow Pass - Detecting Opaque Predicates with angr

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.

Introduction

This obfuscation is an old trick inherited from OLLVM. Simply put: for a basic block, create an identical block before it that contains an opaque predicate (a condition that is always true or always false, so the new block is never actually executed). You can also stuff junk instructions into that new block so a decompiler can’t parse it correctly.

Details here: https://github.com/obfuscator-llvm/obfuscator/wiki/Bogus-control-flow

Implementation details

Source: https://github.com/za233/Polaris-Obfuscator/blob/main/src/llvm/lib/Transforms/Obfuscation/BogusControlFlow.cpp

Let’s analyze it function by function.

1. Cloning a basic block (cloneAlterBasicBlock)

BasicBlock *BogusControlFlow::cloneAlterBasicBlock(BasicBlock *BB) {
  ValueToValueMapTy VMap;
  BasicBlock *CBB = CloneBasicBlock(BB, VMap, "cloneBB", BB->getParent());
  BasicBlock::iterator Iter = BB->begin();
  for (Instruction &I : *CBB) {
    for (unsigned i = 0; i < I.getNumOperands(); i++) {
      Value *V = MapValue(I.getOperand(i), VMap);
      if (V) {
        I.setOperand(i, V);
      }
    }
    SmallVector<std::pair<unsigned, MDNode *>, 4> MDs;
    I.getAllMetadata(MDs);
    for (std::pair<unsigned, MDNode *> pair : MDs) {
      MDNode *MD = MapMetadata(pair.second, VMap);
      if (MD) {
        I.setMetadata(pair.first, MD);
      }
    }
    I.setDebugLoc(Iter->getDebugLoc());
    Iter++;
  }
  return CBB;
}

This clones a basic block, renaming variables and cloning metadata. It makes no modifications — no junk code is added.

2. Splitting basic blocks

/* 将函数中的每个基本块按每 Size 条指令拆分成多个较小的块 */
void BogusControlFlow::splitBasicBlock(Function &F, unsigned Size) {
  std::vector<Instruction *> SplitPoints;
  for (BasicBlock &BB : F) {
    unsigned Idx = 0;
    for (auto Iter = BB.getFirstInsertionPt(); Iter != BB.end(); Iter++) {
      Instruction &I = *Iter;
      if (I.isTerminator()) {
        continue;
      }
      if (Idx % Size == Size - 1) {
        SplitPoints.push_back(&I);
      }
      Idx = (Idx + 1) % Size;
    }
  }
  for (Instruction *I : SplitPoints) {
    BasicBlock *BB = I->getParent();
    BB->splitBasicBlock(I);
  }
}

This splits every basic block into smaller blocks of Size instructions. The point is to increase the block count so more opaque predicates can be inserted later.

3. The process function

First, create two variables Var and Var0 at the function entry:

IRBuilder<> IRB(&*F.getEntryBlock().getFirstInsertionPt());
Value *Var = IRB.CreateAlloca(IRB.getInt64Ty());
Value *Var0 = IRB.CreateAlloca(IRB.getInt64Ty());

Then derive two values Mod and X:

Mod = 0x100000000 - rand32()
X   = randPrime() mod Mod
uint64_t Mod = 0x100000000 - getRand32();
// X is just a random prime % Mod
uint64_t X =
    primes[getRandomNumber() % (sizeof(primes) / sizeof(primes[0]))] % Mod;

Both Var and Var0 are initialized to X:

// Both Var and Var0 are set to X at the beginning of the function.
IRB.CreateStore(IRB.getInt64(X), Var);
IRB.CreateStore(IRB.getInt64(X), Var0);

Next comes the tricky part. First the code, then the analysis:

  std::vector<BasicBlock *> BBs;
  for (BasicBlock &BB : F) {
    // At the end of each basic block
    IRB.SetInsertPoint(BB.getTerminator());
    // a*x - b = x (mod m)
    // (a - 1) * x = b (mod m)
    uint64_t B = getRand32() % Mod;
    uint64_t Inv = getInverse(X, Mod);
    uint64_t A = ((B * Inv) % Mod + 1) % Mod;

    Value *V = IRB.CreateLoad(IRB.getInt64Ty(), Var0);
    IRB.CreateStore(
        IRB.CreateURem(
            IRB.CreateSub(IRB.CreateURem(IRB.CreateMul(IRB.getInt64(A), V),
                                         IRB.getInt64(Mod)),
                          IRB.getInt64(B)),
            IRB.getInt64(Mod)),
        Var0);
    BBs.push_back(&BB);
  }

What does that IR actually compute?

%v = load i64, ptr %Var0
%mul  = mul i64 A, %v
%rem1 = urem i64 %mul, Mod
%sub  = sub i64 %rem1, B
%rem2 = urem i64 %sub, Mod
store i64 %rem2, ptr %Var

As high-level code:

Var0 = ((A * Var0) % Mod - B) % Mod

What does this compute? From the construction:

B = rand32() mod Mod
A = ((B * Inv(X, Mod)) mod Mod + 1) mod Mod

The deliberately constructed A and B satisfy:

(A - 1) * X = B mod Mod

where X can be any value less than Mod. Substituting the definitions of A and B verifies it. Rearranging slightly:

A * X = (X + B) mod Mod

Plugging this into the Var0 transform gives:

BogusControlFlow derivation

So Var0 never actually changes — it stays equal to Var. All that computation is just to stop the compiler/decompiler from optimizing it away.

Note: there’s actually a bug in this implementation. The derivation is correct under mathematical modulo, but the IR uses sub i64 (unsigned 64-bit subtraction) plus urem i64. When (A * X) % Mod < B, the sub underflows (unsigned), wrapping to a large number near 2^64, so the subsequent urem is no longer equivalent to mathematical modulo. For example, with Mod=11, X=3, B=8: mathematically (-8) mod 11 = 3, but in uint64 0 - 8 = 2^64 - 8, and (2^64 - 8) mod 11 = 8 != 3. This makes the opaque predicate no longer always-true, and the program can loop forever.

Now the block and opaque-predicate injection:

for (BasicBlock *BB : BBs) {
    if (isa<InvokeInst>(BB->getTerminator()) || BB->isEHPad() ||
        BB->isEntryBlock()) {
      continue;
    }
    BasicBlock *BodyBB =
        BB->splitBasicBlock(BB->getFirstNonPHIOrDbgOrLifetime(), "bodyBB");
    BasicBlock *TailBB =
        BodyBB->splitBasicBlock(BodyBB->getTerminator(), "endBB");
    BasicBlock *CloneBB = cloneAlterBasicBlock(BodyBB);
    BB->getTerminator()->eraseFromParent();
    BodyBB->getTerminator()->eraseFromParent();
    CloneBB->getTerminator()->eraseFromParent();
    IRB.SetInsertPoint(BB);
    if (getRandomNumber() % 2) {
      Value *Cond = IRB.CreateICmpEQ(IRB.CreateLoad(IRB.getInt64Ty(), Var),
                                     IRB.CreateLoad(IRB.getInt64Ty(), Var0));
      IRB.CreateCondBr(Cond, BodyBB, CloneBB);
    } else {
      Value *Cond = IRB.CreateICmpNE(IRB.CreateLoad(IRB.getInt64Ty(), Var),
                                     IRB.CreateLoad(IRB.getInt64Ty(), Var0));
      IRB.CreateCondBr(Cond, CloneBB, BodyBB);
    }

    IRB.SetInsertPoint(BodyBB);
    if (getRandomNumber() % 2) {
      Value *Cond = IRB.CreateICmpEQ(IRB.CreateLoad(IRB.getInt64Ty(), Var),
                                     IRB.CreateLoad(IRB.getInt64Ty(), Var0));
      IRB.CreateCondBr(Cond, TailBB, CloneBB);
    } else {
      Value *Cond = IRB.CreateICmpNE(IRB.CreateLoad(IRB.getInt64Ty(), Var),
                                     IRB.CreateLoad(IRB.getInt64Ty(), Var0));
      IRB.CreateCondBr(Cond, CloneBB, TailBB);
    }

    IRB.SetInsertPoint(CloneBB);
    IRB.CreateBr(BodyBB);
  }

The insertion is a little involved — it’s a graph transformation. The resulting structure:

BogusControlFlow structure

Source: https://www.apriorit.com/dev-blog/obfuscating-code-to-secure-android-apps

The core is the opaque-predicate construction. If you can optimize the opaque predicate away, the specific insertion layout doesn’t matter — the decompiler prunes the dead blocks automatically.

BogusControlFlow2

Looking quickly at the source, there are two BogusControlFlow passes: BogusControlFlow (the one analyzed above) and BogusControlFlow2. The latter is the simpler version — I believe this is what OLLVM used originally:

Value *createBogusCmp(BasicBlock *insertAfter) {
  // if((y < 10 || x * (x + 1) % 2 == 0))
  Module *M = insertAfter->getModule();
  LLVMContext &context = M->getContext();
  GlobalVariable *xptr = new GlobalVariable(
      *M, Type::getInt32Ty(context), false, GlobalValue::CommonLinkage,
      ConstantInt::get(Type::getInt32Ty(context), 0), "x");
  GlobalVariable *yptr = new GlobalVariable(
      *M, Type::getInt32Ty(context), false, GlobalValue::CommonLinkage,
      ConstantInt::get(Type::getInt32Ty(context), 0), "y");

  IRBuilder<> builder(context);
  builder.SetInsertPoint(insertAfter);
  LoadInst *x = builder.CreateLoad(Type::getInt32Ty(context), xptr);
  LoadInst *y = builder.CreateLoad(Type::getInt32Ty(context), yptr);
  Value *cond1 =
      builder.CreateICmpSLT(y, ConstantInt::get(Type::getInt32Ty(context), 10));
  Value *op1 =
      builder.CreateAdd(x, ConstantInt::get(Type::getInt32Ty(context), 1));
  Value *op2 = builder.CreateMul(op1, x);
  Value *op3 =
      builder.CreateURem(op2, ConstantInt::get(Type::getInt32Ty(context), 2));
  Value *cond2 =
      builder.CreateICmpEQ(op3, ConstantInt::get(Type::getInt32Ty(context), 0));
  return BinaryOperator::CreateOr(cond1, cond2, "", insertAfter);
}

The opaque predicate here is much simpler — no modular inverse. And in Pipeline.cpp, it’s BogusControlFlow2 that’s actually registered as the pass; the more complex BogusControlFlow isn’t used at all.

else if (pass == "bcf") {
      FunctionPassManager FPM;
      FPM.addPass(BogusControlFlow2());
      MPM.addPass(createModuleToFunctionPassAdaptor(std::move(FPM)));
    }

Example

After bcf-obfuscating a csmith-generated test sample:

    if (iVar4 == 0) {
      if (y.12 < 10 || ((x.11 + 1) * x.11 & 1U) == 0) goto LAB_001012af;
      do {
        local_28->f0 = 1;
LAB_001012af:
        local_28->f0 = 1;
      } while (9 < y.14 && ((x.13 + 1) * x.13 & 1U) != 0);
    }
  }
  if (y.16 < 10 || ((x.15 + 1) * x.15 & 1U) == 0) goto LAB_0010132a;

Note this uses the old BogusControlFlow2, so the opaque predicates are simple.

Deobfuscation prototype

The core is recognizing opaque predicates. The workflow:

  1. collect: use DDG-based backward slicing to collect all instructions affecting the branch guard.
  2. merge (optional): an opaque predicate may be split into multiple separate conditions after compilation; merge them into a full predicate. Not encountered in the test samples, so skipped for now.
  3. solve: use symbolic execution on the collected predicate to get an optimized condition. For BogusControlFlow, the result is simply true or false.
  4. patch: patch the binary to remove the opaque predicate’s effect on the decompiler.

In practice each step is more involved:

  1. collect: decide which code constructs to collect. For IndirectCall we just iterate over indirect call sites. BogusControlFlow is trickier — in principle we need to collect over all branch predicates.
  2. solve: symbolically execute along the slice to the branch, then check whether the guard can only take one value. Since an opaque predicate is always-true or always-false, that’s the signal.
  3. patch: just patch to a direct jump — not hard.

The implementation:

Step 1: find all branches

Find all conditional-branch instructions in the target function. In VEX IR, a conditional branch block has jumpkind Ijk_Boring and contains an Exit statement (also Ijk_Boring).

def find_branches(proj, func):
    result = []
    for block_addr in func.block_addrs:
        block = proj.factory.block(block_addr)
        irsb = block.vex
        if irsb.jumpkind == 'Ijk_Boring' and any(
            isinstance(s, pyvex.stmt.Exit) and s.jumpkind == 'Ijk_Boring'
            for s in irsb.statements
        ):
            branch_insn = block.capstone.insns[-1]
            result.append(branch_insn.address)
    return result

Straightforward: walk all basic blocks, find those containing a conditional branch (default exit is Ijk_Boring, with an inner Exit also Ijk_Boring), and record the branch instruction’s address.

Step 2: backward slicing

For each branch, do a backward slice to collect all instructions affecting the guard, using angr’s DDG.

def backward_slice_from(proj, cfg, ddg, target_insn_addr):
    block_node = cfg.model.get_any_node(target_insn_addr, anyaddr=True)
    if block_node is None:
        raise RuntimeError(f"No CFG node found containing 0x{target_insn_addr:x}")

    irsb = proj.factory.block(block_node.addr).vex
    exit_indices = {
        i for i, s in enumerate(irsb.statements)
        if isinstance(s, pyvex.stmt.Exit)
    } or {-2}

    seed_nodes = [
        n for n in ddg.graph.nodes()
        if getattr(n, 'block_addr', None) == block_node.addr
        and getattr(n, 'stmt_idx', None) in exit_indices
    ]
    if not seed_nodes:
        raise RuntimeError(f"No DDG nodes found for ins_addr=0x{target_insn_addr:x}")

    # BFS backward through the DDG
    visited = set()
    queue = deque(seed_nodes)
    slice_cls = set()
    while queue:
        cl = queue.popleft()
        if cl in visited:
            continue
        visited.add(cl)
        slice_cls.add(cl)
        for pred in ddg.graph.predecessors(cl):
            queue.append(pred)
    return slice_cls

Flow:

  1. Find the CFG node containing the target instruction.
  2. Find all Exit statements in the VEX IR — those are the conditional-branch guards.
  3. Find the corresponding DDG nodes as seeds.
  4. BFS backward from the seeds to collect every node reaching them — the backward slice.

The resulting slice_cls is the set of instructions affecting the branch guard’s value.

Step 3: symbolic-execution solving

With the slice in hand, symbolically execute to decide whether the guard is an opaque predicate.

def analyze_branch_guard(proj, slice_cls, branch_block_addr):
    block_addrs = sorted(set(
        cl.block_addr for cl in slice_cls if cl.block_addr is not None
    ))
    if branch_block_addr not in block_addrs:
        block_addrs.append(branch_block_addr)

    state = proj.factory.blank_state(addr=block_addrs[0])
    simgr = proj.factory.simgr(state)

    for next_addr in block_addrs[1:]:
        simgr.step()
        simgr.move('active', 'deadended', lambda s, na=next_addr: s.addr != na)
        if not simgr.active:
            break

Collect and sort all block addresses in the slice, then symbolically execute from the first block, stepping to each next block. After each step, move any state that reached the “wrong” address to the deadended stash — keeping execution on the slice path only.

    # 从VEX IR中获取conditional exit语句
    irsb = proj.factory.block(branch_block_addr).vex
    cond_exit = next(
        (s for s in irsb.statements if isinstance(s, pyvex.stmt.Exit)),
        None,
    )
    if cond_exit is None:
        return 'unconditional', None

    # 在branch block再step一次,观察产生的successor数量
    succs = simgr.active[0].step()

    if len(succs.successors) == 1:
        taken_addr = cond_exit.dst.value
        resolved_addr = succs.successors[0].addr
        if resolved_addr == taken_addr:
            return 'always_true', None
        else:
            return 'always_false', None

    # 有两个successor,用solver检查guard的可满足性
    guard = succs.successors[0].history.jump_guard
    solver = simgr.active[0].solver

    can_be_true  = solver.satisfiable(extra_constraints=[guard])
    can_be_false = solver.satisfiable(extra_constraints=[claripy.Not(guard)])

    if can_be_true and not can_be_false:
        return 'always_true', guard
    elif can_be_false and not can_be_true:
        return 'always_false', guard
    else:
        return 'symbolic', guard

At the branch block, step once more and check how many successors are produced:

  • 1 successor: angr already concretized the guard, so it’s an opaque predicate. Compare the resolved address against the Exit target to tell always_true vs always_false.
  • 2 successors: the guard is symbolic. Use the solver to check satisfiability of the guard being true and false:
    • only true → always_true (opaque predicate)
    • only false → always_false (opaque predicate)
    • both → symbolic (a real branch, not opaque)

Step 4: patch

Once a branch is confirmed as an opaque predicate, patch the binary.

def build_slice_patch(proj, slice_cls, target_addr, insn="jmp"):
    # 收集slice中所有指令的地址和大小
    seen = {}
    for cl in slice_cls:
        addr = cl.ins_addr
        if addr is None or addr in seen:
            continue
        for i in proj.factory.block(addr).capstone.insns:
            if i.address == addr:
                seen[addr] = i.size
                break
    insns = sorted(seen.items())

    # 找到第一个连续区域 >= 5 bytes(jmp指令需要5字节)
    patch_start = patch_total = None
    for i, (addr, size) in enumerate(insns):
        run_size = size
        for j in range(i + 1, len(insns)):
            if insns[j-1][0] + insns[j-1][1] != insns[j][0]:
                break
            run_size += insns[j][1]
            if run_size >= INSN_SIZE:
                break
        if run_size >= INSN_SIZE:
            patch_start, patch_total = addr, run_size
            break

    # 在找到的区域放置jmp指令,其余全部NOP
    asm_bytes, _ = ks.asm(f"{insn} 0x{target_addr:x}", addr=patch_start)
    patches = {}
    patches[patch_start - file_base] = bytes(asm_bytes) + b'\x90' * (patch_total - len(asm_bytes))
    for addr, size in insns:
        if patch_start <= addr < patch_start + patch_total:
            continue
        patches[addr - file_base] = b'\x90' * size
    return patches

Patch strategy:

  1. Collect the addresses and sizes of all instructions in the backward slice.
  2. Find a contiguous region of at least 5 bytes — the size of an x86-64 jmp rel32.
  3. Place an unconditional jmp target there, targeting the branch the opaque predicate always takes.
  4. NOP out every other instruction in the slice — they only compute the opaque predicate, so removing them doesn’t affect correctness.

Main flow

Tie it together:

proj, main_func, cfg, ddg = load_everything(
    TARGET_BINARY, target_func_name=TARGET_FUNC_NAME,
    cfg_type="Emulated", auto_load_libs=False
)

branches = find_branches(proj, main_func)
print(f"Found {len(branches)} branches in {TARGET_FUNC_NAME}.")

all_patches = []
for branch_addr in branches:
    block_node = cfg.model.get_any_node(branch_addr, anyaddr=True)
    block_addr = block_node.addr

    slice_cls = backward_slice_from(proj, cfg, ddg, branch_addr)

    kind, guard = analyze_branch_guard(proj, slice_cls, block_addr)
    print(f"  Guard: {kind}")

    if kind in ('always_true', 'always_false'):
        irsb = proj.factory.block(block_addr).vex
        cond_exit = next(s for s in irsb.statements if isinstance(s, pyvex.stmt.Exit))
        taken_addr = cond_exit.dst.value
        fall_addr = irsb.next.con.value
        patch_target = taken_addr if kind == 'always_true' else fall_addr
        all_patches.append(build_slice_patch(proj, slice_cls, patch_target, insn='jmp'))

if all_patches:
    apply_patches(all_patches, TARGET_BINARY, OUTPUT_BINARY)
    print(f"\nWrote {len(all_patches)} patches -> {OUTPUT_BINARY}")

For each branch:

  1. backward slice
  2. symbolic execution to decide whether it’s an opaque predicate
  3. if so, pick the correct jump target (always_true → taken branch, always_false → fallthrough) and build the patch
  4. write all patches to the output in one pass

Evaluation

Before (obfuscated):

Obfuscated bcf

After deobfuscation:

Deobfuscated

Limitations

  1. Works well on simple opaque predicates like x*(x+1) % 2 == 0; more complex constructions (like the modular-inverse scheme analyzed above) may need stronger constraint solving.
  2. Contiguity assumption in the patch stage. The patch assumes the slice contains a contiguous region of at least 5 bytes for the jmp. Usually true in practice, but theoretically a slice could be extremely fragmented.

Code

https://github.com/Taardisaa/DePolaris

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