PWN 题复现最让人头大的往往不是漏洞利用本身而是环境。题目是 2017 年的系统是 2024 年的中间隔着好几代 glibc、内核默认配置和调试器习惯直接在宿主机上跑很容易出现“远程打得好好的本地栈偏移全乱”、“gdb 里明明能通脚本一执行就崩”这类玄学问题。这篇就以 2017_NJCTF_Messager 为例把 PWN 环境搭建从系统选择、工具链安装、二进制信息收集到栈偏移验证的完整流程过一遍顺带把我踩过的坑全部整理成排查清单给准备复现老题或者刚开始刷 PWN 的人当个参考。1. 开工之前怎么从一道老题反推环境需求拿到一个老 PWN 题目不要急着上网找 writeup先花十分钟分析二进制本身。绝大多数情况下环境问题都藏在二进制文件的特征里。1.1 从二进制特征反推系统版本用file和readelf先摸底。以 Messager 为例这是一个典型的 32 位 ELF 可执行文件调用的是 32 位动态链接器/lib/ld-linux.so.2。这个细节直接决定了你的系统必须装 32 位运行库否则连启动都会报错。第二个关键信息是 glibc 版本。题目编译于 2017 年当年的 Ubuntu 16.04/17.04 自带的 glibc 是 2.23 或 2.24 左右。可以用objdump -T ./messenger | grep GLIBC查看它引用的最低 GLIBC 版本符号如果能看到GLIBC_2.23那基本可以确定这题是在 glibc 2.23 环境下编译的。为什么这么在意 glibc 版本因为one_gadget、system函数地址、__libc_start_main的偏移、格式化字符串打exit的 GOT 偏移全都跟 glibc 强相关。本地环境版本差太远你在本地算好的 libc 偏移到远程就是废的。1.2 搭建之前先列清单我每次搭环境都会先列一张清单避免装到一半发现缺东少西。以 Messager 为例至少需要这些东西Ubuntu 16.04 或 18.04 系统环境虚拟机/容器均可基础编译工具build-essential、gdb、gcc-multilib32 位运行库libc6-dev-i386Python 3 pip 虚拟环境pwntools 库GDB 增强插件pwndbg 或 gef二选一即可checksec 工具pwntools 自带也可单独脚本这些工具的分工很明确file/readelf/objdump负责静态信息收集gdb插件负责动态调试pwntools负责写 exploit 脚本和与远程交互。在 Debian/Ubuntu 系之外的其他发行版上也能完成同样的事情但坑会多不少下文会解释为什么我强烈推荐 Ubuntu 系。2. 系统层准备选对发行版和基础依赖2.1 为什么选择 Ubuntu 而不是其他发行版做 PWN 的圈子里CTF 题目的出题环境基本都是 Ubuntu 系这直接决定了/lib/ld-linux.so.2的路径、glibc 的默认配置、GOT/PLT 的布局在 Ubuntu 系上是最贴近出题人预期的。如果你用的是非常精简或滚动更新的发行版可能会遇到几个问题32 位库装不上、glibc 版本太新导致strcpy这类函数的行为变化、LD_PRELOAD机制被限制。这些问题不是不能解决但会消耗你大量排查时间。第二个原因是老版本 glibc 不太好获取。如果你已经用一个高版本 glibc 的发行版做主力又想复现 2017 年的题目你可能需要自己编译老版本 glibc 或者去折腾各种运行时替换工具这个成本远超直接开一个 Docker 容器装 Ubuntu 16.04 镜像。我的习惯是主力机保持系统纯净所有 PWN 题一律放容器或虚拟机。下面以宿主机已经装好 Docker 为前提如果你更习惯虚拟机操作逻辑完全一样只是少了docker exec这一步。2.2 安装命令实录假设你已经进到 Ubuntu 16.04 容器或者虚拟机的终端里先更新源然后装基础依赖apt-get update apt-get install -y build-essential gdb gcc-multilib \ libc6-dev-i386 python3 python3-pip \ file netcat-openbsd curl这里逐个解释每个包的作用build-essential是编译工具链后期编译辅助 C 程序时会用到gdb是动态调试器不用多解释gcc-multilib和libc6-dev-i386是 32 位程序运行和编译的必备库缺了它./messenger会直接提示找不到/lib/ld-linux.so.2python3和python3-pip用来跑 pwntools 脚本。再强调一次libc6-dev-i386这个包最容易被忽略。很多老题是 32 位 ELF少了 32 位动态链接器你的程序连跑都跑不起来而报错信息却是“No such file or directory”非常迷惑人。2.3 Python 虚拟环境与 pwntools 安装pwntools 是 PWN 的生命线。直接往系统 Python 里装不是不行但我推荐先建一个虚拟环境避免以后其他项目把依赖搞乱python3 -m venv ~/pwnenv source ~/pwnenv/bin/activate pip install pwntools安装完成后做一个快速自检python -c from pwn import *; print(asm(push ebx; pop eax))能正常输出机器码就说明 pwntools 的核心功能已经可用。如果asm这步报错最常见的原因是缺少 GNU 汇编器或者 libc 开发头文件回到第 2.2 节再检查一遍。3. 调试与利用工具链配置3.1 GDB 插件选型参考GDB 裸奔调试 32 位 PWN 程序不是不行但效率很低。裸 GDB 不会告诉你当前栈上偏移 14 处是什么也不会帮你快速查溢出点在哪个寄存器。装一个插件可以极大提速。目前最常见的是三款peda、pwndbg、gef。peda老牌插件输出风格适合从零学pattern生成和查找功能简单直观但新版本维护较少对较新的 GDB 兼容性偶尔出问题。pwndbg近几年的社区主流断点命中时会自动打印寄存器、栈、反汇编配合cyclic和fmtarg这类 pwntools 辅助工具非常顺畅我个人主打这一款。gef轻量跨架构支持好如果你搞 ARM/MIPS 的题会很顺手但 x86 场景下的便利功能不如 pwndbg 丰富。安装 pwndbg 最省心的方式是直接拉官方仓库然后执行安装脚本。它会把 GDB 的初始化配置写到~/.gdbinit里以后每次进 GDB 都会自动加载。需要注意的一点是安装脚本通常会修改.gdbinit如果你以前配置过其他插件先备份。3.2 GDB 插件与 pwntools 的配合方式以前的习惯是在 GDB 里手动下断点、看内存现在更常用的姿势是在 pwntools 脚本里直接调用gdb.attachfrom pwn import * io process(./messenger) gdb.attach(io, gdbscriptb *main100\nc)这段脚本会启动被调试进程然后自动打开 GDB 并停在指定断点。用这种方式调试的位置是真实进程环境和最终跑 exploit 的环境完全一致避免“GDB 里环境变量和脚本里环境变量不同导致偏移漂移”的老大难问题。对于 Messager 这类的格式化字符串题调试时最常用的就是查看格式化字符串的偏移后面在第 5 节专门展开讲。4. 二进制信息收集与验证4.1 file 与 checksec 输出怎么读环境搭好以后第一步是对题目二进制做静态体检file ./messenger checksec --file./messenger实际输出大致是这种风格messenger: ELF 32-bit LSB executable, Intel 80386, \ dynamically linked, interpreter /lib/ld-linux.so.2, \ for GNU/Linux 2.6.32, BuildID[sha1]..., not strippedArch: i386-32-little RELRO: Partial RELRO Stack: Canary found NX: NX disabled PIE: No PIE (0x8048000)把这五行读明白利用思路就清晰了一半。Partial RELRO 意味着 GOT 表的前半部分只读后半部分可写所以printfgot这类目标是可以改写的。Canary found 说明程序编译时开了栈保护如果漏洞点靠栈溢出就先用格式化字符串泄露 canary。NX disabled 意味着栈可执行如果存在可控栈写入甚至可以插 shellcode。No PIE 是关键好消息二进制加载基址固定GOT、PLT、全局变量地址都可以直接写死在 exploit 里。把以上信息组合起来基本可以判断 Messager 这道题的核心资产是格式化字符串漏洞既能泄露 canary 和 libc 地址又能利用 GOT 可写特性劫持控制流。4.2 确认动态链接库依赖在checksec之后我还会看一眼动态库依赖ldd ./messenger正常情况下会显示libc.so.6 /lib/i386-linux-gnu/libc.so.6和/lib/ld-linux.so.2。如果你的环境里ldd报错或者显示“not found”那就回到第 2.2 节补装 32 位库。顺便记录 libc 的基础地址偏移。把这个输出保存下来后面算 libc 基址时很有用python -c from pwn import *; libc ELF(/lib/i386-linux-gnu/libc.so.6); print(hex(libc.symbols[system]))如果这一步能正常打印地址说明 ELF 解析没问题如果报错说明 libc 文件路径不对用find / -name libc.so.6 2/dev/null定位一下。5. 本地调试环境的核心动作5.1 关闭 ASLR 与开启核心转储同一个二进制如果宿主机开了完全随机化每次运行的栈地址、libc 地址都不一样这对调试格式化字符串偏移非常不友好。调试阶段我通常会暂时把 ASLR 关掉等到最后打远程模拟时再按需恢复。关闭 ASLR 有三种常见方式各有适用场景第一种临时修改内核参数sysctl -w kernel.randomize_va_space0这会对整个系统生效优点是简单粗暴缺点是所有程序地址都固定了而且用完记得改回 2否则影响其他正常调试。第二种只对单个进程生效setarch $(uname -m) -R ./messenger这种方式最推荐只关掉当前程序的地址随机化不影响系统全局。注意setarch -R对 32 位程序而言部分发行版需要写成setarch i386 -R ./messenger。第三种在 GDB 里控制。pwndbg 初始化脚本默认会帮你关掉随机化所以你在 GDB 里看到的地址往往比真实运行时更稳定。这一点既是便利也是坑后面第 6 节会提到。同时建议把核心转储打开方便崩溃时直接分析内存现场ulimit -c unlimited如果不改这个值程序崩溃只会产生一句提示不会落盘 core 文件很多现场信息就丢了。5.2 pwntools 调试脚本模板针对 Messager 这种本地调试 远程复现的场景我有一个可以直接抄的模板#!/usr/bin/env python3 from pwn import * context.arch i386 context.log_level debug # 本地调试 io process(./messenger, env{}) # 固定环境变量避免栈偏移漂移 # 远程复现时切换 # io remote(127.0.0.1, 10001) elf ELF(./messenger) # 第一次格式化字符串泄露 payload b%8$p.%9$p io.sendline(payload) io.recvline()几个细节说一下env{}表示给子进程一个空环境变量表这样栈上初始内容可控格式化字符串的偏移很容易和 GDB 里对起来。context.log_leveldebug会打印收发字节方便看清到底泄露了什么。如果你是在纯本地直接跑process(./messenger)就够如果验证远程环境再换remote。5.3 栈偏移核对与格式化字符串现场对 Messager 这种格式化字符串题核心调试点就是确认偏移。做法很简单往程序里输入一段特征明显的字符串比如AAAA.%p.%p.%p...后台输出的第几个%p对应到AAAA那个数字就是格式化参数的偏移。实际操作时我不会手动数一堆%p而是用 pwndbg 在 printf 处下断点然后直接看格式化函数的参数区。也可以更方便一点向程序发送%n$p这种按编号读取的格式去爆破配合脚本逐偏移尝试for i in range(1, 20): io process(./messenger, env{}) io.sendline(bAAAA.%{}$p.format(i).encode()) data io.recvline() if b0x41414141 in data: print(foffset {i}) break io.close()这一小段脚本的思路是发送AAAA.%3$p如果输出里出现0x41414141就说明偏移是 3。找到偏移之后后续的%n写地址、泄露 canary、写 GOT 就都围绕这个偏移展开。这一步务必在本地反复验证因为一旦偏移确认错误后面所有地址读写都会废掉。6. 常见问题与排查实录6.1 本地通了远程却打不通这是复现老题最常见的问题。本地没问题说明利用思路正确远程不通的原因多半是环境差异。典型的就是本地 libc 版本和远程不一致导致你算好的system地址、/bin/sh地址全部失效。这种情况解决办法是确定远程 libc从远程程序泄露一个真实地址比如__libc_start_main然后去 libc 数据库匹配对应版本最后把匹配到的 libc 文件丢进本地环境用LD_PRELOAD加载它复测。如果远程就是你自己用 Docker 起的服务那问题多半出在容器内的 libc 版本和宿主机不一致。进入容器执行ldd ./messenger看容器内实际链接的 libc 路径再根据这个路径修改本地 exploit 中的ELF(./messenger)加载方式。6.2 GDB 里能打通脚本一执行就崩这个问题几乎每个刷 PWN 的人都遇到过。原因很简单GDB 默认帮你关了 ASLR而 pwntools 的process()启动的程序完全走系统默认策略。你在 GDB 中看到的栈地址和 libc 地址是固定的脚本里跑的却是随机化的。解决思路有两个一是把 GDB 调试环境和你脚本环境尽量调成一致也就是让脚本也固定地址在启动命令里加setarch -R二是反过来让 GDB 开启 ASLR 来模拟脚本的真实环境在 pwndbg 里执行set disable-randomization off再调试。我个人更推荐第二种因为最终目标是让 exploit 在随机化下也能稳定工作而不是一直依赖关闭随机化。6.3 32 位程序在 64 位系统上无法启动表现是运行./messenger直接报No such file or directory但文件明明存在。迷惑性极强实际上缺的是 32 位动态链接器/lib/ld-linux.so.2。在 Ubuntu 上安装libc6-dev-i386或libc6-i386即可解决。如果你用的是容器注意基础镜像的架构必须是 amd64并且在容器内同样执行安装命令宿主机的库不会自动映射进容器。6.4 格式化字符串偏移和环境变量相关老题复现时栈偏移在不同环境下会有细微差异原因往往不是程序本身而是进程的环境变量表长度不同。环境变量占用了栈空间导致格式化参数在栈上的位置往前或往后移动。同一个输入在你这台机器上偏移是 6在另一台机器上偏移可能变成 7。避免这个问题的方法是让进程的环境变量表尽量干净。pwntools 的process(./messenger, env{})传入空字典再配合executable和env参数可以把调试环境固定下来。在本地和远程容器里都保持env{}能减少一大半偏移漂移问题。6.5 核心转储文件太大或者没生成开了ulimit -c unlimited之后如果 core 文件没出现在当前目录检查一下系统的 core 文件生成路径cat /proc/sys/kernel/core_pattern如果指向的是管道或者绝对路径把 core 文件路径改回来再试sysctl -w kernel.core_patterncore这样程序崩溃后就会在当前目录生成 core 文件。后面再用 pwndbg 的core命令加载它就可以直接查看崩溃现场不用重新跑一遍。7. 更省事的方案容器化环境7.1 为什么推荐容器而不是直接改宿主机复现老题时最怕环境互相污染。今天装一个 Ubuntu 16.04 兼容库明天又装一个老版本 glibc过段时间系统包管理器里的依赖就乱成一团。容器把环境隔离得干干净净一个题目一个镜像做完即弃完全不心疼。而且容器还有一个隐藏好处可复现性。你在一台机器上调试好的脚本换一台机器拉到同一个镜像跑结果应该完全一致不用再重新折腾一遍环境变量、libc 版本。对写 writeup 或者多人协作的团队来说这是最大的确定性保障。7.2 Dockerfile 参考下面是一个针对 32 位 PWN 题的最小 DockerfileFROM ubuntu:16.04 RUN apt-get update apt-get install -y \ libc6-i386 \ libc6-dev-i386 \ gcc-multilib \ gdb \ python3 \ python3-pip \ netcat-openbsd \ rm -rf /var/lib/apt/lists/* RUN python3 -m pip install pwntools WORKDIR /pwn COPY ./messenger /pwn/messenger RUN chmod x /pwn/messenger CMD [/bin/bash]使用方式docker build -t pwn-messenger . docker run -it --rm pwn-messenger进去之后直接./messenger就能跑。如果你需要模拟远程题目的 socat/xinetd 服务再补一个端口转发服务即可。需要注意容器默认也受宿主机的 sysctl 影响如果你要在容器里关 ASLR仍然需要setarch -R或者启动容器时加--security-opt seccompunconfined但后者一般没必要。8. 一点个人体会环境搭建这件事看起来不起眼实际上占了 PWN 题目复现的大半时间。很多人卡在第一步不是漏洞利用不够熟练而是本地环境千奇百怪今天缺库、明天偏移不对、后天 GDB 版本不兼容。我也走过这些弯路后来干脆形成了一套固定流程先看二进制特征再定系统环境然后统一用 pwntools pwndbg 调试所有跑过的脚本都保存在同一个目录把当时用的 libc 版本、ASLR 状态、环境变量设置全部写在脚本注释里。如果你打算长期做 PWN建议尽早把环境搭建的节奏固化下来不要指望每个题目都临时去搜一篇“环境搭建教程”。一套顺手、稳定、可复现的环境远比任何花哨的调试技巧更能帮你节省时间。做完了 Messager 这道题你还可以顺手做一件事把整个容器目录压缩保留或者把 Dockerfile 存进笔记。这样以后想复盘时三分钟就能把环境拉起来而不是再花一下午去解决“为什么我的 32 位程序跑不起来”。这一点是我踩过多次坑之后最想告诉你的一条建议。