ARTICLE DETAIL

资讯详情

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

从原理到实战:格式化字符串漏洞利用与GOT表覆写全解析

从原理到实战:格式化字符串漏洞利用与GOT表覆写全解析 1. 内容整体设计与思路拆解1.1 这10道题到底在练什么先给结论CTFshow的Pwn入门91到100是格式化字符串漏洞从“会看”到“会用”的跨越题组。前面几题考的是格式化字符串的泄露能力中间几题开始考写入能力最后几题基本上就是在考你能不能把格式化字符串当成一个“任意地址读 任意地址写”的组合工具来用。我在带新手打CTF的时候经常说一句话格式化字符串漏洞本身不难难的是你不知道它能在实战里干多少事。它不像栈溢出那样有个明确的“覆盖返回地址”的固定套路格式化字符串的利用方式很灵活——你可以用它泄露栈上的libc地址可以写GOT表可以改返回地址可以配合栈迁移打ROP链甚至在某些情况下能直接改掉某个关键变量让题目秒变签到题。这10道题的设计逻辑也很有意思它不是把一道难题拆成10道让你反复刷而是每一题都在往里面加一个新东西。我一开始以为就是简单的重复训练刷到后面才发现它其实是在用题目难度梯度逼着你把格式化字符串的几种利用姿势全部过一遍。1.2 适合谁来刷刷完能获得什么如果你是刚看完格式化字符串漏洞原理、但还没动手做过题的新手这个题组非常适合你。它不需要你懂太深的逆向功底也不需要你会用复杂的堆利用技巧它只需要你具备三样东西能看懂C代码、会用pwntools发数据、肯花时间调offset。刷完这10道题你至少能掌握以下几项能力熟练使用%p、%s、%n、%hn、%hhn等格式化字符知道它们各自在什么时候用、有什么副作用。能够快速确定格式化字符串的偏移这是所有利用的基础也是新手最头疼的问题。理解GOT表覆写的原理能在PIE开启和关闭两种情况下分别完成GOT表劫持。知道什么时候该用fmtstr_payload省事什么时候必须手动构造format string——后者才是区分入门和进阶的关键。说实话我在带人刷这个题组的时候发现大多数人卡住的原因不是不懂漏洞原理而是不知道怎么把原理转化成具体的payload构造。所以这篇博文我不会只讲“这题用fmtstr_payload就完了”我会把手动构造的过程也拆开讲包括怎么算偏移、怎么对齐、怎么处理截断问题。2. 格式化字符串漏洞原理回顾与门槛知识2.1 格式化字符串为什么会变成漏洞先说原理但尽量不啰嗦。printf这类函数的第一个参数是格式化字符串后面的参数是需要被格式化的变量。正常情况下格式化字符串里的%d、%s这些占位符应该和后面的参数一一对应。但如果程序员把用户输入直接当成格式化字符串传给了printf比如printf(buf);而不是printf(%s, buf);那问题就来了——当printf解析到格式化字符串里的%x、%p时它根本不知道后面没有对应的参数它会直接从栈上取数据来填充。这就是格式化字符串漏洞的根因参数不匹配导致的信息泄露和任意写。用人话说就是printf是个“按单子取货”的仓库管理员你塞给它的格式化字符串就是取货单。正常情况下一张单子对应几件货但如果单子上写了10个取货项但仓库那边只准备了几件货管理员还是会按照单子上的顺序一路取下去——哪怕那些位置上根本不是给你的货。2.2 为什么能读从栈上“越权取货”假设栈上的布局是这样的格式化字符串地址存放在某个位置它的后面是调用printf时压入的其他参数或局部变量。当我们使用%p时printf会依次读取栈上的数据并以指针格式打印出来。举个例子如果格式化字符串是%p.%p.%p.%p.%p.%p.%p.%p你就相当于让printf一口气把栈上的8个位置都“看”了一遍。这些位置里可能藏着栈地址、libc地址、canary甚至返回地址。这里有个关键点格式化字符串本身所在的位置通常也在栈上。这意味着我们可以通过精确控制offset让某个%p去读取格式化字符串本身的某个字节段。这就是后面所有利用手法的地基——既然能读到格式化字符串本身的内容那我们把任意地址写在格式化字符串里再用%s去读就实现了“任意地址读”。2.3 为什么能写%n的魔法%n的作用是把当前已经打印的字符数写入到一个指针指向的地址中。这个指针从哪里来还是从栈上取。所以我们只要把目标地址放在栈上并且想办法让它出现在正确的位置再配合%n或%hn、%hhn就能往任意地址写入一个可控的值。写过一次就知道了%n写入的是“已经输出的字符数量”这个特性让我们可以精确控制写入值——多打印几个填充字符写入值就变大。但在实战中直接打印几万个字符不太优雅所以我们常用%hn写入2字节和%hhn写入1字节来减少打印量通过分段写入实现任意值的覆盖。2.4 利用前的必备工具pwntools和checksec刷CTFshow的Pwn题最常用的工具就两个pwntools和checksec。前者负责构造payload和交互后者负责查看程序保护。在一个典型的题目环境中用checksec你会看到类似这样的信息$ checksec pwn91 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)看到这些字段心里就要自动生成一个判断RELRO是PartialGOT表可写可以考虑覆写GOT表。No PIE程序加载地址固定GOT地址和函数地址直接写死不用泄露。PIE开启就需要先泄露地址再进行利用。Stack: No canary found栈溢出相关题会更好打但格式化字符串题里canary意义不大因为我们不打栈溢出。我刷91-100的时候第一件事就是把这十道题的checksec结果全部跑一遍然后在笔记里记下每道题的保护情况。这是一个值得养成的习惯——不同的保护组合决定了完全不同的利用策略。3. 实操过程与核心环节实现3.1 每道题的checksec与初次观察记录我把自己刷这10道题时的checksec结果整理了一下大概分布是这样的具体的地址值会因为题目更新有变化但保护策略基本稳定题号保护情况备注91No PIE, Partial RELRO入门题泄露flag92No PIE, Partial RELRO考验基础泄露加简单写入93No PIE, Partial RELRO开始涉及GOT表94No PIE, Partial RELRO结合栈变量修改95PIE开启, Partial RELRO需要先泄露地址96PIE开启, Partial RELRO泄露写入综合97No PIE, Full RELRO不能改GOT换思路98No PIE, Partial RELRO手动构造复杂fmtstr99PIE开启, Partial RELRO需要精细布局100综合收官题综合前面所有技能当然这是我在自己刷题时记录的样本平台题目可能有更新大家以自己实际跑出来的结果为准。但一个共同的规律是这10道题都是64位程序所以格式化字符串的offset通常在6到10之间很少出现需要跑十几个偏移的情况。3.2 从零开始91题的完整利用流程91题是我最喜欢拿来教学的题目因为它足够简单但又完整地展示了格式化字符串利用的全流程。先看伪代码逻辑我根据自己的做题记录还原细节可能因题目版本稍有出入但核心逻辑一致#include stdio.h #include string.h int main() { char buf[0x100]; setbuf(stdout, NULL); puts(Hello CTFer!); puts(Please input your message:); read(0, buf, 0x100); printf(buf); // 漏洞点 puts(\nBye!); return 0; }能看到printf(buf)直接把用户输入当格式化字符串用了没有任何过滤。而且这个程序环境里通常已经cat /flag或者在内存里读文件我们的目标是泄露出flag字符串。第一步确定偏移。我们需要知道格式化字符串在栈上的第几个参数位置。提交下面这种payloadfrom pwn import * p process(./pwn91) p.recvuntil(binput your message:) payload bAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p p.sendline(payload) print(p.recvall().decode())运行后你会看到类似这样的输出AAAA.0x7fffffffe280.0x7f.....(nil).0x7f....0x41414141...如果某个输出是0x41414141那就说明格式化字符串的第6个参数位置正好对应我们的输入开头。这个例子中AAAA刚好在offset 6的位置——后面的0x41414141就是AAAA的十六进制表示。第二步泄露地址。既然知道了offset我们就可以把目标地址放到格式化字符串里然后用%s去读取。在64位程序里一个格式化参数占8字节所以如果我们把目标地址放在第6个参数的位置直接用%6$s就能以字符串形式读取该地址处的数据。第三步泄露flag。CTFshow的题多半会把flag读进内存或直接放在栈上所以先试试用一系列%p把栈上的数据全部dump出来payload b%p. * 30如果flag在栈上你会在输出的某一段看到它的ASCII码形式。如果不上栈就需要反编译看flag的读取逻辑了。我在刷题群里看到很多人卡在这一步问题往往出在payload长度上。如果一次性发太多%p某些版本的printf会因为输出缓冲过长导致数据不完整这时候recvuntil就等不到预期内容。我的习惯是一次先发15个%p确认输出正常后再逐步增加。3.3 手动构造还是用fmtstr_payload各自适合什么场景到了94、95题之后单纯的泄露不够了需要往内存里写东西。pwntools提供了一个非常方便的函数fmtstr_payload。它的用法非常简单from pwn import * payload fmtstr_payload(offset, {target_addr: target_value})这个函数会自动帮你构造格式化字符串把target_value写入到target_addr对应的地址。它内部通过%hhn分段写入避免了打印海量字符的问题。但是它有个很大的问题payload体积大且不可控。在某些限制输入长度或者有字符过滤的题目里fmtstr_payload生成的东西往往没法直接用。另外它默认生成的payload会很长如果你需要同时写入多个地址它就变得更加臃肿。所以我的建议是题目没限制输入长度、没过滤字符直接用fmtstr_payload省时间。题目限制长度、有过滤、或需要精确控制写入值手动构造。手动构造的通用公式如下写入0x1234到地址A、写入0x5678到地址B# 先安排好两个目标地址在栈上的位置 # 假设地址A在offset 6地址B在offset 8 # 目标值A 0x1234, B 0x5678 payload b%4660c%6$hn # 写入0x1234给A这里4660怎么来的0x1234的十进制是4660。%4660c会打印4660个字符这样当前打印字符数累计到4660然后%6$hn把它写入offset 6处的指针指向的地址。如果要连续写多个值就需要处理进位问题——%hn写入的是2字节后续值如果小于当前累计值需要加上0x10000再计算差值。这个过程第一次上手很容易绕晕我的建议是自己拿张纸把每个写入步骤的累计字符数算一遍再敲代码别一上来就抄网上的脚本。3.4 进阶GOT表覆写的完整实战记录到了94题左右题目开始要求修改GOT表来劫持程序流程。我以自己的刷题记录为例还原一个典型的GOT覆写场景。假设程序的伪代码如下int main() { char buf[0x100]; puts(Welcome to pwn94!); printf(The address of printf is: %p\n, printf); read(0, buf, 0x100); printf(buf); return 0; }程序很“贴心”地把printf的真实地址直接打印出来了这明显是让我们算libc基址然后改GOT表让后续某个函数调用变成system(/bin/sh)。利用思路是从输出拿到printf的实际地址。用LibcSearcher或本地libc库算出system和/bin/sh的偏移进而算出system的真实地址。找到printf在GOT表中的位置这个可以用objdump -R pwn94或者readelf -r pwn94查到。用格式化字符串把GOT表中printf条目改成system的地址。在No PIE Partial RELRO的情况下GOT地址是固定的比如0x601018。那我们的写入目标就是got_printf 0x601018 system_addr libc_base libc.symbols[system]然后payload fmtstr_payload(6, {got_printf: system_addr})发送之后程序原本下一次调用printf的地方实际执行的是system。如果运气好后续代码里正好有printf(buf)这类调用而我们的buf内容恰好是/bin/sh那就直接拿到shell了。这里有一个我在带新手时常说的坑改GOT表不是改了立刻生效而是等到该函数下一次被调用时才生效。所以你要想清楚劫持哪个函数、它的下一个调用点在哪、调用时参数是什么。如果瞎改一气很可能程序直接崩溃。3.5 PIE开启怎么办先泄露再写入到了95、96题PIE开启了GOT地址不再固定。这时候需要两步走第一步泄露程序基址。格式化字符串可以读取栈上保存的返回地址返回地址指向程序自身的代码段。拿到这个地址后减去对应的偏移就得到PIE基址。第二次交互时再用算出的基址去覆写GOT表。我刷到95题的时候曾经卡了很久因为我对PIE的理解停留在“地址是随机的”这个层面而忘了程序基址一旦确定内部所有地址的相对偏移是固定的。所以只要泄露一次就全部确定了。实操中可以发送类似这样的payloadpayload b%7$p # 假设offset 7位置是返回地址拿到返回地址后减去你从反编译里看到的对应偏移pie_base leaked_addr - 0x9a8 # 0x9a8是返回地址在二进制中的偏移有了基址之后所有GOT地址都可以算出来got_printf pie_base 0x3018 # 具体偏移用objdump查后面的步骤就和No PIE的情况一样了。3.6 不能改GOT怎么办Full RELRO的替代方案97题开始题目增加了Full RELRO保护。这意味着GOT表变成只读的你不能通过覆写GOT表来劫持流程。这时候就要换思路了。常见的替代方案有覆写返回地址让程序在函数返回时跳转到ROP链。覆写__free_hook或__malloc_hook如果程序有堆操作且libc版本较老。覆写栈上的函数指针。改写某个关键栈变量让程序的判断逻辑被绕过。我在做97题时实际用的是覆写返回地址的方式。思路是先用格式化字符串泄露canary和栈地址然后把栈上保存的返回地址改成one_gadget或ROP链的地址。这里的难点在于格式化字符串一次只能写在栈上已知地址的位置而返回地址在栈上的位置其实是相对固定的只是栈地址随机。所以要先用%p泄露栈地址再算返回地址在栈上的精确位置最后通过格式化字符串对这个地址进行写入。有些题目可能不允许这么复杂如果你发现程序里有全局变量控制着关键逻辑考虑直接改那个全局变量——我见过不少题目就是用这种方式降低难度的注意观察。4. 常见问题与排查技巧实录4.1 偏移算错了怎么快速定位这是刷题群里的日经问题。%p打出来一堆地址但不知道哪个对应自己的输入开头。我的排查方法是在payload开头放一个明显的标记比如AAAA或BBBBBBBB然后发送payload bAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p哪个位置的输出是0x41414141哪个就是输入开头的偏移。注意如果标记是AAAA在64位下读取会显示0x41414141如果是8个A就是0x4141414141414141更容易辨认。一个小细节有些程序会调用read而不是gets来读入输入这样字符串末尾可能没有\x00截断导致格式化字符串后面跟着垃圾数据。建议在payload末尾加\x00来截断避免影响输出结果。4.2 fmtstr_payload写入失败先检查这三件事如果fmtstr_payload发送后程序崩溃或者什么都没发生按顺序排查第一offset是否填对了fmtstr_payload的第一个参数是格式化字符串在参数列表中的位置不能凭感觉填。第二目标地址是否可写如果目标地址在只读段或者GOT被RELRO保护写入会触发段错误。第三写入的字节会不会影响程序的关键逻辑很多新手把某个函数地址覆盖成另一个函数地址后程序在下次调用这个函数时参数对不上直接崩溃。4.3printf输出太长导致交互超时当你用%c打一大段填充字符时输出可能有几万甚至几十万字节。如果直接用recvuntil(b$)这种等待方式可能会因为输出还在传输中而导致超时。解决办法是在发送payload后用sleep配合recv(timeout2)来分批接收或者用p.recvall()等着收完。还有一种做法是尽量用%hn/%hhn分段写入让单次输出控制在几千字节以内。4.4 手动构造时计算累计字符数老出错前面提到连续使用%hn时要考虑“已经打印的字符数”因为%n写入的是累计值。如果第二个要写的值比已打印的少就必须加上0x10000再算差值。我用一个小工具函数来辅助计算这在多字节写入时特别好用def fmt_count(current, target): # current为当前已打印字符数target为目标写入值 if target current: target 0x10000 return target - current然后用%diffc%idx$hn的格式去构造每一段。如果没有这个小函数我打98题时至少会算错三次。4.5 在本地打通了远程却打不通这是所有CTF玩家都会遇到的经典问题。原因通常有两个libc版本不同导致libc基址或函数偏移对不上。解决办法是提前拿到远程环境的libc或者用DynELF之类的动态泄露方式。交互时机不对比如远程有网络延迟你发数据发早了程序还没走到read就结束了。我自己的习惯是本地用process打通后先把exp脚本里的地址相关部分全部改成动态计算再上远程打。远程每打一次就调整一下时间和接收逻辑不要一上来就把recvuntil写死。4.6 常见问题速查表现象可能原因解决办法%p输出没有0x41414141offset没找对增加%p数量或在payload开头放8字节标记写入后程序崩溃目标地址不可写或写入值格式不对确认地址段权限检查RELRO确认value小于0x10000用%hn时程序二次调用函数时行为异常劫持的函数不对或参数不匹配反编译确认调用点参数选参数最合适的函数来劫持远程打不通本地可以libc版本不一致或交互时序问题获取远程libc或改为动态地址推算输出内容超长导致超时%c打印过多字符用recvall接收或改用%hn分段写入5. 一些值得养成的做题习惯和避坑心得5.1 先把程序跑一遍再去看writeup我在带新人的时候反复强调一件事拿到题先自己跑哪怕是随便输入一串%p看看程序反应再去看writeup。因为writeup通常直接告诉你正确答案而你自己跑一遍能看到程序的实际交互流程、输出的具体细节这些是writeup里不会写的东西。比如有些题会先在printf之前调用别的函数把某个地址的值改了导致你按writeup里的offset去打结果不对。这些坑写writeup的人根本不会写进去只有自己跑过才知道。5.2 所有地址都记下来养成随手标注的习惯我刷91-100时笔记本上密密麻麻写满了GOT地址、函数偏移、offset值。到后面的题要用前面的经验时直接翻笔记就行了。比每次重新逆一遍快太多。5.3 看汇编比看伪代码更可靠有些时候反编译器给出的伪代码会和实际汇编行为有细微差别比如它可能把两次内存访问合并成一次让你误以为某个值不需要泄露。遇到写入类题目我强烈建议你切到汇编视图亲眼确认GOT表项是在哪里被调用的参数是从哪里传进去的。这个习惯在100题这种综合题里能救命。5.4 fmtstr_payload不是万能的说实话我在带新人刷题时的标准流程是先用fmtstr_payload打通再尝试手动构造。这样既能快速拿到flag建立信心又能反过来理解工具背后的构造逻辑。而且在真实CTF比赛中很多时候工具生成的payload因为长度或过滤问题没法直接用手动构造就成了必选项。所以不要因为能用工具就跳过手动的学习。我在刷完91-100之后的最大感受是格式化字符串漏洞的利用本质上就两件事——“定位”和“写入”。定位是确定目标地址在栈上的哪个位置能被我们控制写入是用%n系列把要写的值精确送进去。所有题目各种花哨的玩法都逃不过这两步。接下来的进阶方向不管是堆题里配合格式化字符串泄露地址还是更复杂的ROP链构造核心思路都是一样的。最后分享一个实战小技巧如果你在某道题上卡了半小时以上不妨把程序用objdump -d把整个汇编导出来对着汇编把每一个调用printf的地方都标出来重点看它调用前后栈上发生了什么。这个方法看起来笨但在定位“下一次调用发生在哪、参数是什么”这类问题时特别有效我打100题时就是这么硬啃下来的。
返回列表