2025ciscn决赛ez_orw
字数 4537
更新时间 2026-04-25 12:16:55

2025 CISCN 全国大学生信息安全竞赛 决赛 ez_orw 题目 详细解析与教学文档

1. 题目概况

本题是一个基于 Linux 的二进制安全挑战 (PWN),名为 ez_orw。其核心流程涉及:绕过花指令、逆向分析程序逻辑、处理 Protobuf 序列化数据结构、破解基于 RC4 变种的校验算法、构造合法的 shellcode 并满足字符集限制,最终在沙箱 (seccomp) 环境下实现 ORW (Open-Read-Write) 攻击以读取 flag 文件。

2. 程序初始化与关键函数分析

2.1 主函数 (main) 入口与前置操作

  1. 花指令处理:函数开头存在一段无意义的花指令,需在 IDA 中将其 nop 掉,然后重新分析 (U 取消定义,再按 P 创建函数),以使控制流图清晰。
  2. 初始化 (sub_16FE)
    • 通过 time(0) 获取时间戳作为随机数种子。
    • 调用 srand() 初始化随机数生成器。
    • stdin, stdout, stderr 设置为无缓冲模式 (setvbuf(..., 0, 2, 0))。
    • 调用 mmaprand() % 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

  1. 数据读取read(0, buf, 0x100) 读取最多 0x100 字节的用户输入到栈缓冲区。
  2. Protobuf 解码sub_218D 函数(实为 protobuf_c_message_unpack)将输入数据解析为一个 Protobuf 消息结构体,指针存于 qword_5058
  3. Token 校验sub_19A1 函数(应重命名为 verify_giao_token)对消息中的 giaotoken 字段进行校验。这是第二道关键检查
  4. 字段校验:校验通过后,程序检查消息中的两个整数字段:
    • *(ctx + 0x18) == 0x114514 (对应 giaoid)
    • *(ctx + 0x20) == 0x415411 (对应 giaosize)
  5. Shellcode 处理
    • 调用 sub_1908giaocontent 字段的内容进行合法性检查(过滤不可见字符)。
    • 调用 sub_17CC 设置 seccomp 沙箱规则(仅允许 open, read, write, exit, exit_group 等有限系统调用)。
    • 最后,检查 giaocontent 的长度小于之前泄漏的全局变量 dword_5008 的值,并且首次 read 的长度 n0xFF 不超过 0xFF。
    • 如果所有条件满足,则将 giaocontent 的内容复制到初始阶段 mmapRWX 内存页 (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

算法流程

  1. RC4 状态初始化 (rc4_init_state):使用上述 Key 初始化标准的 RC4 状态数组 S[0..255]
  2. 变种加密/解密 (rc4_xor_token_inplace):
    • 该函数不仅用 RC4 算法生成密钥流与输入 (s_1,即我们提供的 giaotoken) 进行异或。
    • 关键变异点:在异或过程中,额外异或了补零后的 Key 缓冲区
    • 核心代码逻辑等效于:s_1[i] ^= rc4_keystream_byte ^ padded_key[i]
  3. 校验:将处理后的 s_1 与固定的 Target 数组 s 进行比较。相等则校验通过。

逆向推导 Token
设:input_token 为我们构造的 giaotokenrc4_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 字段进行严格检查,并最终执行。

两层限制

  1. 字符过滤 (sub_1908):Shellcode 中不能出现任何小于等于 0x1F 或等于 0x7F 的字节(即 ASCII 控制字符和 DEL 字符)。这禁止了使用普通的机器码。
  2. 长度限制
    • giaocontent.length 必须小于之前泄漏的全局变量 dword_5008 的值。
    • 第一次 read 读取的原始输入长度 n0xFF 必须 <= 0xFF (即 ≤ 255)。

解决方案:使用 ALPHA3 等工具将原始 shellcode 编码为纯可见字符 (Printable/Alphanumeric) 的 shellcode。

步骤

  1. 编写原始 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)
    
  2. 使用 ALPHA3 编码
    # 假设使用 rax 寄存器作为编码基址
    bash ./alpha3_encode.sh raw.bin alpha.txt rax
    
    查看 alpha.txt 获取编码后的纯字符 shellcode。
  3. 注意:编码会增大 shellcode 体积,需确保最终长度满足上述限制。

6. Seccomp 沙箱规则

函数 sub_17CC 设置了 seccomp 规则。通过逆向或动态分析 (如 seccomp-tools) 可知,其仅允许少数白名单系统调用,例如:

  • open, openat
  • read, write
  • exit, exit_group
  • 可能还有 brk, mmap 等内存相关调用。
    这符合典型的 ORW (Open-Read-Write) 题目场景,我们的 shellcode 也需要在此约束下工作。

7. 完整利用步骤 (Exploit) 总结

  1. 启动程序,触发失败分支,泄漏 PIE 地址

    • 第一轮输入一个错误的地址(非 "yes" 地址),使程序进入失败分支。
    • 第二轮输入一个 <= 255 的整数(如 0),程序会打印出 dword_5008 的地址。
    • 根据泄漏的地址,计算出程序的基址 (elf_base)。
  2. 计算 "yes" 字符串地址并通过第一重检查

    • yes_addr = elf_base + elf.symbols['yes_string'] (或通过偏移计算)。
    • 在程序重新运行后(或通过进程复用),第一轮输入 hex(yes_addr)
  3. 构造合法的 Protobuf 消息

    • giaoid 字段设置为 0x114514
    • giaosize 字段设置为 0x415411
    • giaocontent 字段填入经过 ALPHA3 编码的、用于读取 flag 的纯字符 ORW shellcode。
    • giaotoken 字段填入通过 第4节算法 计算出的合法 Token 字节。
  4. 序列化并发送

    • 使用生成的 _pb2.py 文件(如 recovered_pb2.py)中的 msgiao 类构造消息对象。
    • 调用 SerializeToString() 方法进行序列化。
    • 将序列化后的字节流发送给程序。
  5. 获取 Flag

    • 程序验证通过后,会将 giaocontent 复制到 RWX 内存并执行。
    • 编码后的 shellcode 会解码自身并执行,最终实现打开、读取并输出 flag 文件的内容。

8. 关键知识点回顾

  • 花指令识别与处理:通过修改字节码 (nop) 并重新分析来清理控制流。
  • Protobuf 逆向:通过数据段中的描述符字符串和结构体布局,恢复原始 .proto 定义。
  • 自定义加密算法分析:识别 RC4 的变种,理解其与标准 RC4 的差异(此处是额外异或了 Key)。
  • Shellcode 编码:使用 ALPHA3 等工具绕过对不可见字符的限制,生成纯字符 shellcode。
  • Seccomp 绕过:在受限的系统调用集中,构造有效的 ORW 链。
  • 利用链构建:将信息泄漏、条件绕过、数据构造、代码执行等多个环节串联成完整的利用过程。
相似文章
相似文章
 全屏