Writing and Removing LLVM Bogus Control Flow

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.

Bogus control flow builds each conditional branch out of an opaque predicate — an expression that is always true or always false at runtime, but looks non-trivial to static analysis. It chains basic blocks together with those conditional jumps and sprinkles in unreachable blocks and no-op functions, all to muddy the control-flow graph.

Compared to control-flow flattening, its CFG comes out more elongated.

Bogus control flow CFG

You’ll typically see multiple if statements, sometimes with extra functions mixed in to make it harder to read — functions that return a constant value.

Here’s the full pass.

Writing the obfuscation pass

#include "llvm/IR/Function.h"
#include "llvm/Pass.h"
#include "llvm/Support/raw_ostream.h"
#include "llvm/Support/CommandLine.h"
#include "llvm/Transforms/Utils/ValueMapper.h"
#include "llvm/Transforms/Utils/Cloning.h"
#include "llvm/IR/Module.h"
#include "llvm/IR/Instructions.h"
#include "SplitBasicBlock.h"
#include "Utils.h"
#include <vector>
#include <cstdlib>
#include <ctime>
using std::vector;
using namespace llvm;

static cl::opt<int> obfuTimes("bcf_loop",cl::init(1),cl::desc("obf function bcf_loop times"));

namespace{
    class BogusControlFlow : public FunctionPass{
        public:
            static char ID;
            BogusControlFlow() : FunctionPass(ID){
                srand(time(0));
            }

            bool runOnFunction(Function &F);
            void bogus(BasicBlock *BB);

            Value* createBogusCmp(BasicBlock *insertAfter);
            Value* createBogusCmp2(BasicBlock *insertAfter);

            Function* createBogusCall(Module *M,int constVal,StringRef funcName);

    };
}

bool BogusControlFlow::runOnFunction(Function &F)
{
    INIT_CONTEXT(F);
    FunctionPass *pass = createSplitBasicBlockPass();
    pass->runOnFunction(F);
    for(int i = 0; i < obfuTimes; i++)
    {
        vector<BasicBlock *> origBB;
        for(BasicBlock &BB : F)
        {
            origBB.push_back(&BB);
        }
        for(BasicBlock *BB : origBB)
        {
            bogus(BB);
        }
    }
    return true;
}

// function that returns a constant value
Function* BogusControlFlow :: createBogusCall(Module *M,int constVal,StringRef funcName)
{
    Function *F = M->getFunction(funcName);
    if(!F)
    {
        FunctionType *Ft = FunctionType::get(Type::getInt32Ty(*CONTEXT),false);
        // create the function
        F = Function::Create(Ft,GlobalValue::PrivateLinkage,funcName,M);
        // create the basic block
        BasicBlock *BB = BasicBlock::Create(*CONTEXT,"",F);
        // return instruction, returns a constant
        ReturnInst::Create(*CONTEXT,ConstantInt::get(Type::getInt32Ty(*CONTEXT),constVal),BB);
    }
    return F;
}

// the first always-true/always-false condition
Value* BogusControlFlow :: createBogusCmp(BasicBlock *insertAfter)
{
    // y < 10 || x * (x + 1) % 2 == 0
    Module *M = insertAfter->getModule();
    // create global variable pointers, initialized to 0
    GlobalVariable *xptr = new GlobalVariable(*M,TYPE_I32,false,GlobalValue::CommonLinkage,CONST_I32(0),"x");
    GlobalVariable *yptr = new GlobalVariable(*M,TYPE_I32,false,GlobalValue::CommonLinkage,CONST_I32(0),"y");
    // load x and y
    LoadInst *x = new LoadInst(TYPE_I32,xptr,"",insertAfter);
    LoadInst *y = new LoadInst(TYPE_I32,yptr,"",insertAfter);
    // comparison: y < 10
    ICmpInst *cmp1 = new ICmpInst(*insertAfter,CmpInst::ICMP_SLT,y,CONST_I32(10));
    // create a function returning 1, named "return_1"
    Function *func1 = createBogusCall(M,1,"return_1");
    // create the call instruction
    CallInst *callFunc1 = CallInst::Create(func1,"",insertAfter);
    // add: function result + x
    BinaryOperator *op1 = BinaryOperator::CreateAdd(x,callFunc1,"",insertAfter);
    // multiply: (add result) * x
    BinaryOperator *op2 = BinaryOperator::CreateMul(x,op1,"",insertAfter);
    // modulo
    BinaryOperator *op3 = BinaryOperator::CreateSRem(op2,CONST_I32(2),"",insertAfter);
    // comparison
    ICmpInst *cmp2 = new ICmpInst(*insertAfter,CmpInst::ICMP_EQ,op3,CONST_I32(0));
    // return the OR of the two comparisons
    return BinaryOperator::CreateOr(cmp1,cmp2,"",insertAfter);
}

// the second always-true/always-false condition
Value* BogusControlFlow :: createBogusCmp2(BasicBlock *insertAfter)
{
    // x * 2 < 9 && (y + 1) * 3 < 10
    Module *M = insertAfter->getModule();
    GlobalVariable *xptr = new GlobalVariable(*M,TYPE_I32,false,GlobalValue::CommonLinkage,CONST_I32(0),"x");
    GlobalVariable *yptr = new GlobalVariable(*M,TYPE_I32,false,GlobalValue::CommonLinkage,CONST_I32(0),"y");
    LoadInst *x = new LoadInst(TYPE_I32,xptr,"",insertAfter);
    LoadInst *y = new LoadInst(TYPE_I32,yptr,"",insertAfter);
    BinaryOperator *op1 = BinaryOperator::CreateMul(x,CONST_I32(2),"",insertAfter);
    ICmpInst *cmp1 = new ICmpInst(*insertAfter,CmpInst::ICMP_SLT,op1,CONST_I32(9));
    BinaryOperator *op2 = BinaryOperator::CreateAdd(y,CONST_I32(1),"",insertAfter);
    BinaryOperator *op3 = BinaryOperator::CreateMul(op2,CONST_I32(3),"",insertAfter);
    ICmpInst *cmp2 = new ICmpInst(*insertAfter,CmpInst::ICMP_SLT,op3,CONST_I32(10));
    return BinaryOperator::CreateAnd(cmp1,cmp2,"",insertAfter);
}

void BogusControlFlow :: bogus(BasicBlock *entryBB)
{
    // split the header BB at the first non-phi instruction
    BasicBlock *bodyBB = entryBB->splitBasicBlock(entryBB->getFirstNonPHI(),"bodyBB");
    // split the body BB at the terminator
    BasicBlock *endBB = bodyBB->splitBasicBlock(bodyBB->getTerminator(),"endBB");
    // unreachable clone block
    BasicBlock *cloneBB = createCloneBasicBlock(bodyBB);

    // remove the terminators
    bodyBB->getTerminator()->eraseFromParent();
    cloneBB->getTerminator()->eraseFromParent();
    entryBB->getTerminator()->eraseFromParent();

    // create always-true/always-false conditions
    Value *cond1 = createBogusCmp(entryBB);
    Value *cond2 = createBogusCmp2(bodyBB);

    // create conditional branches
    BranchInst::Create(bodyBB,cloneBB,cond1,entryBB);
    BranchInst::Create(endBB,cloneBB,cond2,bodyBB);
    BranchInst::Create(bodyBB,cloneBB);
}

char BogusControlFlow::ID = 0;
static RegisterPass<BogusControlFlow> X("bcf","BogusControlFlow a function");

Removing it

Example: re

The target comes from a CISCN 2025 qualifying-round challenge.

Bogus control flow, before deobfuscation

This is a textbook bogus-control-flow signature — lots of opaque predicates. First, rename things to make the analysis easier:

Renamed

Besides the opaque predicates, there are extra functions mixed in to make it harder. Some return a constant, some return an argument.

Here are two ways to remove it.

1. Constant substitution

Constant substitution

By the x86 calling convention, a1 arrives in rcx, a2 in rdx, and the return value lands in rax. So to replace all those helper functions, you just replace the call with an assignment to eax.

For the opaque predicates themselves, replace the assigned register with a constant.

Replacing opaque predicates

Watch instruction lengths: the replacements here are all shorter than the originals. If a replacement ever exceeds the original byte length, you need to NOP out the leftover bytes.

Here’s an IDC script to batch the replacement:

#include <idc.idc>

static NopCode(Addr, Length)
{
    auto i;
    for (i = 0; i < Length; i++)
    {
        PatchByte(Addr + i, 0x90);
    }
}

// check whether the instruction matches
static checkAsm(current_addr,my_insn_name,my_op1,my_op2)
{
    auto insn_name = print_insn_mnem(current_addr);
    auto op1 = print_operand(current_addr,0);
    auto op2 = print_operand(current_addr,1);
    if(my_insn_name == 0)
    {
        my_insn_name = insn_name;
    }
    if(my_op1 == 0)
    {
        my_op1 = op1;
    }
    if(my_op2 == 0)
    {
        my_op2 = op2;
    }
    if(insn_name == my_insn_name && op1 == my_op1 && op2 == my_op2)
    {
        return 1;
    }
    return 0;
}

// return the corresponding value
static getValue(data)
{
    if(data == "cs:_0")
    {
        return 0;
    }
    if(data == "cs:_1")
    {
        return 1;
    }
    if(data == "cs:_2")
    {
        return 2;
    }
    if(data == "cs:_3")
    {
        return 3;
    }
    if(data == "cs:_4")
    {
        return 4;
    }
    if(data == "cs:_5")
    {
        return 5;
    }
    if(data == "cs:_6")
    {
        return 6;
    }
    if(data == "cs:_7")
    {
        return 7;
    }
    if(data == "cs:_8")
    {
        return 8;
    }
    if(data == "cs:_9")
    {
        return 9;
    }
}

static patchMovData(asm_addr,reg,data)
{
    auto value = getValue(data);
    auto reg_data;
    if(reg == "eax")
    {
        reg_data = 0xB8;
    }
    if(reg == "ecx")
    {
        reg_data = 0xB9;
    }
    if(reg == "edx")
    {
        reg_data = 0xBA;
    }
    PatchByte(asm_addr,reg_data);
    PatchDword(asm_addr + 1,value);
    NopCode(asm_addr + 5,2);
}

static main()
{
    auto current_addr = 0x407E53;
    auto end_addr = 0x408859;
    while (current_addr != BADADDR && current_addr < end_addr)
    {
        // mov reg imm
        auto data = print_operand(current_addr,1);
        auto is_data = 1;
        if(strstr(data,"cs:_") != -1)
        {
            is_data = 0;
        }
        auto mov_data = checkAsm(current_addr,"movsx",0,is_data);
        if(mov_data)
        {
            msg("mov data asm addr : %X\n",current_addr);
            auto reg = print_operand(current_addr,0);
            patchMovData(current_addr,reg,data);
        }
        // call return_a2
        auto return_a2_func = print_operand(current_addr,0);
        if(return_a2_func == "return_a2")
        {
            msg("return a2 func : %X\n",current_addr);
            PatchWord(current_addr,0xD089);
            NopCode(current_addr + 2,3);
        }
        // call return_a1
        auto return_a1_func = print_operand(current_addr,0);
        if(return_a1_func == "return_a1")
        {
            msg("return a1 func : %X\n",current_addr);
            PatchWord(current_addr,0xC889);
            NopCode(current_addr + 2,3);
        }
        // return 1
        auto return_1_func = print_operand(current_addr,0);
        if(return_1_func == "return_1")
        {
            msg("return 1 func : %X\n",current_addr);
            PatchByte(current_addr,0xB8);
            PatchDword(current_addr + 1,1);
        }

        current_addr = next_head(current_addr, end_addr);
    }
}

The result:

After deobfuscation

Clean.

2. Pattern resolution

In general, obfuscators don’t write many completely different always-true/always-false conditions — they take one condition and derive variations from it.

Patterns

These four expressions look different, but they’re all essentially random1 * (random2 * 0), which is always 0. You only need to match the identical part of their assembly and patch the conditional jump.

Matching the pattern

An example IDC script:

#include <idc.idc>

static NopCode(Addr, Length)
{
    auto i;
    for (i = 0; i < Length; i++)
    {
        PatchByte(Addr + i, 0x90);
    }
}

static findJmpAddr(current_addr,end_addr,jmp_type)
{
    while(current_addr != BADADDR && current_addr < end_addr)
    {
        auto jz_name = print_insn_mnem(current_addr);
        if(jz_name == jmp_type)
        {
            break;
        }
        current_addr = next_head(current_addr,end_addr);
    }
    return current_addr;
}

static checkAsm(current_addr,my_insn_name,my_op1,my_op2)
{
    auto insn_name = print_insn_mnem(current_addr);
    auto op1 = print_operand(current_addr,0);
    auto op2 = print_operand(current_addr,1);
    if(my_insn_name == 0)
    {
        my_insn_name = insn_name;
    }
    if(my_op1 == 0)
    {
        my_op1 = op1;
    }
    if(my_op2 == 0)
    {
        my_op2 = op2;
    }
    if(insn_name == my_insn_name && op1 == my_op1 && op2 == my_op2)
    {
        return 1;
    }
    return 0;
}

static main()
{
    auto current_addr = 0x407E53;
    auto end_addr = 0x408859;
    while (current_addr != BADADDR && current_addr < end_addr)
    {
        auto current_res;
        auto next_addr;
        auto next_res;
        auto next_next_addr;
        auto next_next_res;
        // data * (data * 0 + 6 + 3 - 9) = 0
        current_res = checkAsm(current_addr,"movsx",0,"cs:_3");
        next_addr = next_head(current_addr,end_addr);
        next_res = checkAsm(next_addr,"movsx",0,"cs:_6");
        if(current_res && next_res)
        {
            auto jz_addr = findJmpAddr(current_addr,end_addr,"jz");
            NopCode(current_addr,jz_addr - current_addr);
            NopCode(jz_addr,1);
            PatchByte(jz_addr + 1,0xE9);
            msg("jz addr : %X\n",jz_addr);
        }
        // cmp 0,data
        current_res = checkAsm(current_addr,"movsx",0,"cs:_0");
        next_addr = next_head(current_addr,end_addr);
        next_res = checkAsm(next_addr,"cmp",0,0);
        next_next_addr = next_head(next_addr,end_addr);
        next_next_res = checkAsm(next_next_addr,"jbe",0,0);
        if(current_res && next_res && next_next_res)
        {
            auto nop_addr = next_head(next_next_addr,end_addr);
            NopCode(current_addr,nop_addr - current_addr);
            msg("cmp 0 addr : %X\n",next_next_addr);
        }
        next_next_res = checkAsm(next_next_addr,"jge",0,0);
        if(current_res && next_res && next_next_res)
        {
            NopCode(next_next_addr,1);
            PatchByte(next_next_addr + 1,0xE9);
            NopCode(current_addr - 5,5);
            msg("cmp 0 addr : %X\n",next_next_addr);
        }

        current_addr = next_head(current_addr,end_addr);
    }
}

This IDC script resolves the random1 * (random2 * 0) expression and the random vs 0 comparison. If substitution already works well, why bother with this approach at all?

Example: hello-obf

The target comes from lilctf 2025.

This one is also bogus control flow — opaque predicates plus constant-returning functions. First, remove it with constant substitution:

#include <idc.idc>

static NopCode(Addr, Length)
{
    auto i;
    for (i = 0; i < Length; i++)
    {
        PatchByte(Addr + i, 0x90);
    }
}

static PatchNumFunc(num,current_addr)
{
    msg("call return num addr : %X\n",current_addr);
    auto patch_num;
    PatchByte(current_addr,0xB8);
    if(num == "return_7")
    {
        patch_num = 7;
    }
    if(num == "return_2")
    {
        patch_num = 2;
    }
    if(num == "return_6")
    {
        patch_num = 6;
    }
    if(num == "return_3")
    {
        patch_num = 3;
    }
    if(num == "return_9")
    {
        patch_num = 9;
    }
    PatchDword(current_addr + 1,patch_num);
}

static getValue(data)
{
    if(data == "cs:_0")
    {
        return 0;
    }
    if(data == "cs:_1")
    {
        return 1;
    }
    if(data == "cs:_2")
    {
        return 2;
    }
    if(data == "cs:_3")
    {
        return 3;
    }
    if(data == "cs:_4")
    {
        return 4;
    }
    if(data == "cs:_5")
    {
        return 5;
    }
    if(data == "cs:_6")
    {
        return 6;
    }
    if(data == "cs:_7")
    {
        return 7;
    }
    if(data == "cs:_8")
    {
        return 8;
    }
    if(data == "cs:_9")
    {
        return 9;
    }
}

static PatchNum(num,current_addr)
{
    msg("mov reg num addr : %X\n",current_addr);
    auto value = getValue(num);
    auto reg = print_operand(current_addr,0);
    auto reg_byte;
    if(reg == "eax")
    {
        reg_byte = 0xB8;
    }
    if(reg == "ecx")
    {
        reg_byte = 0xB9;
    }
    if(reg == "edx")
    {
        reg_byte = 0xBA;
    }
    PatchByte(current_addr,reg_byte);
    PatchDword(current_addr + 1,value);
    NopCode(current_addr + 5,2);
}

static main()
{
    auto current_addr = 0x1400217E8;
    auto end_addr = 0x1400260C2;
    while (current_addr != BADADDR && current_addr < end_addr)
    {
        // call return_num
        auto call_num = print_operand(current_addr,0);
        if(strstr(call_num,"return_") != -1)
        {
            PatchNumFunc(call_num,current_addr);
        }

        // mov reg num
        auto num = print_operand(current_addr,1);
        if(strstr(num,"cs:_") != -1)
        {
            PatchNum(num,current_addr);
        }
        current_addr = next_head(current_addr, end_addr);
    }
}

The result:

After constant substitution

One expression didn’t get folded by IDA because it uses floating-point arithmetic. Each such expression contains the fixed 1 * (7 - (3 + 3)) pattern. In this case pattern resolution removes it more cleanly.

Observing the assembly, it stores eax on the stack. Match the obfuscation segment’s signature and script the removal:

Floating-point obfuscation, before

Floating-point obfuscation, after

#include <idc.idc>

static NopCode(Addr, Length)
{
    auto i;
    for (i = 0; i < Length; i++)
    {
        PatchByte(Addr + i, 0x90);
    }
}

static checkAsm(current_addr,my_insn_name,my_op1,my_op2)
{
    auto insn_name = print_insn_mnem(current_addr);
    auto op1 = print_operand(current_addr,0);
    auto op2 = print_operand(current_addr,1);
    if(my_insn_name == 0)
    {
        my_insn_name = insn_name;
    }
    if(my_op1 == 0)
    {
        my_op1 = op1;
    }
    if(my_op2 == 0)
    {
        my_op2 = op2;
    }
    if(insn_name == my_insn_name && op1 == my_op1 && op2 == my_op2)
    {
        return 1;
    }
    return 0;
}

static findMovStack(current_addr,end_addr)
{
    while(current_addr != BADADDR && current_addr < end_addr)
    {
        auto mov_stack = checkAsm(current_addr,"movsd",0,"xmm0");
        if(mov_stack)
        {
            return current_addr;
        }
        current_addr = next_head(current_addr,end_addr);
    }
}

static findEnd(current_addr,end_addr)
{
    while(current_addr != BADADDR && current_addr < end_addr)
    {
        auto res = checkAsm(current_addr,"cvttsd2si","eax","xmm0");
        if(res)
        {
            return current_addr;
        }
        current_addr = next_head(current_addr,end_addr);
    }
}

static main()
{
    auto current_addr = 0x1400217E8;
    auto end_addr = 0x1400260C2;
    while (current_addr != BADADDR && current_addr < end_addr)
    {
        auto sub_eax = checkAsm(current_addr,"sub","eax",0);
        auto pxor_addr = next_head(current_addr,end_addr);
        auto pxor_xmm0 = checkAsm(pxor_addr,"pxor","xmm0","xmm0");
        auto cvtsi2sd_addr = next_head(pxor_addr,end_addr);
        auto cvtsi2sd_xmm0 = checkAsm(cvtsi2sd_addr,"cvtsi2sd","xmm0","eax");
        if(sub_eax && pxor_xmm0 && cvtsi2sd_xmm0)
        {
            msg("addr : %X\n",current_addr);
            auto mov_stack_addr = findMovStack(cvtsi2sd_addr + 12,end_addr);
            NopCode(mov_stack_addr,1);
            PatchWord(mov_stack_addr + 1,0x8948);
            msg("patch addr : %X\n",mov_stack_addr);
            auto next_addr = next_head(mov_stack_addr,end_addr);
            auto cvttsd2si_addr = findEnd(mov_stack_addr,end_addr);
            auto obf_end_addr = next_head(cvttsd2si_addr,end_addr);
            msg("end addr : %X\n",obf_end_addr);
            NopCode(pxor_addr,mov_stack_addr - pxor_addr);
            NopCode(next_addr,obf_end_addr - next_addr);
        }
        current_addr = next_head(current_addr, end_addr);
    }
}

The result:

After pattern resolution

This last example isn’t the cleanest — I just couldn’t find a file that substitution couldn’t already handle. Pattern resolution is a lot more work than constant substitution, so treat it as an alternative technique rather than the default.

Summary

Bogus control flow steers program flow with always-true/always-false jumps, mixed with unreachable blocks and no-op functions to obfuscate the graph. The simplest removal is replacing opaque predicates with register assignments. In specific cases — where the obfuscated code is mostly identical — you can also match a signature and strip the whole block at once.


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