Indirect Branch Obfuscation with LLVM — Building and Removing It
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.
Indirect branching is the technique of replacing jmp addr with jmp reg, hiding the real block-to-block jump relationships.
Building it
The most basic indirect-branch pass looks like this:
#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 "llvm/IR/IRBuilder.h"
#include "SplitBasicBlock.h"
#include "Utils.h"
#include <vector>
#include <cstdlib>
#include <ctime>
using std::vector;
using namespace llvm;
static cl::opt<bool> enableIndBr("indbr_num",cl::init(1),cl::desc("Indirect branch obfuscation"));
namespace{
class IndirectBranch : public FunctionPass{
public:
static char ID;
IndirectBranch() :FunctionPass(ID){}
bool runOnFunction(Function &F);
void applyIndirectJump(BasicBlock *BB);
};
}
bool IndirectBranch::runOnFunction(Function &F)
{
vector<BasicBlock *> origBB;
for(BasicBlock &BB : F)
{
origBB.push_back(&BB);
}
for(BasicBlock *BB : origBB)
{
applyIndirectJump(BB);
}
return true;
}
void IndirectBranch::applyIndirectJump(BasicBlock *BB)
{
Instruction *terminator = BB->getTerminator();
BranchInst *br = dyn_cast<BranchInst>(terminator);
if(!br) return;
// create an IRBuilder, insert before the terminator
IRBuilder<> builder(terminator);
// unconditional branch
if(br->isUnconditional())
{
// get the target block
BasicBlock *targetBB = br->getSuccessor(0);
// get the target address
BlockAddress *targetAddr = BlockAddress::get(targetBB);
// create the indirect branch (target address, possible address count)
IndirectBrInst *indirectBr = builder.CreateIndirectBr(targetAddr,1);
// add the valid target block
indirectBr->addDestination(targetBB);
// erase the original instruction
br->eraseFromParent();
}
// conditional branch
else if(br->isConditional())
{
Value *cond = br->getCondition();
// get true/false target blocks
BasicBlock *trueBB = br->getSuccessor(0);
BasicBlock *falseBB = br->getSuccessor(1);
// true/false target addresses
BlockAddress *trueAddr = BlockAddress::get(trueBB);
BlockAddress *falseAddr = BlockAddress::get(falseBB);
// select instruction
Value *selectedAddr = builder.CreateSelect(cond,trueAddr,falseAddr);
// create the indirect branch
IndirectBrInst *indirectBr = builder.CreateIndirectBr(selectedAddr,2);
// add the possible target blocks
indirectBr->addDestination(trueBB);
indirectBr->addDestination(falseBB);
// erase the original instruction
br->eraseFromParent();
}
}
char IndirectBranch::ID = 0;
static RegisterPass<IndirectBranch> X("indbr","Indirect Branch Obfuscation Pass");
The classic register jump assigns the address straight into rax, so the target is obvious at a glance.

To make the obfuscation stronger, encrypt/decrypt the address:
#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 "llvm/IR/IRBuilder.h"
#include "SplitBasicBlock.h"
#include "Utils.h"
#include <vector>
#include <cstdlib>
#include <ctime>
#include <string>
using std::vector;
using namespace llvm;
static cl::opt<bool> enableIndBr("indbr_num",cl::init(1),cl::desc("Indirect branch obfuscation"));
namespace{
class IndirectBranch : public FunctionPass{
public:
static char ID;
IndirectBranch() :FunctionPass(ID){
srand(time(0));
}
bool runOnFunction(Function &F);
void applyIndirectJump(BasicBlock *BB);
Value *createDecodedBlockAddress(IRBuilder<> &builder, BasicBlock *targetBB, uint64_t secretKey);
};
}
bool IndirectBranch::runOnFunction(Function &F)
{
INIT_CONTEXT(F);
vector<BasicBlock *> origBB;
for(BasicBlock &BB : F)
{
origBB.push_back(&BB);
}
for(BasicBlock *BB : origBB)
{
applyIndirectJump(BB);
}
return true;
}
Value *IndirectBranch::createDecodedBlockAddress(IRBuilder<> &builder, BasicBlock *targetBB, uint64_t secretKey)
{
Module *M = targetBB->getModule();
const DataLayout &DL = M->getDataLayout();
IntegerType *intPtrTy = DL.getIntPtrType(*CONTEXT);
Type *int8PtrTy = Type::getInt8PtrTy(*CONTEXT);
// target address
BlockAddress *targetAddr = BlockAddress::get(targetBB);
// cast to int for arithmetic
Constant *targetInt = ConstantExpr::getPtrToInt(targetAddr,intPtrTy);
Constant *keyVal = ConstantInt::get(intPtrTy,secretKey);
// encoded address
Constant *encodedInit = ConstantExpr::getAdd(targetInt,keyVal);
// build the global variable name
std::string globalName = targetBB->getParent()->getName().str() + ".indbr.addr";
// create the global, initial value is the encoded value
GlobalVariable *encodedSlot = new GlobalVariable(
*M,
intPtrTy,
false,
GlobalValue::PrivateLinkage,
encodedInit,
globalName
);
// load the global value
LoadInst *encodedVal = builder.CreateLoad(intPtrTy,encodedSlot);
// mark volatile to block optimization
encodedVal->setVolatile(true);
// create the subtraction
Value *decodedVal = builder.CreateSub(encodedVal,keyVal);
return builder.CreateIntToPtr(decodedVal,int8PtrTy);
}
void IndirectBranch::applyIndirectJump(BasicBlock *BB)
{
Instruction *terminator = BB->getTerminator();
BranchInst *br = dyn_cast<BranchInst>(terminator);
if(!br) return;
IRBuilder<> builder(terminator);
// conditional branch
if(br->isUnconditional())
{
BasicBlock *targetBB = br->getSuccessor(0);
uint64_t secretKey = rand();
Value *decPtr = createDecodedBlockAddress(builder,targetBB,secretKey);
IndirectBrInst *indirectBr = builder.CreateIndirectBr(decPtr,1);
indirectBr->addDestination(targetBB);
br->eraseFromParent();
}
// unconditional branch
else if(br->isConditional())
{
Value *cond = br->getCondition();
// get true/false target blocks
BasicBlock *trueBB = br->getSuccessor(0);
BasicBlock *falseBB = br->getSuccessor(1);
uint64_t secretKey = rand();
// get the decoded values
Value *decPtr_true = createDecodedBlockAddress(builder,trueBB,secretKey);
Value *decPtr_false = createDecodedBlockAddress(builder,falseBB,secretKey);
// create the select instruction
Value *selectedAddr = builder.CreateSelect(cond,decPtr_true,decPtr_false);
// create the indirect branch
IndirectBrInst *indirectBr = builder.CreateIndirectBr(selectedAddr,2);
// add the two possible target blocks
indirectBr->addDestination(trueBB);
indirectBr->addDestination(falseBB);
br->eraseFromParent();
}
}
char IndirectBranch::ID = 0;
static RegisterPass<IndirectBranch> X("indbr","Indirect Branch Obfuscation Pass");
Now the value loaded into the register has to go through a global-variable computation:

Indirect-branch obfuscation comes in many forms, of course — these are just the two basic passes.
Removing it
Two ways to de-obfuscate:
1. IDC scripts that match the pattern and patch.
Just like removing control-flow flattening, you match the feature with an IDC script and patch. For the sample above, the global variable is read and combined with a 64-bit value, so you can locate the indirect branch by the jmp rax feature:
auto jmp_insn = print_insn_mnem(current_addr);
auto jmp_op = print_operand(current_addr,0);
if(jmp_insn == "jmp" && jmp_op == "rax")
Then compute the global value and the addend:
auto add_addr = prev_head(current_addr,start_addr);
auto add_val = Dword(add_addr + 2);
add_val = add_val | 0xFFFFFFFF00000000;
msg("add val = %X\n",add_val);
auto mov_addr = prev_head(add_addr,start_addr);
auto rand_offset = Dword(mov_addr + 3);
auto rand_addr = mov_addr + rand_offset + 7;
msg("rand addr = %X\n",rand_addr);
auto rand_val = Dword(rand_addr);
auto jmp_addr = add_val + rand_val;
And finally patch:
NopCode(mov_addr,jmp_addr + 2 - mov_addr);
PatchByte(mov_addr,0xE9);
PatchDword(mov_addr + 1,jmp_offset);
The full script:
static NopCode(Addr, Length)
{
auto i;
for (i = 0; i < Length; i++)
{
PatchByte(Addr + i, 0x90);
}
}
static main()
{
auto current_addr = ;
auto end_addr = ;
auto start_addr = current_addr;
while (current_addr < end_addr && current_addr != BADADDR)
{
auto jmp_insn = print_insn_mnem(current_addr);
auto jmp_op = print_operand(current_addr,0);
if(jmp_insn == "jmp" && jmp_op == "rax")
{
auto add_addr = prev_head(current_addr,start_addr);
auto add_val = Dword(add_addr + 2);
add_val = add_val | 0xFFFFFFFF00000000;
msg("add val = %X\n",add_val);
auto mov_addr = prev_head(add_addr,start_addr);
auto rand_offset = Dword(mov_addr + 3);
auto rand_addr = mov_addr + rand_offset + 7;
msg("rand addr = %X\n",rand_addr);
auto rand_val = Dword(rand_addr);
auto jmp_addr = add_val + rand_val;
auto jmp_offset = jmp_addr - mov_addr - 5;
msg("jmp addr = %X\n",jmp_addr);
msg("jmp offset = %X\n",jmp_offset);
NopCode(mov_addr,jmp_addr + 2 - mov_addr);
PatchByte(mov_addr,0xE9);
PatchDword(mov_addr + 1,jmp_offset);
}
current_addr = next_head(current_addr,end_addr);
}
}
(The example handles the unconditional jump only.)
Running it automatically converts indirect jumps back to direct jumps:

2. Automatic breakpoint + trace removal.
No matter how the indirect jump is built, by the time execution reaches the final jmp the value has been computed — so grabbing it dynamically is the better approach.
But an obfuscated sample has too many jmp instructions to inspect by hand. Instead, use IDC to drop breakpoints in bulk, run once, and collect the jump targets:
#include <idc.idc>
// set the breakpoint's Break attribute
static SetBptBreak(Address, Enable)
{
auto OldFlag = get_bpt_attr(Address, BPTATTR_FLAGS);
if (Enable == 1)
{
if ((OldFlag & BPT_BRK) == 0)
{
msg("%d\n", OldFlag & BPT_BRK);
OldFlag = OldFlag | BPT_BRK;
}
}
else
{
if ((OldFlag & BPT_BRK) != 0)
{
OldFlag = OldFlag & (~BPT_BRK);
}
}
set_bpt_attr(Address, BPTATTR_FLAGS, OldFlag);
}
// set the breakpoint handler
static SetHandlerToBpt(Address, HandlerFuncName, IsBreak)
{
// return calls back into the handler
auto Cond = sprintf("return %s();", HandlerFuncName);
if (check_bpt(Address) == (BPTCK_NONE))
{
add_bpt(Address);
}
// set the Break attribute
SetBptBreak(Address, IsBreak);
// set the breakpoint condition
auto Status = set_bpt_cond(Address, Cond);
if (Status == 1)
{
msg("Successfully set conditional bpt at 0x%x -> call %s\n", Address, HandlerFuncName);
}
else
{
msg("Failed to set conditional bpt at 0x%x\n", Address);
}
}
static Handler1()
{
auto fp = fopen("dump.txt", "a");
fprintf(fp, "jmp %X -> %X\n",rip,rax);
fclose(fp);
return 0;
}
static main()
{
auto start_addr = ;
auto end_addr = ;
auto current_addr = start_addr;
while(current_addr < end_addr && current_addr != BADADDR)
{
auto insn_name = print_insn_mnem(current_addr);
auto op = print_operand(current_addr,0);
// when a jmp rax is found, breakpoint it with Handler1
if(insn_name == "jmp" && op == "rax")
{
SetHandlerToBpt(current_addr, "Handler1", 0);
}
current_addr = next_head(current_addr,end_addr);
}
}
Because of loops there’s a lot of duplication in the dump, so a small Python script dedups it:
file_path = "dump.txt"
with open(file_path,'r') as f:
lines = [line.rstrip('\n') for line in f]
set_line = set(lines)
for s in set_line:
print(s)
Result:
jmp 401581 -> 401583
jmp 4015AA -> 4015AC
jmp 4013D4 -> 4013D6
jmp 4015E6 -> 401583
jmp 4013A5 -> 4013A7
jmp 4015CE -> 4015D0
jmp 4014FA -> 4013A7
jmp 4014D9 -> 4014DB
jmp 4013D4 -> 4014FC
jmp 4015AA -> 4015E8
That gives you the jump-relation table, which you can patch by script or with AI help.
A real sample
The sample is strange_xor.exe, obfuscated like this:

In this snippet only the push ebp in the middle is real program code — everything else exists to serve the indirect jump.
The final jump is computed as 0x77 ^ 0x9908 + 0x393017 = 0x39C996.
Even though the target is computed through a convoluted chain, the obfuscation pattern is fixed — assign + load + assign + jump — so an IDC script can match the pattern and de-obfuscate:
#include <idc.idc>
static NopCode(Addr, Length)
{
auto i;
for (i = 0; i < Length; i++)
{
PatchByte(Addr + i, 0x90);
}
}
static main()
{
auto current_addr = 0x40C996;
auto end_addr = current_addr + 0x10000;
// initial saved ecx value
auto ecx_data = 0x35;
while (current_addr != BADADDR && current_addr < end_addr)
{
auto insn_name = Byte(current_addr);
auto op = print_operand(current_addr, 0);
auto op2 = print_operand(current_addr, 1);
auto i;
///msg("addr: %X\n",current_addr);
// find the pusha instruction
if( insn_name == 0x60)
{
auto asm_name = Byte(current_addr + 1);
auto call_asm_name = Byte(current_addr + 2);
auto call_op = Dword(current_addr + 3);
//msg("pusha\n");
// jmp ebx
// are the next two instructions pushf + call $5
if( asm_name == 0x9C && call_asm_name == 0xE8 && call_op == 0)
{
auto pop_addr = current_addr + 7;
auto xor_addr = current_addr + 16;
//msg("xor_addr : %X\n",xor_addr);
auto xor_data = Dword(xor_addr + 2);
auto offest = ecx_data ^ xor_data;
//msg("xor_data : %X\n",xor_data);
// compute the jump target
auto jmp_addr = (offest + pop_addr) & 0xFFFFFFFF;
msg("jmp_addr : %X\n",jmp_addr);
auto ecx_addr = xor_addr + 9;
// compute the jump offset
auto jmp_offest = jmp_addr - ecx_addr - 5;
// also save the ecx value for the next round
ecx_data = Dword(ecx_addr + 1);
msg("ecx_data : %X\n",ecx_data);
NopCode(current_addr,39);
//msg("nop_addr: %X len: %X\n",current_addr,39);
PatchByte(ecx_addr,0xE9);
PatchDword(ecx_addr + 1,jmp_offest);
//msg("jmp %X\n",jmp_addr);
for(i = 0; i < 54; i++)
{
create_insn(jmp_addr + i);
}
current_addr = jmp_addr;
msg("current_addr: %X\n",current_addr);
auto next_call_insn = Byte(current_addr);
auto next_call_op = Dword(current_addr + 1);
if( next_call_insn == 0xE8 && next_call_op == 0 )
{
auto pop_byte = Byte(current_addr + 5);
if( pop_byte == 0x5B)
{
NopCode(current_addr - 1,12);
}
}
}
}
auto check_byte = Byte(current_addr);
if( check_byte == 0x9D || check_byte == 0x61 || check_byte == 0x60 || check_byte == 0x9C)
{
NopCode(current_addr,1);
}
current_addr = current_addr + 1;
}
}
The result:

Only the useful instructions and jump targets remain; everything else is nop’d out.
And the function decompiles cleanly:

Alternatively, instead of an IDC script you can breakpoint-trace — just tweak the earlier script slightly:
static Handler1()
{
auto fp = fopen("dump.txt", "a");
fprintf(fp, "jmp %X -> %X\n",eip,ebx);
fclose(fp);
return 0;
}
static main()
{
auto start_addr = 0x403001;
auto end_addr = 0x413C98;
auto current_addr = start_addr;
while(current_addr < end_addr && current_addr != BADADDR)
{
auto insn_name = print_insn_mnem(current_addr);
auto op = print_operand(current_addr,0);
if(insn_name == "jmp" && op == "ebx")
{
SetHandlerToBpt(current_addr, "Handler1", 0);
}
current_addr = next_head(current_addr,end_addr);
}
}
This produces the jump table. Part of it:
jmp 4068F4 -> 41207E
jmp 404D77 -> 40C281
jmp 40E202 -> 407CB0
jmp 404155 -> 410C62
jmp 40A019 -> 40AA11
jmp 4057F3 -> 4139A5
jmp 40BFA4 -> 41212A
jmp 40B340 -> 4036FB
jmp 413AC2 -> 406A8F
jmp 40C789 -> 40D40E
jmp 40E652 -> 407150
jmp 40FFC7 -> 40CBDA
jmp 40A6BC -> 404A8A
jmp 40B8CD -> 4076D9
jmp 406C5F -> 409DFB
jmp 408170 -> 40A32B
jmp 40C74F -> 4127E1
jmp 40DBE5 -> 412CFA
jmp 40D4EB -> 40C7FE
jmp 411949 -> 413489
Consistent with what the earlier script patched.
Wrapping up
That’s indirect-branch Pass construction and the two main de-obfuscation approaches. A caveat worth repeating: there’s no one-size-fits-all — in practice each sample needs its own analysis. What scales here is the method, not the script.
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