资讯详情

资讯详情

CTF PWN实战复盘:从栈溢出到堆利用与沙箱ORW的完整链路

这次DASCTF July X CBCTF 4th的pwn方向我一共跟了7道题最后交出5道完整exp剩下两道一道卡在环境差异上一道卡在读题上。趁热把复盘写下来重点不是把exp从头抄到尾而是把每道题触发漏洞时的思考链路、构造payload时反复试错的地方以及调试过程中踩过的坑都过一遍。整体看下来这轮pwn题目不偏栈、格式化字符串、堆、沙箱orw都有覆盖难度曲线也算正常适合拿来当阶段练手。如果你也在学pwn建议别只盯着最后的exp多关注我在正文里标出来的几个关键判断节点什么时候该算偏移、什么时候该换libc、什么时候该考虑沙箱限制。这些才是真正影响拿分速度的地方。1. 比赛概况与选题思路1.1 赛题整体分布与难度速览这轮pwn题的构成比较典型大致可以分为以下几类入门栈题、格式化字符串题、UAF堆题、seccomp沙箱题以及一道偏综合的hard题。我在做题时先把所有题目的附件拉下来分别跑了一次file和checksec确认保护机制之后才决定先做哪道。题目代号类型难度核心考点baby_stack栈入门栈溢出、控制流劫持fmt_game格式化字符串中等偏移定位、GOT覆写note_heap堆中等偏上UAF、fastbin duporw_shell综合偏难seccomp、ORW ROP链hard_pwn综合难多阶段利用实际做题顺序我是反着来的先扫了一眼hard_pwn确认短时间内啃不下来果断回头从baby_stack开始拿分。比赛里时间就是分数一道题卡太久会严重影响心态先把能拿的分拿到手再回头啃硬骨头。1.2 赛前准备与读题方法pwn题复现环境我固定用Ubuntu 20.04 pwntools gdb调试插件用的pwndbg。比赛前我会先把常用脚本模板准备好包括本地启动函数、远程连接封装、libc版本检测逻辑这些能省掉很多重复劳动。题目拿到手以后第一步永远是看保护file ./challenge checksec --file./challengechecksec的输出决定了整个利用方案的方向。Partial RELRO意味着可以打GOT覆写No PIE意味着地址固定可以直接硬编码NX开启意味着栈上执行不了shellcode需要走ROP或者mprotect。这个判断一定要在读代码之前完成我之前有次上来就找漏洞点到最后发现栈不可执行前面做的shellcode全部白费白白浪费了半小时。读题阶段我习惯先用IDA看main函数和几个关键函数程序循环在哪、输入函数是哪个、输入长度是多少、有没有检查函数。遇到堆题还要额外留意菜单功能里的索引检查和重复free限制这类检查往往是出题人故意设置的“门槛”绕过它们才是利用的关键。2. 栈溢出题从ret2csu到完整ROP链2.1 漏洞定位与保护机制检查baby_stack这题拿到手checksec显示开启了NX和Partial RELRO没有开启PIEcanary也开了。按惯例这种配置大概率就是给一个栈溢出点然后让你通过两次ROP完成泄露和劫持。我逆向后发现漏洞点在read函数read读入长度0x100而缓冲区只有0x90字节多出来的部分正好可以覆盖到返回地址。这里有个很关键的细节虽然开启了canary但存在一个信息泄露口子程序在read完之后会用printf打印一段包含栈地址的内存内容canary就在其中。很多栈题不会让你直接打而是给你一个“口子”利用这个口子把cookie拿回来再走ROP。偏移计算也很直接缓冲区起始位置在rbp-0x90返回地址在rbp8所以填充长度是0x90加8也就是0x98。我习惯在gdb里用pattern验证一遍避免手算失误这个习惯救过我很多次因为有时IDA给出的栈布局和实际编译结果会有偏差。2.2 ret2csu构造细节拿到canary之后下一步是ROP布局。由于No PIE程序内gadget地址固定直接用__libc_csu_init里的pop系列gadget就能控制rdi、rsi、rdx三个参数寄存器。这里解释一下ret2csu的核心原理。__libc_csu_init后半段通常长这样pop rbx pop rbp pop r12 pop r13 pop r14 pop r15 ret然后是mov rdx, r15 mov rsi, r14 mov edi, r13d call qword ptr [r12 rbx*8] add rbx, 1 cmp rbp, rbx jne 上一条用第一段gadget把rbx设为0、rbp设为1、r12设为要调用的函数指针位置、r13设为第一个参数、r14设为第二个参数、r15设为第三个参数然后跳转到第二段就能实现一次带三个参数的函数调用。这个技术在glibc 2.23环境里尤其常见libc没有直接用system的需求时用它先调write泄露地址再调read写入shellcode非常顺。2.3 exp脚本与本地实测我的第一段payload思路是泄漏canary之后利用csu调用write(1, writegot, 8)把write的真实地址打印出来然后根据libc库偏移算出libc基址。拿到基址后用one_gadget作为返回目标第二次触发read时重新布置payload返回地址指向one_gadget。完整exp片段如下from pwn import * context.arch amd64 context.log_level debug io process(./baby_stack) io.recvuntil(bLeak: ) canary int(io.recvline().strip()[-18:], 16) log.success(canary: hex(canary)) pop_rbx 0x400600 csu_call 0x4005e0 payload bA * 0x98 payload p64(canary) payload bB * 8 payload p64(pop_rbx) payload p64(0) p64(1) p64(write_got) p64(1) p64(write_got) p64(8) payload p64(csu_call) payload p64(0x4005a0) # main入口继续触发第二次输入 io.sendafter(b , payload) write_addr u64(io.recvuntil(b\x7f)[-6:].ljust(8, b\x00)) log.success(write_addr: hex(write_addr))这里有一个实测踩坑点泄露write地址时如果直接用recv(8)有一定概率把后面附加的换行符也读进来导致u64解析出错。我用recvuntil(b\x7f)再加ljust(8, b\x00)来处理因为libc地址高字节基本都是0x7f开头这种截断思路在实际pwn中非常常用。3. 格式化字符串题任意读写的三板斧3.1 参数偏移定位技巧fmt_game这道题保护全开Partial RELOC都没有GOT表只读所以不能走覆写GOT的路线最终的利用目标是改写栈上的返回地址或通过栈迁移打stdout。格式化字符串漏洞第一件事永远是定位偏移。我习惯先用%p系列去测试%p.%p.%p.%p.%p.%p.%p.%p然后根据输出顺序确定我们可控输入是从第几个参数开始的。这题输入在printf的第二个参数位置实测可控输入从第7个参数开始。规范做法是用%7$p来直接打印不需要把所有参数都打一遍。拿到偏移之后还要从栈上读取canary。格式化字符串泄露canary的方法也很简单canary通常在栈上的某个固定位置先用%p输出几轮比对输出栈地址与canary的低字节00规律就能定位到它。我自己的经验是泄露信息阶段尽量一次性多拿几个栈位置的值这样后面不用反复交互远程有流量限制时尤其重要。3.2 利用格式化字符串完成GOT覆写虽然这题GOT只读但我想先讲一个常见的组合用%n覆写GOT把某项函数改成system。做法是先定位目标GOT地址和system在libc中的偏移然后通过%hhn按字节覆写。比如要写4字节计划是p64(got_addr) p64(got_addr1) p64(got_addr2) p64(got_addr3) payload每段对应一个%hhn各自按字节写完。需要注意的是%hhn写的是到目前为止已输出字符数对256取模所以每次写入前必须精确计算上一个字节输出的字符数量并且用%c补齐差值。这块最烦人但是只要画一张表格把每个字节的当前值和目标值列出来就不会乱。fmt_game这道题的最终方案比这个更绕因为GOT被锁了我选择在栈上构造一个ROP链然后利用格式化字符串覆写返回地址为pop_rdi的gadget间接完成对libc函数的调用。思路不复杂真正费时间的是地址对齐和覆写次数控制稍后细说。3.3 格式串长度控制的实战经验格式化字符串payload的长度控制是一个隐藏的难点。写成这样%100c%7$n表示输出100个字符之后把写入字符数写入第7个参数。如果你想写一个大值比如0x4000那就需要输出16000个字符这在本地没什么问题但远程可能因为带宽和超时直接卡死。我的建议是能用%hhn按字节拆就不要一次写完整4字节。按字节写的最坏情况也就是256个字符一圈配合%c的动态填充payload长度还可以控制。这个思路在几乎所有格式化字符串题里都通用值得形成肌肉记忆。4. 堆题复盘fastbin dup与double free4.1 漏洞成因与保护机制检查note_heap这题我复现的是glibc 2.23环境下的经典fastbin攻击。checksec结果显示保护全开堆题一般也都是默认全开PIE、RELRO、NX、canary全在。保护全开不代表没法做重点是找UAF和double free。程序功能是标准的笔记管理系统新增笔记、编辑笔记、删除笔记、打印笔记。漏洞在删除笔记时没有清空指针free之后指针仍然留在全局数组里这是非常典型的UAF。我可以在free之后再调用打印功能读取堆地址也可以直接再调用编辑功能写入已经被释放的chunk。这里有个真实做题教训UAF题刚上手时别急着构造攻击先把菜单里的限制条件全部列一遍。比如这题限制了索引必须小于16但没有限制同一个索引可以反复delete所以double free是成立的。如果出题人加了索引互斥检查double free路线就废了必须换成其他利用方式。4.2 分配序列的设计与fastbin dup原理fastbin dup的核心在于fastbin链表只检查单次释放的chunk是否与链表头相同而不会检查整个链表里是否已经存在相同chunk。因此连续free两次同一个chunk可以让一个chunk同时出现在链表中两次这样后续分配时就能把同一个地址分配给两个不同的笔记。我的操作序列如下add(0x60) # chunk0 add(0x60) # chunk1用来隔开 delete(0) # 把chunk0放入fastbin delete(1) # chunk1放入fastbin此时chunk0被压在下面 delete(0) # chunk0再次放入fastbin绕过检查这里为什么要加一个chunk1做间隔因为glibc 2.23的检查会拒绝free与当前fastbin头部相同的chunk。连续free两次chunk0会直接触发检查所以中间必须插入一个不同的chunk打断连续释放的路径。之后分配三次第三次就会拿到和第一次相同的chunk地址。此时我可以在编辑功能里把该chunk的fd指针改成__malloc_hook附近的一个地址再经过一次分配就能让目标区域进入fastbin队列最终改写__malloc_hook为one_gadget。4.3 调试中遇到的两个坑这题调试时我踩了两个坑。第一个是__malloc_hook附近的地址校验fastbin在分配时会检查目标地址的size字段是否合法直接改到__malloc_hook本身会失败正确的做法是把fd改成__malloc_hook-0x23让系统在分配时认为那里的size字段是一个合法的fastbin大块。这个偏移是经验值建议直接背下来不同glibc版本略有差异。第二个坑更隐蔽因为程序在打印笔记时会把内容当成字符串直接puts所以写入的payload里如果提前包含\x00打印就会截断导致我之前写入的fd偏移地址无法被正常读取。解决办法也很简单把payload里的地址放在开头然后在调试时用x/20gx直接在内存里确认链表关系不要只看puts的输出。5. 沙箱题ORW ROP链构造全解5.1 识别沙箱规则orw_shell这题是我觉得整轮最值得复盘的一道。拿到程序先不急着找漏洞先检查有没有沙箱。常用指令是seccomp-tools dump ./orw_shell我实际跑出来的结果是程序只允许open、read、write、exit这几个系统调用execve全部被阻断。也就是说就算我拿到shell也执行不了命令必须自己从文件系统里读取flag内容再通过write输出。识别沙箱是个很容易被新手忽略的步骤。之前我在练习里习惯性打了system(/bin/sh)远程返回了个干干净净的空白后来才发现沙箱把execve给禁了浪费了整整一次交互机会。现在只要是pwn题拿到附件后的前两分钟我一定会checksec和沙箱检查一起跑掉。5.2 系统调用序列规划ORW的思路很直接open打开flag文件read读取文件内容write输出到标准输出。对应到amd64系统调用号就是2、0、1。由于程序本身没有提供syscall指令需要从libc或者程序本身的gadget里找实在找不到还可以用mprotect把代码段改为可执行但最优雅的方案还是直接用已有的syscall gadget。攻击流程我分了两步。第一步还是先泄露libc地址因为syscall和pop rdx的gadget在libc里都有拿到libc基址之后这些都能用。第二步再把传入的参数规划成下面这样open(flag, 0) - rdiflag_addr, rsi0, rax2 read(fd, bss, 0x100) - rdi3, rsibss, rdx0x100, rax0 write(1, bss, 0x100) - rdi1, rsibss, rdx0x100, rax1flag路径怎么进内存也是个细节。我的做法是在第一次payload输入时把字符串flag写到bss段上因为bss段既没有PIE随机化又具备可读性后面open的时候直接把地址写到rdi就行。5.3 完整exp与执行效果这段exp是我调得最久的一部分主要因为pop rdx这个gadget在libc里的偏移不太容易直接用相对偏移找到。我最终用ROPgadget从libc.so里搜到了合适的片段然后用libc基址进行换算。payload flat([ pop_rdi, flag_buf, pop_rsi, 0, pop_rax, 2, syscall_addr, pop_rdi, 3, pop_rsi, bss, pop_rdx, 0x100, pop_rax, 0, syscall_addr, pop_rdi, 1, pop_rsi, bss, pop_rdx, 0x100, pop_rax, 1, syscall_addr, main_addr ])执行成功以后终端直接输出了flag内容。这段链子单看起来并不复杂但实际调试中我至少调了四轮。前两轮是open的返回值不是3说明flag文件没打开成功我怀疑是路径字符串在内存里被覆盖了后两轮是read的fd写死成3但服务器环境可能已经打开过别的文件描述符导致读到的东西不对。最后我把rdi设置成从rax里取又在链子中间插了一条mov rdi, rax的gadget才稳定通过。6. 赛后总复盘6.1 一道签到题的快速做法比赛里还有一道很典型的签到栈题逻辑几乎就是入门课上的例子程序开了NX没开PIE函数里有一个明显的栈溢出没有canary还自带一个后门函数直接跳过去就能读到flag。这类题目存在的意义就是快速稳定心态也是pwn方向最基础的得分点。做法就是直接构造payload bA * offset p64(magic_addr)整个过程不超过10分钟。需要注意的是即使是签到题也要检查一下栈对齐问题如果调用system或后门函数时栈没对齐会出现莫名其妙的段错误往上补一个ret就可以了。这个坑我在练习赛遇到过比赛里反而特别注意了。6.2 一组翻车记录与应对思路最后一道hard_pwn我没做出来赛后复现时发现主要卡在两个地方一是多线程环境下的锁机制程序在读取输入时用了一个自实现的锁结构导致我构造的ROP链在部分线程上执行不稳定二是libc版本问题本地用的glibc 2.31远程是2.27部分gadget偏移变了常规的libc database匹配没对准。这道题给我的教训是pwn题如果做到后期发现链子没问题但总是打通不了优先怀疑三点栈对齐、libc版本、沙箱规则。这三者按频率排序出现问题时先快速验证别埋头去调payload。另外一个小技巧比赛前把常用libc版本对应的__malloc_hook偏移、one_gadget偏移、通用gadget偏移整理成一张表拿题就能查。这和写writeup一样是一次投入、多次收益的事情。这轮比赛最后虽然没拿到全题解但把每个考点的坑都踩了一遍以后同类赛题基本能少走一半弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →