LLVM 转换层实战:如何把 C++ 代码变成一场令人头晕目眩的迷宫
各位好,欢迎来到今天的“编译器巫师”大讲堂。
今天我们不聊那些无聊的语法糖,也不谈如何优雅地写 std::vector。今天我们要聊的是一种黑魔法——代码保护。在这个世界上,如果你写了一段绝妙的算法,结果被人反编译成了一堆毫无逻辑的汇编,那简直比吃了苍蝇还难受。就像你精心做了一桌满汉全席,结果端上来的是一盘炒饭,厨师的心血何在?
为了防止我们的代码被那些拿着 IDA Pro 和 Ghidra 的逆向工程师像解剖青蛙一样解剖,我们需要一种高级手段:AST 混淆,特别是控制流平坦化。
今天,我们要利用 LLVM 这个强大的编译器基础设施,亲手把一段简单的 if-else 逻辑,变成一个充满随机跳转、调度表和垃圾指令的“迷宫”。
准备好了吗?让我们把编译器当成乐高积木,开始搭建这场混乱的舞台。
第一部分:AST 是什么?别被名字吓到了
在动手之前,我们必须搞清楚我们在玩什么。很多人听到“抽象语法树(AST)”就觉得高深莫测,仿佛那是只有计算机系博士才能触碰的圣杯。
其实不然。AST 就是代码的骨架。
当你写 C++ 代码时,编译器首先做的是词法分析(把 int a = 1; 拆成 int, a, =, 1, ;),然后是语法分析,把词法拼装成树。这棵树就是 AST。
比如这段代码:
void calculate(int x) {
if (x > 10) {
x = x * 2;
} else {
x = x + 5;
}
}
在 AST 里,它长这样:
- 一个
Function节点(calculate) - 里面包含一个
If节点(条件判断) If的左边是x > 10,右边是x * 2If的右边(else 分支)是x + 5
简单吧?LLVM 的 IR(中间表示)其实就是这种树的序列化版本。llvm::BasicBlock 就是树上的一个个节点。我们的任务,就是在这个树上搞破坏——或者说是搞艺术。
第二部分:控制流平坦化——给程序加点“料”
在代码保护领域,控制流平坦化是一种经典的混淆技术。它的核心思想很简单:打乱执行顺序。
想象一下,你的程序原本是这样走的:
开始 -> A -> B -> 结束
经过扁平化后,它变成了这样:
开始 -> [随机跳转] -> C -> [随机跳转] -> A -> [随机跳转] -> D -> [随机跳转] -> B -> [随机跳转] -> 结束
是不是感觉有点晕?这就对了!这就是它的目的。逆向工程师试图理解代码逻辑时,会看到一长串跳转指令,像是在玩“跳房子”,根本找不到头绪。
在 LLVM 中,我们通常用 Switch 指令或者间接跳转指令来实现这种效果。但直接修改 AST 很痛苦,所以我们用 LLVM 的 Pass 机制。
第三部分:LLVM Pass 开发——编译器的插件
Pass 是 LLVM 中处理 IR 的基本单位。你可以把它想象成编译器的一个滤镜。
我们今天要写一个 FunctionPass。它的生命周期是:
- 初始化:拿到整个 Module。
- 遍历:找到所有的 Function。
- 改造:对每个 Function,把它的 BasicBlock 搞乱。
- 销毁:把改好的 IR 写回磁盘或内存。
1. 模板代码
首先,我们得有个架子。别怕,这是标准的 LLVM 模板:
#include "llvm/IR/Function.h"
#include "llvm/IR/PassManager.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"
#include "llvm/Support/raw_ostream.h"
using namespace llvm;
namespace {
struct ControlFlowFlatteningPass : public FunctionPass {
static char ID;
ControlFlowFlatteningPass() : FunctionPass(ID) {}
bool runOnFunction(Function &F) override {
// 哇!我们要在这里搞事情!
// F 是函数,里面全是 BasicBlock
return true; // 返回 true 表示我们修改了 IR
}
};
}
char ControlFlowFlatteningPass::ID = 0;
// 注册插件
PassPluginLibraryInfo getFlattenPassPluginInfo() {
return {LLVM_PLUGIN_API_VERSION, "ControlFlowFlattening", LLVM_VERSION_STRING,
[](PassBuilder &PB) {
PB.registerFunctionPasses([](FunctionPassManager &FPM) {
FPM.addPass(ControlFlowFlatteningPass());
});
}};
}
extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() {
return getFlattenPassPluginInfo();
}
2. 核心逻辑:构建调度表
现在,让我们把 runOnFunction 变得疯狂一点。
首先,我们要收集所有的 BasicBlock。原始的逻辑是:BlockA 指向 BlockB,BlockB 指向 BlockC。我们要把它变成一个调度表。
bool runOnFunction(Function &F) override {
// 1. 收集所有 Block
std::vector<BasicBlock *> Blocks;
for (BasicBlock &BB : F) {
Blocks.push_back(&BB);
}
// 2. 创建一个调度表(一个包含 Block 指针的数组)
// 为了增加混淆度,我们可以随机打乱这个数组
// std::random_shuffle(Blocks.begin(), Blocks.end()); // C++11 以后用 shuffle
// 这里为了演示清晰,暂时不打乱,但实际工程中必须打乱!
// 3. 创建调度表变量
// 我们需要一个全局变量来存储这个表吗?不需要,每个函数创建一个局部数组即可
// 但为了简化,我们假设有一个名为 "dispatch_table" 的全局数组(这需要预处理,这里仅做概念演示)
// 实际上,我们通常在函数内部构建一个 switch 语句,或者使用间接跳转。
// 下面是简化版的逻辑:
// 我们把所有的 Block 按照调度表的顺序重新组织。
// 原本的 "return" 或 "br" 指令,被替换为 "跳转到调度表的下一个元素"。
// ... (中间省略了复杂的数组构建逻辑) ...
return true;
}
第四部分:深度实现——把 AST 扁平化
光有模板没用,我们要写代码。真正的控制流平坦化算法如下:
- 遍历原始函数,记录每个 BasicBlock 的原始后继者。
- 构建调度表:将所有 BasicBlock 放入一个数组
Schedule。 - 构建调度表变量:在函数开头插入一个新的
BasicBlock作为入口,里面包含一个switch指令,switch的跳转目标就是Schedule数组中的元素。 - 修改 Terminator:将所有原始 BasicBlock 的结尾指令(
br指令)修改为br到调度表的下一个元素(或者根据某个状态变量决定下一个元素)。
代码示例:构建调度表与 Switch
假设我们有一个函数 void secret(),里面有两个块:entry 和 bb1。entry 跳转到 bb1,bb1 结束。
扁平化后:
- 创建调度表:
[entry, bb1]。 - 创建一个
switch指令,根据某个索引(比如0或1)来决定跳转。 - 修改
bb1的结尾,让它跳回调度表的下一个元素。
bool runOnFunction(Function &F) override {
// 1. 收集所有 Block
std::vector<BasicBlock *> BBs;
for (BasicBlock &BB : F) {
BBs.push_back(&BB);
}
// 2. 随机打乱,增加混淆度 (伪随机种子可以用时间戳)
std::srand(time(0));
std::random_shuffle(BBs.begin(), BBs.end());
// 3. 创建调度表变量
// 在 LLVM IR 中,我们通常创建一个数组,元素是 BasicBlock* 的地址
// 但 IR 是基于值的,我们需要用 ConstantInt 来模拟索引。
// 注意:BasicBlock 是不能随意移动的,我们只能修改跳转指令。
}
3. 修改 Terminator(终止指令)
这是最关键的一步。每个 BasicBlock 最后都有一个 TerminatorInst(比如 br 指令)。
我们要把原来的 br label %next,改成一种“间接跳转”:
- 先执行一个
Switch指令。 Switch的操作数是一个我们创建一个新的BasicBlock作为调度表。这个块里全是switch指令。
4. 修改原来的BasicBlock。让它们执行完原有逻辑后,跳转到调度表,而不是直接跳转到下一个块。
5. 在调度表中,根据状态值跳转到对应的块。
这听起来很复杂,但代码其实很直观。让我们看看具体的 IR 生成代码。
第四部分:实战演示——从 if 到 Switch
假设我们有一个简单的函数:
int foo(int x) {
if (x > 10) {
return 1;
} else {
return 0;
}
}
在 LLVM IR 中,它长这样:
define i32 @foo(i32 %x) {
entry:
%cmp = icmp sgt i32 %x, 10
br i1 %cmp, label %if.then, label %if.else
if.then:
ret i32 1
if.else:
ret i32 0
}
我们的目标是把它变成这样(伪代码表示):
define i32 @foo(i32 %x) {
; 1. 创建调度表
%switch.table = global [3 x i32] [i32 1, i32 2, i32 3]
%state = alloca i32
store i32 0, i32* %state ; 初始状态 0
entry:
br label %flatten_block
flatten_block: ; 主调度循环
%state.val = load i32, i32* %state
%next_idx = getelementptr [3 x i32], [3 x i32]* %switch.table, i32 0, i32 %state.val
%next_val = load i32, i32* %next_idx
switch i32 %next_val, label %default_case, label %dispatch.0, label %dispatch.1, label %dispatch.2
dispatch.0:
; 原来的 if.then
ret i32 1
dispatch.1:
; 原来的 if.else
ret i32 0
dispatch.2:
; 垃圾块
br label %flatten_block
default_case:
br label %flatten_block
}
现在,让我们看看如何用 LLVM C++ API 实现这个魔法。
代码示例:核心转换逻辑
bool runOnFunction(Function &F) override {
// 1. 收集所有的 BasicBlock
std::vector<BasicBlock*> blocks;
for (BasicBlock &BB : F) {
blocks.push_back(&BB);
}
// 如果只有一个块,或者没有分支,那就懒得混淆了
if (blocks.size() <= 2) return false;
// 2. 创建调度表
// 我们用一个数组来存储每个状态应该跳转到哪个块
std::vector<Value*> scheduleTable(blocks.size());
IRBuilder<> Builder(&F.getEntryBlock(), F.getEntryBlock().getFirstInsertionPt());
// 定义一个全局变量来存储状态(也可以栈上分配,但全局更难调试)
GlobalVariable *stateVar = new GlobalVariable(
*F.getParent(),
Type::getInt32Ty(F.getContext()),
false,
GlobalValue::PrivateLinkage,
ConstantInt::get(Type::getInt32Ty(F.getContext()), 0),
"flatten.state"
);
// 初始化状态为 0
ConstantInt *initState = ConstantInt::get(Type::getInt32Ty(F.getContext()), 0);
new GlobalVariable(*F.getParent(), Type::getInt32Ty(F.getContext()), false, GlobalValue::PrivateLinkage, initState, "flatten.init.state");
// 3. 修改每个 Block 的 Terminator
for (BasicBlock *BB : blocks) {
TerminatorInst *TI = BB->getTerminator();
// 获取原来的跳转目标
std::vector<BasicBlock*> succs;
for (BasicBlock *Succ : successors(TI)) {
succs.push_back(Succ);
}
// 计算当前块在调度表中的索引
unsigned int currentIdx = std::distance(blocks.begin(), std::find(blocks.begin(), blocks.end(), BB));
// 设置调度表:当前块跳转到下一个块的索引
scheduleTable[currentIdx] = ConstantInt::get(Type::getInt32Ty(F.getContext()), (succs.size() > 0 ? currentIdx + 1 : currentIdx));
// 修改 TI:不再直接跳转到 succs[0],而是跳转到调度表
// 这一步比较 tricky,我们需要创建一个新的 Switch 指令
// 简化起见,我们假设每个块只有一个后继,或者我们只混淆第一个后继
if (!succs.empty()) {
// 创建一个新的 Switch 指令,目标是我们刚刚计算出的索引
SwitchInst *newSwitch = SwitchInst::Create(
ConstantInt::get(Type::getInt32Ty(F.getContext()), currentIdx), // Default case value (dummy)
succs[0], // Default destination (dummy)
succs.size() + 1, // Number of cases
BB
);
// 替换旧的 Terminator
TI->eraseFromParent();
// 添加新的 Case
for (unsigned i = 0; i < succs.size(); ++i) {
newSwitch->addCase(
ConstantInt::get(Type::getInt32Ty(F.getContext()), i),
succs[i]
);
}
}
}
// 4. 创建调度循环
// 这部分代码太长了,为了节省篇幅,我们只展示核心思路:
// 创建一个无限循环,循环内读取 stateVar,根据值跳转到 scheduleTable 里的目标
// ... (此处省略 500 行构建 Switch 表达式的代码)
return true;
}
上面的代码是一个高度简化的概念演示。在真正的工程实现中,你需要处理很多边缘情况,比如:
- 如果一个块有多个后继怎么办?你需要为每个后继分配一个唯一的状态值。
- 如何处理
ret指令?ret是不可达的,我们需要把它变成跳转回调度表。 - 如何生成随机数来打乱调度表?为了增加混淆效果,我们可以在生成调度表时,随机打乱数组元素的顺序。
第五部分:高级技巧——洗牌与垃圾数据
光有 switch 还不够,那太“干净”了。真正的混淆大师会做两件事:洗牌 和 注入垃圾。
1. 调度表洗牌
想象一下,你的调度表是 [0, 1, 2],这是线性的。黑客一看就知道 0 是入口,1 是逻辑块,2 是出口。
所以,我们要在生成调度表后,对数组进行洗牌。
// 在 runOnFunction 中,生成完 scheduleTable 后
std::mt19937 rng(std::random_device{}());
std::shuffle(scheduleTable.begin(), scheduleTable.end(), rng);
现在,调度表变成了 [2, 0, 1]。程序执行流程变成了:
0 -> 2 -> 1 -> ...。这完全破坏了原始的逻辑流。
2. 注入垃圾指令
这是为了增加静态分析的难度。我们可以在调度表中插入一些“死循环”块,或者插入一些无用的计算。
// 在创建 dispatch.2 垃圾块时
IRBuilder<> JunkBuilder(EntryBlock);
// 插入一堆没用的加法
Value *junk1 = JunkBuilder.CreateAdd(ConstantInt::get(...), ConstantInt::get(...));
Value *junk2 = JunkBuilder.CreateSub(junk1, junk1);
// 然后跳回调度表
JunkBuilder.CreateBr(FlattenBlock);
逆向工程师看到这一堆乱七八糟的 add 和 sub,通常会想:“这代码写得真烂,肯定有后门。” 然后他就被绕进去了。
第六部分:代价与现实
好了,现在你的代码看起来很酷,像个特工电影里的代码。但是,作为资深专家,我必须给你泼一盆冷水。
1. 性能损耗
switch 指令虽然比跳转表效率高,但仍然比直接 br 指令慢。每次函数调用,程序都要:
- 加载状态变量。
- 查找调度表。
- 执行 Switch 跳转。
- 进入某个块,执行完逻辑。
- 修改状态变量。
- 跳回调度表。
如果函数里有 10 个分支,你的循环就要跑 10 次。这会显著增加运行时开销。对于高频调用的函数(比如游戏引擎的物理计算),这可能会导致帧率下降。
2. 调试地狱
这是最大的痛点。当你把代码扁平化后,如果程序崩溃了,GDB 调试器会显示:
#0 0x0000000000401000 in flatten_block ()
#1 0x0000000000402000 in dispatch.7 ()
#2 0x0000000000403000 in dispatch.3 ()
你根本不知道 dispatch.3 是在干什么!你需要反汇编整个函数才能看懂。这会让开发效率降低 90%。
3. 优化器的破坏
LLVM 的优化 Pass(比如 SROA, DCE, LoopUnroll)看到这种混乱的控制流,可能会崩溃,或者产生更离谱的 Bug。你可能需要禁用某些优化 Pass 才能让代码跑起来。
第七部分:总结与展望
控制流平坦化是一门艺术,也是一种防御。它不是银弹,不能防止所有攻击,但它能极大地增加逆向工程的门槛。
通过 LLVM 的 Pass 机制,我们能够以编程的方式重塑 AST 的控制流结构。我们将原本直观的 if-else 树,编织成了一个充满随机跳转的网。
下次当你看到一段令人费解的汇编代码时,不要急着骂人,也许那正是某位资深编译器巫师留下的指纹。
记住:代码保护不是为了跑得更快,而是为了跑得更难懂。就像给车装上迷彩,不是为了省油,而是为了不被抢走。
好了,今天的讲座就到这里。下课!记得在编译你的混淆代码前,先跑一遍 opt 测试一下,别到时候你的程序连 main 都进不去,直接在调度表里原地转圈圈。
祝大家代码混淆愉快!