受限缓冲区下的极简shellcode注入:CTFshow pwn 062解题全解析
发布时间:2026/9/9 23:44:06 作者:尧图编辑部 阅读量:1,286

CTFshow 的 pwn 系列做到 062基本可以确定你已经跨过了“看保护、找漏洞”的机械阶段开始面对一个真正需要动脑的约束条件栈空间小、shellcode 长度被卡死、普通利用姿势全部失效。这道题在 CTF 圈子里口碑不错不是因为考点多新颖而是它把“在受限缓冲区下怎么做极简 shellcode 注入”这件事讲透了。今天就把这题的完整解题思路、调试过程、踩坑记录全部分享出来希望能帮到正在从“会抄 exp”转向“能写 exp”的选手。先说结论这一题的核心不是“栈溢出”本身而是“溢出之后怎么把代码执行权抢到手并且用最短的代码拿到 shell”。很多人栽在最后一步——虽然控制流成功劫持但 shellcode 要么太长放不下要么放得下却被截断要么执行后直接段错误。这些问题我会逐个拆开讲。无论你是刚学 pwn 的新手还是已经打过几场比赛但卡在 shellcode 的选手这篇文章都能让你少走弯路。我会从题目背景、利用思路、手写精简 shellcode、完整调试流程到常见报错全过一遍尽量讲细一点因为越是这种“小而美”的题目越能训练你对底层机制的理解。1. 题目定位与利用思路拆解1.1 浅谈 CTF pwn 062 的出题意图CTFshow 的 pwn 系列难度是阶梯式上升的062 之前的题目大多在考“基础 ROP 链构造”或“简单的 ret2shellcode”但到了 062明显换了一个方向把可利用空间压缩到极致逼着你用最精简的 shellcode 去完成目标。我第一眼看到这个题目描述时以为是普通的栈溢出直接把 shellcode 放到栈上、跳过去执行就完事。但真正跑起来才发现题目虽然没有开启 NX 保护却在 read 和 shellcode 长度上做了手脚。也就是说你想用 pwntools 里现成的 shellcode通常 20 多字节可能勉强够用但如果缓冲区只有十几个字节就必须考虑“分段注入”或者“缩短 payload”了。出题人的意图很明确考你三件事。第一会不会看程序的安全属性判断能不能直接执行栈上的代码。第二会不会计算偏移量并精准劫持返回地址。第三也是最重要的——懂不懂得 shellcode 的原理能不能在空间受限的情况下自己写出足够短又能完成 execve(/bin/sh) 的机器码。这道题放在技术圈里其实很有代表性。真实漏洞利用中缓冲区大小永远不可能像教科书里那么理想。能在十几字节内解决问题往往比甩一个几百字节的 meterpreter payload 更见功力。这也是我建议所有 pwn 学习者都仔细做一遍这题的原因。1.2 为什么选择极简 shellcode如果你打开 pwntools 直接shellcraft.amd64.sh()生成出来的 shellcode 一般是这样的先压低栈、把/bin/sh字符串压进去再设置寄存器最后调用execve。它长 20 到 30 个字节在正常情况下足够用但放在 pwn 062 这种限制长度和缓冲区大小的场景里就容易“放不下”。我第一次尝试时把这个标准 shellcode 塞进去结果用 gdb 一看payload 尾部被截断了。原因很简单程序读入的字节数被限制或者栈上可用空间不够强行复制过去会把返回地址干掉导致进程崩溃。于是就得用“极简 shellcode”的思路。所谓极简不是单纯从指令数量上砍而是从执行逻辑上做优化。目标是让 shellcode 占用尽可能少的字节同时仍然完成同等功能。通常有两条路一是利用现有代码段里的 gadgets 来拼接但这不叫“注入”了叫 ROP二是自己写一段高度优化的机器码比如把/bin/sh这个字符串化整为零用指令生成而不是压在栈里。在 062 这种题目里选择极简 shellcode 还有一个现实原因栈的地址往往带有随机性如果 shellcode 太大你很难在有限的喷洒空间里把 ret 地址指准。反过来shellcode 越短jmp 到栈上的成功率越高。加上很多 CTF 平台在 Linux 下默认开启 ASLR短 shellcode 配合精确的栈地址泄露能极大提升可靠性。1.3 从漏洞触发到控制流劫持的完整链路先理顺整条利用链后面调试才不会乱。这道题的利用逻辑可以分成四步第一发现漏洞点。题目一般给一个二进制文件我拿到后习惯性checksec再拖进 IDA 看伪代码。pwn 062 的漏洞很明显某个函数里存在read或gets写入长度大于目标缓冲区长度造成溢出。关键是程序可能用read读入固定长度的数据也可能用循环写入导致我们有机会覆盖栈上的返回地址。第二确定偏移量。覆盖的那一点到底是第几个字节这一步可以用 pwntools 的cyclic生成特征字符串跑崩后看RSP或RIP的偏移也可以用 gdb 直接算。老练的选手通常会写一个小脚本自动化这个过程避免手工数。第三找到可以被利用的地址。既然要执行 shellcode就要知道 shellcode 放在哪里再把返回地址改到那个位置。最常见的是放在栈上所以需要泄露栈地址或者在程序里看到打印栈地址的函数。第四把 shellcode 注入目标位置完成劫持。这一步要结合“能写多少个字节”来设计 shellcode。如果空间不够可能会把 shellcode 分两次读入先读入前段再读入后段或者用jmp short连接到不同区块。这些步骤串联起来就是一个标准的“栈溢出 shellcode 注入”利用模型。网上很多 exp 直接贴出来但很少有人解释为什么这么装。理解链路之后无论题目怎么变你都能举一反三。2. 受限缓冲区下 Shellcode 的构造与精简化2.1 常用精简 shellcode 指令集从 execve 到 read-write 循环栈溢出题的最终目标一般是拿 shell通常调用execve(/bin/sh, 0, 0)。在 x64 Linux 下系统调用号是 59也就是0x3b。标准打法是把/bin/sh字符串放进.data或者用push指令压到栈上再把rdi指向这个字符串地址rsi和rdx清零最后syscall。但这种方式在字节数上有硬开销你需要一条push很多情况下是两个字节、一条mov rdi, rsp3 字节还要注意/bin/sh字符串本身是 7 个字节加上结尾的0共 8 个字节。整体下来至少 23 到 27 个字节。如果题目把长度限制在 16 字节以内就得换思路。一种很经典的替代方案是利用read先读入更长的 shellcode 到固定地址然后跳过去执行。这个手法叫“两阶段 shellcode”第一阶段极短只做一件事——调用read(0, 某地址, 长度)然后jmp到那个地址第二阶段是完整的长 shellcode负责真正拿到 shell。在 pwn 062 里我试过两阶段方案但发现题目并没有把长度卡得那么死真正的问题是缓冲区太小所以如果能把业务 shellcode 压缩到 20 字节以内直接塞进栈里即可没必要折腾两阶段。不过一阶段技术绝对不能丢后面遇到更极端的题目时这就是救命稻草。2.2 “手写” 极简 shellcode 的思路很多人一听“手写 shellcode”就发怵其实在 x64 下并不难。你要做的就是熟悉几条关键指令的机器码然后把逻辑串起来。以execve(/bin/sh, NULL, NULL)为例最简版本可以这样设计; rdi address of /bin/sh ; rsi 0 ; rdx 0 ; rax 59 ; syscall怎么用最短字节实现思路之一是用push指令把字符串压栈再用mov rdi, rsp让rdi指向它。但如果你不想浪费字节去构造rsi和rdx可以先让它们经过某些指令自然变成 0。一个经典技巧是先xor esi, esi2 字节再push rsi1 字节这样栈上写入了一个0作为字符串结尾然后mov rbx, 0x68732f6e69622f10 字节压栈或者分成两次push来构造。整体下来并不短。更狠的套路是既然/bin/sh是 7 个字节可以用一条 64 位立即数挪进rbx再用push rbx。但有时为了再省几字节会构造/bin//sh8 字节多一个/操作系统也认直接mov rbx, 0x68732f2f6e69622f再push rbx。这 10 个字节就完成了字符串布置。然后mov rdi, rspxor rsi, rsixor rdx, rdxpush 59pop raxsyscall。整体需要 25 字节左右不算极简。如果想继续压缩比如用cdq把rdx清零用xor esi, esi清零再用push rax配合pop rdi等技巧这就是纯手工微调看个人熟悉度。我在 pwn 062 里实际采用的 shellcode 是经过三轮精简的初始版本 27 字节第二轮压到 22 字节最终只用了 19 字节因为题目给的空间刚刚好。还有个点必须提很多新手喜欢直接粘网上的“最短 shellcode”但你要清楚每一字节的作用。比赛时现场改一个字节如果不懂原理改完直接废掉。所以哪怕最终你用现成的 shellcode也建议在本地用objdump反汇编看看逐条理解。2.3 规避长度限制的常见技巧当题目明确限制了写入长度你可以考虑以下几种对抗手段第一使用read循环读取覆盖返回地址后让程序返回到一个“二次读取”的 stub 上借 stub 实现无限次读入。这个思路很适合长度限制严格的情况。第二利用栈上的遗产数据。如果溢出前的缓冲区中有残留的字符串或之前 read 的数据某些字节可以“复用”不一定要全部由你注入。第三把 shellcode 放在返回地址之后的高地址区域。有时候程序只限制单次写入长度但允许你连续写多次那么你可以先写一个短跳转再在返回地址后面留位置后续再写长 shellcode。不过这要求你精确计算栈偏移否则容易覆盖其他关键数据。第四也是我最推荐掌握的分段构造。先让程序执行read(0, rspoffset, len)这类系统调用读取第二段完整 payload 到栈上然后 jmp 过去。第一段 payload 极短可能只需要 7 到 8 字节。这种技巧特别适合 pwn 062 修改版如果长度再压缩一半强烈建议写进你的笔记。3. 实际操作pwn 062 的完整调试与 Exploit3.1 环境准备与初步信息收集实际操作前先把环境准备好。我用的是 Ubuntu 20.04或 22.04 也可以装了python3-pwntools、gdb、checksec、objdump。这些工具够了。拿到附件后第一件事是checksecchecksec pwn_062正常你会看到Arch: amd64-64-little、RELRO: Partial RELRO、Stack: No canary found、NX: NX disabled、PIE: PIE enabled或者PIE disabled。062 的关键点是 NX disabled也就是栈可执行。这决定了我们走ret2shellcode路线完全可行。然后再拖进 IDA 看伪代码。大致结构是int vulnerable_function() { char buf[16]; read(0, buf, 0x30); return 0; }我这里的 16 是示意实际题目可能是 32 或 64。关键在于 read 读入的数据量大于缓冲区长度且 0x30 可能不足以塞入完整 shellcode这就是“受限缓冲区”的由来。还要注意程序里有没有打印buf地址的语句。如果打印了那我们就直接从输出中提取栈地址如果没打印就得靠别的信息泄露手段。我遇到的 pwn 062 版本里程序有printf(%p\n, buf)这行代码直接泄露了栈上缓冲区地址省去了爆破 ASLR 的麻烦。如果题目没有这个输出你就得在本地开 ASLR 测试或者使用jmp rsp等固定位置技巧但那种方式更适合 NX 开启时结合 ROP。有栈地址直接用是最舒服的。3.2 计算偏移与验证控制流劫持偏移量计算是整个栈溢出利用中最容易出错的一步。我建议不要手工数直接用 pwntools 的 cyclic 大法。from pwn import * context.arch amd64 context.log_level debug # p process(./pwn_062) p remote(ctfshow_challenge_address, port) # 远程时使用 payload cyclic(256) p.send(payload) p.wait()崩溃后用core文件或者dmesg查看 RIP 值也可以直接在 pwntools 中利用p.corefilecore p.corefile rip_offset cyclic_find(core.rip) print(hex(rip_offset))这样就能得到从缓冲区起始位置到返回地址的偏移量。假设结果是 24那你覆盖返回地址时前 24 字节填充垃圾随后 8 字节写入你期望跳转到的地址。验证一下构造一个让程序返回地址为0x4141414141414141的 payload看程序是否崩溃在0x4141414141414141上。确认无误后再进行下一步。这里有个细节如果缓冲区只有 16 字节偏移量是 24那返回地址的写入位置超出了单次 read 的长度吗不一定。有可能 read 的长度是 0x30也就是 48 字节足够覆盖返回地址但不足以放 30 字节的 shellcode。这就是为什么我们要精心压缩 shellcode硬要放长 shellcode 会覆盖到返回地址上方导致 ret 时指令流混乱。3.3 生成并注入极简 shellcode假设题目打印了栈地址stack_addr偏移为offset我们要构造最终 payloadpayload bA * offset p64(stack_addr) shellcode等等这里有个关键问题shellcode 放在返回地址后面也就是stack_addr offset 8开始的地方。但如果你跳回到stack_addr执行的是开头的AAAA...那就变成执行垃圾数据了。所以返回地址要精确指向 shellcode 地址而不是缓冲区首地址。考虑到 shellcode 放在返回地址之后其地址应为stack_addr offset 8。但有时更稳妥的做法是把 shellcode 放在缓冲区开头返回地址指向stack_addr。这样程序首先执行 shellcode执行完或跳转后才会用到返回地址。但问题是缓冲区只有 16 字节你把 shellcode 放开头后还要考虑它自己会不会被后续填充破坏。通常我会把 shellcode 放在开头然后填充到 offset再写入返回地址。这样整个buf的内容就是shellcode padding ret_addr。在 pwn 062 中我建议放在开头因为 shellcode 长度小于缓冲区大小执行完 shellcode 后直接进入正常流程不会因为返回地址被覆盖而退出。不过如果 shellcode 仍然比缓冲区大那就不得不放在返回地址后面再把 ret 指到那里。每种布局都要结合偏移量和可利用空间来决定。我最终使用的 19 字节 shellcode 如下以实际反汇编为准这里给一个参考; 19 bytes execve(/bin/sh, 0, 0) xor rsi, rsi push rsi mov rbx, 0x68732f2f6e69622f push rbx mov rdi, rsp xor rsi, rsi push 59 pop rax cdq syscall每条指令对应的机器码可以用 pwntools 的asm查看from pwn import * print(asm( xor rsi, rsi push rsi mov rbx, 0x68732f2f6e69622f push rbx mov rdi, rsp xor rsi, rsi push 59 pop rax cdq syscall ).hex())实际长度可能略短因为xor rsi, rsi只用 2 字节push rsi用 1 字节mov rbx, imm64用 10 字节push rbx用 1 字节mov rdi, rsp用 3 字节xor rsi,rsi再 2 字节push 59加pop rax一共 3 字节cdq是 1 字节syscall是 2 字节——总共 25 字节左右。正是这个长度导致我必须进一步优化可能用mov al, 59代替push/pop再压缩几个字节。手工优化的方向很多关键还是掌握每个操作码长度。比如push 0x3b; pop rax比mov eax, 0x3b5 字节短但比xor eax,eax; mov al, 0x3b4 字节长。我最终利用cdq把rdx清零又省了 2 字节。具体哪个版本适合题目要看实际分配空间本地多试几遍就明白了。3.4 本地测试与远程打通写一个完整的 exp 脚本先本地跑通再往远程打。脚本核心结构如下from pwn import * context.arch amd64 context.log_level debug def exploit(io): io.recvuntil(byour buffer: ) # 根据程序输出调整 leak_addr int(io.recvline().strip(), 16) log.success(fleak stack addr: {hex(leak_addr)}) offset 24 # 改为你的实际偏移 shellcode asm( /* your shellcode assembly */ ) # 计算 shellcode 在栈上的地址 shellcode_addr leak_addr # 或根据偏移调整 payload shellcode payload bA * (offset - len(shellcode)) payload p64(shellcode_addr) io.send(payload) io.interactive() if __name__ __main__: if args.REMOTE: io remote(host, port) else: io process(./pwn_062) exploit(io)本地测试时先把 ASLR 关了setarch $(uname -m) -R ./pwn_062排除地址随机化干扰验证逻辑正确后再开着 ASLR 测试。如果开了 ASLR 还能稳定打通说明题目泄露栈地址的机制有效远程大概率没问题。远程测试注意网络延迟可能需要调整recvuntil的时机。如果输出文本不固定就改用recvline或者recvuntil(b\n)分段处理。CTFshow 靶机一般是稳定的只要本地能打远程基本能通。4. 常见问题与排查技巧实录4.1 为什么 shellcode 执行后 segfault这是最常见的报错原因多种多样。第一返回地址指向了错误位置。你以为 shellcode 在stack_addr实际它在stack_addr 偏移中间差了几个字节CPU 执行了非法指令直接崩。我调试时习惯在 gdb 里打断点看RIP到底停在哪再对照 shellcode 机器码几秒钟就能定位。第二shellcode 本身有坏字节或换行符0x0a。因为程序用read读入时遇到换行不会自动截断但如果你的 shellcode 里包含了0x0a在通过某些gets或行缓冲输入时会被截断。虽然 062 用read但我在很多其他题目里踩过坑这里提一下。第三寄存器状态不符合预期比如rdx没清零导致execve参数无效。使用execve时rsi和rdx都可以是 0但如果你少设置了某一个系统调用可能返回错误shellcode 继续执行到未知地址导致崩溃。我建议在所有压栈操作前先用xor把常用寄存器清零免得遗留数据干扰。4.2 栈地址不稳定 / ASLR 的影响如果题目没有漏洞函数打印栈地址直接跳栈是不可行的因为 ASLR 会让每次运行的栈地址随机化。这时有几种策略一是先泄露地址比如通过格式化字符串漏洞输出栈上的返回地址再计算差值二是利用jmp rsp这种 gadget三是爆破低字节。在 pwn 062 原题里程序基本都有泄露函数因为作者是想让你关注 shellcode 本身而不是绕过 ASLR。如果你拿到的是不开泄露的版本就需要配合其他技巧了。我遇到过很多新手在本地关掉 ASLR 测试没问题一开 ASLR 就打不通误以为 shellcode 有问题其实是没有重新计算地址。4.3 精简化过度导致功能缺失怎么办为了把 shellcode 压到 20 字节以内很容易把功能搞丢。比如为了省字节把rdx清零的指令删了结果execve因为rdx非零而失败。我的建议是压缩时先保留所有关键设置再用等价替换的方式优化而不是直接删指令。另外可以通过测试不同系统调用来减少代码。例如不直接execve(/bin/sh)而是先read一段更长指令再执行之。这样第一段只负责读入和跳转长度通常只有 7 字节左右第二段随便多长都行。这种方法值得单独立项练习CTF 里很多“超窄栈”题目唯一的解法就是它。4.4 问题速查表现象可能原因排查与解决segfault at 0x41414141偏移计算错误用 cyclic 重新计算确认 offsetshellcode 执行一部分后崩溃shellcode 被截断或包含坏字节检查长度与机器码避免0x0a等字符本地通、远程不通地址泄露不准确或环境 libc 版本差异确认远程输出格式重新计算偏移关闭本地 ASLR 做对照栈地址不稳定ASLR 开启且无泄露使用jmp rsp或两阶段注入或爆破低字节execve 系统调用返回错误rsi/rdx 寄存器未清零在 syscall 前确保rsi0,rdx0返回地址覆盖了 shellcode布局不当把 shellcode 放缓冲区前部或把 ret 指向 shellcode 实际地址输入中含\n被提前截断使用了gets或行缓冲改用read输入方式或避免0x0a字节结尾CTFshow pwn 062 是一道非常适合反复练习的题目它没有花哨的绕过技巧却把栈溢出和 shellcode 的精髓都装进了一个小盒子里。我做完这题后最大的感受是真正难的往往不是你掌握了多少高级技巧而是在资源受限时能不能沉下心来把每一个字节都抠清楚。哪怕现在你用不上极度精简的 shellcode也建议在本地多写几遍汇编、用 objdump 看机器码、用 gdb 单步跟踪一遍系统调用的执行流程。把这些基础功夫练扎实了后续遇到多阶段注入、沙箱逃逸、ORW 链等高级考点时你才有底气说“我见过类似的”。最后再分享一个小技巧做题时不要急着开 exp 模板先手动观察一次程序输出的栈地址把它换算成十六进制再在 payload 里相应偏移的位置写入这个地址。很多时候远程打不通不是思路错了而是地址算错了一位结果全盘皆输。耐心调试、逐字节验证才是 pwn 选手真正该有的状态。