栈迁移介绍
栈迁移(stack Privoting/Stack Migration)是二进制漏洞利用中非常经典且重要的技能。
简单来说,当存在栈溢出漏洞,但溢出的长度不足以容纳完整的 ROP 链时,我们就需要利用“栈迁移”技术,将栈指针(ESP/RSP)“劫持”到一个我们控制的、空间更大的内存区域(如 BSS 段或堆),让程序误以为那里就是栈,从而继续执行我们的 ROP 链。
技术原理解析
1.核心应用场景
- 溢出空间受限:例如,你发现了一个栈溢出点,但是只能覆盖返回地址(Ret Addr)后的一两个字长,无法写入长达几十字节的
system("/bin/sh")ROP 链。 - 开启了 PIE/ASLR:需要进行多次泄露和计算,但栈空间不够一次性完成。
2.核心指令:leave; ret
栈迁移的灵魂在于利用 x86/x64 汇编中的 leave 指令配合 ret 指令。几乎所有的函数在结束时都会执行这两条指令来恢复栈帧。
我们需要理解这两条指令的本质操作:
leave指令等价于:
1 | mov esp,ebp # 将栈指针 esp 恢复到 ebp 的位置 |
ret指令等价于:
1 | pop eip # 弹出栈顶的值给Eeip,程序流跳转 |
3.攻击原理详解(以32为为例)
利用栈迁移,通常需要执行两次 leave; ret 的逻辑。
步骤一:布局假栈(Fake Stack)
首先,你需要在某个可控的大空间(通常是BSS段或Heap)写入你的ROP链。假设这个地址是Target_Address。

步骤二:控制旧栈(Old Stack)
在原有的栈溢出点,我们需要构造如下Payload:
(1)覆盖Saved ebp
将其修改为:Target_Address - 4(或者是直接执行Target_Address,取决于怎么理解pop,下文详解,这里我用的是Target_Address - 4)。

(2)覆盖return address
将其修改为程序中已有的leave; ret指令执行gadget的地址。

步骤三:执行流推演
当存在漏洞的函数执行到结尾时:
(1)原本的leave执行:
mov esp, ebp:esp回到了当前栈帧底部。

pop ebp:关键点。此时栈顶是我们符号的Fake ebp。于是,寄存器ebp被修改为了Target_Address - 4。
Target_Address - 4的地址出栈,存储在ebp寄存器中。

- 此时,esp指向我们覆盖的
return address(即leave; retgadget的地址)。
(2)原本的ret执行:
pop eip:eip变成了leave; retgadget的地址

- 程序跳转去执行
gadget。
eip就是要执行的下一条命令,下一条命令执行gadget。
(3)gadget的leave执行:
mov esp, ebp:核心发生!!。因为刚才ebp已经变成了Target_Address,所以现在esp瞬间跳到了Target_Address。栈迁移成功!
将ebp寄存器的值赋值给esp,成功实现栈迁移!

pop ebp:esp继续下移4字节(出栈,栈下移,但是地址是增高4字节),把Target_Address - 4处的第一个值弹给ebp(这个值通常不重要,或者设为更下一次迁移的地址)。

(4)gadget的ret执行:
pop eip:此时esp已经位于伪造栈的Target_Address处。程序将从这里取出你的ROP`链的第一条指令地址开始执行。

4. Payload构造示例
假设是32位程序,你需要控制两处内存。
A. 目标区域(Target_Area,比如 BSS 段):这里存放真正的攻击代码。
1 | # 注意:因为 Gadget 的 leave 中有一个 pop ebp,会消耗掉前4字节 |
B. 溢出点(Original Stack):这里只需要修改ebp和eip。
1 | # 假设 padding 是溢出前的填充长度 |
5. 64位系统的区别
64 位系统的逻辑完全一致,区别仅在于:
- 寄存器是 rbp 和 rsp。
- 字长是 8 字节。
- 参数传递优先使用寄存器(rdi, rsi, rdx, rcx, r8, r9),因此 ROP 链通常需要
pop rdi; ret等 gadget,使得 ROP 链更长,栈迁移技术在 64 位下显得更加必要。
6. 常见注意事项
-
BSS 段是否可写:确保你迁移的目标地址(如 BSS)具有写权限,并且不会影响程序的其他全局变量。
-
Gadget 获取:可以使用
ROPgadget --binary ./pwn | grep "leave"来查找。 -
多级迁移:如果一次迁移的空间还是不够,你可以像“接力赛”一样,在一个假栈上再次布置栈迁移 payload,跳到另一个地方。
总结
栈迁移的本质是利用 leave 指令对 esp 和 ebp 的操作,将 esp 强行修改为我们想要的值。
格式化字符串漏洞介绍
1.核心概念
定义:当程序将用户的输入直接作为格式化函数(如 printf)的第一个参数(即格式化字符串本身)时,发生的漏洞。
本质:函数对参数数量的盲目信任。函数仅根据字符串中的 % 符号数量去栈/寄存器取值,而不检查实际传入了多少参数。
2. 漏洞代码特征
有漏洞:
1 | char buf[100]; |
安全写法:
1 | printf("%s", buf); // <-- 格式已固定,buf 仅作为数据参数 |
可以简单的理解,对于%s对应于一个buf,直接从内存中拿buf的值。这个buf是参数值,对于32位是存放在栈中,对于64位是存放在寄存器中,但当buf这个不存在的时候,printf还是会去同样的位置去拿数据,这时候就会造成栈(或寄存器)的信息泄露。
对于有漏洞的例子printf(buf) ,这里如果buf中被输入了%s,那么printf会去同样的位置去找数据,然后输出造成信息泄露。
3. 原理解释
理解核心:格式化字符串 vs 参数栈的不匹配。
正常情况:printf("%x", var) -> printf 从栈/寄存器取出一个值打印。
攻击情况:用户输入AAAA %x ->
(1)printf打印AAAA
(2)遇到%x,printf认为栈上肯定有个参数等着它。
(3)printf函数去栈上(或寄存器,64位参数值存在寄存器上)获取对应位置的数据并打印。
(4)结果:泄露了栈上的敏感数据。
4. 利用速查表(Cheat Sheet)
常用格式说明符(Specifiers)
| 符号 | 作用 | 攻击用途 | 备注 |
|---|---|---|---|
%s |
输出指针指向的字符串 | 任意地址读 | 试图读取该地址指向的内容,若地址非法会 Crash |
%p / %x |
输出参数的值(十六进制) | 栈数据泄露 | 泄露 Canary、Libc 基址、栈地址 |
%d / %i |
输出有符号整数 | 数据泄露 | 较少用,通常用 %p |
%n |
不输出,写入已打印字符数 | 任意地址写 | 最危险的符号,用于修改内存 |
%c |
输出单个字符 | 填充计数 | 配合 %n 控制写入的数值大小 |
关键修饰符
(1)直接访问(Direct Access$)
%10$p:直接打印栈上第10个参数的值。%10$n:将数值写入栈上第10个参数指向的地址。
用途:避免输入几十个%p来够到深处的偏移。
(2)长度修饰(Length Fields)
%hhn:写入 1 byte%hn:写入 2 bytes(short)
用途:分次写入大数值(如地址),避免一次打印过多字符导致超时。
5. 攻击能力(Capabilities)
A. 任意地址读 (Arbitrary Read)
- 目的:绕过 ASLR (泄露 Libc 地址),读取 Flag,读取 Canary。
- 方法:
- 确定输入字符串在栈上的偏移(Offset),例如是第 6 个参数。
- 构造 Payload:
[目标地址] + %6$s。 printf会解析%6$s,去第 6 个参数位置拿值(即我们填入的目标地址),然后打印该地址指向的内容。
B. 任意地址写 (Arbitrary Write)
- 目的:修改 GOT 表(劫持控制流),修改返回地址,修改变量值。
- 原理:利用
%n将“前面打印出的字符总数”写入指定地址。 - 公式:
[填充字符] + [Payload构造] + [%k$n],如果想写入值100,确保在%n之前总共打印了 100 个字符。
6. 利用流程(Exploit Workflow)
(1)确定偏移 (Fuzzing): 输入 AAAA %p %p %p...,观察 0x41414141 (AAAA) 出现在第几个位置。记为 offset。
(2)信息收集 (Leak): 利用 %offset$p 泄露栈上的 __libc_start_main 或其他地址,计算基址 libc_base。
(3) 构造写利用 (Overwrite):
- 目标:
printf_got(或其他函数 GOT)。 - 值:
system_addr。 - 工具:使用
pwntools自动生成。
1 | # payload = fmtstr_payload(offset, {target_addr: value_to_write}) |
7. 防御与绕过(Mitigation)
- 防御
(1)GCC 编译选项:-Wformat -Wformat-security (编译警告)。
(2)FORTIFY_SOURCE:-D_FORTIFY_SOURCE=2。如果格式化字符串在可写段且含 %n,直接 Crash。
(3)代码规范:严禁 printf(variable),必须用 printf("%s", variable)。
- 限制
(1)\x00 截断:printf 遇到空字节停止,构造 Payload 时需将地址放在 Payload 的最后,或者利用栈上已有的指针对其进行修改。
(2)缓冲区大小:如果 buf 太小,可能无法容纳复杂的 Payload。
格式化字符串漏洞练习
fmtstr1

定位漏洞点。

去查看x的地址,为:0x0804A02C,想办法将其修改为4即可获取shell。

利用格式化字符串漏洞。先动态调试程序,这里read函数读取输入的一个A后,查看栈。

发现A输入的这个内容,被存储在了第11个参数的位置,(0x2c - 0x00) / 4 = 11。x的地址也知道了为:0x0804A02C。那么将0x0804A02C使用read去读取,然后0x0804A02C刚好4个字节,利用格式化字符串漏洞,将0x0804A02C地址处的内容修改为4。
%n用于写入已打印的字符数,4字节,就是写入4。
exp如下:
1 | from pwn import * |

fmtstr2

定位漏洞点:

使用gdb去进行动态调试。输入AAAA断点在printf函数处。发现了flag所处的栈的位置。

这个程序是x64的程序,在x64中前6个参数都是存储在寄存器中的,然后再存储到栈中。调试可以看到flag是位于栈中的第3个参数的位置,那么6+3,实际是第9个参数。
这题根据IDA,可以看到代码的逻辑,printf处是存在漏洞的。想办法把第9个参数的值,输出出来,就可以得到flag了。
根据前面学的,使用%s,输出第9个参数的值,这个值是个地址,地址是flag字符串的地址。
payload很简单为:%9$s
