16字节对齐
目录
16字节对齐
在 Pwn 的世界里,16 字节对齐(Stack Alignment) 绝对是新手的“头号杀手”。你可能构造了一个完美的 ROP 链,逻辑无懈可击,但在本地运行或远程攻击时,程序却莫名其妙地在 system 或 printf 内部直接 SIGSEGV(段错误)了。
这种感觉就像你配好了万能钥匙,结果锁芯因为差了 1 毫米而打不开。
1. 为什么会有这个规定?(硬件的“强迫症”)
这个规定源于 x86-64 的 SSE (Streaming SIMD Extensions) 指令集。
为了极致的性能,CPU 处理 128 位(16 字节)的数据(比如浮点数运算)时,会使用类似 movaps (Move Aligned Packed Single-Precision Floating-Point Values) 这样的指令。
- 潜规则:
movaps要求操作的内存地址必须能被 16 整除(即地址的十六进制最后一位必须是0)。 - 后果:如果地址没对齐,CPU 会直接罢工,抛出异常导致程序崩溃。
现代的
glibc函数(如printf和system)在内部实现时为了优化,大量使用了这类指令。
2. 16 字节对齐的“动态变化”
这是最容易绕晕的地方。根据 System V ABI 标准:
在执行
call指令之前,栈顶指针RSP必须是 16 字节对齐的。
让我们跟踪一下栈的变化:
-
调用前:
RSP=0x...00(16 字节对齐,地址以 0 结尾)。 -
执行
call:CPU 自动把返回地址(8 字节)压入栈中。- 此时
RSP变成了0x...F8(不再是 16 的倍数,偏移了 8 字节)。
- 此时
-
进入函数内部:函数的第一条指令通常是
push rbp。- 此时
RSP变成了0x...F0(嘿!又变回 16 的倍数了)。
- 此时
结论:在函数体内部执行逻辑时,编译器默认 RSP 是对齐的。但如果你是通过特殊的手段(比如 ROP)直接跳到了函数的开头,情况就不一样了。
3. Pwn 选手的噩梦:ROP 链中的对齐坑
当你构造 ROP 链时,你实际上是手动在“拼凑”返回地址。
典型的失败案例:
你找到了 system 的地址,并准备好了 pop rdi; ret 传参。你的 Payload 看起来是这样的:
[padding] + [pop rdi; ret] + [/bin/sh_addr] + [system_addr]
在这个过程中,你跳过了正常的 call 流程,直接通过 ret 弹到了 system 内部。如果此时你的 RSP 刚好停在了一个“不对劲”的位置(比如末尾是 8 而不是 0),system 内部一旦执行到 movaps 就会立刻崩溃。
4. 终极补救方案:多加一个 ret
如果你发现你的 ROP 链在调用 system 时崩溃了,而且调试发现挂在 movaps 上,最简单的黑科技就是:在调用目标函数之前,先塞入一个没用的 ret 指令。
修正后的 Payload:
[padding] + [ret_gadget] + [pop rdi; ret] + [/bin/sh_addr] + [system_addr]
- 原理:
ret的本质是pop rip。执行一个额外的ret会让RSP移动 8 个字节,从而实现“从不对齐到对齐”的华丽转身。
5. 如何在调试时识别它?
在 GDB (如使用插件 pwndbg) 中,当你崩溃时:
- 看崩溃的那一条指令。如果是
movaps XMMWORD PTR [rsp+...], xmm...这种。 - 看
RSP的值。如果地址的最后一位不是0(比如是8)。 - 石锤了:这就是 16 字节对齐问题。
总结笔记
| 状态 | RSP 状态 | 备注 |
|---|---|---|
| 标准 Call 之前 | 必须对齐 (ends in 0) | ABI 的强制要求 |
| 执行 Call 后 | 不对齐 (ends in 8) | 因为压入了一个 8 字节的返回地址 |
| ROP 跳转后崩溃 | 通常是 ends in 8 | 导致 system 内部 SIMD 指令报错 |
| 对齐策略 | 增加一个 ret 指令 | 强行平移 8 字节,抵消偏移 |
你现在理解了为什么有时候你的 Payload 逻辑全对,却需要莫名其妙多加一个 ret 才能打通了吧? 你想针对这个对齐问题,实际看一段会导致崩溃的汇编代码,还是说你已经准备好聊聊关于 printf 可变参数和寄存器的那个“终极心机”了?