
做CTF这些年pwn方向始终是我觉得最有“博弈感”的领域。记得第一次在CTFshow平台上刷到“pwn入门-前置基础pwn32-pwn34”这个系列时我正卡在栈溢出的门槛上看了一堆文章仍然不会独立做出一道题。后来静下心把这几道前置基础题反复看了几遍又配合调试器一点点磨栈上的字节才总算把最核心的几条链路打通。如果你也刚开始接触CTF不懂什么是缓冲区溢出不知道pwntools怎么用那么这几道题就是一个很合适的切入点。这篇文章我会完整梳理PWN的前置基础环境怎么搭、栈和调用约定到底怎么回事、pwn32-pwn34这类题目到底考什么再给出一个从拿到文件到getshell的完整实操过程最后把新手常踩的坑列成速查表。希望你看完不仅会做题还能理解背后的原理。1. 环境搭建与工具准备先把“打靶”的弹药备齐1.1 为什么前置基础阶段就要认真搭环境很多人觉得PWN入门难一上来就啃汇编、读内核结果几天就劝退了。我个人的看法是PWN是动手学科光看教程永远学不会必须跑代码、看栈、贴偏移、调地址。前置基础阶段的关键不是学多深而是把一套能稳定复现的本地调试环境搭好。CTFshow平台上这类PWN题通常会给一个二进制文件让你在远程动态容器里连接并拿flag。你需要在本地先分析二进制、写出利用脚本最后打远程。如果本地环境不干净后面所有操作都会变得极其痛苦。比如工具链版本不对pwntools跑出来的偏移和远程不一致或者gdb插件乱七八糟导致调试时看不清关键信息这些都会让你怀疑人生。所以第一步强烈建议用虚拟机装一个干净的Ubuntu 20.04或22.04 LTS。不要直接在Windows上硬搞也不要为了“方便”去装一些精简版Linux很多库缺失会导致你分析时踩坑。VMware或VirtualBox都行分配2核4G内存就够用PWN题目本地运行时负载都不大。1.2 必备工具清单与安装方式CTF PWN常用的工具其实就那么几个不要贪多。我用得最多的是下面这些工具用途备注checksec查看二进制保护机制NX、PIE、Canary等随pwntools安装也可单独安装pwntoolsPython的PWN开发库构造payload、远程交互必装入门核心gdb pwndbg调试二进制看栈帧、寄存器、反汇编pwndbg是gdb插件强烈推荐objdump / readelf静态查看反汇编、节区信息binutils自带ROPgadget搜索gadget构造ROP链进阶后常用前置基础也会用到python3-pip安装Python包系统自带安装命令这里直接给出一套我试过在干净Ubuntu上执行没问题sudo apt update sudo apt install -y python3 python3-pip gdb binutils pip3 install --upgrade pwntools sudo apt install -y python3-pwntools # 这个看情况有的发行版需要装了不冲突接着装pwndbg官网给的是git clone方式git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh如果git clone慢也可以用我用的方式直接下载压缩包解压到本地然后执行里边的setup.sh。装完在gdb里输入pwndbg会显示版本号说明插件生效。注意pwntools和pwndbg都依赖Python3不要再傻乎乎用Python2了。有些老旧教程还在用p32()、u64()这些函数在Python3的pwntools里照样有不用担心。1.3 连接远程容器的基本姿势CTFshow的PWN题通常会给一个nc IP PORT的连接方式。做题前先手动连一下确认环境活着nc 127.0.0.1 20000如果本地没有nc可以用apt install netcat-openbsd。连接后如果有交互提示手动输入一些字符看看程序反应这对后续判断漏洞点很有帮助。我一般会同时开两个终端一个连远程观察输出另一个用python写exp每次改完payload直接打非常方便。2. 前置基础栈、函数调用与缓冲区溢出到底在说什么2.1 从C代码到汇编一场“栈”上的接力赛前置基础的核心就是理解“缓冲区溢出”。不用怕我们先用生活化类比拆开。想象程序里的函数调用像一条流水线每次调用一个函数操作系统其实是编译器生成的代码就会在“栈”上整理出一块临时区域用来存放局部变量、前一个函数的现场信息以及函数返回后要回去的地址。这块区域有个专门的叫法——栈帧。写一个简单的C程序比如#include stdio.h #include string.h void win() { printf(you win!\n); } void func() { char buf[16]; gets(buf); } int main() { func(); return 0; }func函数里的gets(buf)会把用户输入读进16字节的数组。gets不检查输入长度如果你输入超过16个字符多出来的部分会继续往栈的高地址方向覆盖先按顺序吃掉紧挨着buf的旧变量、旧栈帧基址最后吃掉返回地址。返回地址是CPU执行完func后要跳回main继续执行的地址。如果这个地址被覆盖成win函数的地址CPU就会跳到win执行于是拿到“额外”的程序逻辑控制权。这就是最朴素的栈溢出利用思路——劫持返回地址。2.2 x86和x64参数怎么传栈怎么排PWN入门阶段题目主要分x8632位和x6464位两种CTFshow的前置基础两道题往往涉及这两种架构所以必须知道它们的关键区别。32位程序传参是通过栈来传的调用函数前先把参数从右往左压入栈call指令再把返回地址压入栈然后进入被调函数。因此如果函数有多个参数在栈上能看到参数和返回地址挨得很近利用时可以方便地在payload里布置参数。64位程序传参则优先用寄存器前6个参数分别放在rdi, rsi, rdx, rcx, r8, r9。如果参数超过6个多余的才用栈传。所以如果想让程序跳到一个类似system(/bin/sh)的函数通常需要先设置rdi寄存器指向字符串地址这个操作一般通过“ROP链”里的pop rdi; retgadget来完成。还有一个常见坑是栈对齐。x64下很多系统函数如system要求栈指针按16字节对齐否则执行时会movaps指令崩溃。等你踩到这个坑回头再看这句话就会特别有共鸣。2.3 保护机制不是所有栈溢出都能直接利用拿到一个二进制首先看checksec输出。常见保护有Canary栈保护、NX数据不可执行、PIE地址随机化、RELROGOT保护。前置基础阶段大部分题目不会开完全部保护或者只开一个NX让你能通过跳转到代码段ret2text的方式达成目的。Canary函数开头在栈上放一个随机值返回前检查是否被改动。如果存在直接覆盖返回地址会触发报错。NX栈段不可执行。如果开了NX就不能把shellcode塞到栈上执行只能复用程序里已有的代码或系统库。PIE程序基址随机化所有函数地址不再是固定值需要先泄露地址才能构造payload。前置基础不会要求你一次性处理全部保护但至少要知道这几个名词。我见过很多新手拿到题目就盲打checksec都不看一眼结果漏洞找对了但被Canary拦了还以为是脚本问题。3. CTFshow pwn32-pwn34 核心考点拆解三关一次打通3.1 第一关pwn32变量覆盖拿到判断条件的“后门”根据我自己在CTFshow上做前置基础系列的经验pwn32这关的定位是让大家先体会“输入可以覆盖栈上的其他数据”。这类题目通常简化到极致程序里定义了一个固定大小的缓冲区然后读入数据到缓冲区紧接着栈上保存了一个用于条件判断的变量。如果你输入长度刚好覆盖到这个变量并且把一个特定值写进去就能让if判断从“不成立”变成“成立”从而输出flag。这类题不需要控制返回地址连执行流都没变完全靠“覆盖相邻数据”改变程序逻辑。很多入门教程会把这一类归为“栈溢出改变变量”。做这道题的收获是你会亲眼看到一个不安全的输入函数是怎样一步步改写内存的。实际操作中我第一步会找漏洞函数。比如看到gets、read这类危险函数重点看缓冲区变量和敏感变量的相对偏移。偏移可以通过两种方式求一是从汇编反编译里数栈帧布局二是用gdb打断点看栈上地址差。新手建议两种都做一遍加深理解。然后构造payload先在本地试通再打远程。比如偏移是0x2032字节目标值是0x11111111那payload就是ba * 32 p32(0x11111111)。注意32位程序用p32打包整数64位用p64。3.2 第二关pwn33覆盖返回地址让程序“转个弯”pwn33大概率会比上一关再往前走一步直接覆盖返回地址让程序跳到一个打印flag的后门函数。这类函数一般不会在正常逻辑里被调用所以有个生动的名字叫“后门函数”或者“win函数”。在前置基础里它就是唯一目标。这关核心要解决两件事返回地址在栈上的哪个偏移通常在找到漏洞函数后用gdb生成一段长度为N的特征字符串比如pwntools的cyclic(200)程序崩溃时从报错里找到ret地址被覆盖成了哪些字符把其中连续4个字节提取出来用cyclic_find就能算出偏移。目标地址是什么用objdump -d或者nm找后门函数地址。写exp时payload就是padding p32(win_addr)。本地验证成功后可以直接打远程。这一关做通了你对“控制程序走哪里”会有很直观的体感。3.3 第三关pwn34从“跳过去”到“带参调用”到了pwn34题目很有可能会要求你不仅跳到一个函数还要让它以正确的参数执行。比如后门函数不是直接输出flag而是需要system(/bin/sh)这种真正的getshell操作。这时候问题就来了在64位下怎么把字符串地址传给system这就需要用到简单ROPReturn Oriented Programming。思路是因为程序被开了NX不能执行栈上的shellcode但我们可以借用程序里的pop rdi; ret小片段gadget把/bin/sh的地址放进rdi然后让ret到system函数。所以pwn34的核心考点就是ROP链的基本拼接。用ROPgadget --binary ./pwn --only pop|ret能找到可用gadget。payload一般长这样payload ba * offset p64(pop_rdi) p64(binsh_addr) p64(system_addr)如果system地址或字符串地址不好找可能还要借助libc偏移。但前置基础阶段通常程序里已经有system和“/bin/sh”字符串或者后门函数里直接调用了system这就简单很多。到这里pwn32到pwn34其实就把“栈溢出三板斧”练完了覆盖变量、覆盖返回地址、使用基本ROP。这些构成了后续一切栈利用的基座。4. 实操过程从一道“ret2text”题看完整打靶链路4.1 信息收集file和checksec先走一遍为了让你有更直观的感受我模拟一道典型的re2text题目梳理整个实操过程。拿到文件后先用命令看基础信息file pwn34test输出类似pwn34test: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]xxx, not strippednot stripped说明符号表保留着我们可以直接看到函数名。接着看保护checksec --filepwn34test典型输出Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)看见No canaryNo PIE就可以开心地直接覆盖返回地址了。NX开启意味着栈不可执行别往栈上塞shellcode走ROP或ret2text。4.2 反汇编找漏洞函数与后门函数用objdump -d看函数列表objdump -d pwn34test关注main和危险函数。比如看到400612: push rbp 400613: mov rbp, rsp 400616: sub rsp, 0x10 ... 40061f: lea rax, [rbp-0x10] 400623: mov edi, rax 400625: call 400530 getsplt ...这里明显gets把输入读入rbp-0x10的位置缓冲区大小就是16字节。而返回地址在rbp8所以gets输入的第24字节开始就是返回地址偏移就是0x10 0x8 0x18 24。再用objdump -t或nm找后门函数nm pwn34test如果看到一个有趣的函数名比如target或getFlag记下地址例如0x4006b2。4.3 用pwntools编写exp这是最畅快的环节。打开一个python文件写如下内容from pwn import * # 本地调试用 # p process(./pwn34test) # 远程打题用 p remote(地址, 端口) elf ELF(./pwn34test) context.arch elf.arch win_addr 0x4006b2 # 你找到的后门地址 payload ba * 24 # 覆盖到返回地址前的偏移 payload p64(win_addr) # 覆盖返回地址 p.sendline(payload) p.interactive()然后运行脚本。本地运行如果看到输出了flag或者弹出了shell就说明payload正确。远程运行前先确保远程程序逻辑和本地一致。CTFshow这种平台通常给你远程地址后端口会动态分配注意先连接过再打。注意如果远程没有交互interactive()可能看不到东西可以改用p.recvall()或者print(p.recvline())。4.4 本地调试时我的一些习惯本地调试时我喜欢在gets执行完打断点看buf的地址和返回地址的位置。用gdbgdb ./pwn34test b *0x400625 # 执行gets之前 run $(python3 -c print(ba*40)) stack 20或者用pwndbg的好处是断下来后直接输入search查看栈上字符串。更重要的是当我手动改返回地址时可以观察ret之后RIP的变化。用ni单步执行到ret看到rip跳到我指定的地址那种感觉真的很有成就。5. 常见问题与排查技巧实录5.1 为什么本地打出了shell远程却毫无反应这是入门最常见的挫败点。优先检查以下几点远程程序和本地是不是同一个二进制重新下载远程文件对比md5。偏移是否一致不同编译器版本、不同系统栈布局可能不一样。CTFshow平台给的远程环境一般会说明libc版本或内核版本最好在本地也用相同版本的Ubuntu。远程连接后是否有交互提示如果远程有一个输入提示或者需要先选择菜单你的payload可能发早了。可以先用p.recvuntil(...)等待提示。5.2 x64下调用system经常遇到“0xffffffffff600000”或段错误很多新手在ret2libc或ret2text时遇到一个奇怪的现象本地在gdb里打成功直接运行却段错误或者远程直接崩。这个问题八成是栈对齐。因为64位下system内部用到movaps指令要求栈地址16字节对齐。当你ret到system时若RSP的末位不是0就会崩溃。解决办法是在payload里多加一个无关的retgadget让栈先偏移一次再进入systempayload ba * offset p64(ret_gadget) p64(pop_rdi) p64(binsh_addr) p64(system_addr)retgadget可以用ROPgadget --binary ./pwn34test --only ret找通常有一个在所有函数都适用的地址。这个问题我在入门时卡了整整一下午后来才知道一句话就能解决。5.3 用cyclic计算偏移的小坑cyclic生成的随机字符是有规律的。假如程序崩溃时RIP值显示为0x62616163不要直接手算用from pwn import * print(cyclic_find(0x62616163))但要注意大端小端x64是little-endian所以崩溃时RIP里的字节序列是反的。好消息是cyclic_find能自动处理。另一个坑是如果崩溃时RIP不是来自cyclic字符而是被截断或者受到干扰算出来的偏移可能不对。这时可以换用调试器在ret处打断点直接观察栈上的值。还有一次我用cyclic时发现程序崩在__stack_chk_fail那说明开启了Canary单纯算偏移没用需要先泄露Canary。前置基础阶段一般不会遇到这种情况但如果你真的碰到别慌回去看checksec。5.4 动态容器连接延迟或超时CTFshow的PWN题常用动态容器每次连接都会临时启动一个容器所以第一次连接可能要等几秒。如果直接remote()后没收到输出建议在代码里加个超时和重试import time p remote(地址, 端口) time.sleep(2) p.recvline(timeout5)如果还是不通可能是端口已经重启需要回到题目页重新开启新的容器。这类平台容器一般一段时间不活动就会回收所以每次做题前最好确认容器还是活着的。6. 入门路线避坑指南与个人心得6.1 不要急着学偏难怪先把“栈上这点事”玩明白现在网上各种PWN文章满天飞很多新手上来就学堆利用、学tcache poisoning结果连栈帧都画不清楚。我真心建议前面几周只盯一个方向栈溢出。把变量覆盖、ret2text、ret2shellcode、ret2libc、简单ROP都做一遍再把CTFshow的前置基础系列刷完。你会发现后面学格式化字符串、堆利用时很多思路都是从栈借用来的。pwn32-pwn34这个标题里“前置基础”这四个字特别准确它们并不追求让你做出多复杂的攻击而是给你建立“程序在内存里到底怎么运行”的直觉。这种直觉靠看文章建立不起来必须靠一次次调试、一遍遍看栈来磨。6.2 画图比看任何教程都有效我学栈溢出时会在纸上画一个栈的“草图”从高地址到低地址记录[返回地址] [旧rbp] [局部变量]。然后每次写payload都对照这张图验证偏移。时间久了哪怕不画图也能在脑子里模拟出来。遇到不认识的结构就拉着gdb的stack命令亲眼看一下把看到的和脑子里的图对上。6.3 最后分享一个我现在的做题习惯拿到题一定先做三件事file看架构、checksec看保护、nm看符号。不是扫一眼就完事而是把信息记录在草稿纸上。很多题目的突破口其实就藏在保护机制里不开PIE可以硬编码地址不开Canary可以放心覆盖返回地址开了Partial RELRO可能可以改GOT表。养成这个习惯后续做题会越来越顺。这几道前置基础题的难度并不高但它们教会我的东西一直用到现在。如果你能把pwn32-pwn34做通并且能自己独立复现一遍完整的利用流程那么恭喜你你已经正式迈入PWN的大门了。后面还有更多有意思的东西等着加油。