大概是看到这几天的很多文章,关于 Themida,甚至腾讯 ACE 反作弊的虚拟机都被逆向了。
于是想起来去年接到的一个单子,大概是给某个医疗器械做算码器。当时在网上找了一圈,找到了一个可疑的二进制包,里面有 WinLicense 2.x 包起来的程序,还有一个 PYG.dll。直接下载运行就能用,发给对面,这单也就结了。
那问题来了:先用 unlicense 脱掉第一层壳子,拿到解密后代码。算码部分依旧藏在 WinLicense VM 里面。那能不能把 WinLicense VM 跑过的每条指令全记下来,再一步步删掉无用计算、接回跳转和循环,最后把负责生成文件的代码抠出来?
运行行为
在 Win7 虚拟机里看了下,运行后是一个很普通的 Windows 对话框。插入 USB,程序读取 USB SN,然后填写 Company SSO、Service License ID、到期时间和 Class。

点 Generate 以后,程序根据 USB SN 和表单里的东西生成 part1.dat、part2.dat。再把这个 U 盘插到医疗器械里,完成激活。
分析
简单拖到 Detect It Easy 里面看了眼,本体是 WinLicense 2.x。外层壳读取 regkey.dat 确认程序授权,PYG.dll 是绕过了winlicense自带的外层检查+单实例检查。(中外开发者对抗路这一块)

先丢进 unlicense:
.\unlicense.exe SSA.exe可以获得一个脱壳后的 unpacked_SSA.exe,文件从 4.2 MB 变成了 10 MB 多。但是由于导入/符号表的问题,并不能直接运行。
本来还想用 Sogen 配合 KVM 或 Unicorn 直接模拟了,不想开windows机器——但是显然作为虚拟机它的syswow调用处理并没有那么流畅,所以卡住了。
把解包 EXE 放进 IDA 里看眼,代码和字符串都能找到。

接下来找主要逻辑。可以看到 SHA-256:

附近还有 Twofish 生成密钥的函数、Twofish CBC、CreateFile、WriteFile 和 part1.dat、part2.dat 字符串。这样就找到了输出端。IDA 里的交叉引用接不到 Generate 按钮,中间隔着 WinLicense 的调度代码,以及运行时才解开的代码块。

那 WinLicense VM 从哪里启动?


顺着界面上的 Generate 按钮可以定位到按钮编号 1000,它最后落到距离主模块开头 0x7D80 的位置,也就是 RVA 0x7D80。这里是一条 E9 rel32 跳转指令,运行时解包完成以后,它会直接跳到 VM 入口。
Interceptor.attach 会改写入口附近的代码,WinLicense 正好会检查这块内存。于是这里换成硬件执行断点:
const generateEntry = mainModule.base.add(config.generateRva);
function armGenerateEntry(thread) {
thread.setHardwareBreakpoint(
config.hardwareBreakpointId,
generateEntry
);
}断点命中以后,读取 E9 后面的相对偏移,算出这次运行里的 VM 入口,再从栈顶拿 Generate 的返回地址:
vmEntry = generateEntry.add(
5 + generateEntry.add(1).readS32()
);
returnAddress = context.sp.readPointer();Frida 的事件循环在异常回调外面调用 Stalker.follow(threadId)。Stalker 安装好以后,把 gate_flag 写成 1,原线程跳回 Generate。硬件断点只负责抓住 Generate 入口这一次,进入 VM 以后,Stalker 会把执行过的每条指令依次记下来。
Stalker.follow(tracedThreadId, {
transform(iterator) {
let instruction = iterator.next();
let startsBlock = true;
while (instruction !== null) {
if (inVm(instruction.address)) {
const metadata = {
address: instruction.address.toString(),
size: instruction.size,
bytes: readHex(instruction.address, instruction.size),
mnemonic: instruction.mnemonic,
opStr: instruction.opStr,
operands: instruction.operands,
registersRead: normalizeRegisters(instruction, "r"),
registersWritten: normalizeRegisters(instruction, "w"),
startsBlock,
};
iterator.putCallout(
captureAt.bind(null, metadata)
);
}
iterator.keep();
startsBlock = false;
instruction = iterator.next();
}
},
});所以这里分成两层:硬件断点负责找到这次运行里的 VM 入口,Stalker 负责记录 VM 范围内实际执行的每条指令。调度器也在这些指令里面,后面再根据它反复出现的跳转规律认出来。对应代码在 tools/frida/ssa_vm_trace.js,外面的启动和日志保存放在 tools/capture_ssa_trace.py。
完整运行最后落下 7,580,418 条指令,存成一行一条记录的 JSONL,大概 4.2 GB。
中置知识
源代码在变成机器码以前,编译器会先读懂代码结构,再把它改写成一层层更简单的中间代码。SSA 是其中一种记账方式:同一个寄存器每改一次,就换一个编号。LLVM IR 则是 LLVM 能直接检查和优化的代码格式。
反编译器反着走这套流程。它先从机器码里找出一段段代码和它们之间的跳转,再把反复改写的寄存器和内存位置分开编号,最后恢复循环、判断、变量和类型,整理成我们在 IDA 里看到的伪代码。
我们手里的材料,就是这次运行真正走过的指令。哪些地方会重复,哪些地方会分叉,都要从这份运行记录里重新找出来,然后再把循环和判断接回去。
理解这两步以后,就可以开始手搓 VM 反编译了。
指令 -> SSA IR
第一步是把 JSONL 日志整理成方便查询的数据。相同位置的机器指令只存一份;每次实际执行、读到哪个旧值、写出哪个新值、碰了哪段内存、从哪里跳到哪里,再分别记下来。
几类藏在指令里的动作需要自己补上。比如 push、pop、call、ret 都会偷偷读写栈,pushad、popad 一次还会碰很多寄存器。这里把这些访问补全,再一起放进 SQLite。mov ax、mov si、mov di 也要单独处理,因为它们只改寄存器的低 16 位,高 16 位要沿用原来的值。
SSA 的处理逻辑很直白:
eax = 1
eax = eax + 2
变成:
eax_1 = 1
eax_2 = eax_1 + 2一旦每个值都能指回自己的来源,我们就可以从 WriteFile 的输出内容,或者虚拟机自己的某个值开始往回追,只留下会影响结果的指令。
最终得到 12,316,942 个新值、9,707,942 次旧值引用、135,049 段实际跑过的代码,以及 45,920 条位置各不相同的机器指令。
接着统计哪些内存位置读写最频繁,可以认出虚拟机自己的一小块寄存器区,起点在 0x83A6CA。
这块内存后面继续叫 context。context + 0x18 大部分落在 0xD01000 一带,每次增加 4、6、0xC、0xD 或 0x1A,所以它就是下一条虚拟指令的位置,后面简称 VIP。context + 0x68 指向 Windows 栈,主要按 4 字节移动,也就是虚拟栈顶。context + 0x74 保存当前要执行的 VM 代码地址,context + 0xB6 则是下一段代码的地址表。
再往下就能找到位于 0x96BE77 的一个 VM 代码块,这种代码块后面继续叫 handler。它一共执行了 2,021 次,每次要跑 340 条普通 x86 指令。我们从它写回的结果往前追,只留下会影响结果的部分,最后 340 条指令缩成 9 个动作:
- 复制 6 个虚拟寄存器
- 更新一次调度密钥
- VIP 前进 0x1A
- 算出下一段代码地址并跳过去
调度尾部大概是这样的逻辑:
selector = load16(VIP + 0x16)
mixed = selector + context[0x10] + 0x3AB630E3
index = (mixed & 0xFFFF) << 2
next = load32(context[0xB6] + index)
VIP += 0x1A
jump next它会从 VIP 后面取出两字节,混入调度密钥,再拿结果查表,找到下一段代码并跳过去。
SSA IR -> LLVM IR
走到这里,每一种虚拟机的单独指令已经能单独看懂了。下一步顺着前面记好的数值来源,把一长串 x86 指令拼成读取、计算和写回这些简单动作,再翻成 LLVM 能处理的形式。
中间还碰到 22 段运行时临时改写的代码,我先给它们留了一个占位函数:
declare void @wl_opaque_call(i32, i32, i32)后面直接查 SQLite 里的每条指令和内存访问,查询结果全是压栈、出栈和跳回调度代码,访问范围也停留在原生栈上。按普通跳板处理就行。
生成出来的 LLVM 大概长这样:
target triple = "i386-pc-windows-msvc"
define i32 @vm_path_0096be77_9f071e2590888eb6(
ptr %context,
i32 %native_sp
) {
entry:
%context.raw = ptrtoint ptr %context to i32
%vip.addr = add i32 %context.raw, 24
%vip.ptr = inttoptr i32 %vip.addr to ptr
%vip = load i32, ptr %vip.ptr, align 1
ret i32 %vip
}这个例子删掉了大段内存读取和写入,只留下大概样子。完整结果有 234 条执行路径,再按入口合成 147 组。每一组旁边都有一份对照表,可以查到它来自哪段 VM 代码、哪次运行,以及精简前后的内容。
最后用 LLVM 自带的工具完整检查一遍,再让它折叠常量、删掉无用计算。这样每个 VM 代码块留下来的内容都能正常组装,也能正常生成机器码。
LLVM IR -> 拼回完整CFG
LLVM 把每个小片段验明白以后,还得把它们接回一张完整流程图。运行日志里记着每次进入 VM 代码块前的 VIP,顺着这些地址的先后关系连线,就知道哪个片段后面接哪个片段。
先看同一个 VM 代码块会从哪里分叉。遇到分叉时,就从比较结果或返回地址往前追,最后找出 72 个判断条件和 5 种动态返回地址。
然后按每次进入 VM 代码块时的 VIP,把虚拟指令执行串起来。
最终得到了长这样的伪代码:这不就是平坦化控制流吗
for (;;) {
switch (vip) {
case 0xcfc5e0:
slot32(vm, 0x92) = native_stack32(-0x8);
vip = 0xcfc5e8;
break;
case 0xcfc5e8:
vip = 0xcfc5ec;
break;
}
}为了保住全部信息,只能逐个 VIP 列出来。VM 原本就靠一个调度循环来回跳,生成的伪代码外面又套了一层 switch,看起来还是很痛苦。
接下来把直来直去的连续位置合成一段,再找出回头跳的地方。最后压成 16 段代码、22 条跳转和 5 个循环。程序一开始给出的是 flags & 0x40 这种判断结果,顺着它往前追到原始 cmp,就能直接翻译成等于、不等于、有符号小于和大于等于。
这时伪代码已经能看出两个嵌套扫描循环:
for (;;) {
vm_exec_range(vm, 0xcff94f, 0xcff98c);
for (;;) {
vm_exec_range(vm, 0xcff9cc, 0xcffa76);
if (vm_u8(vm, 0x1c) != input_byte)
goto next_record;
vm_exec_range(vm, 0xcffa83, 0xcffadc);
if (vm_u32(vm, 0x1c) < 10)
continue;
break;
}
}继续往回追每个判断的来源,还直接找出了两个 10 字节扫描串:
classType=
startDate=沿着这些扫描和字段写入继续追,动态内存里恢复出从 0x7FA428 开始的连续字节。它和 IDA 里的 0xF2A428 共享同一个 RVA。把同一静态记录的后缀接上,得到一条 12,059 字节、317 字段的 MR 记录。
从CFG回到业务逻辑
VM 里面已经能看到 classType=、startDate= 扫描和记录写入了,接下来要把它接到最终的 part1.dat、part2.dat。
解密后发现格式相当朴素。每块密文都是 0x400 字节,前 0x100 字节放实际内容,后面 0x300 字节补零。每块加密前都会重新设置同一个初始值:
IV = b"GIGAWAABAMIN".ljust(16, b"\0")
key = SHA256(source.ljust(256, b"\0"))每块使用的密钥原料会按位置切换:
两个文件的第 0 块 gaawaabaabiganikaag
part1 第 1 块 USB 序列号
part1 第 2 块以后 USB.SSO.ServiceID
part2 第 1 块以后 USB 序列号part1.dat 一共 76 块,part2.dat 一共 60 块。明文里有 MR、CT、PET、VASC 四条记录。part1 是启用值,part2 是零值基线。part2 最后还有 240 字节怪数据,回到 IDA 一看,它正好等于 0xF32838 处 Twofish q0 表的前 240 字节。原程序把相邻缓冲区一起带进输出了。
这里的 MR CT PET VASC应该是对应的四种不同机器逻辑。
VM 恢复出来的 MR 记录和最终 part1 里的 MR 都是 12,059 字节、317 个字段。两次日期不同的样本只差 6 个字节,全部在 startDate、expireDate、duration。到这里,VM 内部字段写入和最终密文文件终于接上了。
最后的封装
回到解包映像,发现两段模板边界刚好对齐:
0xF2A428..0xF2EE28 part1 template, 0x4A00 bytes
0xF2EE28..0xF32928 part2 template, 0x3B00 bytes原始 WinLicense VM 有 1571 次 VIP 移动,最后生成文件只需要 7 步:
- 复制 part1 模板
- 把 classType 写进 MR
- 把 startDate、expireDate 和 duration 写进四条记录
- 复制 part2 模板
- 清掉 Twofish 的运行标记
- 写入 USB 和 USB.SSO.ServiceID
- 生成密钥并加密所有数据块
封装后的高层逻辑基本就是:
void generate_parts(const GenerateInputs *in, OutputFiles *out) {
uint8_t part1_payload[76 * 0x100] = {0};
uint8_t part2_payload[60 * 0x100] = {0};
write_slot(part1_payload + 0x000, in->usb_serial_number);
write_slot(part1_payload + 0x100,
join(in->usb_serial_number,
in->company_sso,
in->service_license_id,
"."));
memcpy(part1_payload + 0x200, image_va(0xF2A428), 0x4A00);
write_fixed_field(0, "classType", in->class_of_service, 1);
write_dates_to_four_records(part1_payload, in);
write_slot(part2_payload, in->usb_serial_number);
memcpy(part2_payload + 0x100, image_va(0xF2EE28), 0x3B00);
out->part1 = encrypt_payload(part1_payload, 76);
out->part2 = encrypt_payload(part2_payload, 60);
}最后改成python版本,简单运行了一下,没有太大的问题。嗯...那就完结撒花了。