☰
BUUCTF Pwn入门刷题实战:栈溢出、ROP与ret2csu完整记录
2026/10/3 4:35:01 网站建设 项目流程

去年年底我给自己定了个小目标:把BUUCTF上的pwn入门题系统性刷掉一批。结果第一道题就把我整懵了——下载附件,一个ELF文件,拖进IDA看到满屏汇编,函数名倒算友好,可就是不知道从哪下手。后来刷到三四十道才回过味来:栈溢出、ROP、ret2csu、整型溢出、格式化字符串,名字千变万化,本质都是让程序自己交出控制权。这篇文章不写"从零到精通"的大话,就踏踏实实记录我在kali下刷BUUCTF pwn题时配置环境、分析漏洞、调试踩坑的完整过程,适合刚搭好pwn环境、想在实战题库里练手的同学参考。

1. 为什么是BUUCTF:题库分层与刷题心理建设

1.1 一个"练习题集中营"对入门者的意义

练pwn最愁的不是知识,而是"去哪找合适的题"。BUUCTF把历年各大赛事的题目按方向归档,pwn分类下有栈溢出、格式化字符串、堆、整型溢出等标签,比你自己扒GitHub散落的题目要系统得多。每个题目都给出远程端口,附件下载下来就能做,完全不需要在本地搭一堆靶机。环境这块省下的时间,足够你多复盘三题。

另一个好处是题的"梯度"比较自然。早期的pwn题大多是经典的栈溢出、ret2text,难度友好,代码量少,非常适合建立"拿到题-看保护-找漏洞-写exp"的完整流程。等到套路熟悉了,再往ret2libc、ret2csu、堆利用走,每一步都有前一步的积累。刷题最怕一上来就读不懂题解,BUUCTF这点做得不错,热门题目搜索题解非常方便。

1.2 分数、热度和"打卡心态"的问题

BUUCTF的题目分数不是写死的,热门题随着解出人数增加,单题分值会往下掉,所以你看到的分数只能当作"这题被多少人做出来过"的参考,别太较真。真正要较真的是自己的解题能力。

我见过很多人刷题变成了刷"已解决"数量,进去就搜题解,复制exp,跑通,下一题。这样刷一百题也是原地踏步。比较建议的心态是:第一遍独立尝试,卡住再看题解,看懂了合上,自己从零写exp,写通了这题才算过。这样刷虽然慢,但每道题都会留下肌肉记忆,后面做heap题时会发现,很多看似新奇的利用链,其实都是栈溢出时期攒下的老本。

2. kali下的pwn工作台一次配好:工具链与环境坑

2.1 核心工具:pwntools、pwndbg、one_gadget、ROPgadget

pwn刷题离不开四件套:pwntools写exp,pwndbg看运行时状态,ROPgadget找gadget,one_gadget查libc里的万能shell地址。安装顺序建议这样:

sudo apt update sudo apt install python3-pip gdb -y pip3 install pwntools git clone https://github.com/pwndbg/pwndbg cd pwndbg && ./setup.sh

pwndbg装上后,gdb里自带checksec命令,日常够用。如果你习惯单独的checksec脚本也无妨,注意别装重复了。

one_gadget是Ruby写的,需要Ruby环境:

sudo apt install ruby ruby-dev -y sudo gem install one_gadget

ROPgadget用pip装就行:

pip3 install ROPgadget

为什么推荐pwndbg而不是PEDA或GEF?不是它一定更强,而是它的开箱体验最省心,默认显示的上下文直接包含寄存器、栈顶数据和反汇编,信息密度对新手很友好。装一个用熟比三个来回切强得多。

2.2 32位程序跑不起来的坑:让我卡了半天的multilib

很多早期pwn题是i386的,64位kali默认不带32位运行库,你执行./pwn会直接报"No such file or directory"——注意,文件明明存在,报这个就是缺动态链接器。解决办法是装multilib:

sudo dpkg --add-architecture i386 sudo apt update sudo apt install libc6:i386 libstdc++6:i386 libncurses5:i386 -y

还有一类坑是32位程序在gdb里跑起来了,但用pwntools的process()连不上,或者printf输出不刷新。pwntools处理交互和终端不同,所以本地测试时优先以pwntools的process为基准,不要手动开终端跑了一遍正常就觉得万事大吉。

2.3 建议的目录结构与exploit模板

刷题到后面你会发现,找题不麻烦,麻烦的是翻旧exp。我现在的目录是这样的:

~/pwn/buu/ 2024_csaw_warmup/ warmup solve.py 2022_rop_ret2csu/ r2c exp.py

每道题一个文件夹,核心是留一个能直接跑的exp脚本。我自己的模板放这里,新建题时复制一份改改就能用:

#!/usr/bin/env python3 from pwn import * context.arch = 'amd64' context.log_level = 'debug' def conn(): if args.LOCAL: return process('./pwn') else: return remote('node4.buuoj.cn', 端口) p = conn() elf = ELF('./pwn') # 后面按题写逻辑

context.log_level = 'debug'建议默认开着,能清楚看到收发内容,排查交互问题会省很多时间。注意args.LOCAL是pwntools的命令行参数机制,跑本地用python3 solve.py LOCAL,打远程就python3 solve.py,这比每次手动改ip端口可靠。

3. 栈溢出:BUUCTF入门题的"直球"

3.1 返回地址是怎么被改写的

栈溢出题的核心就一件事:修改函数的返回地址。函数调用时,call指令会把下一条指令地址压栈,函数返回时,ret指令把这个地址恢复到rip。如果函数内部有char buf[0x20]这样的局部数组,而你用read(0, buf, 0x80)往里写了超过0x20字节的数据,多出来的部分就会一路覆盖到栈上保存的返回地址:

| 局部变量区域 | | saved rbp | | 返回地址 | <-- 攻击目标 | 调用者栈帧 |

我们发送精心构造的数据,把返回地址改成程序里已有的危险函数(比如system("/bin/sh")),程序一ret,就直接跳过去执行了。这就是ret2text的基本盘,也是所有栈类题的地基。

3.2 checksec输出怎么读

拿到二进制第一件事就是checksec,它会告诉你程序开了哪些保护:

字段含义做题时的直接影响
Arch是32位还是64位决定payload写法,32位参数在栈上,64位参数在寄存器
Canary栈金丝雀开启后不能直接连续覆盖,往往需要先泄露canary
NX栈不可执行开启后不能直接在栈上执行shellcode,得靠ROP
PIE地址随机化开启后程序基址会变,需要泄露地址
RELROGOT表保护Full RELRO时GOT不可写,Partial RELRO时可以改GOT

比如checksec显示NX enabled但PIE disabled,那你基本就可以确定走"栈溢出+ROP链"的路子,程序地址是固定的,gadget地址可以直接写死。Canary开启了就得多一步:先泄露canary,再构造payload时原样填回去。

3.3 用cyclic找偏移,别手数

新手最爱犯的错是拿十六进制一个个数偏移,数错一次浪费半小时。正确姿势是让程序自己告诉你:

gdb ./pwn cyclic 200 run # 程序崩溃后,pwndbg会显示rip寄存器的值,比如 0x6161616161616161 cyclic -l 0x6161616161616161 # 输出:40

cyclic生成的字符序列有规律,cyclic -l会根据崩溃地址反推出偏移。这个方法对所有栈溢出题通用,等于白送的。我现在的流程固定是:先cyclic测偏移,再决定payload布局,效率比手数高一个数量级。

3.4 ret2text与ret2shellcode怎么选

判断标准很简单:NX开着,栈不能执行shellcode,那就找程序里的后门函数或者构造ROP;NX没开,栈可执行,那直接在栈上放shellcode,把返回地址改成栈地址就行。

一种高频组合是:NX没开、程序没开PIE,输入点又在你溢出之后继续让你读一次,那就可以先溢出布置shellcode地址,再读入shellcode到栈上。shellcode直接用pwntools生成:

shellcode = asm(shellcraft.sh())

注意64位shellcode对栈对齐敏感,有些环境需要你在payload里多加一个ret指令做对齐,否则system("/bin/sh")莫名崩掉。这个坑后面还会遇到。

4. ret2csu:老题库里绕不开的"万能补丁"

4.1 一段能控制三个参数的汇编

x86_64调用约定下,函数前三个参数分别放rdi、rsi、rdx。溢出后如果你只能找到pop rdi; ret这一个gadget,那就只能调一个参数的函数,比如system本身只要一个参数,但write、read这种要三个参数的就抓瞎了。

ret2csu解决的就是这个问题。所有glibc编译的64位ELF里,__libc_csu_init这段函数尾部藏着两组现成的gadget:

pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret mov rdx, r15; mov rsi, r14; mov edi, r13d; call [r12 + rbx*8] add rsp, 8; pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret

第一段负责把寄存器填上值,第二段负责把寄存器赋给rdx/rsi/rdi然后调用地址。组合起来,你能控制rdi、rsi、rdx三个参数并调用任意地址,相当于白捡了一张万能调用卡。

4.2 一次真实的write泄露libc过程

最常见的玩法是泄露GOT表里的libc函数地址。比如要调用write(1, write_got, 8)把write的真实地址打出来:

# 假设题目给了 buf 偏移 offset,以及两个csu gadget的地址 payload = b'A' * offset payload += p64(pop_rbx_rbp_r12_r13_r14_r15_ret) payload += p64(0) # rbx = 0 payload += p64(1) # rbp = 1,不参与计算,满足add rsp,8即可 payload += p64(write_got) # r12 = 要调用的地址指针,也就是write@got payload += p64(1) # r13 = rdi,write的第一个参数,fd=1 payload += p64(write_got) # r14 = rsi,write的第二个参数,要泄露的got payload += p64(8) # r15 = rdx,write的第三个参数,字节数 payload += p64(mov_call_ret) # 执行第二段gadget payload += p64(0) # add rsp, 8 吃掉一个栈位 payload += p64(0) * 7 # pop rbx~r15 的一串占位 payload += p64(main_addr) # 回到主函数,进行第二阶段

第二段gadget里的call [r12 + rbx*8],r12填的是write_got,rbx填0,就是call write@got,等价于调用write函数。注意这里填的是GOT地址,不是write的PLT地址,这个坑我踩过一次:直接把函数地址填进r12,程序跳到一个不可执行的内存区域,直接段错误。

4.3 为什么老题库里ret2csu出现率这么高

2016-2018年的pwn题,很多是NX开启、partial RELRO、不开PIE,GOT可写,但程序本身没有system,也没有pop rdi/pop rsi/pop rdx这种全套gadget。这种条件下ret2csu成了标准答案:先泄露libc,再算system和/bin/sh的实际地址,最后再来一轮溢出调system。BUUCTF题库里大量复刻了那个时代的题,所以ret2csu几乎是刷题路上绕不过去的一课。

5. 整型溢出:绕过长度检查的另一条路

5.1 有符号/无符号的"类型陷阱"

整型溢出在pwn里通常不是让你算数,而是制造一种"检查看着没问题,但实际内存已经越界"的假象。看一个典型代码:

char buf[0x100]; int size; scanf("%d", &size); if (size > 0x100) exit(0); read(0, buf, size);

表面看,size超过256就被拦住了。但scanf用%d读的是有符号int,你输入-1,size等于-1,-1显然小于256,检查通过。然后read的第三个参数类型是size_t,无符号,-1在无符号视角下是0xffffffffffffffff,一个巨大的数。read虽然不会真的读4GB,但它会把用户发来的数据原样写进buf,buf只有0x100字节,你发0x300字节就溢出了。

这就是整型溢出最常见的利用方式:用负数绕过正面检查,在底层视角里变成超大无符号数,攻击者实际想发多少发多少。用pwntools打这种题很简单:

p.sendlineafter(b'input:', b'-1') p.send(b'A' * 0x300) # 轻松越过buf边界

5.2 不只是read:负数索引和malloc的骚操弄

整型溢出还会出现在数组索引和堆分配上。比如:

int idx; scanf("%d", &idx); if (idx < 10) { printf("%s\n", names[idx]); }

index被声明成int,输入负数对10取余后可能访问names数组前面的内存,配合地址排布,可能直接读到或改到关键结构体。堆那边的经典操作是malloc(负数),负数转成size_t后变成超大值,或者被截断成一个小值,导致实际分配的空间小于写入量,形成堆溢出。这类题在BUUCTF里比栈溢出的纯套路题稍复杂,但内核还是同一个:类型转换之后,比较逻辑和实际行为不一致。

5.3 审计代码时养成一个"变量三连"习惯

刷这种题,最怕盯着一个check看半天看不出来。我现在看到每个size、len、index变量,都会强制走三遍:先确认变量类型,再看它在比较时的类型,最后看它在使用点的类型。负数和无符号是重灾区,大数和截断是次重灾区。检查通过了不代表数据安全,这是整型溢出题教给我最深的一句话。

6. 调试定位:pwndbg里我每天重复的几条命令

6.1 一套固定的"起手式"

调试不是乱逛,要有固定的起手顺序。拿到一个溢出点,我一般是:

gdb ./pwn checksec b *0x401234 # 在下一次read前后下断点 cyclic 200 run x/20gx $rsp # 看栈上数据分布 telescope $rsp # 追踪栈指针指向的内存

x/20gx是看原始十六进制,telescope会把地址指向的字符串、指针也解析出来,观察payload有没有按预期落到目标位置非常直观。配合pwndbg的自动上下文,你基本不需要手动刷屏看寄存器。

6.2 找gadget和看内存布局

不在gdb里解题时,找gadget用ROPgadget:

ROPgadget --binary ./pwn --only "pop|ret" | grep "rdi\|rsi\|rdx"

如果程序没开PIE,直接用ROPgadget --binary ./pwn列出全部gadget,地址固定。开了PIE的话,记得要在exp里先把程序基址算出来,再往基址上加gadget偏移,不能直接用ROPGadget给出的原始地址。

想看libc里有没有现成的字符串,比如"/bin/sh",可以:

strings /lib/x86_64-linux-gnu/libc.so.6 | grep "/bin/sh"

或者直接在gdb里search "/bin/sh"。这一步在ret2libc题里几乎必用。

6.3 本地通了远程炸:四个方向排查

这是刷题最磨人的阶段,我整理过自己的排查顺序:

现象可能原因排查动作
远程直接崩溃远程libc版本/偏移不同用libc-database或libc.symbols确认版本
栈对齐导致system崩调用system时rsp没对齐payload前加一个ret对齐
收发数据对不上远程程序输出缓冲,或需要先收一段提示加recvuntil、sleep(0.2)
PIE程序地址变了ASLR随机化先泄露基址再二次溢出
远程有沙箱/seccomp程序限制execve检查启动代码里是否有seccomp相关函数

尤其注意最后一点。很多看起来该用system的题,远程环境被seccomp禁了execve,直接system("/bin/sh")怎么都连不上,这时候要用open/read/write这种绕过方式把flag打出来。本地一次通过并不代表远程能通过,这是远程题的基本尊重。

7. 刷题节奏、复盘方法与下一步路线

7.1 单题节奏:30分钟红线

我在BUUCTF刷题时给自己定的规则是:拿到题先checksec、file、strings、IDA定位关键函数,控制在20分钟;如果30分钟还没有形成完整思路,就去看题解的第一段,看懂攻击手段之后合上,自己从零写exp。这里的红线不是"不能看题解",而是"不能带着题解从头抄到尾"。看明白漏洞类型和利用链,后面的payload构造、调试、远程打通都靠自己完成,这道题才能真正变成你的经验。

每道题远程打通的瞬间,我建议顺手把exp保存成exp.py留在题目文件夹里。别小看这一步,三个月后你回来做heap题,想复习ret2csu,翻到自己写的注释清晰的exp,比翻任何博客都管用。

7.2 复盘:从"能跑"到"能讲"

能跑通exp只算完成了50%。刷到中期你会发现,很多题看着代码完全不同,漏洞点八竿子打不着,但利用链骨架惊人相似。这时候就要强迫自己复盘,给每道题写三行笔记:

  • 漏洞点:是什么导致控制流被劫持,具体细节是什么
  • 利用链:从触发漏洞到获取flag,经过了哪几步
  • 绕过点:有哪些保护被绕过,用什么手段绕的

写的时候想象你要把这个解法讲给一个只懂基础的菜鸟听,你会发现很多你以为"理所当然"的步骤其实经不起追问。比如"为什么这里要先泄露libc",答案不只是"为了算system地址",而是"因为远程libc版本未知,直接拿本地的system偏移去打会崩"。

7.3 下一步路线:从栈到堆的过渡

栈溢出、ROP、ret2csu、整型溢出刷稳之后,我在BUUCTF上的下一个方向就是格式化字符串和堆利用。格式化字符串可以泄露任意地址内容甚至改GOT,是栈题和堆题之间的桥梁。堆那边就完全换了思路,不再是"覆盖返回地址",而是伪造chunk、劫持函数指针、打tcache bin,知识密度大很多。

所以我也把这句话送给准备入坑的同学:先别急着上堆,把栈溢出和ROP刷出肌肉记忆,让cyclic、ret2csu、one_gadget这些词从"听说过"变成"下意识用出来",再去看heap题,你会发现自己上手比别人想象中快得多。

最后分享一个小习惯:每刷完一道题,我会把exp里最难理解的那一行注释写明白,然后删掉注释,过几天不看答案重新写一遍。如果能不看注释写出来,这道题才算真正过手。刷题不是做任务,是把别人的套路变成自己的肌肉记忆,这个过程没有任何捷径。

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

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

立即咨询