CTF Pwn 里的栈对齐
CTF Pwn 里的栈对齐
这份笔记针对这几个问题的一些思考及回答:
- 为什么要栈对齐
- 栈对齐机制到底是什么
- 比赛里怎么修
- 常见会踩的坑有哪些
- 还能围绕栈对齐发散到哪些实战点
默认重点讨论 64 位 Linux (SysV AMD64 ABI),因为 CTF 里最常见的“栈对齐炸了”基本都发生在这里。文末单独补 Windows x64。
1. 为什么要栈对齐
在 CTF 里,栈对齐通常不是第一步遇到的问题,而是已经:
- 找到了 overflow offset
- 找到了
pop rdi ; ret - 泄露了
libc - 构造好了
system("/bin/sh")
结果 send payload 后,却没有get shell,而是程序直接崩了。
这时候很大概率不是地址算错,也不是 /bin/sh 不对,而是:
ROP 链破坏了函数调用时默认成立的栈布局
这时候就需要完成那最后一步:栈对齐
1.1 一句话理解
所谓栈对齐,本质上就是:
用
ret伪造函数调用时,是否让目标函数看到一个和正常call进入时一致的栈状态。
只要把这句话想明白,后面的现象都能串起来。
2. 栈对齐机制到底是什么
2.1 正常程序为什么“天然对齐”
在 x86_64 Linux 下,SysV AMD64 ABI 要求:
- 在函数入口,
(%rsp - 8) % 16 == 0 - 等价地说,函数入口常写成:
rsp % 16 == 8
为什么会是 8 而不是 0?
因为正常调用是:
1 | caller: |
也就是说:
- 调用者在
call之前,一般把rsp调到 16 对齐 - 被调函数在刚入口时,由于返回地址已经入栈,所以看到的是
rsp % 16 == 8
2.2 为什么 ROP 容易破坏这个机制
正常程序靠 call 建立调用关系,但 ROP 不是。
ROP 的核心是:
ret从栈上弹一个地址- 跳到那个地址继续执行
- 再
ret - 再跳
(重点)问题在于:
call会主动压入返回地址,额外消耗 8 字节ret不会“补一次 call”
所以如果直接:
1 | payload += p64(pop_rdi_ret) |
那么你是通过 ret 进入 system(),而不是通过正常的 call system 进入。
这意味着:
- 进入
system时的rsp - 和编译器、ABI、glibc 设计时预期的
rsp可能不一样。
2.3 为什么会在 movaps 一类指令处崩
因为某些函数内部会:
- 使用 SSE/XMM 指令
- 把数据搬到栈上
- 假定当前栈满足 ABI 要求
一旦 ROP 链导致 rsp 不在预期对齐状态,这些路径就可能在对齐敏感指令处出错。比赛里最常见的情况就是:
- “崩在
movaps” - “明明链子没问题,怎么进
libc就死了”
3. 从比赛视角理解“对齐”
不要把栈对齐理解成抽象规范,直接把它看成一个比赛里的检查条件:
在“即将进入目标函数”的那一刻,目标函数看到的
rsp是否像它被正常call进来时那样。
3.1 实战结论(习惯)
如果你要 ret 进某个 libc 函数,最常见的经验判断是:
- 若当前
rsp & 0xf == 0,通常可以直接进 - 若当前
rsp & 0xf == 8,通常需要先补一个裸ret
原因是:
- ROP链执行
ret进目标函数时,会先弹掉 8 字节目标地址 - 所以目标函数真正入口看到的是“当前
rsp + 8” - 当前是
0,进去后变8 - 当前是
8,进去后变0
而 SysV 里目标函数入口常要求 rsp % 16 == 8。
3.2 为什么“补一个 ret”经常有效
因为:
- 一个裸
ret会额外吃掉 8 字节 - 这正好能把
rsp % 16在0和8之间切换
所以经常看到wp写:
1 | payload = b'A' * offset |
这个 ret 不是“为了跳转”,而是纯粹为了修对齐。
3.3 示例:为什么这题 system("/bin/sh") 会炸
假设一题 64 位栈溢出里,已经有:
1 | offset = 0x28 |
首先我们写出的 payload 是:
1 | payload = b'A' * 0x28 |
现象:
rdi确实已经是"/bin/sh"- 程序却在
system里崩了
这时不一定是地址或参数构造出错,先看栈消耗:
1 | pop rdi ; ret -> +0x10 |
也就是说,pop rdi ; ret 不会改变当前 rsp % 16 的状态。假如在“即将进入 system 前”观察到:
1 | rsp & 0xf == 8 |
那么当 ret 真正跳进 system 后,入口看到的就是:
1 | (rsp + 8) & 0xf == 0 |
这就很容易出问题。
修法就是补一个裸 ret:
1 | payload = b'A' * 0x28 |
这个 ret 会额外多吃 0x8,把当前 rsp % 16 翻转一次,于是 system 真正入口又回到更常见的好状态。
3.4 示例:为什么有时不补 ret 也能过
第一阶段泄露:
1 | payload = b'A' * offset |
第二阶段拿 shell:
1 | payload = b'A' * offset |
可能出现:
- 第一阶段泄露正常
- 第二阶段
system崩
原因通常不是“puts 不需要对齐而 system 需要”,而是:
- 第一阶段整条链的总栈消耗,碰巧让
puts@plt入口对齐了,完成了“栈对齐” - 第二阶段链更短,反而把
system放进了坏状态,没做到“栈对齐”
所以要记住:
- 对齐不是某个固定模板
- 而是和这一阶段整条链总共吃了多少栈绑定的
4. 比赛里如何判断是不是栈没对齐
4.1 现象判断
以下情况都要优先怀疑栈对齐:
ret2libc参数看起来都对,但system()进不去- 能正常泄露
libc,但get shell崩了 - 崩在
movaps - 崩在
do_system/buffered_vfprintf - 同一条链,本地能打,远程换 libc 后炸
最后一种尤其常见,因为:
- 不同
libc版本内部实现不同 - 有的版本内部路径刚好用到对齐敏感指令
- 有的版本没走到
于是就出现经典的“为什么本地通,远程不通”(இдஇ)
4.2 GDB / pwndbg 里怎么查
比赛里最直接的检查:
1 | p/x $rsp |
重点不是只看 crash 点,而是看两个位置:
- 漏洞函数即将第一次
ret进 ROP 链时 - 即将
ret进目标函数时
因为你真正想知道的是:
- ROP链一共吃掉了多少字节
- 到目标函数入口前,
rsp的低 4 bit 变成了什么
4.3 最稳的办法:手算每个 gadget 吃多少栈
常见 gadget 的栈消耗:
| gadget | 消耗 |
|---|---|
ret |
0x8 |
pop rdi ; ret |
0x10 |
pop rsi ; pop r15 ; ret |
0x18 |
pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret |
0x38 |
leave ; ret |
特殊,需要结合当时 rbp 分析 |
add rsp, 0x28 ; ret |
0x30 |
4.4 手算示例:一条链到底怎么数
假设ROP链长这样:
1 | payload = b'A' * offset |
其中:
ret消耗0x8pop rdi ; ret消耗0x10pop rsi ; pop r15 ; ret消耗0x18
那么到真正进入 system 之前,一共消耗:
1 | 0x8 + 0x10 + 0x18 = 0x30 |
而:
1 | 0x30 % 0x10 == 0 |
说明这一整段 gadget 合起来,并不会改变最初的 rsp % 16。所以最后能不能稳定进 system,关键仍然取决于:
- 漏洞函数第一次
ret进 ROP 链时,起始rsp & 0xf是多少,即:初始值是多少
5. 比赛里最常见的栈对齐修法
5.1 方法一:在目标函数前补一个裸 ret
适用场景:
- 64 位
ret2libc system("/bin/sh")one_gadget- 返回 libc 中其他函数前崩在
movaps
示例:
1 | payload = b'A' * offset |
5.2 方法二:换 gadget
很多时候不用专门补 ret,而是:
- 换一个 gadget
- 栈消耗就变了
- 对齐自然就修好了
例如:
pop rdi ; ret消耗0x10pop rsi ; pop r15 ; ret消耗0x18
后者比前者多一个 8 字节槽位,会翻转对齐状态。
所以比赛里你会遇到这种情况:
- 明明只改了一个 gadget
- 参数也没变
- 结果
system从崩溃变成能打通
这通常就是对齐被顺手修了(◔౪◔)
5.3 修法三:调整 pivot 目标地址
如果是下面这几种情况:
- 栈迁移到
.bss - 栈迁移到 heap
- 栈迁移到
mmap leave ; retxchg rsp, rax ; ret
那么对齐修法经常不是“补一个 ret”,而是:
- 直接让 fake stack 起始地址低 4 bit 合适
- 让 pivot 之后第一段链自然对齐
这在大链、二阶段 ROP、read 写假栈时很常见。
5.4 修法四:跳过函数序言的一部分
有些时候题解会写:
- 不回函数开头
- 而是回到
win+5、system+X这类位置
其目的可能是:
- 绕开某条
push - 绕开某段影响栈布局的前导代码
这招能用,但风险也最大。因为它要求你很清楚:
- 跳过后是否破坏函数语义
- 会不会跳进半个 basic block
- 会不会漏掉必须执行的寄存器初始化
比赛里能不用就尽量不用,除非真的全懂了(hh,怎么可能ㅍ_ㅍ)
5.5 示例:二阶段 ROP + pivot 时怎么想
假设题目流程是:
- 第一阶段 overflow
- 调
read(0, bss, 0x400)把第二阶段链写进.bss - 用
leave ; ret或其他 gadget pivot 到.bss - 第二阶段在
.bss上执行system("/bin/sh")
很多人只关心:
.bss是否可写read是否写成功- pivot 是否成功
但这里还必须关心:
- fake stack 起始地址的低 4 bit
例如你把假栈放在:
1 | 0x404080 |
那它低 4 bit 是 0x0;如果你放在:
1 | 0x404088 |
那低 4 bit 就是 0x8。
这 8 字节差异,可能就决定了:
- 第二阶段
system是否稳定 - 还要不要额外补一个
ret
所以 pivot 题里一个很实用的思路是:
- 不只是“把链写到
.bss” - 而是“把链写到
.bss + 合适偏移”
6. CTF 里最常见的坑
6.1 坑一:不知道在哪里进行栈对齐
最典的:
记住了“栈要 16 对齐”,但不知道是在 call 前还是函数入口
正确理解应该是:
call前,调用者常让rsp % 16 == 0- callee 刚入口时,常看到
rsp % 16 == 8
6.2 坑二:认为 system() 前一定要加 ret
ctfshow pwn040 刚错在这….)
更准确地说:
- 当当前的
rsp状态不对,才需要用ret修 - 如果本来就对,就不该无脑加
虽然在很多题里“加个 ret”确实能过,但也要理解为什么。
ps:想偷懒也可以多加几个ret,都试试,万一成了呢……
6.3 坑三:本地通,远程死活不通,然后:“远端环境有问题吧(#`Д´)ノ”
很多人本地测通后直接打远程,结果远程炸了,第一反应是:
libc不一样- 偏移不对
one_gadget条件没满足- 环境有问题吧…..
这些当然都可能,但大概率是栈对齐。
6.4 坑四:只盯 pop rdi ; ret,忽略整条链的总消耗
很多初学者(比如我ฅ●ω●ฅ)只会盯住:
pop rdi ; retsystem
但对齐从来不是看两个 gadget,而是看:
- 从第一次
ret进链开始 - 到目标函数真正入口为止
- 一共吃掉了多少个 8 字节槽
任何一个长 gadget、ret2csu、栈迁移、额外的 pop,都可能改变结果。
6.5 坑五:pivot 成功了,但最后脚本不通
很多题里:
.bss假栈写进去了leave ; ret也进去了- 但
system还是炸
这通常说明:
- pivot 本身成功
- 但 pivot 后 fake stack 的起始位置没对齐
- 或者 pivot 后第一段 gadget 数量把对齐又翻掉了
7. 围绕栈对齐还能扩展到哪些比赛场景
7.1 ret2libc
这是最经典场景。
尤其是:
pop rdi ; ret"/bin/sh"system
这套模板里最常见的额外一项,就是补一个 空 ret。
7.2 puts@plt 泄露 + 回到 vuln 再打第二阶段
第一阶段经常没炸,第二阶段突然炸,原因可能是:
- 第一阶段链短,碰巧对齐
- 第二阶段链长,或者多了几个 gadget,结果不对齐了
所以不能因为泄露阶段稳定,就默认第二阶段也稳定。
7.3 ret2csu
ret2csu 常用来控 rdi/rsi/rdx,但它的 gadget 很长,典型形态是:
- 一串
pop - 再
ret
这种链极容易改变 rsp % 16,所以每次用完 csu 最好都重新算一次对齐。
7.4 栈迁移
leave ; ret、xchg rsp, rax ; ret、mov rsp, xxx ; ret 这些技巧里,栈对齐会和 fake stack 设计绑死在一起。
同样需要考虑:
- fake stack 放哪
- 起始地址低 4 bit 是多少
- 第一段链的 gadget 消耗
- 最终进入
libc函数前rsp变成什么,是否栈对齐了
7.5 SROP
Sigreturn Oriented Programming 里,虽然重点是伪造 signal frame,但本质上仍然和“栈上布局是否符合预期”有关。它不一定表现为普通 movaps 崩溃,但仍然是在和 ABI/内核期望的栈结构打交道。
7.6 one_gadget
很多人把 one_gadget 打不通都归因到约束条件,其实还有一种情况:
- 地址对
- 约束也差不多满足
- 但进入前栈状态不对
所以打 one_gadget 也建议顺手检查对齐。
7.7 格式化字符串接 ROP
如果题目是:
- 先 fmt leak
- 再劫持返回地址
- 再走 ROP
那么最终还是会回到“进入 libc 函数前的栈状态”这个问题。前面利用原语不同,不改变后面 ROP 对对齐的要求。
7.8 一个很实用的比赛习惯:给 exp 留一个对齐开关
很多比赛里,最快的排障方式不是先完全算死,而是给 exp 留一个可切换的 alignment 开关:
1 | need_align = True |
这样你可以很快验证两件事:
- 加
ret和不加ret的行为差异 - 远程崩溃是不是高概率由对齐引起
8. 一个建议的比赛检查流程
当你怀疑自己被栈对齐卡住时,可以按这个顺序排:
- 确认溢出 offset 没错
- 确认 gadget 地址和 libc 基址没错
- 确认参数寄存器值没错,
/bin/sh地址可读 - 看 crash 点是否在
movaps/system/vfprintf一类位置 - 在“即将进入目标函数”前检查
rsp & 0xf - 若不对,先试补一个裸
ret - 若还不对,重新统计整条链的栈消耗
- 若有 pivot,连 fake stack 起始地址一起重算
9. Linux 和 Windows 的区别(真遇见我就躺了……)
比赛里大多数题是 Linux,但也可能也会碰到 Windows pwn,所以简单写一下差异。
9.1 Linux / SysV AMD64
重点:
- 前 6 个参数走
rdi, rsi, rdx, rcx, r8, r9 - 函数入口常见状态是
rsp % 16 == 8 - 有 128-byte red zone
9.2 Windows x64
重点:
- 前 4 个参数走
rcx, rdx, r8, r9 - caller 需要预留 32 字节 shadow space
rsp以下内存不应当像 SysV red zone 那样被默认信任
所以 WinROP 除了对齐,还要额外考虑:
- shadow space 是否留够
- 假栈是否可写
- API 序言是否会把寄存器 spill 到栈上
10. 总结
- ROP 是用
ret伪造call,所以栈状态不一定天然正确。 - 64 位 Linux 下,目标函数入口常希望看到
rsp % 16 == 8。 - 比赛里最常见修法是在目标函数前补一个空
ret,但本质上还是要算整条链。
- 合法授权:本文所述技术仅适用于已获得明确书面授权的目标或自己的靶场内系统。未经授权的渗透测试、漏洞扫描或暴力破解行为均属违法,可能导致法律后果(包括但不限于刑事指控、民事诉讼及巨额赔偿)。
- 道德约束:黑客精神的核心是建设而非破坏。请确保你的行为符合道德规范,仅用于提升系统安全性,而非恶意入侵、数据窃取或服务干扰。
- 风险自担:使用本文所述工具和技术时,你需自行承担所有风险。作者及发布平台不对任何滥用、误用或由此引发的法律问题负责。
- 合规性:确保你的测试符合当地及国际法律法规(如《计算机欺诈与滥用法案》(CFAA)、《通用数据保护条例》(GDPR)等)。必要时,咨询法律顾问。
- 最小影响原则:测试过程中应避免对目标系统造成破坏或服务中断。建议在非生产环境或沙箱环境中进行演练。
- 数据保护:不得访问、存储或泄露任何未授权的用户数据。如意外获取敏感信息,应立即报告相关方并删除。
- 免责范围:作者、平台及关联方明确拒绝承担因读者行为导致的任何直接、间接、附带或惩罚性损害责任。
- 先授权,再测试
- 只针对自己拥有或有权测试的系统
- 发现漏洞后,及时报告并协助修复
- 尊重隐私,不越界





