Pinguw
Articles12
Tags4
Categories3

Categories

Archive

使用LLVM编写简单混淆Pass(一)

使用LLVM编写简单混淆Pass(一)

LLVM

LLVM 简介

简单来说,LLVM 是一个现代化的、模块化的编译器后端架构。

传统的编译器通常是“端到端”的(针对某种语言开发,直接生成某种机器码)。而 LLVM 将编译过程拆解为三个独立的部分:

  • 前端 (Frontend):将源代码(如 C++, Rust)翻译成一种通用的“中间语言” —— LLVM IR。
  • 中端 (Optimizer):对 LLVM IR 进行各种数学上的优化(比如代码混淆、死代码消除等)。由于 IR 是通用的,这一步的优化逻辑对所有语言都有效。
  • 后端 (Backend):将优化后的 IR 翻译成特定 CPU 指令(如 x86, ARM, RISC-V)。
llvm

LLVM的核心目标是提供一套可移植、模块化、可扩展的编译工具链,它能够支持多种硬件架构并进行代码优化。

LLVM Pass

LLVM 的 Pass 有中端 Pass(IR Pass)和后端 Pass(Machine Pass)两种

  • 中端是 LLVM 最引以为傲的部分,它处理的是 LLVM IR,是硬件无关的优化;

  • 后端的工作是将通用的 IR 转化为具体的机器码,从 IR 逐渐变为 SelectionDAG,最后变为 MachineInstr (MI)

LLVM IR Pass 允许针对代码的不同层级编写 Pass,常见的有:

  • ModulePass:观察整个文件(全局变量、所有函数)。
  • FunctionPass:一次只处理一个函数。这是最常用的,因为大多数优化都是在函数内部进行的。
  • BasicBlockPass:只处理一个基本块(一串没有跳转的指令)。
  • LoopPass:专门针对循环结构进行优化。

本篇的混淆就是实现的 IR Pass 中的 FunctionPass 。

控制流平坦化

原理

FLA 是现在非常常见的一种混淆手段了,通过打乱基本块之间的逻辑顺序从而模糊程序的执行逻辑,使得静态分析难度提高。

具体的原理讲解现在网上有很多文章,基本就是下面两图

混淆前:

before-flattening

混淆后:

after-flattenning

实现

LLVM 版本:12.0.1

思路就是遍历并收集函数中所有原始的基本块,把它们存入一个列表,然后创建一个 switch 指令。根据状态变量的值,决定程序下一步跳转到哪一个原始基本块。

预处理

由于编写的 Pass 是 IR 层的,首先要处理一些特殊指令,比如异常处理:

1
if (isa<InvokeInst>(&I) || isa<LandingPadInst>(&I)) return true;

这样可以跳过 InvokeLandingPad 这一对指令。

我们支持的大概有这些基本的跳转指令:

1
2
3
4
5
6
isa<ReturnInst>(i)
isa<ResumeInst>(i)
isa<UnreachableInst>(i)
isa<BranchInst>(i)
isa<SwitchInst>(i)
isa<IndirectBrInst>(i)

后来发现 IR 中含有大量 PHI 节点,

PHI 节点会出现在基本块的开头,会根据上一个基本块来源于哪一个基本块决定对应的值,形如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
entry:
br i1 %condition, label %if.then, label %if.else

if.then:
br label %merge

if.else:
br label %merge

merge:
; 如果从 if.then 过来,%result 就是 1
; 如果从 if.else 过来,%result 就是 2
%result = phi i32 [ 1, %if.then ], [ 2, %if.else ]
ret i32 %result+

需要把它转成一般汇编,思路是在前一个基本块结尾存储一个值,不同的来源存储的值不同,然后在 PHI 节点处把这个值取出来即可。

首先收集 PHI 节点:

1
2
3
for (Instruction &I : BB) {
if (PHINode* PN = dyn_cast<PHINode>(&I)) phis.push_back(PN);
}

寻找合适的插入点:第一条非 PHI 指令:

1
2
3
4
Instruction* loadInsert = nullptr;
for (Instruction &I : BB) {
if (!isa<PHINode>(&I)) { loadInsert = &I; break; }
}

遍历所有 PHI 指令,

根据来源块编写 store 指令:

1
2
3
4
5
6
7
for (unsigned i = 0; i < PhiNode->getNumIncomingValues(); ++i) {
Value* inV = PhiNode->getIncomingValue(i);
BasicBlock* pred = PhiNode->getIncomingBlock(i);
Instruction* predTerm = pred->getTerminator();
IRBuilder<> pb(predTerm);
pb.CreateStore(inV, slot);
}

然后收集所有使用该 PHI 的指令,并在它们的块中插入 load

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (auto &kv : usesPerBlock) {
BasicBlock* useBB = kv.first;
std::vector<Instruction*>& usersInBB = kv.second;

Instruction* insertPt = nullptr;
for (Instruction &I : *useBB) {
if (!isa<PHINode>(&I)) { insertPt = &I; break; }
}
if (!insertPt) insertPt = useBB->getTerminator();

IRBuilder<> lb(insertPt);
LoadInst* L = lb.CreateLoad(Ty, slot, PhiNode->getName() + ".phi.load");

for (Instruction* UInst : usersInBB) {
UInst->replaceUsesOfWith(PhiNode, L);
}
}

收集基本块

这里没什么好说的,收集除了入口块的所有基本块

1
2
3
4
5
6
7
8
void ControlFlowFlatten::collectOrigBlocks(Function &F, std::vector<BasicBlock*>& out) {
out.clear();
out.reserve(std::distance(F.begin(), F.end()));
for (BasicBlock &BB : F) {
if (&BB == &F.getEntryBlock()) continue;
out.push_back(&BB);
}
}

创建分发块

首先构建一个 switch 结构,检查 loadState 的值,并跳转到对应的基本块:

1
2
BasicBlock* defaultTarget = origBlocks.empty() ? &F.getEntryBlock() : origBlocks[0];
SwitchInst* swi = disBuilder.CreateSwitch(loadState, defaultTarget, origBlocks.size());

然后遍历所有原始代码块,将它们的状态 ID 和对应的基本块地址填入 switch 的路由表里:

1
2
3
4
5
6
for (BasicBlock* BB : origBlocks) {
uint64_t sid = stateOf.at(BB);
Constant* caseValConst = ConstantInt::get(i32Ty, static_cast<uint64_t>(sid) & 0xFFFFFFFF);
ConstantInt* caseVal = cast<ConstantInt>(caseValConst);
swi->addCase(caseVal, BB);
}

关于 loadState 的值可以存储在全局变量或者栈上,我选择掺在代码段中,以达到更高的混淆程度。

创建中转块

设法存储分发块使用的 loadState 的值,并跳转回分发块,执行下一次分发。

1
irb.CreateCall(jmpAsm, { dispatchAddrInt });

我使用的方法是将基本块 ID 插入到正常 opcode 中,然后通过 call $+5 将 ID 压进栈,并打乱正常栈帧,破坏 IDA 的反编译

类似于:

1
2
3
4
call $+5
$stateValAddr ; 被压进栈,在 dispatcher 中被取出然后使用

jmp dispatchBB ; 跳转到 dispatcher

修改跳转指令

简单来说就是把 A -> B 改造成了 A -> 中转块(设置状态为B) -> 分发器 -> B

遍历所有基本块的结束语句,如果是函数结束(return)就正常结束,

1
isa<ReturnInst>(term) || isa<ResumeInst>(term)

然后分别对 BranchInstSwitchInstIndirectBrInst 等进行处理,都是把 -> B 改写成 -> setB

1
BasicBlock* setBlk = getOrCreateSetStateBlock(succState);

打乱基本块顺序

这里也比较容易实现,使用随机数打乱即可。

1
std::shuffle(reorder.begin(), reorder.end(), RNG);

修复问题

主要逻辑已经完善了,但是无法通过 LLVM IR 的 SSA 验证,因为代码块被打乱,在编译器看来变量可能未被定义就已经被使用

解决方案是把所有的寄存器变量降级为内存访问。因为栈是持久的,不受控制流打乱的影响。

先筛选所有受到影响的指令,标准为

  • 跨块使用:在 Block A 中定义,但在其他块(Block B, Block C…)中被引用;
  • 不是特殊指令:排除 PHI、Alloca、终结符等有自己处理逻辑的指令;
  • 无副作用:确保转换不会改变代码原有的运行结果。

对于每一个需要修复的指令 I,在函数的入口块(Entry)创建一个 alloca 指令,在此处存入数据

最后在该变量被引用的地方按需加载。

效果

混淆前:

nofla nofla graph

混淆后:

fla

反编译受到插入代码段的数据的影响未成功,并且流程图已经被破坏,没有收敛

fla graph

待改进

  • 每个基本块 ID 固定不变且按自然数排列
  • 只有一个主分发器,缺失子分发器

参考资料

https://1ens.github.io/2024/11/29/LLVM%E5%85%A5%E9%97%A8/

https://bbs.kanxue.com/thread-288667.htm

https://ctf-wiki.org/reverse/obfuscate/control-flow-flatten/

Author:Pinguw
Link:https://pinguw.github.io/2026/02/22/Reverse/%E4%BD%BF%E7%94%A8LLVM%E7%BC%96%E5%86%99%E7%AE%80%E5%8D%95%E6%B7%B7%E6%B7%86Pass%EF%BC%88%E4%B8%80%EF%BC%89/
看完了吗,再去看看博主的其他文章叭:)