ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FORTIFY_SOURCE防护原理与CTF Pwn题利用实战

FORTIFY_SOURCE防护原理与CTF Pwn题利用实战 CTFshow 的 pwn 系列做到 033 的 FORTIFY_SOURCE其实已经是不少初学者开始觉得怎么突然硬起来了的分水岭。前面的 ret2text、ret2syscall、栈溢出基础都还好说最多就是对着 IDA 找关键函数、算偏移、拼 ROP chain但 FORTIFY_SOURCE 这个开关一打开你会发现同样一段代码编译出来之后的机器码跟之前完全不一样硬编码的 gets 变成 __gets_chk、strcpy 变成 __strcpy_chk你原来算好的偏移可能直接废掉。我这边借着 CTFshow pwn 033 这题把 FORTIFY_SOURCE Level 1 从头到尾拆一遍——它到底编译期做了什么、运行期靠什么拦你、在 pwn 题里面对我们解题的影响有多大、以及最关键的如果题目开了 FORTIFY你的利用思路要往哪个方向调整。这篇不只是复现 writeup更多是让你搞清楚背后的机制以后换一道题也能自己判断怎么打。1. FORTIFY_SOURCE 到底是什么一个被很多人误解的编译选项先说人话版本FORTIFY_SOURCE 是 glibc 和 GCC 配合提供的一套运行时缓冲区溢出检测机制Level 1 是它的最低档防护。它的套路不是禁止你溢出而是在你确实想溢出的时候尽可能让你撞到检查然后崩掉——对攻击者来说这玩意儿比单纯禁用一个危险函数更烦因为表面上啥都没变代码还是能跑但你踩线的那一下它直接终止进程。1.1 编译器做的手脚函数替换的三种情况要理解 FORTIFY_SOURCE先要看你的代码是怎么被编译的。假设你在源码里写了char buf[8]; strcpy(buf, input);没有 FORTIFY 的时候编译器直接把这个 strcpy 调用翻译为对 libc 里strcpy的调用符号就是strcpy。开了-D_FORTIFY_SOURCE1之后只要 GCC 能在编译期确定目标缓冲区的大小比如局部数组、全局数组这种编译期就知道长度的对象它就会把调用替换为带_chk后缀的版本strcpy(buf, input); // 编译后变成 __strcpy_chk(buf, input, 8);注意这个8它是在编译期算出来的目标缓冲区大小。如果 GCC 在编译期无法确定缓冲区大小比如指针传进来的、malloc 出来的堆内存那就分两种情况情况GCC 行为结果编译期能确定目标缓冲区大小替换为__xxx_chk变体运行时由_chk函数检查边界编译期确定不了但知道这块内存来自堆或更大的对象保守处理可能不做检查也可能用保守下限完全不知道用普通版本没有检查存在可利用点这就是为什么很多 CTF 题目开了 FORTIFY但照样有漏洞——因为漏洞往往发生在编译器看不到大小的地方比如经典的read(0, heap_ptr, n)这种heap_ptr是堆指针编译器不知道它指向的对象大小FORTIFY 对这类调用基本无能为力。1.2 FORTIFY Level 1 只做边界检查不重排结构体这里必须先纠正一个常见的说法。很多教程说FORTIFY_SOURCE 会把结构体重排、把函数指针移到只读区这其实是Level 2才引入的一部分行为而且不是全部。Level 1 的职责非常聚焦对显式传入目标缓冲区大小的函数strcpy、memcpy、sprintf、gets、read 等做_chk替换运行时检查目标长度是否超过编译期记录的大小。对于 printf 系列__printf_chk会检查格式串中%n的使用Level 1 是禁止%n写入不可写段Level 2 才整体禁止%n。不改变任何变量布局不移动结构体字段不把指针挪到只读区。所以 Level 1 下你之前在普通编译题里练的偏移计算、ROP 构造思路大体还是能用只是多了几道运行时检查得想办法绕过去或者触发不了检查。2. 从 CTFshow pwn 033 看题一道典型的 FORTIFY 入门关这题我没有把完整的源码和 IDA 反编译贴出来避免直接搬运 writeup 造成的刷屏感但题目考察的核心模式非常典型我把它还原成下面这个等价场景你对照自己的题目就能对上号。2.1 题目场景还原编译期可读的栈缓冲区假设题目给了这样的关键代码int __cdecl main(int argc, const char **argv) { char buf[64]; setbuf(stdout, NULL); puts(Welcome to FORTIFY challenge); printf(Input: ); read(0, buf, 0x100u); return 0; }然后编译命令大概是gcc -o pwn033 pwn033.c -fstack-protector-all -D_FORTIFY_SOURCE1这道题实际编译时还开了栈保护canary这个我们后面会专门说。单看 FORTIFY 的部分核心就是read(0, buf, 0x100u)——注意read确实在 FORTIFY 的监控范围但它的_chk替换有个前提编译器必须知道这次 read 的目标是哪个对象、对象多大。buf是栈上的 64 字节数组编译期可知大小所以整行代码会被替换成__read_chk(0, buf, 4096, 64);__read_chk的签名是这样的ssize_t __read_chk(int fd, void *buf, size_t nbytes, size_t buflen);第四个参数buflen就是编译器记录的目标缓冲区大小64第三个参数nbytes是你传入的期望读取长度0x100。然后__read_chk内部会做这样一个判断if (nbytes buflen) { __chk_fail(); // 直接 abort }回到题目nbytes 0x100 256buflen 64256 64所以只要一调用这个 read程序就立刻触发 __chk_fail 崩掉。这就产生了一个看似矛盾的局面你连输入都还没真正发出去程序就退了。这正是 FORTIFY Level 1 最典型的编译期可检测漏洞特征——只要目标缓冲区大小在编译期是确定的并且你请求的长度超过它运行时就一定触发检查。2.2 但这里有个关键歧义read 真的被替换了吗上面这段是如果编译器确实替换成 __read_chk的推论。实际 CTFshow pwn 033 里你拿到二进制后用 IDA 看往往是看不到 __read_chk 的看到的还是 write 进去的 read 的 plt、libc/lib/x86_64-linux-gnu/libc.so.6里的普通read。这就牵出 FORTIFY 的一个非常容易搞混的技术细节read和write这类系统调用封装在很多编译配置下并不会被替换成_chk版本。原因在于它们本质上是对系统调用的薄封装glibc 的实现里并没有在read符号上做_chk的强绑定或者说 GCC 对read的_chk替换依赖的是头文件里是否声明了对应的__read_chk内建函数。实际上很多 CTF 题目的 FORTIFY 环境里read确实还是普通 read但strcpy、sprintf、gets这类 C 库字符串函数会被替换。所以你不能凭开了 FORTIFY 就假设 read 也被检查。正确做法是拿到题目后先 IDA 看外部函数表确认哪些函数变成了_chk后缀。如果外部函数表里只有__printf_chk、__memcpy_chk那说明编译期真正被 FORTIFY 约束的只有那少数几个函数其他没带_chk的仍然随意利用。CTFshow pwn 033 这道题我见过的版本里由于它的溢出点如果走 read那 read 并没有被替换成__read_chk所以接下来我们讨论的才是真正的解题点漏洞点不在 read 那次输入而在更后面的某个基于栈的字符串函数拷贝。为了把机制讲透我下面用strcpy和sprintf两条典型 FORTIFY 利用路线来分析这也是 pwn 题里最常遇到的两种。3. 两条典型的 FORTIFY 利用路线栈上拷贝与格式化串3.1 路线 Astrcpy/memcpy 的编译期大小替换常见题目模式是char dest[64]; char src[256]; // ... src 可以被输入填满 ... strcpy(dest, src);编译器看到dest是局部数组 64 字节于是替换为__strcpy_chk(dest, src, 64);__strcpy_chk 内部会逐字节拷贝 src 到 dest一旦发现拷贝长度超过 64包括末尾的\0就调用__chk_fail。等于说你不能通过这个 strcpy 实现溢出它会精确卡在你写满 64 字节后、准备写第 65 字节之前。这种情况应该怎么办三个方向放弃这条拷贝路径找别的溢出点。比如源码里如果还有第二个未受保护的read或getsgets 一般也被替换成__gets_chk但某些情况下编译器确定不了时就不替换优先用那里。寻找可溢出的间接写入。例如 strcpy 之后如果还有printf(%s, dest)而 dest 已经被填满但没有\0终止实际上_chk会补\0这条路不好走。返回地址之前还有足够空间就做 ROP但前提是 strcpy 拷贝时没触发检查。比如 dest 是 64你 src 也确实短于 64然后靠其他方式把多余数据填充到返回地址区域——但这就得找别的写手段了。所以面对_chk函数核心思路是不要把宝押在被检查的那次拷贝上溢出而是把它当成一次正常写入想办法利用程序后面读出的数据、或者另一个未检查的调用点来构造覆盖。3.2 路线 Bprintf 的 __printf_chk 与 %n 限制FORTIFY 下的printf会变成__printf_chk(1, format, ...)。第一个多出来的参数1表示这是 stdout。对于 Level 1__printf_chk的限制只有一个比较关键当格式串里出现%n时目标地址不能指向只读段。实际上 glibc 的__printf_chk实现中如果检测到%n配合的指针指向了只读段比如指向某个字符串常量、或者 GOT 表中的地址就直接报错。这条限制对格式化字符串攻击的影响很大。之前你如果是用%n改 GOT、改返回地址在 FORTIFY Level 1 下要小心GOT 表所在的内存页是可写的所以%n改 GOT 一般不会被这个检查拦住但如果目标地址落在只读段比如你想用%n改某个字符串字面常量所在地址就会触发检查。不过更常见的影响是__printf_chk对格式串本身的解析没有本质变化%x、%p、%s泄露栈内容依然正常工作。所以格式化字符串泄露在 FORTIFY Level 1 下几乎不受影响。这个结论对 pwn 033 这类题目意义很大如果题目里存在格式化字符串漏洞你可以照常泄露栈上数据、泄露 canary、泄露 libc 地址唯一要规避的就是用 %n 写只读段这种操作。3.3 一个实操判断技巧如何快速知道题目卡点在哪拿到一个开了 FORTIFY 的 pwn 题我建议按下面流程扫一遍checksec看编译保护开关特别确认 FORTIFY 级别通常是 Fortified 或者 Fortified 未显示具体级别的情况。IDA 打开看 External functions 列表里有没有__xxx_chk。有哪几个就说明哪几个被替换了。对每个_chk函数关注它的第四个参数对于 read/recv 是 buflen或第三个参数对于 strcpy/strncpy 是 dest 大小。找出用例中没有被替换的输入/拷贝路径那才是可利用的溢出点。如果所有输入点都被替换了那就得思考溢出是否真的必要——比如是不是可以借助栈上已有的数据布局来劫持控制流。4. FORTIFY 叠加 Canarypwn 033 里最常见的组合拳CTFshow pwn 033 之所以是前置基础的倒数第二类题是因为它通常会同时开栈保护和 FORTIFY。两个保护叠加很多初学者就彻底懵了。这一节我们把两层保护分开看再合起来分析入口。4.1 Canary 拦的是溢出后改返回地址这个动作栈保护Stack Canary对应编译选项-fstack-protector-all的原理是函数入口处从 FS 段TLS取一个随机值压入栈放在返回地址之前函数退出时会检查这个值有没有被改变。如果你溢出覆盖了返回地址几乎必然要先覆盖这个 canary一覆盖检查就失败程序 abort。它的针对性非常明确只防连续覆盖到返回地址的线性溢出。如果你通过任意地址写、或者只改返回地址中间几个字节且不碰 canary它就不起作用。4.2 FORTIFY 拦的是一次读/写超过目标大小这个动作FORTIFY 则不同它在每次调用_chk函数时用编译期记录的大小做判断——不管你之后要覆盖什么只要这次调用请求的字节数超过了编译期大小就当场失败。所以在开了 Fortify 的题里我们常看到这种双保险设计FORTIFY 负责把显式的read/strcpy/memcpy请求卡死让你没法一次写入超长数据。Canary 负责在你通过看起来合法长度、但实际利用多次写入/逻辑漏洞构造的溢出到达返回地址前拦一道。两道闸门配合下来单纯靠read 输入 0x100 到 64 字节缓冲区这种经典模型就直接死了。4.3 但这两道闸门都有缝隙先说 FORTIFY 的缝隙编译期只知道大小的地方才检查不知道大小的调用不检查。最常见的就是堆上分配的指针char *p malloc(64); read(0, p, 0x100); // p 是 malloc 返回值编译器不知道指向的对象大小这种情况下GCC 对 malloc 返回值的处理有很多历史版本差异。老版本 GCC 在 FORTIFY 下可能不替换 read新版本有时会因为malloc的属性知道了大小而替换成__read_chk(0, p, 0x100, 64)。CTF 题目编译环境多样你得看实际二进制。另一个缝隙循环内的小步写入。比如char dst[64]; int i 0; while (input[i]) { dst[i] input[i]; i; }这种逐字节拷贝不是strcpy不在 FORTIFY 的静态替换范围内所以照样能溢出。再说 Canary 的缝隙如果你先通过格式化字符串漏洞把 canary 泄露出来然后在后续溢出时把 canary 原样写回栈保护就形同虚设。泄露 canary 用的是%p或者%s这在 FORTIFY Level 1 下不受影响所以这条结合路线是可行的。pwn 033 这道题的常见解法里很多时候就是先找一个格式化字符串或者可控制输出内容的点来泄露 canary/libc然后再利用那个未受保护的写入点去覆盖返回地址。如果你发现题目里所有拷贝都被_chk拦死了那就需要找表面上不涉及超长拷贝、但实际造成写入的逻辑漏洞。5. 完整解题步骤如何在实战中确认 FORTIFY 状态并展开利用这一节我直接给出一套可复用的流程以 CTFshow pwn 033 这类前置基础题为场景。你手头如果有题目二进制跟着做一遍就会很清楚。5.1 第一步checksec 和 IDA 交叉确认checksec ./pwn033输出里关注这几个字段保护项常见值对攻击影响Canary foundYes 或 No有则需泄露/绕过FORTIFYYes / 无显示确认是否启用以及 levelNX enabledYes栈不可执行需 ROPPIE enabledYes / No有则需泄露程序基址然后 IDA 打开在 Imports 窗口搜chk。你可能会看到__printf_chk__memcpy_chk__strcpy_chk__read_chk注意只要看到任何一个_chk函数说明 FORTIFY 已生效但只有被调用的那几个是生效点。5.2 第二步定位未被 FORTIFY 覆盖的输入/输出点对每个输入函数做三个判断它在源码层面的目标对象是不是编译期可知大小的对象如果是它的实际调用是否带上了_chk后缀如果不是那它就是你的主要攻击面。常见漏网点scanf系列__isoc99_scanf不受 FORTIFY 影响。fgets有更严格的检查但它本身就是受限读取。gets通常会被替换成__gets_chk但在 FORTIFY 实现里gets其实是从 glibc 2.16 开始被移除的直接用gets编译可能直接报错题目一般不这么出。自己实现的my_read、input函数内部是read且编译器确定不了大小的话就不会被检查。找到攻击面之后传统的栈溢出模型才成立。5.3 第三步构造利用链的常见次序我这里给一个保守但稳定的利用次序泄露 canary利用任意格式化字符串或越界输出点读取保存 canary 的栈地址。canary 在 x64 下通常是栈上rbp-0x8的位置低 1 字节为\x00。泄露 libc 地址借助 GOT 表里某个已解析函数地址或者格式化字符串直接泄露__libc_start_main的返回地址。计算 one_gadget 或 system 地址。触发溢出把整个 payload 拼成padding canary padding retaddr其中 retaddr 指向利用链目标。如果题目里 FORTIFY 拦截了某次输入则寻找另一个写点完成最后一步覆盖。比如你有两个输入点第一个被_chk拦截第二个是未保护的read那就用第二个写点来打。5.4 第四步绕不过去的典型情况与对策如果所有输入点都被 FORTIFY 覆盖了那基本可以确定题目考察的不是传统溢出而是下面几个方向之一逻辑漏洞比如整数溢出导致的长度混淆、类型混淆最终让某个函数复制长度判断失效。堆利用FORTIFY 对堆溢出几乎不设防堆对象的大小不是编译期常量所以 UAF、double free、fastbin dup 等堆利用手段基本不受影响。劫持_chk_fail有一种思路是覆盖 GOT 表中的__chk_fail函数指针把程序原本的检测失败跳转劫持到我们想要的函数比如 system(/bin/sh)。但这个思路在 PIEFull RELRO 下不可行因为 GOT 只读。前置基础题通常不开 Full RELRO所以存在机会。__chk_fail劫持这个思路我单独强调一下——它几乎是 FORTIFY 题目专属的利用方式。原理是当__xxx_chk检测到溢出时它不直接 abort而是调用__chk_failplt。如果你能通过一个未受保护的溢出点把 GOT 表里__chk_fail的地址改成system的地址那么当某个_chk函数触发检查时程序就会跳进system执行。当然前提是你能传参也就是还要构造好调用参数。这种情况很少出现在纯 pwn 前置基础题但作为思路了解很有价值。6. 实测常见的编译差异为什么同样代码在不同环境表现不一样很多读者在复现 CTFshow pwn 033 时会遇到我本机编译的二进制和题目都不一样的情况。这是必然的因为 FORTIFY 的替换行为高度依赖 GCC 版本、glibc 版本、头文件声明和编译参数。我把几个关键差异列出来差异点表现原因G GCC 8 vs GCC 12read是否被替换新版本对read/recv的 internal 大小追踪能力更强-O0vs-O2FORTIFY 是否真正生效FORTIFY 往往依赖优化才能传递大小信息-O0下很多函数不替换32 位 vs 64 位__printf_chk的参数布局32 位下多参通过栈传64 位下通过寄存器传影响格式化字符串偏移计算堆对象 vs 栈对象malloc指针传给 read 是否被检查编译器对 malloc 返回值的对象大小推导在不同 glibc 头文件里有差异这就解释了为什么网上奇奇怪怪的 writeup 里有说这题 FORTIFY 没生效的、有说read 被 check 了的——他们大概率拿的不是同一套编译产物。实操建议看题时先看它给的 libc 版本和 pwn 环境。CTFshow 这类在线平台通常固定了编译环境以平台给出的二进制为准。在自己的 Ubuntu 上随便gcc编译复现很容易得出错误结论。另一个容易踩的坑是编译参数里的优化级别。gcc -D_FORTIFY_SOURCE1如果不开-OGCC 可能会警告warning: _FORTIFY_SOURCE requires compiling with optimization (-O)意思很直接FORTIFY 在没开优化时很可能部分失效。题目如果开了 FORTIFY几乎一定是带-O1或-O2编译的。你用-O0复现看到的反汇编会非常不一样。7. 以 pwn 033 为跳板FORTIFY 题目的一般化利用心法到了这一步你应该能明白 FORTIFY 并不是多高深的东西它的核心逻辑就一句话编译器在编译期记录对象大小运行时检查请求是否越界。而我们做 pwn 的思路本质上是寻找编译器不知道大小或者程序自己提供了一个绕过检查的通道。我总结了四个一般化原则适用于几乎所有开了 FORTIFY 的题目以_chk函数列表为准不要以源码直觉为准。同一个read在有的环境被检查有的没有IDA 里的函数名是唯一事实。FORTIFY 不能防多次小步写入和堆上的越界。所以堆题基本无视 FORTIFY栈题则需要找循环写入、数组索引错误等位置。能用格式化字符串泄露就不怕 FORTIFY。泄露类操作几乎不受影响唯一注意别用%n写只读区。如果题目同时开了 Canary FORTIFY优先考虑泄露而非爆破。爆破 canary 在 64 位下不现实配合格式化字符串泄露才高效。CTFshow 033 的最后一问我记得很多 writeup 里是利用了__stack_chk_fail和 FORTIFY 的组合跳转也有人是干脆绕开了 FORTIFY 找系统调用点。哪种都行核心是你得先清楚当前二进制到底哪次 IO 是自由的。7.1 一个小实验帮你加深理解如果你手边有 Linux 环境可以做个最小验证// test.c #include stdio.h #include string.h int main(int argc, char **argv) { char buf[16]; strcpy(buf, argv[1]); printf(%s\n, buf); return 0; }编译两个版本gcc -o test0 test.c gcc -o test1 test.c -O2 -D_FORTIFY_SOURCE1 objdump -d test1 | grep chk第二个版本里你大概率会看到__strcpy_chkplt的调用。再用 gdb 分别跑./test1 AAAA...超长参数观察普通版 segfault 而 fortified 版在__strcpy_chk里直接 abort 或调用__chk_fail。这个小的对比实验比背十遍理论都有用。7.2 关于 __chk_fail 的进阶利用提示__chk_fail在 libc 里的实际行为是调用__fortify_fail再调用abort。但在编译产物里它是以 plt 形式被引用的所以如果你能改 GOT修改__chk_failgot为systemplt或one_gadget。同时让某个_chk函数在被调用时rdi/rsi里恰好是/bin/sh的地址这种情况下参数可能是源缓冲区指针。这种技巧属于 FORTIFY 下比较偏门的借力打力在纯前置基础题里出现的概率不大但在更高级的 CTF 里偶尔能看到。读者当作扩展知识了解即可不必在 033 上死磕这条路。8. 从 CTF 到现实FORTIFY 在真实程序里的价值与局限写到最后我还是想聊一下这个保护机制在实际工程里的意义因为你理解它存在的理由之后做 pwn 题时会有更多预判。FORTIFY_SOURCE 是一个低成本、高收益的漏洞缓解措施。它不需要改动源码只要重新编译时加一个宏和一个优化级别就能拦下一大批经典且机械的缓冲区溢出。现实世界里的很多漏洞其实是开发者用strcpy、sprintf、gets留下的FORTIFY 能把这些直接拉闸。但它也只是一个缓解措施不是安全边界。它的局限非常明显一旦对象大小不是编译期常量堆、指针、复杂数据结构检查就退化。它不能防止逻辑漏洞、UAF、TOCTOU 等更高层问题。它依赖编译器的推导能力GCC 版本不同覆盖范围也不同。所以你在真实项目中看到某个程序开了 FORTIFY并不能说明它安全只能说它堵住了一类低水平错误。而做 CTF 的时候出题人开 FORTIFY 往往不是想让你无解而是想考察你能不能发现编译器的盲区在哪里。CTFshow pwn 033 这道题放在整个 FORTIFY 系列里属于 Level 1本质上就是让你先学会识别和确认这个机制。过了这关后面遇到 FORTIFY 更复杂的变体比如 FORTIFY 堆利用、FORTIFY 逻辑绕过你至少有一个正确的分析框架不会一上来就对着__strcpy_chk发呆。最后再分享一个实操习惯遇到未知二进制时我习惯先运行一遍加长的输入观察它是在输入函数返回后崩溃还是在输入函数内崩溃。在前者说明 FORTIFY 没拦住这次写入在后者很可能就是__chk_fail被触发了。这种黑盒观察配合 IDA 静态分析能让你在几分钟内判断出题目的核心保护是什么、该从哪里下手。希望这篇能帮你在 FORTIFY 这道坎上顺畅跨过去继续往下打也不慌了。
返回列表