C++ 抽象语法树(AST)混淆:在 C++ 代码保护工程中利用 LLVM 转换层实施复杂的控制流平坦化

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 * 2
  • If 的右边(else 分支)是 x + 5

简单吧?LLVM 的 IR(中间表示)其实就是这种树的序列化版本。llvm::BasicBlock 就是树上的一个个节点。我们的任务,就是在这个树上搞破坏——或者说是搞艺术。


第二部分:控制流平坦化——给程序加点“料”

在代码保护领域,控制流平坦化是一种经典的混淆技术。它的核心思想很简单:打乱执行顺序

想象一下,你的程序原本是这样走的:
开始 -> A -> B -> 结束

经过扁平化后,它变成了这样:
开始 -> [随机跳转] -> C -> [随机跳转] -> A -> [随机跳转] -> D -> [随机跳转] -> B -> [随机跳转] -> 结束

是不是感觉有点晕?这就对了!这就是它的目的。逆向工程师试图理解代码逻辑时,会看到一长串跳转指令,像是在玩“跳房子”,根本找不到头绪。

在 LLVM 中,我们通常用 Switch 指令或者间接跳转指令来实现这种效果。但直接修改 AST 很痛苦,所以我们用 LLVM 的 Pass 机制。


第三部分:LLVM Pass 开发——编译器的插件

Pass 是 LLVM 中处理 IR 的基本单位。你可以把它想象成编译器的一个滤镜。

我们今天要写一个 FunctionPass。它的生命周期是:

  1. 初始化:拿到整个 Module。
  2. 遍历:找到所有的 Function。
  3. 改造:对每个 Function,把它的 BasicBlock 搞乱。
  4. 销毁:把改好的 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 指向 BlockBBlockB 指向 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 扁平化

光有模板没用,我们要写代码。真正的控制流平坦化算法如下:

  1. 遍历原始函数,记录每个 BasicBlock 的原始后继者。
  2. 构建调度表:将所有 BasicBlock 放入一个数组 Schedule
  3. 构建调度表变量:在函数开头插入一个新的 BasicBlock 作为入口,里面包含一个 switch 指令,switch 的跳转目标就是 Schedule 数组中的元素。
  4. 修改 Terminator:将所有原始 BasicBlock 的结尾指令(br 指令)修改为 br 到调度表的下一个元素(或者根据某个状态变量决定下一个元素)。

代码示例:构建调度表与 Switch

假设我们有一个函数 void secret(),里面有两个块:entrybb1entry 跳转到 bb1bb1 结束。

扁平化后:

  1. 创建调度表:[entry, bb1]
  2. 创建一个 switch 指令,根据某个索引(比如 01)来决定跳转。
  3. 修改 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,改成一种“间接跳转”:

  1. 先执行一个 Switch 指令。
  2. Switch 的操作数是一个我们创建一个新的 BasicBlock 作为调度表。这个块里全是 switch 指令。
    4. 修改原来的 BasicBlock。让它们执行完原有逻辑后,跳转到调度表,而不是直接跳转到下一个块。
    5. 在调度表中,根据状态值跳转到对应的块。

这听起来很复杂,但代码其实很直观。让我们看看具体的 IR 生成代码。


第四部分:实战演示——从 ifSwitch

假设我们有一个简单的函数:

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);

逆向工程师看到这一堆乱七八糟的 addsub,通常会想:“这代码写得真烂,肯定有后门。” 然后他就被绕进去了。


第六部分:代价与现实

好了,现在你的代码看起来很酷,像个特工电影里的代码。但是,作为资深专家,我必须给你泼一盆冷水。

1. 性能损耗

switch 指令虽然比跳转表效率高,但仍然比直接 br 指令慢。每次函数调用,程序都要:

  1. 加载状态变量。
  2. 查找调度表。
  3. 执行 Switch 跳转。
  4. 进入某个块,执行完逻辑。
  5. 修改状态变量。
  6. 跳回调度表。

如果函数里有 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 都进不去,直接在调度表里原地转圈圈。

祝大家代码混淆愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注