
周六上午打开比赛平台的时候我还在犹豫要不要碰PWN。前几次CTF里我都是被分到了Web和MiscPWN一直属于听说过没摸过的状态。这次SHCTF的题目列表拉下来PWN题有两道分数都不低而且一眼扫过去就能猜到考点方向一道栈溢出一道整数溢出。这两类题放在同一场赛事里其实挺典型的也特别适合作为入门选手的练手对象。赛后我把做题的过程和心理活动重新捋了一遍发现最值得记录下来的反而不是最后拿没拿到flag而是环境怎么搭题目怎么读利用怎么一步步试出来的完整思路。这篇就当作一份给同样卡在PWN门口的人看的复盘笔记重点聊那些坑、细节和判断。1. 赛前先想清楚SHCTF的PWN到底在考察什么很多初学者一听到PWN这个词脑子里浮现的都是电影里那种飞快敲键盘的画面总觉得这辈子都学不会。其实放到CTF里PWN的门槛并没有想象中那么高尤其是学生赛和入门赛里出的题目考察的核心就三块栈溢出、整数溢出、格式化字符串。SHCTF这类赛事更是如此出题人不会为难你能拿出来的基本就是经典考点换个壳。PWN本质上是程序分析加漏洞利用的合体。你拿到的是一个编译好的二进制可执行文件需要先通过静态分析和动态调试搞清楚它内部发生了什么再找出它对输入的处理缺陷最后构造一段特殊输入让程序做出超出设计者的预期动作——最常见的就是弹出一个shell或者控制它的输出内容。整个过程可以被拆成独立的步骤每一步都有对应的工具和套路只要按顺序来新手也能稳住。赛前盘点让我心里稍微有个底但真正到手做题时第一道坎其实不是题目本身而是运行环境。pwn环境配置这件事很多人以为装个Kali就万事大吉实际上远没那么简单。工具版本、调试器插件、Python依赖互相打架的情况非常多我这次就在环境上耽误了差不多半小时所以第二部分单独说一说。2. pwn环境配置用Kali搭一套不乱打架的工具链PWN做题目前体系很成熟基础工具就那几样Python 3、pwntools、gdb、checksec。其中pwntools和gdb是绝对的主力前者负责收发数据、构造payload和计算偏移量后者负责在进程崩溃的瞬间查看寄存器和内存状态。许多新手死在这一步是因为gdb里没有装任何增强插件导致反汇编输出可读性很差寄存器变化看得一头雾水。我的做法是先装gdb多平台调试插件再装pwntools顺序不能反。因为pwntools在导入后可能会覆盖一些gdb的默认行为如果插件后装有时会遇到符号表加载异常的问题。Kali下具体操作大致是这样sudo apt update sudo apt install -y python3 python3-pip gdb netcat-traditional git clone https://github.com/pwndbg/pwndbg.git cd pwndbg ./setup.sh pip3 install pwntools这里有个小细节值得说明Kali自带的Python 3版本通常够用直接用pip3装pwntools就行。如果你之前装了别的工具链导致pip环境被搞乱过建议用虚拟环境隔离不要在全局环境里硬怼。我第一次就是因为电脑里有一个旧版Capstone库跟pwntools里的反汇编功能冲突导入pwn模块时直接报错。后来在venv里重装了一遍才正常。python3 -m venv ~/pwnenv source ~/pwnenv/bin/activate pip install pwntools装完以后启动gdb验证一下pwndbg是否生效。如果命令行提示符变成了pwndbg说明插件加载成功。再接着用checksec命令看二进制文件的保护机制。注意这个checksec在pwndbg里也内置了不需要特意另装直接运行即可。环境配好之后我通常会在本地写一个最小测试程序来验证工具链是否畅通。比如写一个简单的gets函数读输入的C程序编译后扔给pwntools发送一长串字符看程序是否Segmentation fault。这一步很值它能提前确认你的gdb、pwntools和系统glibc之间没有暗坑否则等到比赛题目真正打开时才追环境问题节奏就全乱了。3. 拿到PWN题先别慌从file和checksec读出出题人的思路环境就绪以后面对一个陌生的ELF文件我习惯按固定顺序操作。第一步用file命令看文件类型第二步用checksec看防护机制第三步用strings扫一遍可见字符串第四步再做反汇编。file ./pwn_test checksec --file./pwn_test为什么顺序这么重要因为file的结果决定你要不要换架构。很多新人看到32位的ELF和64位的ELF默认按同一种套路处理结果payload构造时栽了大跟头。32位程序参数通过栈传递64位程序前几个参数走的是寄存器栈对齐要求也不同。仅这一条就足以让不检查架构的人白熬夜。checksec给出的信息更是直接关系到利用方案的选择常见保护项和应对思路可以整理成一张对照表保护机制含义对利用方式的影响NX开启栈不可执行不能直接执行shellcode考虑ret2libcPIE开启程序基址随机化需要先泄露地址或利用部分覆盖Canary开启栈金丝雀保护溢出前必须绕过Canary校验RELRO完全开启GOT表只读格式化字符串改GOT的方案受限我这次在SHCTF的栈溢出题里遇到的组合就是典型的NX开启、无Canary、PIE未开。看到这个组合脑子里立刻知道方向不需要费劲去泄露地址重点是如何在栈不可执行的约束下构造ROP链。strings这一步经常被忽略但它特别有用。程序里残留的打印提示、函数名、甚至调试信息都能帮你快速锁定漏洞入口。比如题目里如果出现/bin/sh这样的字符串那多半是在暗示你往shellcode或ret2libc的方向考虑。如果出现类似Too long!的提示大概率对应某个长度检查逻辑后面可能会隐藏整数溢出。真正打开反汇编界面后第一要找的是危险函数。gets、strcpy、strcat、sprintf这一族都是经典溢出入口。用pwndbg反汇编主函数重点观察栈空间分配大小和拷贝长度这些数字直接决定payload里的填充长度。4. 栈溢出利用的完整链路从偏移量计算到拿到shell栈溢出的套路本身并不复杂但它像组装模型一样每一步都必须精确。我以这次做的题目为例把整条链路串一遍。这道题的伪代码逻辑大概是程序先打印一行提示然后调用gets读取用户输入到局部缓冲区接着判断一个全局变量是否会变成某个特定值不做任何长度限制。编译时关闭了Canary开启了NX未开PIE。这个配置对新手特别友好因为即使被溢出了也不会被Canary打断不需要猜测随机基址。第一步确定缓冲区到返回地址的偏移量。这一步我直接用pwntools的cyclic来完成from pwn import * context.log_level debug io process(./pwn_stack) payload cyclic(200) io.sendline(payload) io.wait() # 从core dump或gdb中读取RIP的实际值出现Segmentation fault后用gdb查看崩溃时的地址。pwndbg会自动提示RIP: cyclic offset或者你可以在gdb里直接运行cyclic_find。比如崩溃地址是0x6161616a对应的偏移量就是42。这个数很关键它表示从缓冲区起始位置到返回地址之间需要填充42字节。第二步寻找可以利用的函数或后门。很多入门题的代码里都会留一个未调用的函数里面封装了system(/bin/sh)或者cat flag的操作。反汇编里找到这个函数的地址比如0x08048456。如果题目没留后门就得走ret2libc路线先泄露puts地址再算出libc基址。第三步构造返回地址覆盖。由于NX开启栈里不能执行shellcode所以直接跳到后门函数即可。payload长度非常短payload bA * 42 p32(0x08048456) io.sendline(payload) io.interactive()发送之后程序会重新走到那个函数打开一个shellflag就都在眼前了。第一次真正跑通的时候屏幕上的$提示符出现的一瞬间那种成就感确实很提气。不过栈溢出还有一个隐藏考点就是返回地址覆盖后你能跳到哪里并不等于你能控制完整的调用链。有时候后门函数是先打印再退出那没问题但如果你想实现任意代码执行还得考虑栈对齐问题。64位程序里调用system(/bin/sh)时如果栈没对齐会直接崩溃这是新手最容易踩的大坑。解决办法就是在payload里多加一个ret指令的地址让栈在进入system之前先对齐到16字节边界。这种硬试出来的经验比教程里看到的更让人印象深刻。5. 整数溢出的隐蔽性对比长度检查后发生的越界第二道题考察整数溢出这题比栈溢出更考验对代码逻辑的理解。很多时候漏洞的表象是栈溢出但触发它的前提条件却是整数溢出造成的一次错误判断。题目程序里有一段逻辑输入一个长度值然后根据这个值决定从输入里拷贝多少数据到缓冲区。乍一看长度被限制了怎么想都不应该溢出。但如果这个长度值被存储在一个有符号变量里而实际参与比较或拷贝时被转换成了无符号类型情况就完全不同了。举个例子一个32位的有符号整数如果输入的是0xFFFFFFFF那么它在有符号视角下是-1。当代码执行if (len 10)这样的判断时-1确实小于10检查通过了。可是紧接着拷贝时计算的是memcpy(dest, src, len)参数len被隐式转换成了无符号数也就是4294967295。于是memcpy会尝试拷贝将近4GB的数据缓冲区早就被撑爆了。这就是整型溢出在PWN里的经典玩法表面限制形同虚设溢出也就必然发生。我在Solving时先用file确认程序是64位然后在源码级别确认长度变量是int类型。随后用一段测试输入验证栈上缓冲区的覆盖情况。当时我写了一段Python脚本循环尝试不同的长度边界from pwn import * # 依次尝试 0x7fffffff, 0xffffffff 等特殊值 for num in [0x7fffffff, 0x7ffffffe, 0xffffffff, 0xfffffffe]: r process(./pwn_int) r.recvuntil(blength:) r.sendline(str(num).encode()) r.recvuntil(bdata:) r.send(bA * 200) print(r.poll(blockFalse)) r.close()当发送0xffffffff时进程明显退出异常说明检查被绕过拷贝长度判断已经超限。这时再利用溢出控制返回地址思路就和第一部分完全一样了。整数溢出最迷惑人的地方在于题目可能连特殊值都不给提示。你需要手动试出哪个数值能让逻辑失效。判断方法也不难就是穷举几个有代表性的数字比如0x7FFFFFFF、0x80000000、0xFFFFFFFF观察程序行为的变化。在gdb里配合观察比较跳转指令的分支情况基本上十分钟内就能定位漏洞。这类题想巩固的话要理解C语言里的隐式类型转换规则。signed和unsigned混用时到底谁被转成谁是跟操作数的位宽和类型等级有关的。我刚入门时一直记反后来就用一个小口诀有符号和无符号打架时通常无符号赢。这个口诀虽然不严格但在绝大多数CTF题目里都够用。6. 调试与利用过程中真正让人崩溃的五分钟整个做题过程不是一路顺畅的。我印象最深的一处卡壳是在栈溢出题已经构造好payload、send出去、屏幕上却没有出现shell。我当时第一反应是地址算错了翻来覆去地核对调用后门函数的地址。结果问题根本不是地址而是本地程序的路径带了argv[0]参数问题——我直接用process(./pwn_stack)启动时二进制文件依赖的某个so找不到导致程序在进入主逻辑之前就崩溃了。这类环境型bug在PWN里特别常见。Kali自带工具虽然全但glibc版本和题目里打包的libc版本未必一致这时候最简单的办法是下载题目自带的libc然后用pwninit或patchelf把链接器指过去。具体命令可以参考patchelf --set-interpreter ./ld-2.27.so ./pwn_stack patchelf --set-rpath . ./pwn_stack还有一次崩溃是忘记考虑64位程序的栈对齐。system函数在glibc内部会用到SSE指令如果调用时栈指针恰好是16的倍数而非8的倍数movaps就会触发Segmentation fault。这种问题在本地调试时会表现得非常莫名其妙地址对、函数对、参数对但就是崩。解决办法就是在发送payload时在返回地址后面多塞一个ret gadget。这个细节非常值得记下来因为十次做ret2libc有七次新手崩溃原因都是它。另外pwndbg的cyclic和gdb.attach组合用起来很方便。我发现有些时候脚本里写上gdb.attach(io)程序会停住等你手动调试。这种模式下可以逐步观察payload的结构在栈上的排布情况比事后分析core dump直观得多。不过每场比赛我都会把握一个度不能在调试器里陷太久——如果某一步连续试了四十分钟还不行果断换一个角度重新读源码很可能你纠结的地方根本不是漏洞点。7. 给同样卡在PWN门口的人三个回归实操的建议打完这次SHCTF的两道PWN题我最真实的感受是PWN并没有那么遥不可及但它是一道需要重复练习才能建立手感的技术。入门阶段给自己定几条务实的路线比到处收藏教程有用得多。第一把工具链练到条件反射的程度。拿到题目第一反应就应该是file、checksec、strings接着打开pwndbg。这些操作不需要犹豫练到像吃饭喝水一样自然就对了。第二一定要自己动手跑完至少十个经典的栈溢出入门题。光看writeup和只看解题视频效果都很差因为每个程序保护组合不一样调试中遇到的问题也各不相同这些经验只能靠实战积攒。第三养成赛后写复盘笔记的习惯尤其是把你卡壳的地方原原本本记下来哪怕是很蠢的忘记加ret对齐一周后回头看会发现这些问题解决了你的水平也就上去了。我个人的体会是入门PWN最需要的不是聪明而是耐心。环境配置出错就慢慢排查偏移量算错就重新跑一次整数溢出试不出来就多换几个边界值。这些动作本身都不难难的是愿意一遍遍坐下来磨。等到某一天你发现自己闭着眼睛都能把payload拼出来那一刻回头看第一次碰PWN时的手忙脚乱就会觉得一切都很值得。