LLVM PASS安全:从编译器原理到漏洞利用实战

📅 2026/8/23 9:51:05
LLVM PASS安全:从编译器原理到漏洞利用实战
1. 从二进制到中间表示为什么LLVM PASS会成为新的安全战场如果你玩过一段时间的CTFCapture The Flag或者研究过二进制安全那你对pwn这个词一定不陌生。它通常指代通过利用程序漏洞如栈溢出、堆利用、格式化字符串等来获取程序控制权的攻击技术。传统的pwn题目标大多是编译好的二进制可执行文件ELF、PE我们面对的是汇编指令、内存布局和处理器架构。但最近一两年一个有点“异类”的题目类型开始在一些高水平的比赛中出现那就是LLVM PASS类pwn题。我第一次遇到这种题目时感觉像是打开了新世界的大门。它不再直接给你一个./vuln二进制文件让你去逆向和利用而是给你一个pass.so动态库文件或者干脆是opt工具和一段LLVM IR中间表示代码。你的目标变成了分析这个自定义的LLVM PASS编译器优化插件中的漏洞并利用它来达成任意代码执行。这相当于把战场从程序运行时Runtime提前到了程序编译时Compile-Time攻击者从“利用运行的程序”变成了“利用编译程序的工具”。这背后的核心是LLVMLow Level Virtual Machine。虽然名字里有虚拟机但它现在主要是一个模块化的编译器基础设施。它的关键设计是使用统一的中间表示LLVM IR前端如Clang将C/C等源码转换成IR后端再将IR转换成目标机器码。而PASS就是运行在IR上的一个分析或转换插件比如进行死代码消除、内联优化等。比赛方可以编写一个包含漏洞的自定义PASS当这个PASS去处理一份恶意的、精心构造的LLVM IR代码时漏洞就会被触发。为什么这会成为新的方向首先它提升了门槛过滤了只会用现成工具的新手更考察对编译器原理和底层内存模型的理解。其次它拓展了安全的视野编译器安全本身就是一个极其重要但容易被忽视的领域。一个被攻破的编译器可以悄无声息地污染所有经它编译的程序。最后它非常“酷”结合了软件漏洞利用和编译器知识充满了探索的乐趣。这篇文章我就以一个过来人的身份带你从零开始拆解LLVM PASS pwn题的套路。我会假设你有基本的C/C编程经验和初步的二进制安全概念比如知道什么是栈、什么是指针但不需要你事先精通LLVM。我们会从环境搭建开始一步步走到漏洞分析和利用构造最终实现“在编译阶段就拿到shell”的目标。2. 战场准备搭建LLVM与PASS开发调试环境工欲善其事必先利其器。玩转LLVM PASS pwn第一步就是搭建一个顺手的开发调试环境。这里和传统pwn不同我们需要的不是gdb和pwntools而是LLVM套件和与之匹配的源码。2.1 编译指定版本的LLVM强烈建议从源码编译LLVM而不是直接安装系统包管理器提供的版本。原因有三第一题目往往针对特定LLVM版本如11.0.0、12.0.0版本差异可能导致IR语法或API行为不同直接复现失败。第二编译时开启调试信息我们才能用GDB深入跟踪PASS的执行流程。第三我们可以获得完整的头文件和库文件方便自己编写PASS进行学习。以下是在Ubuntu 20.04/22.04上编译LLVM-12.0.0的步骤这是一个比较常见且稳定的版本# 1. 安装必要的依赖 sudo apt-get update sudo apt-get install -y cmake ninja-build build-essential libz-dev python3-distutils # 2. 下载LLVM 12.0.0源码包含子项目如clang wget https://github.com/llvm/llvm-project/releases/download/llvmorg-12.0.0/llvm-project-12.0.0.src.tar.xz tar -xf llvm-project-12.0.0.src.tar.xz cd llvm-project-12.0.0.src # 3. 创建构建目录并配置CMake mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelWithDebInfo \ # 带调试信息的发布版本 -DLLVM_ENABLE_PROJECTSclang \ # 同时编译clang -DLLVM_ENABLE_ASSERTIONSON \ # 开启断言便于捕捉错误 -DCMAKE_INSTALL_PREFIX/opt/llvm-12.0.0 \ # 安装到指定目录避免污染系统 -DLLVM_TARGETS_TO_BUILDX86 # 只编译X86后端加快速度 # 4. 开始编译利用所有CPU核心过程较久可能需30分钟以上 ninja -j$(nproc) # 5. 安装到指定目录 sudo ninja install编译完成后将安装路径加入环境变量方便使用echo export PATH/opt/llvm-12.0.0/bin:$PATH ~/.bashrc echo export LD_LIBRARY_PATH/opt/llvm-12.0.0/lib:$LD_LIBRARY_PATH ~/.bashrc source ~/.bashrc现在你可以使用clang-12、opt-12等命令了。opt工具是我们之后加载和运行PASS的核心。注意RelWithDebInfo构建类型在提供调试信息的同时保持了较高的优化级别是调试大型项目如LLVM的较好折衷。纯Debug构建会极大降低运行速度并占用更多磁盘空间。2.2 理解PASS的基本结构从Hello World开始一个最简单的LLVM PASS长什么样它就是一个共享库.so文件实现了LLVM预设的接口。我们写一个打印函数名的PASS来感受一下。创建文件HelloPass.cpp#include llvm/Pass.h #include llvm/IR/Function.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { // 1. 定义PASS类继承自FunctionPass表示以函数为处理单元 struct HelloPass : public FunctionPass { static char ID; // PASS的唯一标识符 HelloPass() : FunctionPass(ID) {} // 2. 重载runOnFunction方法这是PASS的逻辑入口 bool runOnFunction(Function F) override { errs() Hello from function: F.getName() !\n; return false; // 返回false表示没有修改该函数true表示有修改 } }; } char HelloPass::ID 0; // 初始化ID // 3. 注册PASS第一个参数是命令行参数名第二个是显示名第三个是是否只分析不修改 static RegisterPassHelloPass X(hello, Hello World Pass, false, false);编译这个PASS# 使用我们编译的clang链接LLVM库 clang -shared -fPIC llvm-config --cxxflags HelloPass.cpp \ llvm-config --ldflags -lLLVMCore -lLLVMSupport \ -o HelloPass.sollvm-config是一个很好用的工具能自动给出正确的编译和链接 flags。现在我们写一个简单的测试程序test.c// test.c void foo() {} int main() { foo(); return 0; }将其转换为LLVM IR比特码格式clang -S -emit-llvm -O0 test.c -o test.ll # 生成可读的.ll文本IR clang -c -emit-llvm -O0 test.c -o test.bc # 生成二进制的.bc比特码IR使用opt加载我们的PASS运行opt -load ./HelloPass.so -hello test.bc /dev/null你会在终端看到输出Hello from function: foo! Hello from function: main!恭喜你已经成功运行了第一个自定义PASS。这个流程就是题目的基础题目提供一个有漏洞的xxx.so你编写或生成一份特殊的exploit.ll/bc用opt加载运行触发漏洞。实操心得调试PASS时经常需要查看LLVM IR的结构。opt工具的-view-cfg等选项可以生成函数控制流图需要安装Graphviz但更常用的是在PASS代码中直接用errs() F \n;打印整个函数的IR。对于复杂的漏洞分析将IR打印到文件仔细阅读是必不可少的步骤。3. 漏洞在哪里LLVM PASS的常见漏洞模式分析传统pwn的漏洞源于程序员对内存操作的疏忽如不检查边界。而LLVM PASS的漏洞则源于PASS开发者对LLVM IR数据结构复杂性的低估以及对LLVM API的误用。IR本身是强类型的、相对安全的但错误的使用方式会引入内存安全问题。以下是几种典型的漏洞模式3.1 类型混淆Type Confusion与非法转换LLVM IR是强类型的每个值Value都有其具体的类型Type比如i3232位整数、i32*指向i32的指针、%struct.foo结构体类型。许多PASS需要对IR进行转换或分析这涉及到类型检查和转换。如果转换前没有进行严格的类型检查就可能产生类型混淆。一个典型的危险操作是dyn_cast或cast。dyn_castInstruction(someValue)会尝试将someValue动态转换为Instruction派生类如CallInst如果转换失败则返回nullptr。而castInstruction(someValue)则假设转换一定成功失败则导致断言失败或未定义行为。在发布版断言被关闭中失败的cast可能导致使用错误的虚函数表指针进而引发内存错误。漏洞代码示例bool runOnFunction(Function F) { for (auto BB : F) { for (auto I : BB) { // 危险假设所有指令都是 CallInst函数调用指令 CallInst *CI castCallInst(I); // 如果I是StoreInst、LoadInst等cast会失败 // 后续使用CI-getCalledFunction()等操作在Release版本中可能崩溃 } } return false; }安全做法总是先使用isaCallInst(I)判断或者使用dyn_cast并检查返回值是否为空。if (auto *CI dyn_castCallInst(I)) { // 安全地使用CI }3.2 迭代器失效Iterator Invalidation这是C STL容器的经典问题在LLVM中同样存在。LLVM PASS经常需要遍历基本块BasicBlock中的指令Instruction列表并可能在其中插入或删除指令。如果在遍历过程中修改了底层容器可能会导致迭代器失效后续对失效迭代器的解引用会造成段错误。漏洞代码示例for (auto I BB.begin(), E BB.end(); I ! E; I) { if (/* 某个条件 */) { Instruction *InstToDelete *I; I; // 试图先移动迭代器 InstToDelete-eraseFromParent(); // 再删除指令但此时I可能已经失效 // 更糟糕的是如果直接 InstToDelete-eraseFromParent()那么I本身立即失效后续的I行为未定义。 } }安全做法LLVM提供了安全的模式。对于删除可以使用llvm::ilist的特殊性但更通用的方法是先收集要删除的指令遍历结束后再统一删除或者使用llvm::BasicBlock::iterator的erase方法它会返回下一个有效的迭代器。for (auto I BB.begin(), E BB.end(); I ! E; ) { if (/* 某个条件 */) { I I-eraseFromParent(); // eraseFromParent 返回指向下一条指令的迭代器 } else { I; } }3.3 对未初始化的或伪造的IR数据结构的使用在pwn题中攻击者可以完全控制输入的LLVM IR文件。这意味着他可以构造出语法合法但语义上非常“怪异”的IR比如一个getelementptr指令的索引是负数或巨大的数字一个call指令调用的函数指针是一个整数常量。一个防御不足的PASS可能会直接相信IR中提供的信息而不加验证。例如// 假设PASS想获取数组访问的索引值 if (auto *GEP dyn_castGetElementPtrInst(I)) { Value *Idx GEP-getOperand(1); // 获取索引操作数 if (auto *CI dyn_castConstantInt(Idx)) { uint64_t Val CI-getZExtValue(); // 直接提取整数值 // 如果攻击者构造的索引值非常大Val可能溢出或者后续用Val做数组下标时导致非法内存访问 } }攻击者可能构造一个索引值为0xFFFFFFFFFFFFFFFF的GEP指令当PASS试图用这个值去访问某个内部数据结构时就可能造成越界读写。3.4 自定义数据结构中的内存管理错误如果PASS内部维护了自己的数据结构比如用std::vector或std::map来记录分析信息那么这块内存的管理就需要开发者自己负责。这里就可能出现典型的C内存错误堆溢出、Use-After-Free、双重释放等。例如PASS可能用一个std::mapInstruction*, SomeInfo*来存储每条指令的分析结果。如果在PASS运行结束后doFinalization方法中没有正确清理这些SomeInfo*指针就会造成内存泄漏在题目环境中可能不重要。但更致命的是如果PASS的多个方法如runOnFunction和runOnBasicBlock并发或重复操作这个map没有做好同步或状态管理就可能导致map内部结构损坏进而使LLVM核心库在访问时崩溃这种崩溃往往可以被转化为控制流劫持。4. 实战拆解一个简单的UAF漏洞PASS分析与利用理论说得再多不如看一个实际例子。我们假设有一个自定义的“简单分析PASS”它存在一个Use-After-Free漏洞。我们的目标是利用这个漏洞在opt加载PASS处理我们恶意IR时实现任意代码执行。4.1 漏洞PASS源码分析假设VulnPass.cpp关键部分如下struct VulnPass : public ModulePass { // 这次继承ModulePass处理整个模块 static char ID; std::mapFunction*, std::vectorInstruction** funcInstMap; // 危险存储指针的指针 VulnPass() : ModulePass(ID) {} bool runOnModule(Module M) override { for (auto F : M) { auto *instList new std::vectorInstruction*(); // 在堆上分配vector for (auto BB : F) { for (auto I : BB) { if (isaCallInst(I)) { instList-push_back(I); // 记录所有CallInst } } } funcInstMap[F] instList; // 存储指针 } analyzeModule(); // 分析阶段 return false; } void analyzeModule() { // 模拟一个复杂的分析过程过程中可能会“清理”某些函数的数据 for (auto it : funcInstMap) { if (it.first-getName().contains(helper)) { // 假设名为helper的函数是临时函数分析完就删 delete it.second; // 释放vector内存 it.second nullptr; // 但map中仍保留了这个空指针项 } } // ... 后续还有其他分析步骤 ... processRemainingInstructions(); // 漏洞触发点 } void processRemainingInstructions() { for (auto it : funcInstMap) { if (it.second) { // 检查指针非空 for (auto *I : *(it.second)) { // 对helper函数it.second是nullptr但检查通过了 // 实际上如果it.second是悬空指针或nullptr这里解引用就会崩溃 // 但假设这里有一个更复杂的条件判断漏洞... errs() Processing: *I \n; } } } } // 缺少析构函数来清理所有分配的vector // ~VulnPass() { for (auto it : funcInstMap) delete it.second; } };漏洞点分析内存管理混乱funcInstMap存储了裸指针std::vectorInstruction**。runOnModule中为每个函数new了一个vector。提前释放在analyzeModule中对函数名包含helper的项执行了delete it.second;并将指针置为nullptr。但是注意processRemainingInstructions中的循环条件if (it.second)。对于helper函数这个条件为false所以不会进入内层循环似乎安全真正的UAF关键在于攻击者可以控制IR。如果攻击者能构造一个函数其名字在分析阶段包含helper从而触发delete但在processRemainingInstructions阶段之前通过某种方式可能利用LLVM的另一个合法PASS或IR特性修改了函数名使其不再包含helper。那么在processRemainingInstructions中if (it.second)检查时it.second仍然是被释放的那个指针不是nullptr因为只对helper函数置空了改名的函数对应的项没有被置空但指向的内存已被释放。后续解引用*(it.second)就会导致Use-After-Free。注意这个例子为了说明原理进行了简化。实际LLVM IR中函数名可能不易动态修改但类似的“状态不一致”漏洞是存在的比如PASS假设某些IR结构在分析过程中保持不变但攻击者通过精心构造的IR序列使其改变。4.2 构造恶意IR进行攻击我们的目标是制造一个UAF场景并尝试控制释放后的内存std::vectorInstruction*对象最终实现代码执行。这需要深入了解LLVM和系统内存分配器如glibc的ptmalloc。第一步理解目标对象结构std::vector通常包含三个指针或指针加容量大小的组合start、end、end_of_storage。在64位系统上一个vector对象大小可能是24字节。当这个vector被释放其占用的内存块会回到堆管理器中。第二步堆风水Heap Feng Shui我们需要用我们可控的数据去“占位”被释放的vector内存。在LLVM环境中我们可以在IR中嵌入大量的常量数据ConstantDataArray或者创建大量的临时指令。这些数据在内存中分配时可能会从同一个堆分配器中获取内存。通过精心控制分配和释放的顺序在IR中体现为创建和销毁的序列我们可以让攻击者控制的数据块恰好覆盖那个被释放的vector对象。第三步控制流劫持如果我们可以覆盖vector的虚函数表指针如果vector实现有虚函数或者覆盖其start指针那么在processRemainingInstructions中执行for (auto *I : *(it.second))时就会从我们控制的地址读取“指令指针”Instruction*。Instruction*被解引用后LLVM会调用其方法如打印操作*I这可能会触发虚函数调用或访问其内部字段。如果我们能伪造一个Instruction对象使其虚表指针指向我们控制的内存就有可能劫持控制流。构造恶意IR的伪代码思路创建一个函数helper_original确保PASS的第一阶段会为它分配vector并记录call指令然后将其删除释放vector。在IR中立即创建大量包含可控数据的全局变量或常量数组尝试占用刚刚释放的vector内存块。这些可控数据被精心构造模仿std::vectorInstruction*的内存布局其中start指针指向另一块我们控制的内存区域A区域A中存放的是伪造的Instruction*指针。伪造的Instruction*指针指向我们构造的另一个内存区域B区域B中是一个伪造的CallInst对象其虚函数表指针被替换指向我们注入的shellcode地址考虑JIT编译区域或一个ROP链。当PASS尝试遍历并打印这个被“占位”的vector时就会沿着我们设计的路径执行最终触发代码执行。这听起来很复杂确实如此。LLVM PASS pwn的利用难度通常远高于传统用户态pwn因为它涉及到对LLVM内部对象模型和堆分配器的深刻理解。在真实比赛中题目往往会简化漏洞模型例如漏洞可能只是一个简单的栈缓冲区溢出发生在PASS内部的固定大小数组上这样利用起来就更接近传统pwn题。4.3 利用链的调试与完成调试LLVM PASS的利用需要结合GDB和LLVM的调试信息。关键步骤启动optgdb --args opt -load ./VulnPass.so -vulnpass exploit.bc设置断点在PASS的漏洞函数如processRemainingInstructions和内存分配/释放函数如operator new/delete上设置断点。观察内存当vector被释放后使用heap命令如果GDB插件如pwndbg/peda已安装或手动查看堆块状态确认释放地址。跟踪占位单步执行后续的IR处理观察我们的“占位”数据是否成功分配到了目标地址。可以使用watch命令监控目标地址的内存变化。控制流劫持当程序最终解引用我们控制的指针并崩溃时查看崩溃现场的寄存器状态和栈回溯确认控制流是否已跳转到预期地址。成功利用的标志通常是能够执行execve(/bin/sh, ...)系统调用。由于opt本身是一个具有正常权限的进程拿到它的shell就意味着题目攻克。5. 从解题到精通技能提升路径与资源推荐通过上面的分析你应该对LLVM PASS pwn有了一个从概念到实战的初步认识。但这只是一个开始。要真正精通这类题目你需要系统地补充以下几块知识5.1 核心知识体系构建LLVM IR精通你需要像读汇编一样熟练阅读IR。掌握其基本指令alloca,load,store,add,icmp,br,call,getelementptr等、类型系统、函数与模块结构。官方手册《LLVM Language Reference Manual》是最好的资料。LLVM PASS API熟悉了解常用的核心类Module,Function,BasicBlock,Instruction及其各种子类CallInst,LoadInst,StoreInst等、Value,Type,Constant。学会使用LLVM_DEBUG宏输出调试信息使用DominatorTree等分析工具。C内存漏洞利用深化虽然背景是编译器但漏洞本质还是C程序的内存错误。你需要深入理解现代Linux堆管理glibc ptmalloc, tcache、C虚函数表机制、RTTI结构。传统pwn中的堆利用技巧如tcache poisoning, unlink, house of系列在这里依然可能适用。LLVM JIT与ORC一些更高级的题目可能涉及LLVM的即时编译JIT引擎。理解llvm::ExecutionEngine和ORCOn-Request CompilationAPI的工作方式可能会发现新的攻击面比如注入恶意机器码到可执行内存页。5.2 实战训练资源官方示例LLVM源码中的llvm/lib/Transforms/目录下有很多官方PASS示例如Hello,CountOp等。这是学习PASS编写规范的最佳起点。CTF赛事题目复盘关注国内外高质量CTF赛事如DEF CON CTF Quals, PlaidCTF, RCTF, 0CTF/TCTF中出现的LLVM相关题目。通常在赛后会有参赛队伍发布详细的Writeup解题报告。GitHub上搜索“CTF LLVM PASS pwn writeup”可以找到很多资源。自定义漏洞练习尝试自己编写一个包含简单栈溢出漏洞的PASS然后为自己出题尝试利用。这个过程能让你最深刻地理解漏洞产生和利用的全链条。可以从修改上述的VulnPass开始比如将std::vector换成固定大小的数组Instruction* arr[10]然后制造一个索引越界写。5.3 调试技巧与工具链GDB脚本化针对LLVM庞大的数据结构编写GDB Python脚本或使用pwndbg的增强功能来漂亮地打印Instruction,BasicBlock等信息能极大提升调试效率。Sanitizer工具在开发和学习阶段使用-fsanitizeaddress,undefined编译你的PASS和opt工具可以帮助快速发现内存错误和未定义行为。虽然比赛环境通常没有但它是学习漏洞成因的利器。LLVM自有工具llvm-dwarfdump,llvm-objdump等工具可以帮助你分析编译后的对象文件结构。opt的-print-after-all和-print-before-all参数可以打印每个PASS运行前后的IR状态用于跟踪IR变换过程。这条路走下来并不轻松它要求你横跨编译器原理和二进制安全两个领域。但每解开一道这样的题目带来的成就感也是无与伦比的。它不仅仅是一次解题更是一次对软件生命链更深层次——编译环节——的安全审视。当你下次再用clang -O2编译代码时或许会对那个默默工作的优化器多一份别样的警惕与好奇。