尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CTF Pwn实战:从题目分析到利用链构建的高效流程

CTF Pwn实战:从题目分析到利用链构建的高效流程 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或ret2libcCanary栈上存在随机值函数返回前校验需要先泄露canary或寻找覆盖点外的信息泄露PIE程序地址随机化需要先泄露程序基址或利用不需要绝对地址的gadgetRELROFullGOT表只读ret2plt、写GOT这类老思路失效需要换攻击面FORTIFY开启后部分函数会做额外检查某些格式化字符串和溢出手法会被拦截这里给新手一个建议拿到checksec输出后不要只看“开没开”就完事要本能地形成一句话预案。比如NX enabled Partial RELRO很多题的标准打法就是ret2libc 改写GOT如果Canary found那第一反应该是有没有格式化字符串配合泄露canary或者能不能用覆盖指针的方式跳过canary校验。第三步strings过一遍尤其适合找flag字样、后门函数提示、程序自带的特定输出。很多入门题和部分中档题会故意把后门函数命名成win、backdoor、shell之类的并不是只有你一个人会看strings出题人就是在等你看到。第四步把程序跑起来正常输入几个值观察它的输出格式和崩溃特征。这一步能让你对题目的交互协议有直观感受。有时候出题人会在交互里故意输出一些地址、函数指针这些都是明显的“白给信号”。2.2 第二阶段确定漏洞类型并快速收敛方向信息收完之后再进IDA或Ghidra。我习惯先在左侧函数窗口里扫一眼函数列表看程序规模。如果函数很少多半是栈题或者简单格式化字符串题如果函数特别多还带了一堆结构体那大概率是堆题或者C程序需要做好心理准备。接下来按优先级找漏洞类型栈题最重要的三个函数是read、scanf、gets。看到gets基本上可以默认存在栈溢出直接算偏移就行。看到read(buf, size)这类调用关注一下size和buf所在栈帧的距离判断是否可溢出。看到scanf(%s)也是经典溢出入口且这类题目常常配合栈结构设计。格式化字符串题重点看是否把用户输入直接传给printf、sprintf、fprintf这类函数。利用点无非是泄露栈地址、泄露libc地址、改写GOT或__malloc_hook。堆题的入口通常在add/delete/edit/print这类菜单函数里。重点看edit是否越界off-by-one、delete后指针是否置空UAF、add时size是否被约束。想快速判断的话可以拿两个相同size的chunkfree一个再edit另一个看是否影响已free的chunk内容这是最原始的UAF测试思路。还有一个容易被忽略的地方程序主逻辑之外的处理函数。比如有些题把“功能选项处理”放在多线程或循环里但退出条件或者信号处理函数中藏有后门这类后门逻辑甚至可能不会出现在常规函数列表中比如通过函数指针注册的handler需要你在逆向时留意交叉引用和中断向量表。2.3 第三阶段本地调试和远程利用的分界线很多新手在本地能打通一上远程就傻眼。原因往往是出在环境差异。最典型的就是libc版本不一致。这里讲一个实操里很管用的判断方法先在本地获取泄露出的某个libc函数地址然后把低12位记下来再到libc-database这类工具或libc.rip这类在线服务里查对应libc版本远程版大概率是比赛方统一版本的libc比对低12位能快速排除不少干扰项。另一种情况是远程是动态容器现在比赛里非常常见的出题方式每轮开一个新的容器实例、随机分配一个端口这类题目需要你把地址泄露和利用过程写成自动化脚本别搞手动输入。pwntools里remote的sendline与recvuntil配合循环重试是动态容器题的基本功。碰到“连接后立刻就结束”的情况很可能题目本身需要先读取或写入一段特定握手内容再进入主逻辑这时候把交互协议落实到脚本里比纠结远程环境靠谱得多。这里要特别强调一个理念本地打通只是第一步远程打通才是得分点。我的建议是写利用脚本时从一开始就用context.log_level debug把交互打全本地通了之后先改remote试一次如果失败优先看脚本逻辑而非远程环境因为多数情况下脚本里对偏移、payload长度的假设在远程同样生效唯一可能变的就是libc版本和地址。3. 栈溢出实战流几个必须形成本能的片段3.1 计算偏移以后别再每个字节去数日常比赛里我最常用的计算偏移方式有两种。第一种是pwntools的cyclic构造几百字节的环形字符串从崩溃点的返回地址里取出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的基本链路是第一次漏洞利用先调用putsplt或writeplt这类输出型函数把某个libc函数通常是__libc_start_main或putsgot的真实地址打出来然后根据libc符号偏移算出system和/bin/sh的地址第二次漏洞利用构造system(/bin/sh)的ROP链。整套链路里泄露出真实libc地址后最重要的一步是确认libc版本。我的建议是转换成十六进制后丢到一个本地维护的libc数据库中搜索。很多时候比赛中并不会明确告诉你libc版本只给一个libc.so.6文件那就更简单——直接在本地把这个文件跑起来或使用pwninit一键配好linker和libc环境远程利用的成功率会大幅上升。这里补充一个经验libc低12位常常是各类破解工具的唯一依据但极端情况下不同版本的低12位可能相同所以尽量同时利用多个函数的泄露地址例如泄露puts和printf两个地址来交叉验证。4. 工具链与训练资源按这个清单准备就够了4.1 每周必用的核心工具清单Pwn方向工具不在多而在精。我平常用到的核心清单大概是Python 3 pwntools写exp的主力框架别再用纯Python发字节pwntools的p64、ELF、ROP、process/remote封装太强了。gdb pwndbg或GEF调试必用pwndbg在查看栈上布局、cyclic定位、GOT/PLT信息上非常好用。checksec一般集成在pwntools里工具级别不复杂但每次都要跑。习惯养成后对保护机制会形成直觉。one_gadget在execve约束满足的情况下可以直接拿到一个gadget地址某些题目加了seccomp只能open/read/write时这个就不能直接用但作为快速验证手段很值。ROPgadget或ropper搜索pop rdi这类gadget。习惯上用ROPgadget --binary ./xxx --only pop|ret组合筛选。IDA Pro或Ghidra逆向分析主力没有就用Ghidra免费且跨平台。LibcSearcher或本地的libc-databaselibc版本匹配神器。不过我个人更喜欢自带libc.so.6文件的题目直接用GDB把对应偏移抠出来最准。还有一个经常被忽略的工具是patchelf它能把本地ELF的动态链接器和libc替换成目标版本。遇到题目给了远程libc、本地系统版本不匹配时patchelf --set-interpreter和patchelf --replace-needed能帮你快速搭一个和远程一致的执行环境不打无准备之仗。以我自己的配置经验来说还有一个隐藏技能写一个tmux gdb pwntools的联动脚本把exp的verbose输出、gdb调试、日志打印放到不同面板。这样在比赛现场信息密度高的时候你能一边看脚本运行状态一边看原始终端输出效率完全不一样。4.2 训练平台和题库怎么选先说入门阶段我比较推荐从自带的经典训练营起步比如在线的Pwn题目集合里先把栈题刷透。很多题库的题是按难度从易到难排列的比如从纯栈溢出、无保护的简单题开始逐步过渡到Full RELROPIECanary。这个过程一定要亲自动手调试只看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 -s或strings匹配没有就给比赛方发反馈格式化字符串能泄露但改写GOT失败RELRO是FullGOT不可写考虑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...把前十几个栈槽打出来对照自己输入内容的位置来确定索引基准。第二个坑堆题的malloc和free行为受tcache影响极大。现在多数glibc版本默认开启了tcache一个bin最多缓存7个同size的chunk超出会进入fastbins。题目如果声明用的是特定老版本glibc2.23或2.27tcache机制可能不存在或行为不一样。你必须先看题目给的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开关。到现场只需要改host、port和关键偏移、地址即可。这种“半自动化”的状态能极大缓解现场紧张导致的低级失误。另一个建议是注意保存每次连接和尝试的日志。哪怕一道题当时没做出来赛后复盘时日志缺失会让你完全忘记当时的交互情况。建议脚本中用log_file把recv和send都记录到文件赛后就能完整复现当时的思路。7. 关于这个系列我还有几句实在话连续更新了五篇从最初教大家怎么装pwntools到现在能聊完整的比赛策略很多读者在后台问过的同一个问题是到底怎么才能稳定提升Pwn水平我的回答一直很朴素——多做题、多复盘、多写自己的工具脚本不要只收藏writeup而不去复现。我自己带训练赛时的体会是Pwn题目真正难的不是某个知识点而是把知识串成流程的能力。一道题放你面前从file到checksec到调试到写exp再到远程验证每一步都有大量你不注意就会翻车的细节。这个流程熟练到形成肌肉记忆之后比赛里你会发现自己节省了大量试错时间。最后再分享一个小习惯每做完一道有代表性的题我都会把题目文件和exp按“保护机制-漏洞类型-利用链”的命名规则归档。比如命名格式是stack_ret2libc_Canary_PIE这样到了比赛前复习扫一眼目录就能把常见的题型和应对思路全部唤醒。这套方法对新手尤其有效相当于你自己给自己建了一本可以不断增补的“Pwn随身册”。后续如果还写内容我想把专题拆到更细的利用链上比如seccomp绕过、SROP、堆的tcache stashing unlink这类相对进阶的主题。如果你在按这个系列学习不妨先把这篇里提到的流程跑通多拿几道简单题和中档题练手再来挑战那些更“艺术”的内容。
返回列表