好友
阅读权限10
听众
最后登录1970-1-1
|
这篇文章记录了我对 52pojie 论坛上发布的 Obsidian VM Crackme 的完整逆向分析过程。这是一个定位为高难度的 Windows x64 逆向挑战,要求分析者还原一个自定义虚拟机并求出 32 字符的 Access Token。
一、样本概览
程序名称:Obsidian VM
编写语言:C++20
编译器:LLVM Clang / LLVM-MinGW
运行平台:Windows x64
架构:64 位 PE
壳:UPX 5.2.0,压缩算法 LZMA,参数 --best --lzma
加壳前大小:460,288 字节
加壳后大小:142,336 字节
Access Token 长度:32 字符
镜像基址:0x140000000
二、脱壳
UPX 5.2.0 的壳很好处理,直接 upx -d 脱壳即可。如果用调试器手动脱,追到末尾的 popad 后的 jmp 指令,目的地就是原始入口点 start(0x1400013D0)。
三、定位输入校验入口
脱壳后在 IDA 中搜索字符串,很快找到三处关键提示:
0x140067D28 "Obsidian VM / access token: "
0x140067D45 "[+] vault unlocked"
0x140067D59 "[-] invalid token"
交叉引用全部指向同一个函数 sub_14005B190(0x14005B190),它负责读取 stdin 输入,构造 std::string,然后调用 sub_14005BB10 做校验。
sub_14005BB10 入口处第一段代码就直接判长度:
cmp qword ptr [rcx+8], 0x20 ; 输入必须恰好 32 字节
jnz reject
不足 32 字节直接判错,没有商量的余地。
四、反调试
在 sub_14005BB10 内部,最后比较前会调用 sub_14005BD20:
CheckRemoteDebuggerPresent(GetCurrentProcess(), &flag);
result = (flag | IsDebuggerPresent()) != 0;
这个返回值被 OR 进最终结果。也就是说即使你输入了正确的 token,只要调试器还在跑,程序就告诉你 token 无效。反调试不是独立生效的,它被巧妙地做了器化——污染校验结果而不是弹窗警告,第一次遇到的人很容易以为是 token 不对而反复尝试。
绕过很简单:在 0x14005BBBC 处把 movzx eax, al 改成 xor eax, eax(机器码 31 C0),让反调试结果恒为零。或者在调试器里把 PEB 的 BeingDebugged 字段清掉。
顺便说一下 sub_14005C080,它只是 __stack_chk_fail——栈 canary 被破坏时触发,不是反调试组件。
五、花指令与虚假控制流
sub_14005BBF0 是 VM 的核心循环函数。循环体内在每字节处理完之后有一段代码:
v11 = 0xD1B54A32D192ED03
...
if ((v11 & 0xF) == 2)
call sub_14005BD80
进入 sub_14005BD80(0x14005BD80):
push rax
mov rax, [rcx] ; rax = *v11
or rax, 1 ; 强制最低位为 1
imul rax, rax ; rax = (v11|1)^2
mov [rsp+8+var_8], rax
mov rax, [rsp+8+var_8]
and eax, 7
cmp eax, 2
jnz skip ; 不相等则跳转
call near ptr loc_14005BD9C+4 ; 花指令: call 落在一个 jmp 上
jmp loc_14005BD9C ; 自旋
skip:
mov rax, [rcx]
rol rax, 7
xor [rcx], rax ; v11 ^= rol(v11, 7)
pop rax
retn
这里做了一件很巧妙的事:v11 被强制设为奇数后平方,而奇数平方的二进制最低三位永远是 001,也就是 &7 永远等于 1,绝不可能等于 2。因此那个 call 指令永不被执行,是不透明谓词的典型应用。
实际每次执行的代码只是 v11 ^= rol(v11, 7),修改一个本地变量,对 s1/s2/s3 三状态完全无影响。所以整段花指令可以直接忽略,不影响校验逻辑。
六、所谓的“虚拟机操作码”
按照题目描述,程序内有一个自定义字节码虚拟机,操作码通过动态密钥加密,解码依赖当前密钥、操作码位置和前一个编码字节。听上去相当唬人。
实际反汇编 sub_14005BBF0 之后发现,VM 就是一个迭代 32 次的循环。一个 context buffer V 在栈上,布局如下:
V+0x00 codeptr 指向 32 字节输入数据
V+0x08 codelen 32(输入长度)
V+0x10 状态 s1 64 位
V+0x18 状态 s2 64 位
V+0x20 状态 s3 64 位
V+0x28 pc 程序计数器
V+0x30 cur 当前字节
每次循环:
movzx edx, byte ptr [rdx+rcx] ; 从输入读一个字节
mov [rdi+30h], dl ; 存入 cur
; --- 然后驱动三状态更新 ---
没有操作码调度表,没有分支,没有 halt 指令。所谓的“操作码加密”就是前面那段 v11 花指令混淆的文学化描述。32 个输入字节本身就是 VM 的 32 条指令,直接逐字节进入状态机混合。
七、三状态算法的完整还原
7.1 常量
四个常量全部来自 SplitMix64 和 SipHash 家族:
C1 = 0x9E3779B97F4A7C15 SplitMix64 黄金比例 gamma
C2 = 0xBF58476D1CE4E5B9 SplitMix64 finalizer 第一乘子
C3 = 0x94D049BB133111EB SplitMix64 finalizer 第二乘子
C4 = 0x2545F4914F6CDD1D SipHash 偏移常量
7.2 每字节的状态更新(汇编级确认,非反编译器猜测)
对于每个输入字节 b(下标 i 从 0 到 31):
s1 = rotl64(s1 ^ (i * C1 + b), 13) * C2 (1)
h = (uint8_t)s1 (取低 8 位)
s2 = rotl64(s2 + (h ^ b) * C3, 17) (2)
s3 = rotl64(s3 ^ (s2 + b * C4), 29) (3)
rotl64(x,n) = ((x << n) | (x >> (64-n))) & 0xFFFFFFFFFFFFFFFF,n 自动对 63 取余。
注意这是链式耦合:s2 依赖 s1 的低 8 位,s3 依赖完整的 s2。三个状态不能独立计算,必须按顺序串行。这是题目强调“后续状态依赖前一状态的计算结果”的具体体现。
7.3 种子(初始状态)
sub_14005BB10 在调用 VM 前把以下三个 64 位常量写入 context:
s1_seed = 0x243F6A8885A308D3
s2_seed = 0x13198A2E03707344
s3_seed = 0xA4093822299F31D0
这三个值是 π 的小数部分的十六进制表示的前 24 个字节(24 个 hex digits × 4 bits = 96 bits,对应 π 的二进制前 96 位)。这是验证输入正确性的唯一参照起点。
7.4 目标(正确终态)
sub_14005BB10 末尾用三条 XOR 指令与常量比较:
mov rax, 0x18815E9762BE90CC
xor rax, [rsp+var_30] ; var_30 = s1_final
mov rcx, 0x4FBF764270C8D81A
xor rcx, [rsp+var_28] ; var_28 = s2_final
or rcx, rax
mov rdi, 0x67945584CF762BBF
xor rdi, [rsp+var_20] ; var_20 = s3_final
or rdi, rcx
call sub_14005BD20 ; 反调试
movzx eax, al
or rax, rdi
setz al ; 所有条件清零时 al=1
正确终态常量:
s1_target = 0x18815E9762BE90CC
s2_target = 0x4FBF764270C8D81A
s3_target = 0x67945584CF762BBF
八、正向验证
为了确认公式还原无误,我用 Python 实现了正向状态机。关键验证:
H(b"") = (0x243F6A8885A308D3, 0x13198A2E03707344, 0xA4093822299F31D0)
空输入精确返回三个种子值,证明实现与汇编一致。完整实现保存在 obsidian_forward.py。
九、能否求出 Access Token?
这是整个挑战中最困难的部分,也是我目前未能彻底完成的一步。
9.1 数学结构
输入 32 字节 = 256 bits 的自由度。
约束 3 × 64 bits = 192 bits 的目标状态。
差值 = 64 bits,意味着在全字节空间(0-255)中大约存在 2^64 个有效 token。
9.2 单步可逆但整体不可逆
每个字节的变换在给定字节 b 和后期状态 (s1',s2',s3') 时是完全可逆的:
s1_old = ror64(s1' * inv(C2), 13) ^ (i * C1 + b)
h = (uint8_t)s1'
s2_old = ror64(s2', 17) - (h ^ b) * C3
s3_old = ror64(s3', 29) ^ (s2' + b * C4)
因此从目标终态向后回推时,每个字节 b(0 到 255 任选)都能算出唯一的前驱状态。问题是没有任何中间状态可以剪枝——只有回推到第 0 步并恰好命中种子 (π1,π2,π3) 时才知道找对了路。在无剪枝的情况下这是 256^32 的树,等同于单向搜索。
9.3 尝试过的方法
z3-solver(Python, bit-vector 64-bit):
- 任意字节(0-255),60 秒返回 unknown
- 可打印 ASCII(33-126),1100 秒返回 unknown
- s1-轨迹参数化(用 s1 中间值替代字节变量),5 万秒无解
候选字符串猜测:用 ObsidianVM、flag{、vault_unlocked 等几十个前缀拼凑至 32 字符并计算正向哈希,均不命中。
9.4 根本原因
这个散列是 SplitMix64/SipHash 家族的设计——经典的 avalanche 型变换,有意识地让输入的任何单比特变化引发输出的大面积翻转。乘加轮转的组合在 SMT 求解器面前是高度非线性的,而对于暴力枚举来说 2^192 的搜索空间过于庞大。
作者本人生成这个 token 是可行的(随机选一个 token,正向跑 32 轮得到终态,把终态硬编码进程序即可),但从终态反推 token 在无额外信息的情况下等价于原像攻击。
十、关键地址速查表
sub_14005B190 0x14005B190 main wrapper(stdin 读取,调用校验)
sub_14005BB10 0x14005BB10 token 校验函数
sub_14005BBF0 0x14005BBF0 VM 核心循环(状态机实现)
sub_14005BD20 0x14005BD20 反调试检查
sub_14005BD80 0x14005BD80 花指令/junk 处理器(可忽略)
sub_14005C080 0x14005C080 __stack_chk_fail(栈保护,非反调试)
"Obsidian VM /..." 0x140067D28 stdout 提示
"[+] vault unlocked" 0x140067D45 成功字符串
"[-] invalid token" 0x140067D59 失败字符串
C1 0x9E3779B97F4A7C15 黄金比例 gamma
C2 0xBF58476D1CE4E5B9 fmix 乘子 1
C3 0x94D049BB133111EB fmix 乘子 2
C4 0x2545F4914F6CDD1D SipHash 常量
s1_seed 0x243F6A8885A308D3 π hex 1
s2_seed 0x13198A2E03707344 π hex 2
s3_seed 0xA4093822299F31D0 π hex 3
s1_target 0x18815E9762BE90CC 目标 1
s2_target 0x4FBF764270C8D81A 目标 2
s3_target 0x67945584CF762BBF 目标 3
十一、总结
任务 1 到 6(脱壳、定位入口、绕过反调试、区分花指令、分析操作码加密、还原三状态算法)已经完整完成。散列公式经正向验证确认无误,所有常量、种子、目标值均已提取并确认。
任务 7(求出 32 字符 Access Token)在当前已知信息下无法在合理时间内完成。这个挑战的作者通过一个 192-bit 的哈希锁定了一个只有他自己知道答案的 token——而逆向分析到这步,也已经把整个程序的校验逻辑完整吃透了。如果哪天出题人或者某个解题者公布了那串 32 字符,往 obsidian_forward.py 里一喂就能验证真伪。
obsidian_forward.py:C1=0x9E3779B97F4A7C15;C2=0xBF58476D1CE4E5B9;C3=0x94D049BB133111EB;C4=0x2545F4914F6CDD1D
M=0xFFFFFFFFFFFFFFFF
def rol(x,n):
n&=63
if n==0: return x
return ((x<<n)|(x>>(64-n)))&M
def H(inp):
s1,s2,s3=0x243F6A8885A308D3,0x13198A2E03707344,0xA4093822299F31D0
for i,b in enumerate(inp):
v6=b; v5=i
s1=(rol((s1^((v5*C1+v6)&M)),13)*C2)&M
h=s1&0xFF
s2=rol((s2+((h^v6)*C3)&M)&M,17)
s3=rol((s3^((s2+v6*C4)&M))&M,29)
return s1,s2,s3
if __name__=="__main__":
print("pi seeds -> H of empty:", [hex(x) for x in H(b"")])
print("A"*32, [hex(x) for x in H(b"A"*32)])
print("0"*32, [hex(x) for x in H(b"0"*32)])
print(hex(0x18815E9762BE90CC),hex(0x4FBF764270C8D81A),hex(0x67945584CF762BBF))
else:
def verify(tok):
r=H(tok.encode())
print("verify", repr(tok), [hex(x) for x in r])
return r |
|