资讯动态

栈保护Canary绕过:泄露、爆破与检查逻辑迂回

发布时间:2026/10/26 14:09:09 来源:尧图企业网站定制
做二进制方向的题checksec 跑完看到 Canary found 就转头去翻别的附件这个习惯我早年也有。后来被一道题硬逼着正面处理栈保护才想明白一件事canary 的设计目标其实很窄它只赌一件事——你在不知道金丝雀值的前提下改不掉返回地址。它不防泄露不防爆破更防不住那些压根不去碰返回地址的玩法。换句话说它是道筛子不是堵墙。这篇就把“绕过”这两个字拆开讲从把 canary 读出来、到逐字节撞出来、再到干脆绕开检查逻辑三条路线各自的适用前提、动手步骤、以及实际调试时会撞上的坑全部过一遍。不管你是刚能看懂汇编、一遇保护就卡壳还是已经能做几道无保护题想往上走一层下面这些都能直接拿去对着调试器复现。1. 先把 canary 拦的是哪一步看清楚再谈绕1.1 从函数序言和尾声看它的工作位置很多人对 canary 的印象停留在“栈上多了一个随机数”但真正决定绕过难度的是这个随机数被放在哪、什么时候被比较、比较失败之后走向哪里。拿一段最普通的代码做例子void vuln(void) { char buf[64]; read(0, buf, 128); }用-O0 -fstack-protector-all编出来序言和尾声大概是这样vuln: push rbp mov rbp, rsp sub rsp, 0x50 mov rax, qword ptr fs:[0x28] ; 取 master canary mov qword ptr [rbp-0x8], rax ; 存一份到栈上 lea rax, [rbp-0x50] mov esi, 0x80 mov rdi, rax call read mov rdx, qword ptr [rbp-0x8] xor rdx, qword ptr fs:[0x28] ; 和 master 比对 je .ok call __stack_chk_fail .ok: leave ret关键在于位置关系缓冲区起点在rbp-0x50canary 在rbp-0x8保存的 rbp 在[rbp]返回地址在[rbp8]。从缓冲区末尾到返回地址中间横着 8 字节的 canary 和 8 字节的 saved rbp。这意味着任何想改返回地址的线性溢出必然先踩过 canary。这是整个保护机制的全部力量来源——它不是让你写不进去而是让你写进去之后活不下来。所以绕过思路天然分成两大类要么想办法知道 canary 的值让踩过去的那 8 个字节写回原样要么找一个不经过这条线性路径的写入点或者破坏检查逻辑本身。前者是泄露和爆破的战场后者是指针改写、TLS 覆盖、GOT 劫持的地盘。1.2 最低字节固定为 0x00 的双面性canary 有个几乎成了标志的设计最低字节恒为\x00。原因很实在早期很多溢出配合strcpy、puts、printf(%s)这类以空字符结尾的函数工作如果 canary 是纯随机的 8 字节那么一旦溢出写到 canary 位置后续的字符串输出就会把 canary 原样打印出来——等于白送。把最低字节钉死成\x00字符串在这里就断了泄露最多泄露 7 个字节第 8 个字节永远是 0。这个设计在防御上确实有效但它同时也给了攻击方两个便利。第一爆破的时候最后一字节不用猜总共只需要处理 7 个字节。第二当你用puts(buf)或者write(1, buf, n)打印一段刚好填满缓冲区、末尾没有结束符的数据时输出会越过缓冲区一路读下去直到撞上 canary 的那个\x00才停。也就是说你不仅能泄露还能精确地知道泄露在哪结束。实测中还有个容易忽略的细节如果你的溢出原语是strcpy、scanf(%s)、gets这类以\x00结尾的函数你根本没法往 canary 里写非零字节——因为一旦进入 canary 区域第一个字节就是 0写入中断后面全填成 0。这种情况下想打返回地址基本无解只能换思路。所以拿到题目第一件事是看清楚输入函数是什么read、fgets、memcpy是二进制安全的strcpy、scanf(%s)不是。这个判断比 canary 本身更早决定你的攻击面。1.3 比较失败之后的那条调用链__stack_chk_fail是 glibc 提供的函数内部大概是这样void __attribute__((noreturn)) __stack_chk_fail(void) { __fortify_fail(stack smashing detected); }__fortify_fail会调用__libc_message最终打出*** stack smashing detected ***: terminated然后abort()。这条链有两点值得注意一是__stack_chk_fail是通过 PLT/GOT 调用的如果程序只开了 Partial RELRO那么它的 GOT 表项是可写的这本身就是一条绕过路径后面第 4 节会展开。二是abort()可能产生 core dump在某些服务器配置下会拖慢爆破节奏甚至写满磁盘这个坑在实战里非常真实。另外要提醒一句不同编译器、不同 libc 版本的失败处理细节不一样。有的嵌入式工具链会直接__builtin_trap()触发非法指令有的会调用__stack_chk_fail_local。如果你在调试时发现断不到__stack_chk_fail别急着怀疑人生先objdump -d看看 PLT 里到底有什么。2. 泄露派把 canary 从栈上读出来再打2.1 溢出边界卡在 canary 之前让程序自己把栈吐出来这是最经典也最稳的一条路前提是程序在读完输入之后还会把缓冲区打印出来。构造思路很简单让写入长度刚好等于“缓冲区起点到 canary 起点”的距离把缓冲区填满不留结束符然后触发输出。void vuln(void) { char buf[64]; read(0, buf, 72); // 72 缓冲区到 canary 的距离 puts(buf); // 没有 \x00会一直读到 canary 的 0x00 }这时候puts会输出 72 个垃圾字节加上 7 个 canary 字节遇到第 8 个字节的\x00停下。你在接收端把前 72 字节切掉剩下的 7 字节加上自己补的\x00就是完整的 canary。这里有个必须强调的点72 这个数字不是通用常数一定要自己在调试器里量。缓冲区大小是 64但编译器可能插入了对齐填充也可能中间还夹着别的局部变量。我见过一个函数里声明了char buf[32]和int fd结果 canary 到缓冲区起始的距离是 0x38 而不是 0x28因为fd占了一段。用 gdb 在puts调用前打断点p/x $rbp-0x8减一下p/x buf就知道准确值了。还有一种变体是用write(1, buf, n)输出这时候不依赖结束符只要n大于实际有效数据长度就能把 canary 带出来。这类程序往往更容易打因为write是二进制安全的你可以精确控制读多少、泄露多少。2.2 格式化字符串一次 printf 把整个栈点着看如果程序里有printf(buf)这种把用户输入直接当格式串的写法那 canary 基本等于白给。x86-64 下前 6 个参数走寄存器从第 7 个开始从栈上读你可以用%p一路刷下去观察哪个位置出现了形如0x??????????????00的值——低字节是 00这在%p的输出里非常显眼。输入: %p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p 输出: 0x7ffd... 0x0 0x7f... (nil) ... 0xa4f3e1c2b5d67800看到低两位是00的那个八成就是 canary。确认方法是用 gdb 在printf前断下来p/x *(unsigned long*)($rbp-0x8)对一下。用%n$p可以精确定位省得每次数。比如测试出是第 11 个参数那 payload 里就写%11$p。更狠的玩法是用%s配合一个已经泄露的栈上指针做任意地址读直接去读fs:0x28的值——不过这条路要求你先有泄露、再有布局属于进阶操作初学阶段用%p刷出 canary 足够了。格式化字符串还有个间接用途它能一次性泄露 canary、PIE 基址、libc 基址三样东西把 ASLR 带来的不确定性全部消掉。我在实战里遇到带格式化字符串又有 canary 的题基本都会先花二十分钟把能泄露的全泄露一遍再慢慢想怎么写。2.3 越界读、未初始化内存和其他输出口子泄露不一定非要靠溢出。有些程序会把整个结构体或者一段固定长度的缓冲区打日志而那段内存里恰好有 canary 的残留。这类情况在真实项目里比 CTF 题里更常见。判断有没有这类口子看三件事有没有把栈上的数据以可控长度写出去的地方比如日志函数、序列化函数、调试打印有没有先读后写的结构比如read到一个结构体字段然后整体输出有没有把栈上指针或者栈地址打印出来的地方。最后一种虽然不直接给 canary但栈地址一旦泄露配合其他原语比如任意写就有机会直接读fs:0x28。需要提醒的是泄露是一条一次性的路。一旦你拿到了 canary后续所有 payload 都要把这个值原样填回去包括后续可能需要重新连接的场景。所以在本地验证通过之后一定把 offset 和 canary 位置写成常量或者脚本变量别每次手填。3. 爆破路线fork 服务下逐字节猜3.1 fork 之后 canary 为什么能保持不变这是爆破可行性的全部前提。canary 的 master 值是在进程启动时由内核通过辅助向量提供的随机数初始化到 TLS 里的位置在fs:0x28。fork()复制的是整个地址空间包括 TLS 段所以子进程天生继承父进程的 canary 值。只要服务器是“父进程 acceptfork 出子进程处理”的模型每个连接对应的子进程 canary 就是同一个值可以一个字节一个字节地试。反过来如果服务器是“预先 fork 好一批进程每个进程循环 accept”pre-fork 模型那么这批进程的 canary 各不相同爆破就退化成每次 1/256 的运气游戏基本不可行。判断方式是连续连几次看子进程 PID 是不是每次都变、是不是新起的进程。用ps -ef或者抓包看 TCP 行为都能判断。还有一个更细的点如果服务器开了线程池glibc 里子线程的 canary 是从主线程复制过来的所以同一进程内所有线程 canary 相同。但这不影响结论——关键看是不是同一个进程。3.2 一次完整的爆破循环长什么样假设已经确认偏移是 72 字节程序在溢出后要么继续正常输出、要么直接 abort可以这样写from pwn import * context.log_level error HOST, PORT 127.0.0.1, 9999 def attempt(payload): p remote(HOST, PORT) try: p.recvuntil(binput:, timeout3) p.send(payload) data p.recvall(timeout1) except Exception: data b finally: p.close() return data canary b\x00 for i in range(7): for guess in range(256): payload bA * 72 canary[:i] bytes([guess]) data attempt(payload) if bstack smashing not in data and bdone in data: canary bytes([guess]) print(fbyte {i}: 0x{guess:02x} - {canary.hex()}) break else: print(fbyte {i} 没撞出来检查偏移或服务模型) break print(canary , canary)判断条件是最容易翻车的地方。上面用的是“没有触发 stack smashing 而且出现了正常结束的标志done”这个标志要看具体程序。有的题目成功时只打印一个换行有的会进入下一轮输入等待有的干脆没有任何输出。稳妥的做法是同时看两头错误字节时输出里有stack smashing或者连接被直接关闭正确字节时连接保持正常、能看到后续提示。如果服务端不支持recvall比如连接一直挂着不断那就改成读固定长度或者设置更短的超时。我一般会先用一个必然错误的 payload 打一次看看“失败长什么样”再用一个猜测的 payload 打一次对比两次的差异把判断条件校准好再跑循环。3.3 1793 次连接背后的现实成本理论上 7 个字节各试 256 次加上最后一字节固定为 0最坏情况 1793 次连接。听起来不多实际跑起来问题一堆问题表现处理办法服务器限流连接被拒、延迟骤增加sleep(0.01)降低并发连接数上限卡在recvuntil超时用完立刻close()别攒着core dump 生成磁盘增长、响应变慢检查ulimit -c必要时本地复现服务不自动重启打挂一次后续全失败用while循环拉起或者本地容器跑输出缓冲导致读不全判断条件误判加shutdown(send)或者用recv循环偏移算错一字节永远撞不出来第 0 字节时打印全部响应人工看一次还有个隐藏坑某些程序在read之后会先做一次长度校验或者拷贝然后才到 canary 检查。这时候即使 canary 猜对了也可能因为前面某个字段非法而提前退出导致你以为猜错了。解决办法是仔细看反汇编确认整条路径上没有别的检查点把那些字段填成合法值。爆破的价值在于它完全不需要泄露在“有 canary 但没有任何输出函数”的题目里几乎是唯一解。代价是连接次数多、容易被服务器踢所以本地验证一定要跑通再上远端。4. 不打 canary 的迂回改指针、改 TLS、改失败处理4.1 改写栈上指针让写入发生在别处这条路的思路是既然线性溢出必然会踩 canary那就在不踩 canary 的前提下改掉一个后面会被用到的指针。典型场景是这样void vuln(void) { char buf[64]; char *dst safe_buf; read(0, buf, 80); // 溢出 16 字节能覆盖 dst strcpy(dst, buf); // dst 已被改写写到任意地址 }缓冲区 64 字节dst紧跟在后面read的长度 80 刚好覆盖dst但离 canary 还差一段。这时候你改的是指针本身canary 完好无损函数正常返回。后续strcpy就变成了一个任意地址写原语。这类打法难在布局要精确。dst距离buf有多远、覆盖多少字节、写入内容里不能带\x00如果strcpy的话、目标地址要写到哪每一步都得在调试器里量。好处是一旦打通威力比单纯覆盖返回地址更大——你能写 GOT、写 hook、写任意数据段。同样的思路还有几种变体改写一个被当作长度使用的整型变量、改写一个函数指针、改写FILE*结构里被用到的字段。判断有没有这类机会看函数里有没有“缓冲区之后跟着一个指针或长度变量并且这个变量在后面被用到”的结构。4.2 覆盖 TLS 里的 master canary如果前面的任意地址写原语已经拿到了最直接的收尾是去改fs:0x28处的 master canary把它改成你 payload 里写进去的值。这样子函数尾声比对时栈上和 TLS 里都是你控制的值检查自然通过。# 伪代码已经拿到任意写 fake bB * 8 write(wherefs_base 0x28, datafake)难处在于要知道fs_base。这个值可以通过泄露栈上指针、读 TLS、或者利用已知偏移推算。在 PIE 打开、栈随机化的环境里获取fs_base本身是个独立课题通常要配合信息泄露。所以这条路一般不是首选而是在已经有较强原语之后的“顺手收尾”。还有一种情况是-mstack-protector-guardglobal编译出来的程序canary 存在一个全局变量里而不是 TLS。这种反而好打因为全局变量的地址在 PIE 泄露之后是固定的直接改就行。4.3 劫持__stack_chk_fail的 GOT 表项这条最省事也最吃程序配置。原理是canary 比对失败后程序会调用__stack_chk_failplt而 PLT 会跳到 GOT 里存的地址。如果程序只开了 Partial RELROGOT 可写把这个表项改成system或者某个 one-gadget那么“检查失败”反而变成了“跳到我们想要的地方执行”。关键细节是这时候寄存器状态是什么。canary 失败时rdi通常指向*** stack smashing detected ***这个字符串rsi是它的长度。如果改成systemrdi指向这个固定字符串执行的命令就是那句提示文本没有实际用处。所以实践中更常见的是改成 one-gadget或者先做一次栈迁移再跳到 ROP 链。改成 one-gadget 的约束跟平时一样要满足对应的寄存器条件一般要求 rsp 附近可控。而 canary 失败那一刻的栈状态比较难控所以成功率不高得多试几个 gadget。如果程序开了 Full RELRO-Wl,-z,relro,-z,nowGOT 在加载时就变成只读这条路直接堵死。所以看保护的时候RELRO 的状态和 canary 一样重要两个要一起判断。4.4 从 longjmp 和异常处理里绕开检查点setjmp/longjmp是另一条经典旁路。setjmp会把当前的寄存器状态、栈指针、返回地址存进一个jmp_buflongjmp时整体恢复。如果jmp_buf就在栈上且能被溢出覆盖那么longjmp跳过去的时候根本不走 canary 检查——因为函数压根没返回它是被“跳走”的。C 的异常处理、siglongjmp、甚至某些实现里的_Unwind_RaiseException都有类似性质。判断方法是在反汇编里搜setjmp、__sigsetjmp、_setjmp的调用看jmp_buf落在栈的哪个位置。还有一类更粗暴的如果函数在返回之前调用了exit()或者abort()而atexit注册的 handler 里有关键操作那么控制 handler 的调用链也能绕过 canary。这些都属于“不去触碰检查点”的思路需要对程序的整体控制流有把握。4.5 干脆不栈溢出最后一种“绕过”其实是承认现实canary 只管栈溢出管不了堆。堆溢出、UAF、double free、整数溢出导致的越界、文件描述符劫持、环境变量注入这些路径完全在 canary 的视野之外。我在实战里遇到带 canary 的程序第一反应不是“怎么绕它”而是“有没有别的入口”。一个程序往往有多个输入面网络协议字段、配置解析、文件名拼接、序列化反序列化。栈溢出只是其中最直观的一种而别的地方可能压根没上保护。花十分钟看看全貌比死磕 canary 划算得多。5. 调试台上的确认定位 canary 的偏移与值5.1 直接读fs:0x28在 gdb 里最直接的写法gdb ./vuln b *vuln0x20 run p/x *(unsigned long*)($fs_base 0x28)pwndbg 和 gef 都提供了canary命令直接打印当前进程的 canary 值。如果没有这些插件fs_base在较新的 gdb 里可以直接用老版本可能需要p/x $fs_base先确认能不能识别。栈上的那份副本用x/gx $rbp-8就能看。两个值应该一致如果不一致说明你断在了序言之前序言还没把值搬上去。32 位程序在gs:0x14注意寄存器段前缀不一样。跨架构调试时这个细节经常被搞混。5.2 用 cyclic 和断点确定偏移最稳的偏移确定方法不是靠猜是靠 patternfrom pwn import * payload cyclic(200)把 payload 发进去等程序崩溃看崩溃时$rbp或者返回地址处的内容然后cyclic -l 0x6161616c就能反推出偏移。对 canary 场景断点要打在函数尾声比对之前看[rbp-8]被 payload 的哪一段覆盖了。我一般会打两个断点一个在read之后用x/20gx $rbp-0x60把整个栈帧铺开看一个在比对指令处确认对应位置。有个小技巧把 payload 写成可辨识的分段比如bA*8 bB*8 bC*8 ...然后看哪一段落到了 canary 位置、哪一段落到了 saved rbp比单纯用循环字符更快定位结构。5.3 调试时跳过检查的实用开关本地验证攻击链时经常需要临时让 canary 检查失效方便单独调后面的 ROP 部分。有三种做法第一种是在 gdb 里直接改栈set {unsigned long}($rbp-8) *(unsigned long*)($fs_base0x28)第二种是在__stack_chk_fail下断点然后用return强制返回。这个做法对noreturn函数有点暴力可能破坏栈平衡只在单步调试时用。第三种是编译一个不带保护的同版本二进制做对照用来确认“如果 canary 不存在我的链能不能打通”。这个方法最省事但要小心保护关了之后栈布局会变——canary 占的 8 字节没有了所有偏移整体位移不能直接搬过去用。5.4checksec之后还要再确认一次checksec显示 Canary found只代表编译时开了保护选项不代表每个函数都被保护。-fstack-protector默认档位只保护“包含字符数组或者调用了 alloca”的函数。如果你溢出的那个函数里只有一个int它可能压根没插 canary。验证方法是直接看反汇编objdump -d ./vuln | grep -c stack_chk objdump -d ./vuln | grep -A2 -B2 fs:0x28数一下有几处fs:0x28的读取就能知道哪些函数被保护了。我踩过一次坑checksec显示有 canary结果目标函数因为只有一个char*参数没被插桩白白研究了半天绕过。后来养成习惯动手之前先数一遍。另外-fstack-protector-strong覆盖范围大得多任何有数组、地址被取用的局部变量都会插桩-fstack-protector-all是全部函数。分清楚这三档能省很多时间。6. 从防守方回看这些绕过手法教会了我们什么6.1 编译选项的档位差异比想象中大编译选项保护范围典型场景-fno-stack-protector完全不插桩追求性能的嵌入式-fstack-protector含字符数组或 alloca 的函数很多发行版的历史默认-fstack-protector-strong含数组、取地址局部变量的函数现代发行版默认-fstack-protector-all全部函数安全敏感场景有性能代价差异体现在实际效果上非常明显。用默认档位编译的程序攻击者只要找到那个没被插桩的函数就能长驱直入canary 形同虚设。所以评估一个二进制时“有没有 canary”这个问题不够精确应该问“我要打的这个函数有没有 canary”。6.2 canary 单独存在时撑不住的几件事canary 从来不是独立防线它必须和其他机制配合。单独开 canary 的情况下泄露、爆破、GOT 覆盖、堆漏洞四条路全都通着。要把爆破这条路堵上得控制连接频率、限制单 IP 并发让 1793 次连接变得不现实同时避免 pre-fork 之外的模型暴露稳定性——虽然这点在实践中很难完全做到但至少可以加失败计数和封禁。要把 GOT 覆盖堵上得开 Full RELRO 加BIND_NOW让 GOT 在加载后只读。要把泄露堵上得保证任何输出都带明确长度绝不在溢出之后打印缓冲区也绝不让用户输入当格式串。要把堆漏洞堵上那是另一个话题但至少别在开了 canary 之后就以为万事大吉。6.3 写代码时我给自己定的几条硬规矩第一所有输出带长度。不管是日志还是协议响应write(fd, buf, n)里的n要么是常量要么来自可信计算绝不来自用户输入的上限值。第二格式化字符串永远写死。printf(%s, user)而不是printf(user)snprintf永远带长度返回值要检查——截断是需要处理的错误不是可以忽略的警告。第三缓冲区边界计算不靠脑算。用sizeof(buf)而不是手写数字用strncpy之后手动补\x00所有涉及长度的运算都写在一行里并且加注释说明单位。第四保护选项成套开。canary、PIE、Full RELRO、NX、_FORTIFY_SOURCE2一起上单独开一个的心理安慰作用大于实际效果。检查的方式不是看编译脚本是编译完拿工具扫一遍产物。第五也是我觉得最容易被忽略的一条对 fork 模型的服务器做速率限制。canary 在 fork 模型下是稳定的这个特性本身不是漏洞但它把爆破的成本从“不可行”降到了“一千多次连接”。加上连接频率限制和失败计数能把这条路重新推回不可行的区间。说到最后分享一个我自己踩过的教训。早年做一道题checksec显示 Canary found我花了整整一个晚上研究怎么泄露最后发现目标函数里根本没有 canary——真正的溢出点在另一个没被插桩的辅助函数里。从那以后我养成一个习惯拿到二进制先objdump数一遍fs:0x28出现的位置把每个受保护函数的边界画在纸上再决定从哪个函数下手。这个动作花不了五分钟但能省掉很多无效的深夜。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑