☰
Canary 栈保护绕过全解析:泄露、爆破、改写与避开
2026/10/26 5:51:02 网站建设 项目流程

刚入行那会儿,第一次在比赛里遇到开了 Canary 的栈溢出题,我拿着老一套的 smash 打法怎么都打不进去,盯着 GDB 里那个以\x00结尾的 8 字节神秘数值看了半个钟头,才慢慢反应过来它叫什么、干什么用、为什么我每次都被它拦下来。Canary 的绕过方法,说到底就是围绕三个问题展开的:那个值你能不能拿到、你能不能让它保持不变、你能不能干脆不去碰它。这篇文章我想把这层东西彻底拆开讲一遍,从它藏在哪儿、编译器怎么生成、怎么判断目标到底开没开,到几条真上手能用的路径——泄露、爆破、改写和避开,每条都配上我自己写过的最小示例和踩过的坑。

内容主要面向几类人:正在打 CTF 的、做二进制审计的、写底层代码想理解编译器防护的,以及单纯好奇"这个检查点到底有多结实"的人。文中所有示例都是我自己写的演示程序,跑在本地实验环境里,不针对任何真实软件。

1. 先搞明白 Canary 防的是什么,才知道该往哪儿绕

1.1 一次经典栈溢出的完整链路

想绕过一个东西,得先知道它卡在哪一步。经典的栈溢出打法是这样的:程序里有个局部字符数组,比如char buf[64],代码用gets或read这类不做边界检查的函数往里塞数据。栈帧的布局从低地址往高地址大致是:局部数组、填充、保存的 rbp、返回地址、调用者的栈帧。你多塞的字节会顺着这个顺序一路往上盖,盖到返回地址那 8 个字节时,函数退出执行ret,CPU 就跳到被你改过的地址去了。这就是整个链条里最值钱的一步——控制流被劫持。

Canary 干的事情非常朴素:它在局部数组和保存的 rbp 之间,硬插了一个机器字长的值。函数一进来就把这个值写进去,函数返回前再读出来跟源头的值比一次,对不上就直接调用__stack_chk_fail把进程干掉。

我用一个最小的例子说明,代码大概长这样:

#include <stdio.h> #include <unistd.h> void vulnerable(void) { char buf[64]; read(0, buf, 256); /* 明显超长,故意的 */ write(1, buf, 64); } int main(void) { vulnerable(); return 0; }

编译时加不加-fstack-protector-strong,反汇编出来的函数序言完全不一样。加了保护之后,vulnerable开头会多出两条指令,大致是mov rax, qword ptr fs:[0x28]和mov qword ptr [rbp-0x8], rax,结尾则反过来比较一次。这两条指令就是这个机制的全部秘密所在,理解了它们,后面的所有绕过思路都是顺理成章的推演。

1.2 全局 guard 与帧绑定 cookie:两种截然不同的设计

这里有个特别容易混淆的点,很多人(包括早期的我)以为所有平台上的 Canary 都是同一套逻辑,其实差别很大,而差别直接决定了你的绕过手段能不能复用。

一类实现是全局共享的。函数序言从线程本地存储的固定偏移处读出一个值,存到当前栈帧;函数结束时再从同一个位置读一次做比较。这个值在整个进程生命周期内是固定的,多个线程各自有自己的副本,但同一个线程里所有函数的源头是同一个。这种设计下,你只要在任意一个函数里泄露一次,这个值就能在所有地方通用——一次泄露,全进程复用,性价比极高。

另一类实现是帧绑定的。栈上存放的不是原始值,而是原始值与当前栈帧地址(比如 rbp 或 rsp)异或之后的结果。函数返回时,它拿栈上这份拿去跟"源头异或当前帧地址"的结果比。这种设计的好处是:就算你把某一帧里的这个值泄露出来了,换到另一个栈帧去用也会立刻对不上,因为异或的基址变了。

这两者的区别在实际操作中非常关键。我在做审计的时候,第一件事就是确认目标属于哪一类——如果是从源码编译的,看编译器和选项基本能判断;如果是闭源二进制,就看比较指令的形式,是直接cmp还是先xor再cmp。

注意:判断错这一类,会导致你辛辛苦苦泄露出来的值在别处完全用不了,白费半天功夫。

1.3 三种经典形态:终止符型、随机型、随机异或型

除了上面说的"全局 vs 帧绑定",这个值本身的内容生成方式也有好几种,了解它们能帮你判断哪些泄露手段有效。

第一种是终止符型。生成的时候把最低字节强制置零,其余字节随机。为什么置零?因为大多数导致溢出的字符串函数(strcpy、strcat、gets)遇到\x00就停,你没法用它们完整地把这个值覆盖掉。这种做法成本最低,但防护也最弱——你只要能读到它,低字节基本可以确定是 0,等于免费送你一个字节。

第二种是纯随机型。整个字长全部来自高质量随机源,没有强制置零。这种就麻烦了,泄露时必须完整读到全部字节,一个都不能少。

第三种是随机异或型。随机值和栈帧地址做异或,前面已经说过。

还有一类更"温和"的变体,会在值里嵌入\n、\r甚至\xff,专门用来卡住基于行读取或者基于字符串截断的攻击。遇到这种,你在写 payload 的时候要格外小心,因为某些传输通道会把\n当成记录分隔符,你发过去的东西可能被拆成两条。

实际遇到的绝大多数情况,最低字节都是零,这个经验值在做爆破的时候能省你整整一轮(差 256 倍的差距)。

2. 动手前先确认:目标到底开没开、开的是哪一档

2.1 静态侧:从符号、节区和指令特征找线索

拿到一个二进制,别急着写 payload,先花两分钟做检查,能省掉后面大把的无效尝试。

最直接的办法是看它有没有引用那个失败处理函数。如果二进制里出现了对__stack_chk_fail的引用,那基本可以确定至少是部分函数开了保护。不过要注意,开了保护不等于所有函数都受保护,编译器的策略通常是按风险分级来的——有的档位只保护含数组的函数,有的只保护含字符数组的,有的则一律保护。所以看到这个符号只能说"有",不能说"全是"。

从反汇编层面看特征更准确。在函数序言里找fs:段的读取(32 位上是gs:段),典型长这样:

endbr64 push rbp mov rbp, rsp sub rsp, 0x50 mov rax, qword ptr fs:[0x28] mov qword ptr [rbp-0x8], rax

看到这两条,这个函数就是受保护的,而且保护位置就在rbp-0x8。反之,如果某个函数只有push rbp; mov rbp, rsp; sub rsp, ...没有这套读写,那它要么被编译器判定为低风险没开保护,要么整个编译单元就没开。这一点在实战里非常有用:同一个程序里往往是"一部分函数有、一部分没有",你完全可以从没保护的那个函数下手。

如果是动态链接的程序,还可以看导入表里有没有那个失败处理函数。某些壳或者加固方案会把它改成内部实现,那就得靠指令特征来判断了。

2.2 动态侧:一次可控崩溃能告诉你很多

静态判断有时候会漏,尤其是面对加过壳或者做过代码混淆的目标。这时候最省事的验证方式是让它崩一次,看崩溃时的输出。

构造一个明显超长的输入灌进去,如果进程输出类似*** stack smashing detected ***这样的信息然后自己退出,那就是这个检查点被触发了,保护确实生效。如果进程是收到了段错误信号直接挂掉,没有那段提示,那多半没有这层保护,或者你溢出的位置根本没碰到检查点。

还有一个更精细的验证方式:用调试器附着上去,在函数返回前下断点,看栈上那个位置的值与源头是否一致。我习惯在函数序言执行完之后读一次fs:[0x28]的值,记下来,然后在函数返回前再读一次栈上那个位置,两个一对比就一目了然。

这个方法还有个额外好处:能顺便告诉你这个值长什么样。是不是最低字节为零、有多少字节是有效的,一眼就看清楚了,为后面的路线选择提供依据。

2.3 三个高频误判

有几个坑我踩过不止一次,值得单独拎出来说。

第一个坑是"看到了保护就以为整条路断了"。实际上保护只在函数返回时生效,如果你能找到别的劫持点——比如同一个函数里存在通过函数指针调用的间接跳转、存在会提前longjmp的分支、或者根本不需要返回到调用者——那这个检查点就形同虚设。

第二个坑是把"没触发提示"当成"没有保护"。有些情况下进程确实有保护,但你溢出的长度不够,压根没盖到检查点,或者你盖到了但后续逻辑直接exit而没有走正常的返回路径。这两种情况都会让你误判。

第三个坑是忽略了编译选项的差异。同一份源码,-O0和-O2编译出来的栈帧布局可能差出几十个字节,受保护的函数也可能不同。我在本地复现的时候经常遇到"本地能打通、远端打不通"或者反过来的情况,最后发现就是优化级别不一致导致的偏移不同。

提示:本地复现时,务必确认编译选项、库版本、内核特性与目标尽可能一致。实在对齐不了,就靠调试器现场量偏移,别信纸面推算。

3. 泄露路线:把 Canary 读出来

3.1 格式化字符串是最省事的入口

如果程序里存在把用户输入直接当格式串用的地方,比如printf(buf)这种写法,那泄露就变得非常轻松。原理不用我多讲——格式串指令会按你指定的位置去栈上取参数,%p连着多写几个,就能把栈上从当前位置往后的一串值全部打印出来。

具体怎么定位那个值?我的做法是先灌一串AAAAAAAA.%p.%p.%p...,看输出里第几个位置出现了0x4141414141414141,那个位置就是你输入缓冲区的起始偏移。知道了这个偏移,往后数到 Canary 在栈帧里的相对位置,用%N$p精确取即可。

这里有个小技巧:因为最低字节通常是零,打印出来的值往往只有 6 到 7 个有效字节(32 位上是 3 字节)。所以你在还原完整值的时候,要手动把最低字节补零。我第一次做的时候没注意,直接把0x1a2b3c4d当成完整值,结果怎么都过不去,折腾了半天才发现少补了那个 0。

格式化串泄露还有个变体用法:如果输出通道支持二进制,你也可以用%s配合栈上已有的指针把整段内存读出来。但这种方式容易把进程读崩,因为遇到非法地址就挂了,得靠运气。

3.2 输出长度失控与越界读

格式化串不是唯一路径。很多程序自己就会把数据打回来,比如某些服务在收到请求后会回显一段内容。如果回显的长度超过了你实际写入的长度,那么超出的部分就是栈上残留的旧数据——里面很可能就有那个值。

这种场景特别常见于"读了固定长度、但按实际接收长度回写"的配合失误。我用过一个演示程序,它先read64 字节到缓冲区,然后write回 256 字节。正常请求看起来毫无异常,但只要你注意到回显比请求长了 192 字节,那多出来的 192 字节就是栈上的垃圾,而脏数据区里就藏着上一帧留下的敏感值。

越界读是另一类。数组下标不检查、或者循环边界写错,都能让你读到数组之外的内容。这类问题在字符串处理密集的代码里特别多,而且往往能连着读出一大串,不止这一个值。

还有一种更隐蔽的情况是未初始化的栈变量。函数里声明了一个局部数组但没清零,随后又把它输出出去,那么这111块内存里就带着调用者留下的痕迹。这类问题极其容易被忽略,因为代码看上去完全"正常"。

3.3 泄露之后怎么用:偏移、对齐与覆盖顺序

拿到值只是第一步,真正决定成败的是如何把它嵌进 payload。

覆盖的顺序是这样的:先用填充字节填满到缓冲区末尾,然后原样放上那个泄露出来的值,再往后是保存的 rbp(如果存在的话),最后才是你要写入的返回地址。这个顺序不能乱,因为栈是往高地址生长的,先写的内容位于低地址。

偏移量怎么确定?两种办法。一是靠调试器,在函数入口处把缓冲区起始地址和检查点地址都记下来,两者相减就是精确偏移。二是靠模式串,用一段有规律的可识别字符串灌进去,崩溃时看寄存器里是哪个片段,反推出偏移。我一般两台都用,先用模式串粗定位,再用调试器精修。

对齐问题在 32 位平台上要特别留意。因为字长变了,填充量、值的大小、地址的位数都不一样,同一个程序在 32 位和 64 位下打起来完全是两回事。我有一次拿 64 位调好的脚本去跑 32 位环境,结果偏移全错,浪费了一下午。

注意:局部变量在栈上的排列顺序不一定跟源码里声明的顺序一致,编译器会做对齐优化。别用眼睛看代码推算偏移,一定要用调试器量。

4. 爆破路线:一次一个字节地试

4.1 fork 为什么让爆破成立

爆破这条路的核心前提,是"每次尝试时那个值都不变"。如果目标是一般的一次性进程,你猜错了它就退出了,下次启动时值重新随机,那这条路根本走不通。

但网络服务程序的常见写法是在主进程里fork出一堆子进程来处理连接。这里有个关键细节:fork出来的子进程会完整复制父进程的地址空间,包括线程本地存储里的那份源头值。只要主进程不重启,所有子进程的源头值完全相同。

这就给了你一个天然的"预言机":每个连接对应一个新子进程,它的值跟上一个连接完全一样。你可以一个字节一个字节地试,猜对就往下推进,猜错就重连再来。理论上 8 字节需要 8 轮,每轮最多 256 次,总共 2048 次尝试——而因为第一字节通常已知为零,实际只需要 7 轮,约 1792 次。对一个本地网络来说,这个量级几分钟就跑完了。

判断"猜对"的信号是崩溃形式的不同:猜错的字节导致检查失败,进程会输出那个 smash 提示;猜对时检查通过,程序会继续走后面的逻辑,表现完全不同(比如返回了别的内容、或者进入了下一段流程)。这个差异非常明显,用脚本捕获输出就能自动判断。

4.2 逐字节爆破的核心脚本

我把自己常用的脚本骨架整理了一下,逻辑很简单,就是把猜测拼成一个"已确认前缀 + 当前猜测字节 + 填充"的 payload 发过去。

from pwn import * context.log_level = 'error' HOST, PORT = '127.0.0.1', 9999 CANARY_LEN = 8 OFFSET_TO_CANARY = 72 # 从缓冲区起点到检查点,调试器量出来的 def try_byte(prefix, guess): p = remote(HOST, PORT) payload = b'A' * OFFSET_TO_CANARY payload += prefix + bytes([guess]) p.send(payload) try: data = p.recv(timeout=1.0) except EOFError: data = b'' p.close() # 崩溃信息里不含 smash 提示,说明这一字节通过 return b'smashing' not in data canary = b'\x00' # 最低字节已知为零 for idx in range(1, CANARY_LEN): for guess in range(256): if try_byte(canary, guess): canary += bytes([guess]) print(f'[+] byte {idx} = {guess:#04x} canary = {canary.hex()}') break else: print('[-] 这一轮没猜出来,多半是判断信号写错了') break print('[+] canary =', canary.hex())

这段代码里最容易出错的地方是"判断信号"。我在每个目标上都会先手动发两次——一次明显错的、一次明显对的——把两种情况的输出都看一遍,再决定用文本匹配还是用连接行为(比如是否立刻断开、是否收到特定长度的响应)来判定。盲目套用别人的脚本,十有八九在信号判断上翻车。

还有一点,recv的超时值要调好。设太短,程序还没崩你就判定"通过"了,结果后面全错;设太长,跑一次要等半天。我的经验是先测一下正常请求的响应时间,取它的三五倍作为超时。

4.3 爆破的成本估算与噪声处理

真正跑之前,先算一下时间成本。假设一次尝试 50 毫秒(建立连接 + 发送 + 接收),1792 次就是 90 秒左右;如果每次 200 毫秒,那就是 6 分钟。这个量级通常是可以接受的,但如果目标是远程服务、网络延迟又高,可能就要想办法并发。

并发这么做:每轮内部,多个猜测值是互相独立的,完全可以并行发。但要注意服务端的并发限制,打太猛会被限流甚至封掉。我的做法是用一个小线程池,控制在 8 到 16 个并发,既快又不容易触发防护。

噪声处理是另一个大头。有些服务在异常输入时会返回各种奇怪的状态码,导致你的判断信号失效。这时候可以引入"重复确认"机制:某个字节猜"通过"之后,用同样的前缀再试一次,两次都通过才认定。这会增加一点开销,但能大幅降低整体重来的概率——毕竟已经猜出来的前缀一旦有错,后面全都白干。

还有个小细节值得留意:某些服务在处理连接时有超时重连逻辑,你的 socket 可能被静默关闭,导致读到空数据被误判为"通过"。所以判断要基于"收到了什么",而不是"没收到什么"。

5. 改写路线:不猜也不读,直接动它本身

5.1 线程本地存储里的主副本才是真正的源头

前面说过,函数入口读、函数返回比,这两个动作读的是同一个位置。也就是说,如果你能对这个位置做一次写操作,那么栈上那份旧值压根就不重要了——你只要在函数返回前把源头改掉,栈上那份自然也跟着"失效",比较必然不过。等等,这里得说清楚方向:栈上那份是函数入口时写进去的,你改源头的话,返回时比较就会失败。所以正确的做法反过来——把那 8 个字节写进栈上的位置,不行,因为源头不匹配。

真正可行的思路是:主动把栈上那份改掉,同时保证源头是同样的值。而源头你控制不了,除非你能对它做写操作。

所以准确地说,这条路成立的前提是你拥有一个"任意写"能力。有了任意写,你可以直接把源头改成一个已知值,然后 payload 里就填那个值。整个过程不需要泄露、不需要爆破,一步到位。

这个位置在 64 位环境下是fs:0x28,但你要注意,直接往那个地址写是不行的,因为它是相对段寄存器计算的。实战中通常是先想办法拿到线程控制块的实际地址(很多程序会把它挂在某个全局指针上,或者你可以通过pthread_self之类的调用泄漏出来),再算出偏移。有些情况下,程序自己就有个全局结构体指针指过去,那就更方便了。

注意:直接改源头会影响整个线程里所有未返回的函数。写的时候要小心别把正在执行的调用链搞乱,最好是选一个"马上就返回"的时机下手。

5.2 把失败处理函数变成你的跳板

就算你没本事改源头,还有一个非常有价值的攻击面:那个失败处理函数本身。

原理很直观——函数在比较失败后会调用它。这个调用在很多编译配置下是通过过程链接表完成的,也就是说,目标地址存在一个可写的内存槽里。如果你有办法写那个槽,那么当检查失败时,CPU 不会真的去执行失败处理逻辑,而是跳到你指定的地方去。

这招的美妙之处在于:你根本不需要知道那个值是什么。你可以随便填一段垃圾去触发失败,失败的那一刻就是控制流被劫持的那一刻。这相当于把"防护"本身变成了"入口"。

实操上有两点要注意。第一,这个槽位在完全静态链接或者用了某些加固方案时可能不可写,得先确认它的内存属性。第二,劫持之后你只有一次机会,因为进程此时已经处于异常路径,任何进一步的错误都会直接终止。所以你要跳的目标必须是"一步到位"的,比如一个能给你返回 shell 的函数或者一段布置好的指令序列。

我在做这类分析时,习惯先用调试器在这个函数上打个断点,确认它确实被调用、调用时的寄存器状态是什么样,然后再决定跳到哪儿。寄存器状态很关键,因为很多可用目标需要特定的参数。

5.3 抢在检查之前把控制流带走

还有一种完全不同的思路:既然检查只在函数末尾发生,那我何必等到末尾?

函数体内部可能有很多可以劫持的点——虚函数调用、函数指针回调、结构体里的跳转表、longjmp的恢复点、异常处理链。只要你能在函数返回之前把控制流带走,那个检查点就永远不会被执行到。

这条路不需要你理解那个值,也不需要任何泄露或爆破,纯靠对程序逻辑的分析。它的门槛在于:你得找到一个"在栈溢出发生之后、函数返回之前"能够被触发的劫持点。这通常意味着你得把溢出写得足够"温柔",只覆盖到某个局部变量而不碰检查点,然后靠那个被污染的局部变量去影响后续的控制流。

举个例子,如果函数里有个局部函数指针,而且它在数组之后声明,那么你用较短的溢出就能只改到这个指针而不碰检查点。等函数调用它的时候,控制流就走了。这类机会在面向对象的代码里特别多,因为虚表指针往往也放在栈上。

这条路我个人最喜欢,因为它不需要跟那个值正面较劲,完全是"绕过去"的思路。缺点是需要对目标代码非常熟悉,逆向工作量比较大。

6. 避开路线:完全不去碰那 8 个字节

6.1 栈迁移:把栈搬到你能完全掌控的地方

栈迁移的核心思想是:不跟当前栈帧较劲,直接换一个栈。

做法是利用函数尾声的leave; ret组合。leave等价于mov rsp, rbp; pop rbp,也就是说它把栈顶直接设成了 rbp 指向的位置。如果你能控制 rbp 的值,那你就能让rsp指向任意位置,ret就会从那里取地址。这就是所谓的"栈迁移"或"栈劫持"。

关键在于,你只需要覆盖保存的 rbp 和一个返回地址——不需要碰那个值。因为检查是在函数返回前做的,而如果你用leave; ret把栈整个搬走了,后续执行就不再依赖原来的栈帧了……但这里得澄清一个常见的误解:检查依然会发生,因为它在ret之前。

所以严格来说,纯粹的栈迁移并不能绕过检查。真正可行的是"迁移 + 提前劫持"的组合:先用一个不影响检查点的写操作把目标区域布置好,再靠一次能绕过检查的跳转过去。或者更常见的用法是配合泄露——先泄露值,再用迁移把后续的 ROP 链放到一个更大的、不受栈大小限制的区域里。

我在实际使用中,栈迁移更多是用来解决"栈空间不够放下整条链"的问题,而不是用来绕检查。这两件事经常被混为一谈,写到这里特意澄清一下。

6.2 换一个写目标:不去覆盖返回地址

既然覆盖返回地址会触发检查,那就不覆盖它。程序里可写的、能影响执行流的东西太多了:

一是函数指针表。很多程序用结构体数组来组织回调,这些数组往往在可写段里。只要能改其中一项,调用时就跳过去了。

二是全局变量里的标志位。某些程序会根据一个全局标志决定走哪条分支,把这个标志改掉,就能进入一条本来不该进入的路径,而这条路径里可能藏着更大的问题。

三是堆上的元数据。如果溢出发生在堆上而不是栈上,那压根就碰不到栈检查点,思路完全换一套。

四是文件描述符和配置。某些服务会从一个配置结构里读取要执行的路径或者要加载的模块名,改掉它就能让程序自己去加载你指定的东西。

这些路径的共同点是:它们都发生在函数返回之前,或者根本不涉及栈帧销毁,所以那个检查点始终不会被触发。代价是你需要对程序的数据结构有相当深入的理解。

6.3 部分覆盖:只改低字节的巧劲

还有一个很实用的技巧是部分覆盖。地址空间布局随机化让完整地址不可预测,但页内偏移是不变的。所以很多时候你只需要覆盖返回地址的低 1 到 2 个字节,就能把它导向同一个页内的另一个位置。

这个技巧和 Canary 有什么关系?关系在于:如果溢出的长度刚好只够到返回地址的低字节,那你必然已经完整覆盖了检查点。所以它本身不能绕过检查,但它是"泄露之后精确利用"的重要手段——当你已经拿到那个值,接下来的问题就变成"怎么用最少的字节完成劫持",部分覆盖能大幅提高成功率,因为不需要猜完整地址。

我在打远程目标时经常用这招:泄露值 + 部分覆盖返回地址低两字节,指向同一个函数里的另一条分支。这样既不需要泄露代码基址,也不需要构造完整链,payload 短、成功率高、还省事。

7. 踩坑实录与速查

7.1 高频问题排查表

把常见现象和对应原因整理成一张表,出问题的时候对着查,比盲目改脚本快得多。

现象最可能的原因排查动作
每次都崩,从没成功过偏移量算错用调试器在函数入口和返回前各读一次,确认检查点相对缓冲区的距离
泄露出来的值只有 6 字节有效最低字节被强制置零手动补\x00,别直接当完整值用
爆破跑到一半全部失败服务端重启过,源头值变了检查服务是否有自动重启或超时回收机制
payload 发出去被截断传输通道把某些字节当分隔符改用二进制安全的方式发送,避免经过会做转义的中间层
检查通过但后续崩溃覆盖返回地址时踩到了别的数据缩短溢出长度,或者检查保存的 rbp 是否被误伤
本地能打、远端不行优化级别或库版本不一致对齐编译选项,用调试器现场重新量偏移
泄露脚本输出乱码值里含不可打印字节用十六进制输出,别用字符串形式打印

这张表里我踩得最多的是第一条和第六条。偏移量这个东西,看着简单,但因为栈对齐、编译器优化、调用约定差异,实际值经常跟直觉差几个字节。我现在养成的习惯是:不管多小的程序,都先在调试器里量一遍,量完再写脚本。

7.2 一些文档里不写的经验

最后分享几个从实际操作里抠出来的细节,这些东西在教科书里基本看不到。

第一,判断信号一定要手动验证两遍。我在每个新目标上都会先手动发一个明显错误的 payload,观察输出;再发一个明显正确的,观察输出。两次的结果差异才是可靠的判断依据。直接套用别人脚本里的字符串匹配,失败率高得吓人。

第二,注意进程的生命周期。有些服务是"一次连接一个进程",有些是"一个进程处理多个连接",还有的是"预派生一批进程轮流用"。这三种模式下,源头值的稳定性完全不同。第二种模式下值会变,爆破就不成立了;第三种模式下,你必须确保整个爆破过程落在同一个进程上。

第三,泄露之后立刻验证。拿到值之后,别急着往下写利用链,先构造一个"填对值 + 覆盖返回地址为一个已知崩溃地址"的 payload 发一次,确认程序是跳到了那个地址而不是报 smash。这一步花不了半分钟,但能帮你排除掉后面所有的"是不是值不对"的怀疑。

第四,本地复现时把 ASLR 关掉。倒不是因为它影响这个值,而是因为关掉之后崩溃地址可预测,调试起来轻松很多。等逻辑跑通了再开回来验证。

第五,写脚本时把日志打足。爆破过程中每一轮的结果、每一次尝试的耗时、失败时的原始输出,全都记下来。出了问题回头看日志,比重新跑一遍快得多。

第六,别忽视 32 位和 64 位的差异。字长、寄存器、调用约定、参数传递方式全都不一样,同一份源码编出来的两个版本,利用脚本基本不能复用。我现在的习惯是先把两边的偏移都量出来记在笔记里,省得每次重新做。

我个人在实际操作中的体会是,这个检查点本身并不复杂,复杂的是它周围那一圈东西——编译选项、栈布局、进程模型、传输通道,每一个变量都可能让你的判断失效。真正花时间的从来不是"想出一个绕过方法",而是"确认当前这个场景下,哪种方法成立"。把静态检查、动态验证、偏移测量这三件事做扎实,剩下的就是套用现成的思路,一步步推进。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询