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.

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.

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

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

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.

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:

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.

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.

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:

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:


#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:

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