2025 CISCN 全国大学生信息安全竞赛 决赛 ez_orw 题目 详细解析与教学文档
1. 题目概况
本题是一个基于 Linux 的二进制安全挑战 (PWN),名为 ez_orw。其核心流程涉及:绕过花指令、逆向分析程序逻辑、处理 Protobuf 序列化数据结构、破解基于 RC4 变种的校验算法、构造合法的 shellcode 并满足字符集限制,最终在沙箱 (seccomp) 环境下实现 ORW (Open-Read-Write) 攻击以读取 flag 文件。
2. 程序初始化与关键函数分析
2.1 主函数 (main) 入口与前置操作
- 花指令处理:函数开头存在一段无意义的花指令,需在 IDA 中将其
nop掉,然后重新分析 (U取消定义,再按P创建函数),以使控制流图清晰。 - 初始化 (
sub_16FE):- 通过
time(0)获取时间戳作为随机数种子。 - 调用
srand()初始化随机数生成器。 - 将
stdin,stdout,stderr设置为无缓冲模式 (setvbuf(..., 0, 2, 0))。 - 调用
mmap在rand() % 0x7FFFFFFF的随机地址附近,映射一页 (0x1000 字节) 具有 RWX (可读、可写、可执行) 权限的内存区域,其地址被存储于全局变量dest中。此区域是后续 shellcode 的最终执行位置。
- 通过
2.2 第一重检查
.text:0000000000001F45 lea rax, [rbp-10h]
.text:0000000000001F49 mov rsi, rax
.text:0000000000001F4C lea rax, aLlx ; "%llx"
... (调用 scanf 读入 64 位十六进制数) ...
.text:0000000000001F67 lea rax, aYes ; "yes"
.text:0000000000001F6E cmp rdx, rax
.text:0000000000001F71 jnz short loc_1F8E
- 程序使用
scanf("%llx")读取一个 64 位整数 (而非字符串)。 - 将输入的值与全局字符串
"yes"的地址进行比较。 - 绕过方法:需要计算或泄漏出程序中硬编码的字符串
"yes"的地址,并将其作为 64 位十六进制数输入。
2.3 失败分支与信息泄漏
如果上述检查失败,程序会进入一个分支:
if ( cs:dword_5008 > 0xFF ) { ... } else {
printf("%p", &dword_5008);
}
- 程序会再次用
scanf("%d")读取一个整数。 - 如果这个输入值 不大于 0xFF (即 <= 255),程序会以
%p格式打印出全局变量dword_5008的地址。 - 利用点:这相当于泄漏了一个全局变量的地址,可用于计算程序基址 (PIE 绕过),因为
dword_5008的偏移是固定的。
2.4 主处理函数 (sub_1D96) 与 Protobuf 解析
通过第一重检查后,进入核心函数 sub_1D96。
- 数据读取:
read(0, buf, 0x100)读取最多 0x100 字节的用户输入到栈缓冲区。 - Protobuf 解码:
sub_218D函数(实为protobuf_c_message_unpack)将输入数据解析为一个 Protobuf 消息结构体,指针存于qword_5058。 - Token 校验:
sub_19A1函数(应重命名为verify_giao_token)对消息中的giaotoken字段进行校验。这是第二道关键检查。 - 字段校验:校验通过后,程序检查消息中的两个整数字段:
*(ctx + 0x18) == 0x114514(对应giaoid)*(ctx + 0x20) == 0x415411(对应giaosize)
- Shellcode 处理:
- 调用
sub_1908对giaocontent字段的内容进行合法性检查(过滤不可见字符)。 - 调用
sub_17CC设置 seccomp 沙箱规则(仅允许open,read,write,exit,exit_group等有限系统调用)。 - 最后,检查
giaocontent的长度小于之前泄漏的全局变量dword_5008的值,并且首次read的长度n0xFF不超过 0xFF。 - 如果所有条件满足,则将
giaocontent的内容复制到初始阶段mmap的 RWX 内存页 (dest),并跳转执行。
- 调用
3. Protobuf 数据结构恢复
通过逆向 protobuf-c 的描述符,可以恢复出原始的数据结构定义。
关键线索:在 .data.rel.ro 节找到字符串引用 "giao.msgiao" 和 "Msgiao"。
恢复后的 .proto 文件内容:
syntax = "proto3";
package giao;
message msgiao {
int64 giaoid = 1;
int64 giaosize = 2;
bytes giaocontent = 3;
bytes giaotoken = 4;
}
对应的内存结构体 (ProtobufC 表示):
typedef struct {
ProtobufCMessage base; // 0x00 ~ 0x17
int64_t giaoid; // 0x18
int64_t giaosize; // 0x20
ProtobufCBinaryData giaocontent; // 0x28 len, 0x30 data
ProtobufCBinaryData giaotoken; // 0x38 len, 0x40 data
} Giao__Msgiao;
ProtobufCBinaryData包含len(长度) 和data(数据指针) 两个成员。- 这解释了逆向代码中访问
*(ctx + 0x40)对应的是giaotoken.data。
4. Token 校验算法 (verify_giao_token) 详解
这是本题的加密核心。算法是一个自定义的 RC4 变种。
核心常量:
- Key:
"114514giaogiaogiao99"(存储在变量_114514giaogiaogiao99中)。 - Target: 栈上的一串固定字节数组
s,其十六进制值为:
0xB95FA87BA6AF366A, 0x918D1C0CC7837D63, 0xF877F9B36B6EF2D3, 0x8EFDECFCE888E2BF, 0x40FE92FD
算法流程:
- RC4 状态初始化 (
rc4_init_state):使用上述 Key 初始化标准的 RC4 状态数组S[0..255]。 - 变种加密/解密 (
rc4_xor_token_inplace):- 该函数不仅用 RC4 算法生成密钥流与输入 (
s_1,即我们提供的giaotoken) 进行异或。 - 关键变异点:在异或过程中,额外异或了补零后的 Key 缓冲区。
- 核心代码逻辑等效于:
s_1[i] ^= rc4_keystream_byte ^ padded_key[i]
- 该函数不仅用 RC4 算法生成密钥流与输入 (
- 校验:将处理后的
s_1与固定的 Target 数组s进行比较。相等则校验通过。
逆向推导 Token:
设:input_token 为我们构造的 giaotoken,rc4_keystream 为 RC4 生成的密钥流,padded_key 为补零到合适长度的 Key,target 为目标值。
算法满足:input_token ^ rc4_keystream ^ padded_key == target
因此,合法的 input_token 应为:target ^ rc4_keystream ^ padded_key
解题脚本 (Python 实现 RC4 及 Token 生成):
def rc4_init_state(key):
if isinstance(key, str):
key = key.encode()
s = list(range(256))
k = [key[i % len(key)] for i in range(256)]
j = 0
for i in range(256):
j = (j + s[i] + k[i]) & 0xff
s[i], s[j] = s[j], s[i]
return [s, 0, 0] # 状态数组, i, j
def rc4_crypt(state, data):
s, i, j = state
out = bytearray(data)
for idx, ch in enumerate(out):
i = (i + 1) & 0xff
j = (j + s[i]) & 0xff
s[i], s[j] = s[j], s[i]
out[idx] ^= s[(s[i] + s[j]) & 0xff]
return bytes(out)
# 计算合法 Token
key = b"114514giaogiaogiao99"
target = bytes.fromhex('6A36AFA67BA85FB9637D83C70C1C8D91D3F26E6BB3F977F8BFE288E8FCDE8FEFD92FE40')
state = rc4_init_state(key)
# RC4 密钥流是加密全0数据产生的
keystream = rc4_crypt(state, b'\x00' * len(target))
# 构造 padded_key (长度与 target 一致)
padded_key = (key * (len(target) // len(key) + 1))[:len(target)]
valid_token = bytes(a ^ b ^ c for a, b, c in zip(target, keystream, padded_key))
5. Shellcode 构造与限制绕过
成功通过 Token 校验后,程序会对 giaocontent 字段进行严格检查,并最终执行。
两层限制:
- 字符过滤 (
sub_1908):Shellcode 中不能出现任何小于等于 0x1F 或等于 0x7F 的字节(即 ASCII 控制字符和 DEL 字符)。这禁止了使用普通的机器码。 - 长度限制:
giaocontent.length必须小于之前泄漏的全局变量dword_5008的值。- 第一次
read读取的原始输入长度n0xFF必须<= 0xFF(即 ≤ 255)。
解决方案:使用 ALPHA3 等工具将原始 shellcode 编码为纯可见字符 (Printable/Alphanumeric) 的 shellcode。
步骤:
- 编写原始 ORW Shellcode:
from pwn import * context.arch = 'amd64' sc = asm(shellcraft.open('flag', 0)) sc += asm(shellcraft.read('rax', 'rsp', 0x100)) sc += asm(shellcraft.write(1, 'rsp', 0x100)) open('raw.bin', 'wb').write(sc) - 使用 ALPHA3 编码:
查看# 假设使用 rax 寄存器作为编码基址 bash ./alpha3_encode.sh raw.bin alpha.txt raxalpha.txt获取编码后的纯字符 shellcode。 - 注意:编码会增大 shellcode 体积,需确保最终长度满足上述限制。
6. Seccomp 沙箱规则
函数 sub_17CC 设置了 seccomp 规则。通过逆向或动态分析 (如 seccomp-tools) 可知,其仅允许少数白名单系统调用,例如:
open,openatread,writeexit,exit_group- 可能还有
brk,mmap等内存相关调用。
这符合典型的 ORW (Open-Read-Write) 题目场景,我们的 shellcode 也需要在此约束下工作。
7. 完整利用步骤 (Exploit) 总结
-
启动程序,触发失败分支,泄漏 PIE 地址:
- 第一轮输入一个错误的地址(非
"yes"地址),使程序进入失败分支。 - 第二轮输入一个
<= 255的整数(如0),程序会打印出dword_5008的地址。 - 根据泄漏的地址,计算出程序的基址 (
elf_base)。
- 第一轮输入一个错误的地址(非
-
计算
"yes"字符串地址并通过第一重检查:yes_addr = elf_base + elf.symbols['yes_string'](或通过偏移计算)。- 在程序重新运行后(或通过进程复用),第一轮输入
hex(yes_addr)。
-
构造合法的 Protobuf 消息:
giaoid字段设置为0x114514。giaosize字段设置为0x415411。giaocontent字段填入经过 ALPHA3 编码的、用于读取flag的纯字符 ORW shellcode。giaotoken字段填入通过 第4节算法 计算出的合法 Token 字节。
-
序列化并发送:
- 使用生成的
_pb2.py文件(如recovered_pb2.py)中的msgiao类构造消息对象。 - 调用
SerializeToString()方法进行序列化。 - 将序列化后的字节流发送给程序。
- 使用生成的
-
获取 Flag:
- 程序验证通过后,会将
giaocontent复制到 RWX 内存并执行。 - 编码后的 shellcode 会解码自身并执行,最终实现打开、读取并输出
flag文件的内容。
- 程序验证通过后,会将
8. 关键知识点回顾
- 花指令识别与处理:通过修改字节码 (
nop) 并重新分析来清理控制流。 - Protobuf 逆向:通过数据段中的描述符字符串和结构体布局,恢复原始
.proto定义。 - 自定义加密算法分析:识别 RC4 的变种,理解其与标准 RC4 的差异(此处是额外异或了 Key)。
- Shellcode 编码:使用 ALPHA3 等工具绕过对不可见字符的限制,生成纯字符 shellcode。
- Seccomp 绕过:在受限的系统调用集中,构造有效的 ORW 链。
- 利用链构建:将信息泄漏、条件绕过、数据构造、代码执行等多个环节串联成完整的利用过程。