karollooool/vvm-writeup

GitHub: karollooool/vvm-writeup

一份 HackTheBox 自定义虚拟机 crackme 的完整逆向解题报告,覆盖了从二进制静态分析到 XOR 去混淆、字节码反汇编、约束提取与求解的全链路。

Stars: 1 | Forks: 0

# VVM — Hack The Box Crackme 解题报告 于是我坐下来研究了一个名为 `vvm` 的小型 ELF 文件,根据其横幅信息显示版本为 `v0.0.3`。结果发现它属于那种虚拟机类型的 crackme:作者在 x86 架构之上编写了自己的微型字节码语言,对处理程序进行了混淆,并将密码校验逻辑隐藏在字节码中。整个文件只有大约 23 KB。虽然小巧,但信息密度极高。 以下是我从头到尾的完整解题过程。 ## 初步印象 基本的初步分析。该文件是一个剥离了符号表的 64 位 Linux PIE 可执行文件。运行它只会打印一个横幅并要求输入密码,这几乎是一个可执行文件能给出的最乏味的反应了,所以我直接将其拖入了 IDA。 `main` 函数短得让人有些尴尬: ``` int main() { sub_2910(); // banner sub_1530(); // VM init sub_2870(); // VM dispatch loop return 0; } ``` `sub_2870` 才是令人感兴趣的部分。稍微整理后,它看起来是这样的: ``` int *ip = (int *)&unk_5544; int i = dword_5540; // starts at 25 while (i != 28) { qword_74E0[i](&dword_5540, &ip, &unk_6220, &unk_6200); i = *ip; // next opcode lives at ip ++ip; // step past it } ``` 因此,我们面对的是一个经典的线程化 VM。操作码为 4 字节整数。`qword_74E0` 是调度表,`unk_6220` 是一个值栈,`unk_6200` 保存栈指针,而初始操作码 `25` 被塞入了 `dword_5540`,这样第一条指令就可以直接位于 `0x5540`,其操作数位于 `0x5544`。 操作码 `28` 是停机标志。该表有 29 个槽位,但第 28 号槽位从未被调度过。 ## 调度表在运行时构建 `qword_74E0` 位于 `.bss` 段中,因此静态 dump 出来的结果全都是零。其初始化函数 `sub_1530` 是个庞然大物,包含上百个局部变量和一堆 SSE 洗牌指令干扰,但每个处理程序的模式都是相同的: 1. 从 `.data` 段中的某个 `byte_XXXX` 数据块读取 N 个字节。 2. 将每个字节与一个滚动密钥进行 XOR 运算。 3. 通过 `mmap` 分配一个可执行内存页。 4. 将解码后的字节复制进去。 5. 将该内存页的指针存入 `qword_74E0` 的某个槽位中。 在 29 个处理程序中,有 4 个根本没有经过 XOR 编码。这些是直接与 libc 交互的处理程序: | 操作码 | 函数 | 功能 | |---|---|---| | 3 | `sub_1320` | READ(通过 `getline`) | | 7 | `sub_13E0` | `ptrace(PTRACE_TRACEME)` 反调试 | | 10 | `sub_13A0` | PRINT + free | | 15 | `sub_12A0` | PACK(将 N 个 qword 转换为字节数组) | 其余的 24 个处理程序都经过了 XOR 混淆。 ## 解码处理程序 在 Hex Rays 中,XOR 循环大概长这样: ``` v2 = 42; i = 555813674; v201 = 10829; for (v1 = 1; v1 != N; v1++) { out[v1 - 1] = src[v1] ^ v2; v2 = *((BYTE *)&i + (v1 % 6)); } ``` 密钥流存在于整数 `i`(4 个字节)与 `v201`(2 个字节)拼接而成的原始字节中。文件夹中已有的报告把那些字节写错了,这就是为什么我第一次解码出来的全是乱码。实际的小端序布局如下: ``` i = 0x21210B2A → 2A 0B 21 21 v201 = 0x2A4D → 4D 2A window = [0x2A, 0x0B, 0x21, 0x21, 0x4D, 0x2A] ``` 对于输出字节 `j`,其有效密钥流为: ``` j = 0 : 0x2A (initial v2) j ≥ 1 : window[j % 6] ``` 我的健全性检查很简单。二进制文件中的每个 x86 函数都以 `endbr64` 开头,即 `f3 0f 1e fa`。如果我的解码器为每个处理程序生成的前四个字节与此相符,那就说明密钥是正确的。事实确实如此。 ## 阅读处理程序 一旦解码完成,你会发现这些处理程序都非常小巧。每个大约在 20 到 60 字节之间,大多数不到 40 字节。下面是操作码 5 的代码,它非常具有代表性: ``` endbr64 movsxd rax, [rcx] ; rax = sp shl rax, 3 mov rsi, [rdx + rax 8] ; top value add [rdx + rax 16], rsi ; second from top += top sub dword [rcx], 1 ; sp ret ``` 所以操作码 5 是 ADD。我遍历了所有 24 个编码处理程序和两个本地处理程序,并整理出了完整的指令集: | 操作码 | 名称 | 操作码 | 名称 | |----|------|----|------| | 0 | STRLEN | 14 | POP | | 1 | SHL | 15 | PACK `imm` (native) | | 2 | MOD | 16 | PICK `imm` | | 3 | READ `imm` (native) | 17 | CALL `target, argc` | | 4 | DIV | 18 | INDEX `imm` | | 5 | ADD | 19 | EQ | | 6 | MUL | 20 | NE | | 7 | PTRACE (native) | 21 | top = top*8 + 24 | | 8 | OR (32 bit) | 22 | JNZ `imm` (pop, jump if nonzero) | | 9 | top = (top + 17) % 3 | 23 | DUP | | 10 | PRINT (native) | 24 | SUB | | 11 | JMP `imm` | 25 | PUSH `imm` | | 12 | top = 6*top 12 | 26 | SHR | | 13 | STRIP (trim trailing newline) | 27 | JMPB `imm` (jump back) | | | | 28 | HALT | 关于后来坑了我一把的一些怪异细节,在此补充几点说明: * `PICK imm` 会将 `stack[sp 1 imm]` 压栈。因此 `PICK 0` 相当于 DUP,`PICK 1` 相当于获取栈顶第二个元素,以此类推。 * `INDEX imm` 即 `stack[sp 1] = (signed char)top[imm]`。这包含了从字节到整数的符号扩展。这一点非常关键,因为如果某个标志字节设置了第 7 位(bit 7),它将被符号扩展为 `0xFFFF...FF`,从而破坏我们接下来要看到的 32 位打包操作。因此,该标志必须是纯 ASCII 字符。 * `JMP` 不会跳过自身的操作数。而 `JNZ` 会。因此这两个操作码是基于不同的锚点来计算其跳转目标的。这个问题让我多挠了好几分钟的头。 * `CALL target, argc` 是个有趣的操作。它会保存返回地址的 IP,切换到 `target` 处的字节码,并递归运行主调度循环,直到被调用方遇到 `HALT`。然后它取出栈顶元素作为返回值,将其写回 `argc` 个槽位之前的位置,并弹出 `argc` 个条目。换言之,HALT 同时充当了 RET 的角色。非常经典。 ## 反汇编字节码 字节码从 `0x5540` 开始运行,一直到 `.data` 段结束处的 `0x61E0`。这大约是 800 个 dword。我写了一个只有一页纸的线性反汇编器,因为每条指令的宽度刚好都是 `1 + nargs` 个 dword,其输出如下(前几行): ``` 5540 [dw 0]: PUSH 174 5548 [dw 2]: PUSH 2 5550 [dw 4]: DIV 5554 [dw 5]: PUSH 52 ... ``` 稍加浏览,其高层结构便一目了然: 1. **序言,dw 0 到约 180。** 一长串的 PUSH / ADD / SUB / MUL / DIV / MOD 操作,通过 PACK 构建了两个字节数组,打印出横幅,并在此过程中调用 PTRACE 进行反调试。这种算术大杂烩只是对常量 `"vvm v0.0.3\n"` 和 `"What is the password: "` 的混淆。 2. 位于 dw 150 的 **READ 32**。从 stdin 读取最多 32 个字节。 3. 紧随其后的 **STRIP**,用于去除末尾的换行符。 4. 位于 dw 181 的 **JNZ 97**,它会无条件跳转(因为栈顶是输入指针,非零)到位于 dw 280 的转发指令 **JMP 22**,进而跳转到真正开始干活的地方 dw 303。 5. **dw 282 到 302:函数 F1。** 这是后续所有 `CALL 282, 4` 调用的被调用方。 6. **dw 303 到 454:八次 F1 调用**,每次将四个输入字节打包成一个 dword。 7. 位于 dw 454 的 **JMP 16**,用于跳过 F2。 8. **dw 456 到 470:函数 F2。** 9. **dw 471 到约 555:八次 F2 调用**,将每个 dword 按固定的量进行循环移位。 10. **dw 555 到 591:八次减法操作**,依次减去硬编码的常量。 11. **dw 593 到 607:一连串的八个 JNZ**,如果发现有任何差值不为零,它们都会分支跳转到同一个失败代码块。 12. **成功路径**:更多的 PACK 和 PRINT 操作,用于输出 `"Correct!"` 字符串。 因此,这个校验过程用伪代码表示大致如下: ``` dN = pack(input[pN0], input[pN1], input[pN2], input[pN3]) for N in 0..7 assert rotl32(dN, sN) == constN for N in 0..7 ``` ## F1 和 F2 到底做了什么 **F1** 从栈顶取出四个字节 `a, b, c, d` 并返回 `a | b<<8 | c<<16 | d<<24`。即小端序 dword 打包。我逐条指令进行了跟踪,以确保 32 位 OR 确实是 32 位的,并且经过符号扩展的 qword 输入不会泄漏高位。事实证明,每个 `SHL` 和 `OR` 处理程序在存回时都做了零扩展至 32 位的处理。因此,只要输入字节是纯 ASCII,F1 就会准确地给出你所期望的 dword。 **F2** 接收 `(x, n)` 并返回 `rotl32(x, n)`。其整个函数体简直就是 `(x << n) | (x >> (32 n))` 的直译,由五次 PICK、一次左移、一次右移和一次 OR 组合而成。毫无花哨可言。 ## 提取索引排列 八个 F1 调用点中的每一个都包含四个带有字面偏移量的 `INDEX` 操作。我通过反汇编逐一手动提取了它们: ``` d0 ← input[24], input[14], input[27], input[15] d1 ← input[11], input[7], input[12], input[4] d2 ← input[6], input[9], input[2], input[18] d3 ← input[19], input[13], input[20], input[26] d4 ← input[28], input[16], input[23], input[8] d5 ← input[3], input[30], input[21], input[22] d6 ← input[25], input[5], input[10], input[31] d7 ← input[29], input[1], input[0], input[17] ``` 所有 32 个索引 `0..31` 在这八个组中各精确出现了一次,这是一个极好的完整性检查。这也说明 flag 共有 32 个字符。 从 F2 调用点读取的八次循环移位参数如下: ``` d7 ← shift 15 d6 ← shift 19 d5 ← shift 7 d4 ← shift 18 d3 ← shift 12 d2 ← shift 20 d1 ← shift 14 d0 ← shift 7 ``` 而那八个常量,是按照它们被检查的顺序从 `PUSH imm; SUB; JNZ` 链中读取出来的(其顺序之所以是 d7, d6, d5, d4, d3, d2, d1, d0,是因为随着差值在栈顶不断累积,`PICK 7` 的读取位置会随之攀升): ``` r(d7, 15) == 706975780 r(d6, 19) == 1972114847 (signed) r(d5, 7) == 1170424423 (signed) r(d4, 18) == 574761715 (signed) r(d3, 12) == 213630203 (signed) r(d2, 20) == 1193747491 r(d1, 14) == 353885596 r(d0, 7) == 1237732311 (signed) ``` ## 解决问题 整个逆向过程只需十几行 Python 代码即可搞定。对于每个 dword,将期望的常量按相同移位量进行反向旋转,拆分为四个小端序字节,然后将它们放入 32 字节缓冲区的正确槽位中即可。 ``` def rotr32(v, n): v &= 0xFFFFFFFF return ((v >> n) | (v << (32 n))) & 0xFFFFFFFF targets = { 7: (15, 706975780), 6: (19, 1972114847 & 0xFFFFFFFF), 5: (7, 1170424423 & 0xFFFFFFFF), 4: (18, 574761715 & 0xFFFFFFFF), 3: (12, 213630203 & 0xFFFFFFFF), 2: (20, 1193747491), 1: (14, 353885596), 0: (7, 1237732311 & 0xFFFFFFFF), } packs = [ (0, (24, 14, 27, 15)), (1, (11, 7, 12, 4)), (2, ( 6, 9, 2, 18)), (3, (19, 13, 20, 26)), (4, (28, 16, 23, 8)), (5, ( 3, 30, 21, 22)), (6, (25, 5, 10, 31)), (7, (29, 1, 0, 17)), ] flag = bytearray(32) for k, (shift, c) in targets.items(): d = rotr32(c, shift) for i, idx in enumerate(packs[k][1]): flag[idx] = (d >> (8 * i)) & 0xFF print(flag.decode()) ``` 输出: ``` HTB{v1rTu4L_p4sSw0rD_t3ChN0loGy} ``` ## 验证 我将该二进制文件放入一个 Docker ubuntu 容器中,通过管道传入候选 flag,得到了我最想看到的那一个词: ``` ======================= vvm v0.0.3 ======================= What is the password: Correct! ``` 大功告成。 ## 结尾感想 关于这道题,有几个我很喜欢的地方: * 这个 VM 极其小巧。每个处理程序都能在一屏内显示完整,没有隐藏状态,也没有奇怪的全局变量。这让你觉得直接阅读代码是有回报的。 * 作者将第一个操作码放入了 PC 变量本身,因此第一次调度时会读取 `dword_5540`,然后从 `0x5544` 获取操作数。虽然是个小细节,但这是一种模糊“寄存器”与“字节码”边界的绝佳方式。 * 字节码开头的算术面条代码起到了极好的误导作用。如果你试图对整个程序进行符号执行,你会崩溃的。但如果你只是略过寻找 `READ`、`CALL`、`JNZ`,你大约只需三十秒就能看透其真实结构。 * 校验本身关于输入是线性的。没有分支,也没有依赖于前序字节的密钥调度。因此,一旦你掌握了打包的排列方式和旋转常量,逆向它就是分分钟的事。 非常有趣的一道题。让我们继续下一题。
标签:Crackme, CTF Writeup, DNS 反向解析, ELF 文件, HackTheBox, IDA Pro, Linux 安全, Python3.6, x86-64, 二进制分析, 云安全运维, 云资产清单, 代码混淆, 内存映射, 动态代码生成, 异或解密, 自定义调度表, 虚拟机保护, 逆向工具, 逆向工程