2014 HITCON CTF WriteUp
🏆 赛事概况
赛事全称:HITCON CTF 2014
比赛时间:2014 年 8 月 16 日 12:00 至 8 月 18 日 12:00(UTC+8),共 48 小时
比赛形式:线上 Jeopardy(解题)模式
参赛规模:吸引了来自全球 71 个国家的 1,020 支队伍参赛
题目类型:涵盖 Trivial、Web Security、Binary Processing、Forensics、Pwn3d 等类别
奖金(新台币):冠军 22,000 元,亚军 15,000 元,季军 10,000 元
🏅 最终排名
| 排名 | 队伍名称 | 得分 | 备注 |
|---|---|---|---|
| 冠军 | fuzzi3 (日本) | 5,249 | |
| 亚军 | Dragon Sector (波兰) | 5,151 | 当时 CTF 世界排名第一 |
| 季军 | 9447 (澳大利亚) | 4,949 | |
| 第9名 | BambooFox (中国台湾) | - |
Pwn
stkof(Unlink 攻击 + GOT 表劫持 + libc 泄露)
先来分析第一个函数,它允许用户指定一个堆块编号,再指定写入长度,最后向该堆块写入数据

1 | int edit_chunk() |
在第二种情况下,如果目标内存的后面紧挨着其他堆块,那么越界写入就可能覆盖后续堆块的管理信息

1 | int delete_chunk() |
分析完全部代码过后,我们正常运行程序,申请两个堆块
1 | 1 |
IDA 反编译创建堆块代码,dword_602100 这个全局变量在程序 .bss/.data 段中的实际存储地址是 0x602100

因为我们创建了两个堆块,所以这个地址的值应该是 2
x:examine,查看内存/wd:格式说明w:word,通常表示 4 字节 / 32 位d:decimal,按有符号十进制显示

验证通过后根据分析代码中的 “获取堆块地址” 找到伪代码 ptr = (&::s)[v2];

其中 v2 是用户输入的编号,&::s 的地址是 0x602140,双击进去就能看到,并且注意,这是 指针数组

/4gx:格式说明4:重复 4 次,也就是连续看 4 个单元g:giant word,通常表示 8 字节 / 64 位x:hexadecimal,按十六进制显示

首先,程序启动时操作系统会自动将 .bss 段全部初始化为 0,而程序第一次创建堆块时使用的是 ++dword_602100,所以第一个堆块从 s[1] 开始存

因为这个 &::s 是指针数组,所以 s[0] 的指针存储的是 0x0
一个指针占 8 字节,所以第一个堆块指针 0x602140 + 8 = 0x602148 保存 0xe056b0
第二个堆块指针 0x602140 + 16 = 0x602150 保存 0xe05b00
现在测试两个堆块的距离
p:print,计算并输出表达式

1 | 0x450 = 0x40 + 0x410 |
这非常符合下面的内存布局

第一次执行 printf() 时,glibc 可能为标准输出分配一个缓冲区,而这个缓冲区恰好可能需要大小为 0x410 的 Chunk

所以这两个堆块不是直接相邻的,我们现在要观察的是堆块头部来验证我们的推测
malloc() 返回的是用户数据地址,64 位程序中,Chunk Header 位于用户数据地址前面 0x10 字节的位置
1 | 第一个堆块: |
每一行都有两个 8 字节的数据:
第一个值:
prev_size第二个值:
size,包含状态标志

| Chunk | 头部地址 | size 字段 | 实际 Chunk 大小 |
|---|---|---|---|
| Chunk 1 | 0xe056a0 | 0x41 | 0x40 |
| 中间 Chunk | 0xe056e0 | 0x411 | 0x410 |
| Chunk 2 | 0xe05af0 | 0x91 | 0x90 |
为什么 0x41 对应实际大小 0x40?
因为 size 字段最低位的 1 是 PREV_INUSE 标志,表示前一个物理相邻 Chunk 被标记为正在使用
现在我们可以根据地址计算
1 | 头部地址:0xe056a0 |
因此,我们已经证明

1 | # 查看标准输出流 stdout 内部缓冲区的起始地址,并以十六进制显示 |

1 | # 查看缓冲区的结束地址 |

接下来测试创建第三个堆块看是否相邻,大小还是 128
两个地址相差 0x90,这说明 Chunk 3 紧挨着 Chunk 2

回忆 Edit 函数
1 | ptr = chunk_table[index]; |
如果选择编号 2,那么
1 | ptr = 0xe05b00; |
现在想一想:
写入
0x80字节,会覆盖 Chunk 2 申请的 128 字节用户数据。写入
0x88字节,就会额外覆盖 Chunk 3 的 prev_size 字段。写入
0x90字节,还会覆盖 Chunk 3 的 size 字段
编写调试脚本
1 | payload = b"A" * 0x80 |
先在菜单第 4 个功能入口设置断点,然后重新运行
此时 Edit 已经完成,堆溢出已经发生,但第 4 个功能还没有执行,方便我们安全地观察被覆盖的头部
第三个堆块指针 0x602150 + 8 = 0x602158
1 | b *0x400BA9 |

通过 edit(2) 写入 Chunk 2 的数据,成功修改了 Chunk 3 的堆管理信息

我们下一步要让它变成一个有意义的值
1 | prev_size = 0x90 |
1 | from struct import pack |
注意修改前的 size 正常为 0x91,修改后为 0x90,相当于清除了最低位的 PREV_INUSE 标志

这相当于告诉 glibc:Chunk 3 前面的那个 Chunk 已经是空闲状态了,我释放 Chunk 3 的时候,可以尝试把它们合并起来
学习两个新概念:fd 和 bk
glibc 管理某些空闲 Chunk 时,会使用双向链表
其中:
fd(forward pointer):指向链表中的后继节点bk(backward pointer):指向链表中的前驱节点

假设三个空闲堆块按照 A、P、B 的顺序连接,我们只看中间的 Chunk P
1 | P->fd = B; |
这两行的意思分别是:
P->fd = B:P 保存了后继 Chunk B 的地址P->bk = A:P 保存了前驱 Chunk A 的地址
在本题使用的经典 64 位 glibc Chunk 结构中,相关字段的布局是:

现在想象我们要把中间的 Chunk P 从双向链表中移除
要让 A 和 B 直接连接,需要修改两个指针
1 | // 让 B 的前驱直接变成 A |
所以 Unlink 最核心的工作,就是通过 fd 和 bk 修改前后两个节点的指针,让它们绕过 P,直接连接
正常情况下 fd、bk 应该指向真正的空闲 Chunk
1 | P->fd = B; |
注意:Chunk 2 的真实头部位于 0xe05af0,它是由 glibc 创建和管理的
而我们准备伪造的 Chunk,头部要放在 0xe05b00,也就是 Chunk 2 的用户数据区域内部。

正常情况下,0xe05b00 是一片用来保存用户数据的内存,但我们故意写入以下数据
1 | payload = p64(0) |
现在,0xe05b00 开始的内存就变成了

我们没有真的申请一个新的 Chunk,只是往 Chunk 2 的用户数据中写了四个 8 字节整数
但是,这四个整数恰好按照 glibc 的 Chunk 结构排列
1 | struct malloc_chunk { |
所以,如果 glibc 后续把 0xe05b00 当成 Chunk 的起始地址,它就会按照这四个字段来解释我们写进去的数据
在之前我们就说过可以修改 Chunk 3 的头部,故意让它指向 Fake Chunk
1 | prev_size = 0x80 |
当我们执行 free(s[3]);
glibc 发现 Chunk 3 的 PREV_INUSE 是 0,于是认为前面的 Chunk 已被释放,需要向前合并
接着,它根据 prev_size 计算前一个 Chunk 的位置
1 | 0xe05b80 - 0x80 = 0x05b00 |
而 0xe05b00 正好就是我们放置 Fake Chunk 的地方
因此,glibc 会尝试把这里的内存作为一个空闲 Chunk 来处理

你可以把整个过程理解为三个动作:
造假: 在 Chunk 2 的用户数据中,写入假的
prev_size、size、fd、bk误导: 修改 Chunk 3 的
prev_size和PREV_INUSE,让 glibc 向前寻找时恰好找到 Fake Chunk触发: 执行
free(3),让 glibc 在通过必要检查后对 Fake Chunk 执行 Unlink
因此,我们并没有真正创建一个新的堆块。我们只是让内存分配器把一段普通的用户数据误认成堆块管理结构
Unsafe Unlink:通过堆溢出伪造空闲 Chunk,并篡改相邻 Chunk 的元数据,在 free() 向前合并时诱导 glibc 执行 Unlink,从而修改指定内存地址中的指针值
第一段 Payload:完成 Unsafe Unlink
1 | from pwn import * |
假设成功执行 free(s[3]);,并触发 Unlink,那么 s[2] = 0x602138;
Unlink 之后,&s[2] 仍然是 0x602150,只是 s[2] 保存的值从 0xe05b00 变成了 0x602138
因此我们可以从 0x602138 开始写入任意指定长度的数据,看一下这片内存
| 地址 | 内容 |
|---|---|
| 0x602138 | 指针数组前 8 字节 |
| 0x602140 | s[0] |
| 0x602148 | s[1] |
| 0x602150 | s[2] |
| 0x602158 | s[3] |
| 0x602160 | s[4] |
假设我们通过 edit(2) 写入
1 | payload = p64(0) |

这意味着 s[0] = 0x602160;,接下来执行 edit(0);,就会把数据写入 0x602160
1 | from struct import pack |
设置断点
1 | tbreak *0x400BA9 |

| 位置 | 预期值 | 意义 |
|---|---|---|
| s[0] | 0x602160 | 指向我们指定的位置 |
| s[2] | 0x602138 | 证明 Unlink 改写仍然有效 |
| s[3] | 0x0 | 已经通过 Delete 清空 |
| s[4] | 0x4847464544434241 | 成功写入 ABCDEFGH |
现在我们已经知道,能够把一个堆块指针改为自己指定的地址
我们要借此一次性修改三个指针
第二段 Payload:改写全局指针数组
1 | payload2 = p64(0) |
1 | 写入起点 0x602138: |
前面已经将
1 | s[0] = free@GOT |
第三段 Payload:劫持 free@GOT 并泄露 puts
1 | payload3 = p64(elf.plt['puts']) |
这样就就能泄露 puts 的真实地址
现在 s[2] 已经被我们设置成了 atoi@GOT,所以只需
1 | payload4 = p64(system_addr) |
接下来主函数会重新执行
1 | fgets(nptr, 10, stdin); |
但是 atoi 的 GOT 表项已经被改写,所以只需要发送
1 | p.sendline(b'/bin/sh') |

完整版 EXP
1 | import json |