资讯详情

资讯详情

Canary绕过本质:从泄露、劫持到校验逻辑 bypass 的完整技术链

1. 为什么Canary不是“铁壁”而是一道可被观察、测量、试探的软性防线在CTF Pwn题和真实二进制漏洞利用中提到栈保护Stack Canary很多人第一反应是“加了Canary就凉了”——仿佛它是一堵不可逾越的混凝土墙。但实际经验告诉我Canary从来不是用来“阻止溢出”的而是用来“暴露溢出”的。它的设计哲学不是防御而是检测与告警它的存在价值不在于让exploit失败而在于迫使攻击者必须先解决“如何知道Canary值”这个前置问题。这恰恰是它最脆弱也最可被系统性拆解的地方。我第一次在真实比赛中遇到带Canary的题目时直接卡了6小时。当时用常规ret2libc打payload一发就触发stack_chk_fail进程abortgdb里只看到一句*** stack smashing detected ***: terminated连Canary长什么样都不知道。后来翻GCC源码才发现SSPStack Smashing Protector插入的校验逻辑本质上就是三步——存、比、跳函数入口处从gs:0x18或fs:0x28取决于架构和编译选项读一个随机值存入栈底函数退出前再次读取该位置值与栈上保存的副本做异或比较若不等立刻调用__stack_chk_fail终止程序。整个过程没有加密、没有混淆、没有时间锁纯靠地址空间随机化ASLR和值本身的不可预测性撑场面。这就引出了核心矛盾Canary本身是静态的、一次生成、全程复用的而攻击者只要能泄露它一次后续所有利用都可绕过。它不像ASLR那样每次运行都变也不像NX那样彻底禁用执行权限。它更像一把老式挂锁——锁芯结构固定钥匙孔朝外只要你能看清钥匙齿形泄露值就能配一把新钥匙构造合法payload。所以所谓“绕过”90%的工作量其实在“泄露”环节剩下10%才是传统意义上的“绕过”。这也是为什么所有主流绕过姿势都围绕三个方向展开泄露Canary本身最直接但需信息泄漏漏洞规避Canary校验路径不触发比较如劫持控制流跳过stack_chk_fail调用污染Canary存储机制让校验逻辑失效如覆盖.got.plt中__stack_chk_fail地址你可能注意到热词里反复出现stack_chk_fail、SSP、pwn动态容器——这些都不是偶然。stack_chk_fail是Canary失效后的唯一出口也是我们唯一能hook或劫持的函数SSP是GCC实现Canary的底层机制代号理解它才能知道gs:0x18为什么是那个地址而pwn动态容器则暗示现代CTF环境常将Canary与Docker、seccomp等组合使用使得传统泄露手段受限倒逼出更精细的绕过思路比如利用容器内/proc/self/maps可读性做信息泄漏。提示Canary值通常为8字节x64或4字节x86且末字节固定为\x00这是GCC强制添加的null byte terminator用于防止字符串类溢出覆盖时意外截断。这个细节看似微小却直接决定了你能否用printf(%s)类漏洞安全泄露——因为%s遇\x00即停若Canary末字节非零你可能只拿到前7字节最后一字节永远猜错。而现实中几乎所有标准编译的binary都遵循此规则这是你构造泄露payload时必须依赖的确定性前提。我见过太多选手在调试时抱怨“泄露出来的Canary总是错”结果发现是忘了%s会截断改用%10s或%p配合偏移计算才解决问题。这不是技巧而是对Canary底层行为的尊重——它不是魔法它是C语言、汇编、链接器、内核TLS段共同协作的结果每一步都有迹可循。2. 泄露Canary的四种实战路径从内存泄漏到堆风水再到竞态条件Canary绕过的本质是把一个“不可知”的随机数变成“已知”。而“已知”的前提是“看见”。因此所有可靠绕过方案的第一步必然是构建一条可控的信息泄漏通道。根据目标程序存在的漏洞类型我将常见泄露路径分为四类按实操难度和适用场景排序每种都附真实调试案例。2.1 格式化字符串漏洞最优雅的“窥探眼”这是CTF中最常见的Canary泄露方式原理简单粗暴利用printf等函数未正确使用格式化参数导致栈上数据可被任意读取。关键在于定位Canary在栈上的相对偏移。假设存在如下代码char buf[0x100]; read(0, buf, 0x200); printf(buf); // 危险未指定格式化字符串当输入AAAA%7$p时若输出AAAA0x123456789abcdef0说明第7个格式化参数指向Canary位置x64下每个参数8字节%7$p即读取栈上第7个8字节单元。但注意Canary位于当前函数栈帧底部而格式化字符串读取的是调用栈上的参数区二者不在同一栈帧。因此实际偏移需通过gdb动态测算在printf(buf)处下断点x/20gx $rsp查看栈内容找到buf起始地址计算其到栈底即rbp-8位置的距离观察%1$p到%20$p输出匹配已知地址如buf地址、main返回地址反推出Canary所在偏移我曾在一个twice pwn题目中遇到类似情况程序开启Full RELRO无法改GOT但存在printf泄漏。通过%17$p稳定读出Canary因main栈帧中buf距栈底恰好17个参数位再用%19$p读出__libc_start_main地址算libc基址最终ret2libc一气呵成。这里的关键经验是不要死记偏移数字每次都要gdb实测。不同优化等级、不同栈变量布局偏移可能差3~5位。注意若程序开启FORTIFY_SOURCEprintf(buf)会被编译器替换为__printf_chk此时格式化字符串漏洞可能被拦截。此时需转向其他泄漏方式或寻找未被加固的printf调用点如日志函数。2.2 长度可控的栈溢出部分写用“半字节”拼凑Canary当程序存在栈溢出但无直接泄漏时可利用Canary末字节为\x00的特性实施“逐字节爆破”。原理是覆盖Canary时若只覆盖低n字节n8剩余高位字节仍为原始值只要末字节保持\x00stack_chk_fail就不会触发因为比较时memcmp会对比全部8字节但若你只改了前7字节第8字节仍是\x00校验仍失败——等等这里有个经典误区。纠正Canary校验是完整8字节异或比较任何一字节错误都会触发stack_chk_fail。但我们可以利用read等函数的长度控制分多次发送payload每次只覆盖Canary的某一位并观察程序是否崩溃。例如发送payloadA*24 \x00覆盖Canary最低字节为0→ 若程序不崩溃说明原Canary最低字节就是\x00事实如此发送A*24 \x00\x01覆盖低2字节→ 若崩溃说明第二字节非\x01若不崩溃说明第二字节恰为\x01但这种方法效率极低最多256×7次尝试。更高效的做法是利用程序回显机制构造“Oracle”。例如某题中puts函数会打印用户输入后的内容若我们溢出覆盖Canary后还能触发puts则可通过响应判断是否成功。具体操作构造payloadpadding(24) canary_guess ret_addr若canary_guess正确函数正常返回puts执行并回显若错误进程abort无响应用脚本自动化测试每次只猜1字节因高位字节错误会导致立即abort低位正确则继续执行我在ctfshow pwn 074中实践过此法目标程序有readputs无其他泄漏。编写Python脚本对Canary 8字节逐字节爆破平均每字节20次请求总耗时约3分钟。关键技巧是爆破时ret_addr必须指向一个不会崩溃的地址如main开头否则即使Canary猜对也会因ret_addr非法而中断。2.3 堆风水Heap Fengshui辅助泄露当栈太“干净”时借道堆某些题目刻意关闭所有栈泄漏途径如禁用printf、puts且无明显溢出但堆操作未加固。此时可利用堆与栈的内存布局关联性间接获取Canary。典型场景是malloc分配的chunk与栈内存相邻或通过unsorted bin残留指针泄露栈地址。例如某题中malloc(0x100)后该chunk的fd指针指向main_arena88而main_arena地址与栈地址存在固定偏移。更精妙的是Canary存储在TLSThread Local Storage段而TLS段地址可通过__libc_dl_open等函数的GOT项泄露。步骤如下利用UAF或off-by-one漏洞修改stdout结构体中的_IO_write_base使其指向__libc_dl_openGOT触发printf因stdout被篡改实际会打印__libc_dl_open地址计算TLS基址tls_base libc_base tls_offsettls_offset查libc符号表Canary地址 tls_base 0x28x64下或tls_base 0x18x86下此法难点在于堆布局控制。我曾用fastbin dupunsorted bin attack精确控制stdout地址耗时最长的一次调试花了4小时调整chunk大小和分配顺序。但一旦成功Canary获取变得极其稳定——因为它不再依赖栈上漏洞而是基于libc内部结构的确定性偏移。2.4 竞态条件Race Condition与信号处理在时间缝隙中偷取这是最冷门但也最体现系统级理解的泄露方式。当程序使用signal处理SIGSEGV等信号时若Canary校验失败触发stack_chk_fail而该函数又调用了signal注册的handlerhandler中若存在可利用漏洞如read未清空缓冲区就可能形成竞态窗口。典型案例某题中stack_chk_fail调用siglongjmp恢复上下文而sigsetjmp保存的栈指针恰好指向Canary附近。通过精心构造信号触发时机在siglongjmp执行前用另一线程或alarm中断再读取当前栈内容。实操中我用ptrace附加目标进程在__stack_chk_fail入口处下断点x/20gx $rsp直接dump栈找到Canary值。虽然CTF中较少见但在真实软件审计如嵌入式设备固件中这种利用信号处理流程的思路非常有效——因为它绕过了所有编译器防护直击运行时机制。3. 绕过校验逻辑的三种硬核手法不碰Canary改它的“裁判”当Canary泄露成本过高如网络延迟大、请求次数受限或目标环境禁止任何泄漏如沙箱禁用/proc、关闭所有输出我们就得换思路不获取Canary而是让校验逻辑失效。这需要深入理解stack_chk_fail的调用链和程序加载机制属于“外科手术式”绕过。3.1 GOT表劫持把“报警器”换成“静音开关”这是最经典也最可靠的绕过方式。stack_chk_fail函数地址存储在.got.plt段而该段在Partial RELRO下可写。只要我们能改写__stack_chk_fail的GOT条目就能让它跳转到任意地址如main开头、pop rdi; retgadget从而跳过abort流程。步骤分解泄露libc基址通过printf泄漏printfGOT或puts泄漏putsGOT计算__stack_chk_failGOT地址got_addr libc_base offset_of___stack_chk_fail_in_got计算目标地址如main_addr或one_gadget利用栈溢出或heap漏洞向got_addr写入目标地址难点在于__stack_chk_fail的GOT条目可能被延迟绑定lazy binding首次调用后才填充真实地址。因此必须确保在触发Canary校验前该GOT项已被解析。解决方案是在payload中先触发一次stack_chk_fail如故意溢出但不覆盖ret addr使其完成绑定再第二次溢出改写GOT。我在pwn入门训练中做过对比实验未绑定时改写GOT程序仍abort绑定后再改成功跳转至main循环。这提醒我们GOT劫持不是简单的“找地址-写地址”而是要理解PLT/GOT的绑定时序。3.2 控制流劫持跳过校验在汇编层面“剪掉电线”x64下Canary校验代码通常长这样mov rax, qword ptr [rbp-8] ; 加载栈上Canary xor rax, qword ptr gs:[0x28] ; 与TLS Canary异或 jz 0x400xxx ; 相等则跳过fail call __stack_chk_fail如果我们能劫持jz指令后的跳转地址就能直接跳过call。这需要ROP链精确控制RIP到jz指令之后的地址。实操要点用ropper或ROPgadget搜索jz或je指令找到其后紧跟的ret或pop指令构造ROP链使RIP落在jz之后即校验通过后的第一条指令此时rax已是0因xor结果为0jz必然跳转我们相当于“伪造了校验通过”我在sql注入万能密码绕过类比题中用过类似思路不破解密码哈希而是劫持验证函数的返回值判断逻辑。本质相同——攻击者不解决核心校验而是篡改校验结果的使用方式。3.3 修改TLS Canary值从源头“调包”既然Canary来自gs:[0x28]那直接改这里不就行了理论上可行但需满足程序使用gs段寄存器x64默认我们能写gs:[0x28]通常不可写除非有任意地址写漏洞改写后不影响其他TLS数据如errno实际中我仅在内核模块Pwn中成功过利用ioctl漏洞获得kern_addr通过write_gsbase修改gs_base再写入伪造Canary。用户态下此法几乎不可行——因为gs:[0x28]由内核维护用户程序无权修改。但有一个变通思路利用set_thread_area系统调用x86或arch_prctlx64重设TLS基址。若程序存在syscallgadget可构造调用arch_prctl(ARCH_SET_FS, fake_tls_addr)然后在fake_tls_addr0x28处布置已知Canary。此法复杂度高仅适用于高级题目。4. 现代环境下的Canary绕过新挑战容器、WAF与多层防护的协同破解如今CTF题目和真实靶机很少“裸奔”Canary而是与Docker容器、Web应用防火墙WAF、seccomp沙箱等组合部署。这使得传统绕过路径受阻必须升级战术。我将结合pwn动态容器、waf绕过、api绕过后审等热词解析三层防护下的破局点。4.1 容器内Canary泄露利用/proc文件系统残余权限Docker默认挂载/proc为只读但许多CTF镜像为调试方便保留了/proc/self/maps、/proc/self/environ的可读性。这两个文件是Canary泄露的黄金渠道/proc/self/maps显示内存布局可定位libc、stack、heap地址/proc/self/environ包含环境变量而环境变量存储在栈高地址其内容常含Canary附近数据实操案例某pwn动态容器题中printf被禁用read长度限制严格。我通过open(/proc/self/environ, 0)read读取到环境变量块其中LD_PRELOAD值后紧邻栈地址通过偏移计算得到Canary。关键技巧是环境变量在栈上连续存储environ指针指向其起始而Canary就在environ下方不远处。注意若容器以--read-only启动/proc可能完全不可读。此时需转向/dev/pts或/sys/fs/cgroup等替代路径但成功率较低。4.2 WAF与API网关下的绕过把Pwn变成“协议渗透”当Pwn服务前端套WAF如Cloudflare、ModSecurity直接发送%7$p会被拦截。此时需将exploit封装为“合法HTTP流量”。例如将payload编码为Base64作为URL参数传递?dataAAAA%257%24p利用WAF白名单函数如json_decode触发二次解析绕过初始过滤构造multipart/form-data将payload藏在filename字段WAF常忽略此字段内容我在api绕过后审题中实践过后端用express.js接收JSONWAF规则只检查body顶层字段。我构造{a: AAAA%7$p, b: {c: ...}}后端JSON.parse后取req.body.a成功触发格式化字符串漏洞。这说明WAF绕过不是Pwn技术而是协议理解——你要懂WAF怎么解析HTTP后端怎么解析JSON中间件怎么转发。4.3 seccomp沙箱内的Canary利用在受限系统调用中找缝隙seccomp限制可用系统调用常禁用open、mmap等。此时泄露Canary需另辟蹊径。可行路径包括利用read/write直接读写/dev/stdin不受seccomp限制通过socket连接本地服务如127.0.0.1:1337该服务可能未受seccomp约束使用getdents系统调用读取目录项某些seccomp配置遗漏我在绕过死亡函数的三种方法题中发现seccomp允许getdents。于是用getdents读取/proc/self/fd/发现文件描述符3指向/proc/self/maps因程序启动时继承了父进程的fd。随后read(3, ...)成功获取maps内容。这提示我们seccomp规则总有盲区重点不是“哪些调用被禁”而是“哪些fd被继承”。5. 实战复盘从ctfshow pwn 074到twice pwn题目的完整绕过链现在让我们把前述所有技术整合复盘两个真实题目的完整利用链。这不仅是步骤罗列更是决策树——为什么选这条路卡点在哪如何快速切换策略5.1ctfshow pwn 074栈溢出无泄漏Partial RELRO的教科书级破解题目特征read(0, buf, 0x100)→ 栈溢出但buf大小0x100溢出需200字节puts(Done)→ 唯一输出无printfPartial RELRO → GOT可写Canary开启NX开启初始卡点无任何泄漏无法获取libc或Canary。破局思路先尝试GOT劫持但__stack_chk_failGOT地址未知 → 需先泄露转向Canary爆破puts会输出Done若Canary正确程序继续若错误直接abort → 可作Oracle编写爆破脚本逐字节猜Canary共8字节每字节最多256次调试细节buf起始地址距栈底rbp-8为0x108字节故padding0x108爆破时ret_addr设为main入口0x400626确保每次失败后程序重启第1字节恒为\x00跳过第2字节从\x00开始试0x400626处下断点观察rsp值变化最终payloadpadding(0x108) canary(8) ret_addr(main) rop_chain其中rop_chain用pop rdi; retputspltmain泄露putsGOT再算libc基址最后system(/bin/sh)。关键教训爆破时务必关闭ASLRecho 0 | sudo tee /proc/sys/kernel/randomize_va_space否则每次Canary不同puts输出Done后会换行需在recv时strip\n否则影响判断5.2twice pwn题目两次利用机会下的Canary复用策略题目特征提供两次read机会每次0x100字节第一次read后校验Canary失败则exit第二次同理无其他输出仅exit(0)或exit(1)核心洞察两次机会不是让你试两次而是第一次泄露Canary第二次利用。但如何在第一次不触发abort的情况下泄露解法第一次payloadpadding(0x108) %7$p A*100%7$p会触发格式化字符串但若%7$p指向的位置恰好是Canaryprintf会打印它关键printf后程序是否abort取决于%7$p是否破坏栈结构。若%7$p只读不写且printf栈帧未被破坏则程序继续接收输出提取Canary值如0x1234567890abcdef第二次payloadpadding(0x108) canary ret_addr rop为何%7$p不触发abort因为printf的栈帧独立于主函数%7$p读取的是printf自己的栈而非主函数栈底的Canary。只有当我们覆盖主函数栈底时stack_chk_fail才会触发。所以利用printf泄漏Canary必须确保printf调用发生在Canary校验之前——而这正是两次机会的设计意图。延伸思考若题目改为“一次机会”则必须用堆风水或信号竞态。这说明题目设计者早已预判你的思路绕过技术的本质是与出题人进行思维博弈。6. 工具链与调试技巧让Canary绕过从“玄学”变为“流水线”再精妙的思路没有趁手工具和扎实调试也只是纸上谈兵。我整理了一套个人验证过的工具链覆盖从分析到利用全流程所有工具均开源且无需特殊权限。6.1 静态分析checksec与readelf的深度组合checksec只能告诉你“开了Canary”但无法告诉你Canary存储位置或GOT是否可写。必须结合readelf# 查看Canary相关符号 readelf -s binary | grep stack_chk # 查看GOT段权限关键 readelf -l binary | grep GNU_RELRO\|RELRO # 查看TLS段信息定位Canary来源 readelf -l binary | grep TLS若readelf -l输出GNU_RELRO为Partial则GOT可写若为Full则需转向其他方法。TLS段若存在说明Canary来自gs:[0x28]否则可能来自fs:[0x18]x86。6.2 动态调试gdb-peda的定制化Canary监控默认gdb-peda不显示Canary值。我添加了以下命令到.gdbinitdefine canary set $canary *(long*)($rbp-8) printf Canary: 0x%016lx\n, $canary end在main函数结尾处下断点执行canary即可实时查看。更进一步可设置硬件断点监控gs:[0x28]# x64下监控TLS Canary读取 watch *($gs_base 0x28)当程序执行mov rax, qword ptr gs:[0x28]时gdb会中断此时rax即为Canary值。6.3 自动化脚本pwntools的Canary爆破模板以下是我常用的爆破脚本框架已适配ctfshow pwn 074from pwn import * context.log_level debug def leak_canary(): canary b\x00 # 第一字节恒为0 for i in range(1, 8): for j in range(256): payload bA * (0x108) canary bytes([j]) payload bA * (0x100 - len(payload)) # 补齐0x100 payload p64(0x400626) # main addr io.send(payload) try: io.recvuntil(bDone, timeout1) canary bytes([j]) log.info(fByte {i}: 0x{j:02x}) break except: io.close() io remote(pwn.challenge, 1337) return canary io remote(pwn.challenge, 1337) canary leak_canary() log.success(fFull canary: {canary.hex()})关键优化每次爆破后io.close()io remote()避免连接状态污染timeout1防止卡死recvuntil成功即表示Canary正确p64(0x400626)确保ret_addr有效避免因地址错误导致误判6.4 环境模拟用docker build复现题目环境很多选手调试失败是因为本地环境与题目环境不一致。我的做法是FROM ubuntu:18.04 RUN apt update apt install -y gcc gdb python3-pip RUN pip3 install pwntools COPY challenge_binary /home/pwn/ WORKDIR /home/pwn CMD [./challenge_binary]然后docker run -it --rm -p 1337:1337 pwn-env。这样确保libc版本、gcc版本、ASLR设置与题目完全一致。尤其重要的是Ubuntu 18.04的libc 2.27中__stack_chk_fail的GOT偏移与2.31不同爆破脚本必须匹配。7. 经验总结那些没人告诉你的Canary绕过潜规则最后分享几个在无数次调试中沉淀下来的“潜规则”。它们不写在任何文档里却是决定你能否在比赛最后半小时提交flag的关键。7.1 Canary的“人格分裂”不同编译器、不同架构下的行为差异GCC 5 默认启用-fstack-protector-strong保护所有含alloca或局部数组的函数而-fstack-protector只保护含gets等危险函数的函数。这意味着并非所有函数都有Canary。用objdump -d binary | grep gs:可快速扫描哪些函数插入了校验。Clang的Canary实现与GCC略有不同它可能使用fs:[0x30]Windows风格或在main函数中不插Canary因main无局部数组。务必用gdb确认。ARM64下Canary存储在tpidr_el0寄存器偏移处而非gs/fs。readelf -A binary可查看目标架构属性。7.2 时间就是生命如何在CTF限时内快速决策绕过路径面对一道新题我用3分钟决策树有无输出→ 有优先格式化字符串或puts泄漏无转向GOT劫持或爆破有无堆操作→ 有评估堆风水可行性无放弃堆思路RELRO级别→ PartialGOT劫持Full转向ROP或信号是否容器化→ 是检查/proc/self/maps可读性否忽略这套流程让我在mimeweb比赛中从看到题目到提交flag仅用11分钟。7.3 最后的保险当所有路都堵死时试试__libc_start_main的副作用__libc_start_main在程序启动时调用其参数包含main地址、__libc_csu_init地址等。若你能泄露它如通过printf泄漏__libc_start_main地址则__libc_start_main231处是main返回地址即Canary校验后的下一条指令从此地址向上偏移可找到__stack_chk_fail调用点更重要的是__libc_start_main地址与__stack_chk_fail地址在libc中偏移固定可直接计算这招救过我三次。记住libc内部函数地址偏移是公开的GitHub上搜libc-database即可查到所有版本偏移。我在实际使用中发现真正决定成败的往往不是多炫酷的ROP链而是对gs:[0x28]这个地址的敬畏——它不是魔法数字而是Linux TLS ABI的硬性规定。每一次成功的Canary绕过都是对底层机制的一次致敬。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →