CTF Pwn实战:从题目分析到利用链构建的高效流程
2026/9/19 3:28:47 网站建设 项目流程

1. 收官篇该聊点什么

写到这个系列的第五篇,意味着Pwn这条线总算要画上一个阶段性句号。前四篇里,我把环境搭建、工具链、栈溢出基础、格式化字符串和堆利用的常见套路都拆开讲过。这一篇不再按知识点平铺,而是把散落在各个题目里的思路拧成一股绳,从“拿到一道Pwn题之后到底按什么顺序思考”这个角度,把整个流程重新捋一遍。如果你正在准备比赛,或者刚开始系统打CTF里面的Pwn方向,这篇就是我给你的“考前速记”。

先说清楚一个态度问题:Pwn模块从来不是“背题型”就能拿分的模块。原因也很简单,出题人每年都会在常规栈题、堆题上叠加新限制,比如沙箱、指令长度限制、文件描述符关闭、seccomp规则过滤,甚至把ARM架构和RISC-V架构搬上比赛。只有把底层的原理真正吃透,才能在变化里找到不变的部分。但反过来,原理吃透之后,确实有一条相对固定的“解题流水线”可以大幅度提高效率。这篇文章要整合的,就是这条流水线上最关键的几个环节。

2. 拿到题目之后我建议你按这个顺序操作

2.1 第一阶段:把题目文件和环境信息“榨干”

有相当一部分新手拿到Pwn题就直接丢进IDA里开始按F5,我个人反而建议先不要急着看反编译结果。先在终端里把下面这几件事做完,几十秒成本,但能帮你避开一堆弯路。

第一步,用file命令看文件类型。是64位ELF还是32位ELF,是动态链接还是静态链接,这个信息直接决定后面用什么工具、能不能随手搜到现成gadget。比如静态链接的题目常常可以直接在里面找syscall,省去很多麻烦;而动态链接的题目往往需要你泄露libc地址。

第二步,用checksec检查保护机制。这是Pwn题的“体检报告”,每一行保护都需要你心里有一个对应的利用策略:

保护机制开启后的含义直接影响
NX栈不可执行不能直接执行shellcode,需要ROP或ret2libc
Canary栈上存在随机值,函数返回前校验需要先泄露canary或寻找覆盖点外的信息泄露
PIE程序地址随机化需要先泄露程序基址,或利用不需要绝对地址的gadget
RELRO(Full)GOT表只读ret2plt、写GOT这类老思路失效,需要换攻击面
FORTIFY开启后部分函数会做额外检查某些格式化字符串和溢出手法会被拦截

这里给新手一个建议:拿到checksec输出后,不要只看“开没开”就完事,要本能地形成一句话预案。比如NX enabled + Partial RELRO,很多题的标准打法就是ret2libc + 改写GOT;如果Canary found,那第一反应该是有没有格式化字符串配合泄露canary,或者能不能用覆盖指针的方式跳过canary校验。

第三步,strings过一遍,尤其适合找flag字样、后门函数提示、程序自带的特定输出。很多入门题和部分中档题会故意把后门函数命名成winbackdoorshell之类的,并不是只有你一个人会看strings,出题人就是在等你看到。

第四步,把程序跑起来,正常输入几个值,观察它的输出格式和崩溃特征。这一步能让你对题目的交互协议有直观感受。有时候出题人会在交互里故意输出一些地址、函数指针,这些都是明显的“白给信号”。

2.2 第二阶段:确定漏洞类型并快速收敛方向

信息收完之后,再进IDA或Ghidra。我习惯先在左侧函数窗口里扫一眼函数列表,看程序规模。如果函数很少,多半是栈题或者简单格式化字符串题;如果函数特别多,还带了一堆结构体,那大概率是堆题或者C++程序,需要做好心理准备。

接下来按优先级找漏洞类型:

栈题最重要的三个函数是readscanfgets。看到gets基本上可以默认存在栈溢出,直接算偏移就行。看到read(buf, size)这类调用,关注一下size和buf所在栈帧的距离,判断是否可溢出。看到scanf("%s")也是经典溢出入口,且这类题目常常配合栈结构设计。

格式化字符串题重点看是否把用户输入直接传给printfsprintffprintf这类函数。利用点无非是泄露栈地址、泄露libc地址、改写GOT或__malloc_hook

堆题的入口通常在add/delete/edit/print这类菜单函数里。重点看edit是否越界(off-by-one)、delete后指针是否置空(UAF)、add时size是否被约束。想快速判断的话,可以拿两个相同size的chunk,free一个再edit另一个,看是否影响已free的chunk内容,这是最原始的UAF测试思路。

还有一个容易被忽略的地方:程序主逻辑之外的处理函数。比如有些题把“功能选项处理”放在多线程或循环里,但退出条件或者信号处理函数中藏有后门,这类后门逻辑甚至可能不会出现在常规函数列表中(比如通过函数指针注册的handler),需要你在逆向时留意交叉引用和中断向量表。

2.3 第三阶段:本地调试和远程利用的分界线

很多新手在本地能打通,一上远程就傻眼。原因往往是出在环境差异。最典型的就是libc版本不一致。这里讲一个实操里很管用的判断方法:先在本地获取泄露出的某个libc函数地址,然后把低12位记下来,再到libc-database这类工具或libc.rip这类在线服务里查对应libc版本;远程版大概率是比赛方统一版本的libc,比对低12位能快速排除不少干扰项。

另一种情况是远程是动态容器(现在比赛里非常常见的出题方式,每轮开一个新的容器实例、随机分配一个端口),这类题目需要你把地址泄露和利用过程写成自动化脚本,别搞手动输入。pwntools里remotesendlinerecvuntil配合循环重试,是动态容器题的基本功。碰到“连接后立刻就结束”的情况,很可能题目本身需要先读取或写入一段特定握手内容,再进入主逻辑,这时候把交互协议落实到脚本里比纠结远程环境靠谱得多。

这里要特别强调一个理念:本地打通只是第一步,远程打通才是得分点。我的建议是写利用脚本时从一开始就用context.log_level = 'debug'把交互打全,本地通了之后,先改remote试一次,如果失败,优先看脚本逻辑而非远程环境,因为多数情况下脚本里对偏移、payload长度的假设在远程同样生效,唯一可能变的就是libc版本和地址。

3. 栈溢出实战流:几个必须形成本能的片段

3.1 计算偏移:以后别再每个字节去数

日常比赛里我最常用的计算偏移方式有两种。第一种是pwntoolscyclic:构造几百字节的环形字符串,从崩溃点的返回地址里取出4字节或8字节,再通过cyclic_find直接拿到偏移。这个方法在验证栈溢出是否可控、以及拿到返回地址偏移上都很快。

第二种适用于函数有参数约束的情况,比如read只接收指定字节数。这种情况下可以用模式串但需要在预期边界附近分段标记,或者干脆在调试器里从RSP与目标缓冲区的差值确认偏移。说句笨办法但很稳的话:调试器永远是你的最终解释。

偏移确认后,payload的通用拼法是:padding + 返回地址 + 后续参数/链节。但这里有一个特别容易翻车的点:有些题目的栈溢出发生在循环内,比如一次读入后主函数又返回到输入函数继续读,这时候你覆盖了返回地址,下次读入可能就覆盖了整个payload,导致逻辑断裂。建议写payload之前,先仔细阅读反编译里有没有重复调用read、以及调用次序是否会造成第二段输入覆盖第一段构造的ROP链。

3.2 ret2text:最容易拿分的题型,不要漏掉后门

老手常开玩笑说,ret2text就是“出题人把flag喂到你嘴边”。具体表现就是程序里有一个system("/bin/sh")execve("/bin/sh", 0, 0)这样的后门函数。你只需要覆盖返回地址跳过去即可。

不过简单是相对的,有几个小细节常让人卡住:

  • 后门函数地址必须以实际加载地址为准。如果开启了PIE,你需要先泄露程序基址;如果没开PIE,直接写后门的绝对地址通常没问题。
  • 如果后门函数里调用system但参数是从栈上取的,要确保调用链上栈布局也符合预期,必要时在payload里提前把参数布置到正确偏移。
  • 有些后门函数在main的返回路径上被调用,经过leave; ret之后栈指针已经变化,需要你检查返回地址覆盖后,后续栈内容是否还是你控制的那一段。

如果你发现题目里没有现成后门函数,别灰心,可以尝试在二进制里搜/bin/sh字符串。搜到的话,配合system的地址(或者execve的syscall gadget)也能构成一次有效利用。

3.3 ret2libc:泄露地址是唯一核心

ret2libc的基本链路是:第一次漏洞利用,先调用puts@pltwrite@plt这类输出型函数,把某个libc函数(通常是__libc_start_mainputs@got)的真实地址打出来;然后根据libc符号偏移,算出system/bin/sh的地址;第二次漏洞利用,构造system("/bin/sh")的ROP链。

整套链路里,泄露出真实libc地址后,最重要的一步是确认libc版本。我的建议是转换成十六进制后,丢到一个本地维护的libc数据库中搜索。很多时候比赛中并不会明确告诉你libc版本,只给一个libc.so.6文件,那就更简单——直接在本地把这个文件跑起来,或使用pwninit一键配好linker和libc环境,远程利用的成功率会大幅上升。这里补充一个经验:libc低12位常常是各类破解工具的唯一依据,但极端情况下不同版本的低12位可能相同,所以尽量同时利用多个函数的泄露地址(例如泄露putsprintf两个地址)来交叉验证。

4. 工具链与训练资源:按这个清单准备就够了

4.1 每周必用的核心工具清单

Pwn方向工具不在多,而在精。我平常用到的核心清单大概是:

  • Python 3 + pwntools:写exp的主力框架,别再用纯Python发字节,pwntools的p64ELFROPprocess/remote封装太强了。
  • gdb + pwndbg(或GEF):调试必用,pwndbg在查看栈上布局、cyclic定位、GOT/PLT信息上非常好用。
  • checksec:一般集成在pwntools里,工具级别不复杂,但每次都要跑。习惯养成后对保护机制会形成直觉。
  • one_gadget:在execve约束满足的情况下,可以直接拿到一个gadget地址;某些题目加了seccomp只能open/read/write时,这个就不能直接用,但作为快速验证手段很值。
  • ROPgadgetropper:搜索pop rdi这类gadget。习惯上用ROPgadget --binary ./xxx --only "pop|ret"组合筛选。
  • IDA Pro(或Ghidra):逆向分析主力,没有就用Ghidra,免费且跨平台。
  • LibcSearcher或本地的libc-database:libc版本匹配神器。不过我个人更喜欢自带libc.so.6文件的题目,直接用GDB把对应偏移抠出来最准。

还有一个经常被忽略的工具是patchelf,它能把本地ELF的动态链接器和libc替换成目标版本。遇到题目给了远程libc、本地系统版本不匹配时,patchelf --set-interpreterpatchelf --replace-needed能帮你快速搭一个和远程一致的执行环境,不打无准备之仗。

以我自己的配置经验来说,还有一个隐藏技能:写一个tmux+ gdb + pwntools的联动脚本,把exp的verbose输出、gdb调试、日志打印放到不同面板。这样在比赛现场信息密度高的时候,你能一边看脚本运行状态一边看原始终端输出,效率完全不一样。

4.2 训练平台和题库怎么选

先说入门阶段,我比较推荐从自带的经典训练营起步,比如在线的Pwn题目集合里先把栈题刷透。很多题库的题是按难度从易到难排列的,比如从纯栈溢出、无保护的简单题开始,逐步过渡到Full RELRO+PIE+Canary。这个过程一定要亲自动手调试,只看writeup与看小说无异。

到了进阶阶段,历年比赛原题是比任何教程都有效的素材。这里我给一个非常朴素的筛选标准:优先做那些你能拿到完整题目包(二进制、libc、Dockerfile)的题目。没有Dockerfile的题,至少要能被patchelf还原环境。如果题目构造环境太龌龊,其实很影响训练效率。

还要专门提一下动态容器题目。现在很多比赛为了防作弊、给选手提供独立环境,会给每道Pwn题分配独立的容器和随机端口。我建议平常训练时也习惯用这种模式:远程服务每次重启地址和libc版本随机,练习用pwntools写稳定的自动化解题脚本。总在本地一条命令跑到底,一到比赛就会觉得“怎么哪哪都对不上”。

5. 常见问题与排查技巧实录

5.1 我把每年带新人时遇到的高频问题汇总了一下

问题现象可能原因排查和解决思路
本地能打通,远程连接超时自己写的exp里有交互式shell逻辑,但远程是动态容器,需等待端口初始化remote之前先尝试循环connect,或检查目标端口是否开放
payload发过去程序直接崩溃(本地常见)偏移算错、gadget地址中包含了断点字节或无效字节cyclic重新定位偏移,gdb里查看崩溃时的RSP/PC值
泄露的libc地址在数据库中查不到libc版本太新或太特殊查看题目是否提供了libc.so.6,用readelf -sstrings匹配;没有就给比赛方发反馈
格式化字符串能泄露,但改写GOT失败RELRO是Full,GOT不可写考虑ret2libc或改写可写段的内存(如.bss
栈溢出可以覆盖返回地址,但system("/bin/sh")执行后shell输入不工作输入输出缓冲未同步,或shell没有获取到完整控制流尝试在payload中追加cat flag代替/bin/sh,或使用sendafter确保远程读到完整命令
堆题UAF利用时程序崩溃没有正确安排chunk布局,double-free顺序不对GDB里观察tcachebin链表,确认释放和申请顺序
远程exp第一次成功后,第二次跑又失败PIE随机化变化、libc版本不一致、地址偏移错误检查脚本中是否使用绝对地址,调整泄露逻辑,必要时把payload改为相对地址或栈迁移

这些问题的共通点是:绝大多数不是“题目出了bug”,而是你对内存布局的理解和远程环境存在偏差。出问题的时候重新回到调试器的视角,沿着“从哪个地址写入、在哪个地址崩溃、崩溃后返回地址是谁”这条线去看,基本都能定位。

5.2 两个最值得记录的深坑经验

第一个坑:格式化字符串的索引问题。printf(&buf)这样的漏洞利用,在32位和64位下格式化参数索引起点不一样。32位程序前几个参数直接在栈上,64位程序前几个参数在寄存器里。我见过太多新手在64位下把一个%n对齐到错误的参数位置,导致写了不知道哪里的地址。处理方式是先用%p.%p.%p.%p...把前十几个栈槽打出来,对照自己输入内容的位置来确定索引基准。

第二个坑:堆题的mallocfree行为受tcache影响极大。现在多数glibc版本默认开启了tcache,一个bin最多缓存7个同size的chunk,超出会进入fastbins。题目如果声明用的是特定老版本glibc(2.23或2.27),tcache机制可能不存在或行为不一样。你必须先看题目给的libc版本,再决定利用链是走fastbin attack还是tcache dup。不要拿新版本glibc的经验硬套老题目,否则会莫名死掉。

6. 比赛现场的时间分配与心态策略

6.1 拿到题目的前30分钟比什么都重要

比赛是CTF里限时高压的最大考验。我个人的策略是:赛前先把每道题的保护机制、函数列表、可能的利用方向快速过一遍,然后把题目难度分成三档。简单题(大概率是栈题+后门函数)用20到30分钟解决,中档题(需要泄露libc或常常是格式化字符串)给1小时,剩下的时间留给堆题和环境特殊题。

如果一道题在30分钟内连漏洞类型都没定位,我建议先放一放,做做其他方向的题。很多比赛Pwn题目分值相近,但Web、Misc、Crypto也有稳定拿分点,时间分配千万别一根筋。Pwn题经常出现“几个小时死磕无果,最后发现是思路选错了”的情况,适时止损反而能让你在最后阶段回来时脑子更清醒。

6.2 现场可能出现的意外状况要提前准备预案

比赛现场最让人难受的不是题目难,而是环境问题。比如远程容器排队拥堵、连接数超限、靶机IP变化,甚至比赛平台自己的验证码机制。这些不是你能控制的,但可以提前准备好“稳定利用脚本模板”。

我的建议是:把一份完整的exp模板提前写好。模板里包含连接远程服务的函数、循环重试逻辑、处理flag匹配的正则、日志输出开关、以及本地gdb.attach开关。到现场只需要改hostport和关键偏移、地址即可。这种“半自动化”的状态能极大缓解现场紧张导致的低级失误。

另一个建议是注意保存每次连接和尝试的日志。哪怕一道题当时没做出来,赛后复盘时日志缺失会让你完全忘记当时的交互情况。建议脚本中用log_filerecvsend都记录到文件,赛后就能完整复现当时的思路。

7. 关于这个系列,我还有几句实在话

连续更新了五篇,从最初教大家怎么装pwntools,到现在能聊完整的比赛策略,很多读者在后台问过的同一个问题是:到底怎么才能稳定提升Pwn水平?我的回答一直很朴素——多做题、多复盘、多写自己的工具脚本,不要只收藏writeup而不去复现。

我自己带训练赛时的体会是:Pwn题目真正难的不是某个知识点,而是把知识串成流程的能力。一道题放你面前,从filechecksec到调试到写exp再到远程验证,每一步都有大量你不注意就会翻车的细节。这个流程熟练到形成肌肉记忆之后,比赛里你会发现自己节省了大量试错时间。

最后再分享一个小习惯:每做完一道有代表性的题,我都会把题目文件和exp按“保护机制-漏洞类型-利用链”的命名规则归档。比如命名格式是stack_ret2libc_Canary_PIE,这样到了比赛前复习,扫一眼目录就能把常见的题型和应对思路全部唤醒。这套方法对新手尤其有效,相当于你自己给自己建了一本可以不断增补的“Pwn随身册”。

后续如果还写内容,我想把专题拆到更细的利用链上,比如seccomp绕过、SROP、堆的tcache stashing unlink这类相对进阶的主题。如果你在按这个系列学习,不妨先把这篇里提到的流程跑通,多拿几道简单题和中档题练手,再来挑战那些更“艺术”的内容。

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

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

立即咨询