RCTF 2025 RE wp
RCTF 2025 RE wp
chaos
RCTF{AntiDbg_KeyM0d_2025_R3v3rs3}
chaos2
有花指令
全patch了
patch要注意下
如main中
不仅红框部分需要nop,绿框也需要
再修改下函数边界即可(不会的话可以问我),然后UCP即可
恢复逻辑,是个rc4
main调用 EnumUILanguagesA 触发所有反调试回调,随后通过 rc4_init / rc4_apply 对 128 字节密文表解密并输出 flag。
0x401440在模块中搜索标记 0x12345678,找到后记录其后 0x80 字节的地址(0x402818),这里存放 RC4 key,初始为 flag:{Th1sflaglsG00ds}。
每个反调试函数都在未检测到调试器时把 key 的一个字节修正成正确值,使其最终变成 flag:{ThisflagIsGoods}:
check_PEB_BeingDebugged (0x401090) 把 key[8] 从 ‘1’ 改成 ‘i’。
check_ProcessDebugPort (0x401200) 调 NtQueryInformationProcess class 7,返回 0 时把 key[14] 改成 ‘I’。
exception_key_patch (0x4012a0) 使用 NtClose 触发异常并在 SEH 中把 key[17] 写成 ‘o’。
check_ProcessDebugFlags (0x4013a0) 查询 class 31,返回 1 时写 ‘o’ 到 key[18]。
- 这四处patch成功后,rc4_init以 128 字节 key 做标准 RC4 KSA,rc4_apply对密文异或得到RCTF{AntiDbg_Reversing_2025_v2.0_Ch4llenge}。
exp
1 | from typing import List |
Onion [赛后]
赛后做了下 其实不是难题,赛中主要去参加了R3con,回来实在不想做了(因为没仔细看以为每层的加密算法不一样 😂)
sub_171D0中包含了如何解释opcode
像洋葱一样剥就行 有几个方法
- 叠工作量,每个去逆
- 写自动脚本,因为有具体的模式,不是每层不一样的算法
sub_171D0 载入 full_vmcode,在 0x10000 字节缓冲区里跑一个自制 VM,并把用户输入的 50 个 64 位 key写到 VM 内存 0xE000 开始的位置。
0x0000 - 0xDFFF : VM 字节码
0xE000 - 0xE18F (400 字节): 50 个 64 位用户输入密钥
0xE190 - 0xFFFF : 栈空间
这个是opcode功能的识别
| opcode | 语义(64-bit 寄存器 r0~r7,16-bit pointer1/pointer2,栈指针 stack_ptr) |
|---|---|
| 0x00 | NOP |
| 0x01 target | 无条件跳转:pc = target |
| 0x02 target | flag==0 时跳转(“if not equal”) |
| 0x03 target | flag!=0 时跳转(“if equal”) |
| 0x11 addr | ptr1 = addr(16-bit) |
| 0x12 addr | ptr2 = addr |
| 0x15 r | r = qword[ptr1];flag = (r==0) |
| 0x16 r, imm | r = imm;flag = (r==0) |
| 0x17 dst, src | dst = src;flag = (dst==0) |
| 0x18 r, +off | r = qword[(ptr1 + off) mod 0x10000];flag = (r==0) |
| 0x19 r | qword[ptr1] = r |
| 0x1A dst, offReg | 取 byte:dst = byte[(ptr1 + (offReg & 0xFFFF))];flag = (dst==0) |
| 0x1B dst, offReg | 写 byte:byte[(ptr1 + (offReg & 0xFFFF))] = dst & 0xFF |
| 0x1C r | r = (r + 1) & MASK64;flag = (r==0) |
| 0x1D r | r = (r - 1) & MASK64;flag = (r==0) |
| 0x1E r, sh | r >>= sh;flag = (r==0) |
| 0x1F r | ptr1 = (ptr1 + (r & 0xFFFF)) & 0xFFFF |
| 0x25 dst, src | dst &= src;flag = (dst==0) |
| 0x26 dst, src | dst ^= src;flag = (dst==src)? |
| 0x27 r, sh | r <<= sh;flag = (r==0) |
| 0x29 r, imm | tmp=r; r ^= imm; flag = (tmp==imm) |
| 0x2A r, imm | r &= imm;flag = (r==0) |
| 0x2B dst, offReg | 取 byte:dst = byte[(ptr2 + (offReg & 0xFFFF))] |
| 0x2C dst, offReg | 写 byte:byte[(ptr2 + (offReg & 0xFFFF))] = dst & 0xFF |
| 0x32 r, imm | 比较:flag = (r == imm) |
| 0x80 | “保存 PC”指令,后续 0x81/0x82 用 |
| 0x81 idx | call_table[idx] = saved_pc + 3 |
| 0x82 idx | 调用:把返回地址压栈,pc = call_table[idx] |
| 0x83 | RET:从栈弹回 |
| 0x84 r | 把 r 压到 VM 栈(stack_ptr -= 8) |
| 0x85 r | 从 VM 栈弹出到 r |
| 0x90 byte | 输出字符(打印 flag 时用) |
| 0xFF | HALT(结束,返回某个寄存器值) |
写个小脚本翻译一下第一层的opcode成asm
1. 读取full_vmcode,加载到一块 0x10000 字节的缓冲区。
2. 提示 “Enter 50 64-bit keys”,将用户输入的 50 个 64-bit 数写入虚拟机内存,实现方式是把每个 key 存到 0xE000 + i * 8。
3. 初始化虚拟机(8 个 64-bit 寄存器、两个 16-bit data pointer、栈/调用表、条件标志等),然后以full_vmcode为指令区执行解释器。
基于这些理解,让ai大致写了一个翻译脚本
1 | from __future__ import annotations |
1 | python3 disasm_vm.py 0x5a9 0x6f4 |
1 | 05a9: setp1 0xe000 |
伪C逻辑如下:
1 | uint64_t inverse_transform(uint64_t y) { |
直接:
1 | python3 disasm_vm.py 0x02be 0x0715 |
1 | 02be: push r0 |
我放在一起了
其实应该是
python3 disasm_vm.py 0x02be 0x055c,也就是 call-table 入口 0x20 所指向的那段字节码(起始 full_vmcode:0x02be,结束 0x05a3),文件里就是那次 call 0x20 的完整被调函数。
入口 full_vmcode:0x05a9–0x0714(用 python3 disasm_vm.py 0x5a9 0x714 可再现),它负责从 0xE010 取 key、做两次可逆的加/异或混淆、执行 Speck 调用并比较结果;2) 被调用的 call 0x20 代码,即 layer1_call20.asm 中 full_vmcode:0x02be–0x05a3 的那大段嵌套循环
(Speck64/128 实现)。把两段拼在一起就是“第一次处理”的完整指令流。
求解第一个key
1 | #!/usr/bin/env python3 |
运行后得到第三个key
1 | 0xa28f38bd0463522c |
一些问题的解释:
定位首层片段
虚拟机执行前会用 0x80/0x81 idx/0x03 建好 call table,每条形如 … 80 01 A4 05 … 81 20 …。python3 disasm_vm.py 0x5a9 0x6f4 这个范围是通过搜索 0x81 0x20 得到 call index 0x20 的入口偏移 full_vmcode:0x05a4,从 setp1 0xe000 (full_vmcode:0x05a9) 起正好是最外层“取第 3 个 key 并处理”的主体,在那里能看到读取 0xE010、一串 mov_imm/xor_imm/“逐位加法”循环,再写入 0x7200 和 0x7208 后 call 0x20。
好像是rc4的东西
python3 disasm_vm.py 0x0116 0x02c0
1 | 0116: setp1 0x7100 |
同时
1 | 0715: mov r0, r5 ; 恢复原始 key |
call 0x10 的实现就在 full_vmcode:0x0200–0x02b7(用 python3 disasm_vm.py 0x0200 0x02c0 能看到完整指令),它是一个标准的 RC4 PRGA
在进入 VM 后的初始化(full_vmcode:0x0116–0x01f7)里,先把 0x7000 处的 S 盒置成 0…255,再用 0x7100 的 8 字节 key 做 RC4 KSA。寄存器在 sub_171D0 里全部 memset 成 0,所以 r0 起始为 0,setp1 0x7100; storep1 r0 把 8 个 0 写进 key 区,等价于一个 8 字节全 0 的 RC4 密钥。
call 0x10 里能看到典型的 PRGA:r2 = (r2+1)&0xFF,r3 = (r3+S[i])&0xFF,交换 S[i]/S[j],取 keystream byte S[(S[i]+S[j])&0xFF],最后与 ptr2 指向的密文异或,把结果写回 ptr2。循环次数就是 r1 = 0x6057。
那段 call 0x10 不是随便 XOR,而是一个 RC4 解密器。它在入口(full_vmcode:0x0116)把当前 r0 写到 0x7100 作为 8 字节密钥,然后做 KSA/PRGA 去 XOR ptr2 指向的密文。第一层通过校验后会把原始 key 放回 r0(0x0715: mov r0, r5),所以 RC4 的密钥其实就是我们刚算出的第一层 key 0xA28F38BD0463522C。
1 | from pathlib import Path |
python3 disasm_vm.py 0 0x200 stage2.bin
1 | 0000: setp1 0xe000 |
可以看到第二层流程几乎与第一层一致:取 key[1](偏移 0xE008),依次 XOR/减去几个 64 位常量,最后 XOR 0x99d8…,把新的 Speck 代码写到 0x7200。然后同样 call 0x20(Speck64/128,密钥改成 0x8d85b3156df9f721 || 0x28e3d33340bc0884),比较 r0 是否等于 0x659391A5DC3522B3,通过后恢复原始
key,调用 call 0x10 以该 key 为 RC4 密钥,再去解 0x5E60 字节的第三层。
第二层和第一层一样,只是换了一组可逆算子和 Speck 密钥。把第一层 key(0xA28F38BD0463522C)当成 RC4 密钥再跑 call 0x10,0x072a 开始的 0x6057 字节就会解成 stage2.bin,从头的指令能读出下面的管线:
- setp1 0xE000; loadp1_off r0, 0x0008 取第 2 个 64-bit key。
- 依次执行 3 次 XOR(0x95714C91BC8B306F, 0x4303F92241DD9A9F, 0x311E18C91413B58C)。
- 两次“sub gadget”把 0x8DF6073D0DBBFF09、0xEE5744EFE81E97B7 从寄存器里减掉(跟第一层一样是先取补码再加)。
- 两次“add gadget”把 0xF8A82A8DBDB78C3F、0x58E8ABFC7618F5FD 加回去(这次不做补码,所以是加法)。
- 再 XOR 0x99D88C4FA4CC68AA。
- 像上一层一样在 0x7200/0x7208 写入 Speck64/128 的新 key(小端拆成 [0x6DF9F721, 0x8D85B315, 0x40BC0884, 0x28E3D333]),call 0x20,比较 r0 是否等于 0x659391A5DC3522B3,成功后把原 key 放回 r0,用它做 RC4 解出下一层。
把这一套流程都编码进了 test1.py,现在它既能描述第一层,也能逆出第二层。直接运行脚本:
1 | #!/usr/bin/env python3 |
layer1 key = 0xa28f38bd0463522c
layer2 key = 0xbf11b34d0ce941cc
所以后续步骤是:
- 用 Speck 逆向还原第二层常量,算出 key[1](方法跟第一层脚本一样,换常量即可)。
- 拿这个 key 做 RC4,解 full_vmcode[0x0921 : 0x0921+0x5E60] 得到第三层,再继续同样的分析。
就是剥洋葱…逻辑差不多,要写个自动脚本
需要
解开key->获得下一段长度和偏移->rc4解密->识别更改的地方->解密得到key
这里解密测试碰上了个问题
也就是Rc4解密的时候也是嵌套的
也就是说第50层要经过50次解密,所以之前的rc4解密代码得换种写法
1 | from pathlib import Path |
final exp:
1 | import struct |
可以得到key
1 | Layer 1: key_index=2, key=0xa28f38bd0463522c |
最终flag
需要识别0x90(其实不用我们直接根据最后的信息0x6706是offset,就可以打印出来了)
python3 disasm_vm.py 0x6706 0x6800 stage51.bin
1 | 6706: print_byte 0x52 |
1 | # 所有 print_byte 里的十六进制字节 |
RCTF{VM_ALU_SMC_RC4_SPECK!_593eb6079d2da6c187ed462b033fee34}















