|
https://blog.back.engineering/21/06/2021/
一、目的
本文的目的是对上一篇文章 VMProtect 2 - 虚拟机架构细节中披露的先前工作进行阐述,并纠正一些错误。此外,这篇文章将主要关注使用上一篇文章中披露的静态分析工具,并提供一些详细的、但非官方的 VTIL 文档。本文还将展示 githacks.org/vmp2 上的所有项目,但是,这些项目可能会发生变化。
二、意图
进行这项研究的目的是通过代码虚拟化和代码混淆来加深对软件保护的了解,而不是为了盈利或诽谤 VMProtect。VMProtect 的作者应该受到尊重,因为他们的工作显然花费的代价很大,可以说是经受住了时间的考验。
三、定义
3.1 Code Block
虚拟指令块或代码块是包含在虚拟分支指令之间的虚拟指令序列。例如,JMP 指令和下一个 JMP 或 VMEXIT 指令之后的任何指令。代码块在 C++ 中表示为一个结构体 (vm::instrs::code_block_t),其中包含虚拟指令向量以及结构体本身中包含的代码块的起始地址。有关给定代码块的其他元数据也包含在该结构内部,例如该代码块是否分支到其他两个代码块、还是仅分支到一个代码块或退出虚拟机。
3.2 VMProtect 2 IL
中级表达或语言。将编码和加密的虚拟指令视为可用的、本机形式的虚拟指令。那么 IL 将是更高级别的表示,通常 IL 表示是指编译器和汇编器使用的代码的表示。VMProtect 2 IL 的一个示例是 VMAssembler 对其进行词法分析,或者更具体地说是包含 IL 的文件。
四、VMProtect2 - 项目概述
尽管 githacks.org/vmp2 上似乎有相当多的项目,但实际上只有一个大型库项目和继承该库的较小项目。VMProfiler 是 VMProfiler Qt、VMProfiler CLI、VMEmu 和 VMAssembler 的基础库。这些项目中的每一个都是基于静态分析的,因此 VMHook 和 um-hook 不继承 VMProfiler。
4.1 VMHook - 概述
VMHook 是一个非常小的 C++ 框架,用于挂钩 VMProtect 2 虚拟机,um-hook 继承了这个框架并提供了如何使用该框架的演示。VMHook 不是用来发现虚拟指令及其功能,而是用来改变它们。
4.1.1 VMHook - um-hook 例子
- .data
- __mbase dq 0h
- public __mbase
- .code
- __lconstbzx proc
- mov al, [rsi]
- lea rsi, [rsi+1]
- xor al, bl
- dec al
- ror al, 1
- neg al
- xor bl, al
- pushfq ; save flags...
- cmp ax, 01Ch
- je swap_val
- ; the constant is not 0x1C
- popfq ; restore flags...
- sub rbp, 2
- mov [rbp], ax
- mov rax, __mbase
- add rax, 059FEh ; calc jmp rva is 0x59FE...
- jmp rax
- swap_val: ; the constant is 0x1C
- popfq ; restore flags...
- mov ax, 5 ; bit 5 is VMX in ECX after CPUID...
- sub rbp, 2
- mov [rbp], ax
- mov rax, __mbase
- add rax, 059FEh ; calc jmp rva is 0x59FE...
- jmp rax
- __lconstbzx endp
- end
复制代码
um-hook 是一个继承 VMHook 的项目,它演示了挂钩 LCONSTBZX 虚拟指令并欺骗其立即值。这随后会影响后面的虚拟移位函数结果,最终导致虚拟例程返回 true 而不是 false。
4.2 VMProfiler - 概述
VMProfiler 是一个 C++ 库,用于对 VMProtect 2 二进制文件进行静态分析。这是 VMProfiler Qt、VMProfiler CLI、VMEmu 和 VMAssembler 的基础项目。VMProfiler 还继承了 VTIL,并包含 VM Handler 配置文件和提升程序。
4.2.1 VMProfiler - VM Handler 分析
通过模式匹配算法找到 VM Handler 并对其进行分类,该算法的第一次迭代只是比较原生指令字节。然而,这已被证明是无效的,因为对原生指令的更改不会导致不同的结果,但会更改原生指令字节,从而导致算法错误分类,甚至无法识别 VM Handler。考虑以下指令变形,所有这些指令变形在执行时都具有相同的结果,但每个指令变形都有自己独特的字节序列。
- 0: 36 48 8b 00 mov rax,QWORD PTR ss:[rax]
- 4: 48 8b 00 mov rax,QWORD PTR [rax]
- 0: 36 48 8b 04 05 00 00 mov rax,QWORD PTR ss:[rax*1+0x0]
- 7: 00 00
复制代码
为了处理这种情况,我们设计并实施了一种新的迭代剖析算法。这种新算法仍采用模式匹配,但为 VM Handler 的每条指令定义了一个 lambda。该 lambda 通过引用接收一个 ZydisDecodedInstruction 参数,并返回一个布尔值。如果给定的解码指令符合所有比较条件,则结果为 true。使用 zydis 可以更精细地比较操作数。例如,上图中两条指令的操作数二都是 ZYDIS_OPERAND_TYPE_MEMORY 类型。此外,这两条指令的操作数基数都是 RAX ,两条指令的助记符相同,这种简约的比较思维正是剖析算法的基础。
- vm::handler::profile_t readq = {
- // MOV RAX, [RAX]
- // MOV [RBP], RAX
- "READQ",
- READQ,
- NULL,
- { { // MOV RAX, [RAX]
- []( const zydis_decoded_instr_t &instr ) -> bool {
- return instr.mnemonic == ZYDIS_MNEMONIC_MOV &&
- instr.operands[ 0 ].type == ZYDIS_OPERAND_TYPE_REGISTER &&
- instr.operands[ 0 ].reg.value == ZYDIS_REGISTER_RAX &&
- instr.operands[ 1 ].type == ZYDIS_OPERAND_TYPE_MEMORY &&
- instr.operands[ 1 ].mem.base == ZYDIS_REGISTER_RAX;
- },
- // MOV [RBP], RAX
- []( const zydis_decoded_instr_t &instr ) -> bool {
- return instr.mnemonic == ZYDIS_MNEMONIC_MOV &&
- instr.operands[ 0 ].type == ZYDIS_OPERAND_TYPE_MEMORY &&
- instr.operands[ 0 ].mem.base == ZYDIS_REGISTER_RBP &&
- instr.operands[ 1 ].type == ZYDIS_OPERAND_TYPE_REGISTER &&
- instr.operands[ 1 ].reg.value == ZYDIS_REGISTER_RAX;
- } } } };
复制代码
上图显示的是 READQ 配置文件。请注意,并非虚拟机处理程序的每一条指令都必须有一个 zydis lambda,只有足够多的指令才能为其构建一个独特的配置文件。事实上,还有一些 READQ 的原生指令没有使用 zydis 比较 lambda。
4.2.2 VMProfiler - 虚拟分支检测算法
虚拟分支中最明显的一致性是 PUSHVSP 的使用,当 VSP + 0 和 VSP + 8 堆栈中有两个加密值时,就会执行这条虚拟指令,这些加密值将使用给定数据块的最后一个 LCONSTDW 值进行解密。因此,基于这两个一致性,可以创建一个非常小的算法,算法的第一部分将简单地使用 std::find_if 和反向迭代器来定位给定代码块中的最后一个 LCONSTDW。该 DWORD 值将被解释为 XOR 密钥,用于解密两个分支的加密相对虚拟地址。现在执行第二条 std::find_if 以查找 PUSHVSP 虚拟指令,该指令执行后,堆栈上将出现两个加密的相对虚拟地址。该算法将把每条 PUSHVSP 指令的前两个堆栈值解释为加密的相对虚拟地址,并与最后一个 LCONSTDW 值进行 XOR 运算。
- std::optional< jcc_data > get_jcc_data( vm::ctx_t &vmctx, code_block_t &code_block )
- {
- // there is no branch for this as this is a vmexit...
- if ( code_block.vinstrs.back().mnemonic_t == vm::handler::VMEXIT )
- return {};
- // find the last LCONSTDW... the imm value is the JMP xor decrypt key...
- // we loop backwards here (using rbegin and rend)...
- auto result = std::find_if( code_block.vinstrs.rbegin(), code_block.vinstrs.rend(),
- []( const vm::instrs::virt_instr_t &vinstr ) -> bool {
- auto profile = vm::handler::get_profile( vinstr.mnemonic_t );
- return profile && profile->mnemonic == vm::handler::LCONSTDW;
- } );
- jcc_data jcc;
- const auto xor_key = static_cast< std::uint32_t >( result->operand.imm.u );
- const auto &last_trace = code_block.vinstrs.back().trace_data;
- // since result is already a variable and is a reverse itr
- // i'm going to be using rbegin and rend here again...
- //
- // look for PUSHVSP virtual instructions with two encrypted virtual
- // instruction rva's ontop of the virtual stack...
- result = std::find_if(
- code_block.vinstrs.rbegin(), code_block.vinstrs.rend(),
- [ & ]( const vm::instrs::virt_instr_t &vinstr ) -> bool {
- if ( auto profile = vm::handler::get_profile( vinstr.mnemonic_t );
- profile && profile->mnemonic == vm::handler::PUSHVSP )
- {
- const auto possible_block_1 = code_block_addr( vmctx,
- vinstr.trace_data.vsp.qword[ 0 ] ^ xor_key ),
- possible_block_2 = code_block_addr( vmctx,
- vinstr.trace_data.vsp.qword[ 1 ] ^ xor_key );
- // if this returns too many false positives we might have to get
- // our hands dirty and look into trying to emulate each branch
- // to see if the first instruction is an SREGQ...
- return possible_block_1 > vmctx.module_base &&
- possible_block_1 < vmctx.module_base + vmctx.image_size &&
- possible_block_2 > vmctx.module_base &&
- possible_block_2 < vmctx.module_base + vmctx.image_size;
- }
- return false;
- } );
- // if there are not two branches...
- if ( result == code_block.vinstrs.rend() )
- {
- jcc.block_addr[ 0 ] = code_block_addr( vmctx, last_trace );
- jcc.has_jcc = false;
- jcc.type = jcc_type::absolute;
- }
- // else there are two branches...
- else
- {
- jcc.block_addr[ 0 ] = code_block_addr( vmctx,
- result->trace_data.vsp.qword[ 0 ] ^ xor_key );
- jcc.block_addr[ 1 ] = code_block_addr( vmctx,
- result->trace_data.vsp.qword[ 1 ] ^ xor_key );
- jcc.has_jcc = true;
- jcc.type = jcc_type::branching;
- }
- return jcc;
- }
复制代码
注意:使用该算法不会提取虚拟分支所依赖的底层标志,这也是目前这种算法的缺点之一。
4.3 VMProfiler Qt - 概述
VMProfiler Qt 是一个基于 C++ Qt 的小型 GUI,允许检查虚拟指令跟踪。这些跟踪是通过 VMEmu 生成的,包含每条虚拟指令的所有信息。GUI 包含一个用于虚拟寄存器值、原生寄存器值、虚拟堆栈、虚拟指令、可扩展虚拟分支的窗口,最后是一个包含所有虚拟机处理程序及其本地指令和转换的选项卡。
4.4 VMProfiler CLI - 概述
VMProfiler CLI 是一个命令行项目,用于展示所有 VMProfiler 功能。该项目仅包含一个文件 (main.cpp),但对于那些有兴趣将 VMProfiler 作为其代码库的人来说,这是一个很好的参考。
- Usage: vmprofiler-cli [options...]
- Options:
- --bin, --vmpbin unpacked binary protected with VMProtect 2
- --vmentry, --entry rva to push prior to a vm_entry
- --showhandlers show all vm handlers...
- --showhandler show a specific vm handler given its index...
- --vmp2file path to .vmp2 file...
- --showblockinstrs show the virtual instructions of a specific code block...
- --showallblocks shows all information for all code blocks...
- --devirt lift to VTIL IR and apply optimizations, then display the output...
- -h, --help Shows this page
复制代码
4.5 VMEmu - 概述
VMEmu 是一个基于 unicorn-engine 的项目,它模拟 VM Handler 以随后解密虚拟指令操作数。VMEmu 继承了 VMProfiler,后者有助于确定给定的代码块中是否包含虚拟 JCC。VMEmu 目前不支持转储模块,因为转储模块可以有多种形式,转储模块没有一种标准的文件格式,因此对转储模块的支持将伴随另一个基于 unicorn-engine 的项目来生成标准转储格式。
- Usage: vmemu [options...]
- Options:
- --vmentry relative virtual address to a vm entry... (Required)
- --vmpbin path to unpacked virtualized binary... (Required)
- --out output file name for trace file... (Required)
- -h, --help Shows this page
复制代码
4.5.1 Unicon 引擎,静态解密操作码
为了静态解密虚拟指令操作数,首先必须了解这些操作数是如何被加密的。VMProtect 2 用于加密虚拟指令操作数的算法可以表示为一个数学公式。
考虑到上述图形,操作数的解密只是函数 F 的逆运算,这个逆运算被生成为原生 x86_64 指令,并嵌入到每个 VM Handler 以及 calc_jmp 中。人们可以通过在 C/C++ 中重新实现这些指令来简单地模拟它们,然而我对这些指令的实现仅仅是为了加密,而不是解密。在这种情况下,更倾向于使用 unicorn-engine,因为只需模拟这些 VM Handler,就会生成解密的操作数。
要理解的是,没有任何运行时值可能影响操作数的解密,因此可以忽略无效的内存访问。然而,运行时值可以改变哪些虚拟指令块被解密,因此需要在执行分支虚拟指令之前保存仿真 CPU 的上下文。这将允许在分支指令之前恢复仿真 CPU 的状态,但还可以改变仿真 CPU 将采取的分支,从而允许静态地完全解密所有虚拟指令块。
重申一下,unicorn-engine 的使用是为了计算 F(e,o) 和 G(e,o) ,其中 e 采用本地寄存器 RBX 的形式,o 采用本地寄存器 RAX 的形式,Tm,Fn 采用转换 m 的形式。
此外,不仅可以使用 unicorn-engine 获取解密的操作数,还可以为每一条虚拟指令快照虚拟堆栈的视图。这使得算法可以利用堆栈上的值,对原生 WinAPI 的调用是在虚拟机之外完成的,除了少数情况,例如 VMProtect 2 打包器 VM Handler,它在 RCX 中使用指向字符串 NTDLL.DLL 的指针调用 LoadLibrary 。
4.5.2 虚拟分支
看到所有代码路径是极其重要的,考虑最基本的情况,其中一个参数被检查是否为 nullptr。
- auto demo(int* a)
- {
- if (!a)
- return {};
- // more code down here
- }
复制代码
对上述代码进行分析而无法看到所有代码路径将导致无用的结果。因此,查看虚拟机内部的所有分支是首要任务。在本节中,我将详细介绍 VMProtect 2 虚拟机内部的虚拟分支工作原理,以及我设计的用于识别和分析所有路径的算法。
首先,并非所有代码块都以分支虚拟指令结束,有些以虚拟机退出或绝对跳转结束。因此,需要一种算法来确定给定的虚拟指令块是否会分支。为了生成这样的算法,需要对虚拟机分支机制有深入的了解,特别是本地 JCC 如何被翻译成虚拟指令。
考虑本地 ADD 指令可能受影响的标志位。标志 OF 、 SF 、 ZF 、 AF 、 CF 和 PF 都可能根据计算结果受到影响。原生分支通过 JCC 指令完成,这取决于特定标志或标志的状态。
- test rax, rax
- jz branch_1
复制代码
请参考上述代码,了解如果 ZF 标志被设置, JZ 本地指令将跳转到 branch_1。可以以这样一种方式重新实现上述代码,即只使用原生 JMP 指令和其他一些数学和堆栈操作。将分支指令的数量减少到单个本地 JMP 指令。
考虑到原生 TEST 指令对两个操作数执行按位 AND ,相应地设置标志,并忽略 AND 结果。可以简单地用一些堆栈操作和原生 AND 指令替换原生 TEST 指令。
- 0: 50 push rax
- 1: 48 21 c0 and rax,rax
- 4: 9c pushf
- 5: 48 83 24 24 40 and QWORD PTR [rsp],0x40
- a: 48 c1 2c 24 03 shr QWORD PTR [rsp],0x3
- f: 58 pop rax
- 10: ff 34 25 00 00 00 00 push branch_1
- 17: ff 34 25 00 00 00 00 push branch_2
- 1e: 48 8b 04 04 mov rax,QWORD PTR [rsp+rax*1]
- 22: 48 83 c4 10 add rsp,0x10
- 26: 48 89 44 24 f8 mov QWORD PTR [rsp-0x8],rax
- 2b: 58 pop rax
- 2c: ff 64 24 f0 jmp QWORD PTR [rsp-0x10]
复制代码
上述代码中未使用 bittest/test,因为它是通过 AND 和 SHR 实现的。虽然将一条指令转换为多条指令似乎会适得其反,最终需要做更多的工作,但事实并非如此,因为这些指令将在其他方向上重复使用。使用上述汇编代码模板,可以非常简单地重新实现所有 JCC 指令。即使是 JRCXZ 、 JECXZ 和 JCXZ 等分支指令,也可以通过简单地将 RAX 与上例中的 RCX/EAX/CX 互换来实现。
上述代码虽然是原生 x86_64,但提供了一个 VMProtect 2 如何在虚拟机内部执行分支的可靠示例。不过,VMProtect 2 通过数学混淆增加了额外的混淆功能。首先,推入堆栈的两个地址都是加密的相对虚拟地址。这些地址通过 XOR 进行解密。尽管 XOR、SUB 和其他数学运算本身已被混淆为 NAND 运算。
- push encrypted relative virtual addresses onto the stack...
- LCONSTQ 0x19edc194
- LCONSTQ 0x19ed8382
- PUSHVSP
- ; calculate which branch will be executed, then read its encrypted address on the stack...
- LCONSTBZXW 0x3
- LCONSTBSXQ 0xbf
- LREGQ 0x80
- NANDQ
- SREGQ 0x68
- SHRQ
- SREGQ 0x70
- ADDQ
- SREGQ 0x48
- READQ
- ; clear the stack of encrypted addresses...
- SREGQ 0x68
- SREGQ 0x70
- SREGQ 0x90
- ; put the selected branch encrypted address back onto the stack...
- LREGQ 0x68
- LREGQ 0x68
- ; xor value on top of the stack with 59f6cb36
- LCONSTDW 0xa60934c9
- NANDDW
- SREGQ 0x48
- LCONSTDW 0x59f6cb36
- LREGDW 0x68
- NANDDW
- SREGQ 0x48
- NANDDW
- SREGQ 0x90
- SREGQ 0x70
- ; removed virtual instructions...
- ; …
- ; load the decrypted relative virtual address and jmp...
- LREGQ 0x70
- JMP
复制代码
如前所述,VMProtect 2 使用 XOR 运算对推入堆栈的相对虚拟地址进行解密和加密。特定加密相对虚拟地址的选择是通过移位给定标志来完成的,移位的结果是其值为 0 或 8。然后,将 VSP 添加到移位结果中,计算出加密的相对虚拟地址所在的地址。
- #define FIRST_CONSTANT a60934c9
- #define SECOND_CONSTANT 59f6cb36
- unsigned int jcc_decrypt(unsigned int encrypted_rva)
- {
- unsigned int result = ~encrypted_rva & ~encrypted_rva;
- result = ~result & ~FIRST_CONSTANT;
- result = ~(~encrypted_rva & ~SECOND_CONSTANT) & ~result;
- return result;
- }
复制代码
请注意,FIRST_CONSTANT 和 SECOND_CONSTANT 互为倒数。
4.6 VMAssembler - 概述
VMAssembler 是一个虚拟指令汇编器项目,最初只是一个玩笑。不管它有什么意义,它都是一个有趣的项目,可以让个人更加熟悉 VMProtect 2 的功能。VMAssembler 使用 LEX 和 YACC 来解析文本文件中的标签和虚拟指令标记。然后,它根据通过命令行指定的特定虚拟机对这些虚拟指令进行编码和加密。最后会生成一个 C++ 头文件,其中包含组装好的虚拟指令以及 VMProtect 编辑好的原始二进制文件。
4.6.1 汇编程序阶段
VMAssembler 使用 LEX 和 YACC 来解析文本文件,以查找虚拟指令名称和立即值。VMAssembler 有四个主要阶段:词法分析和解析、虚拟指令编码、虚拟指令加密,最后是 C++ 代码生成。
4.6.2 第一阶段 - 词法分析和解析
词法分析和标记解析本身是两个阶段,但我将把这两个阶段合二为一,因为它们的结果是 C++ 可以管理的数据结构。
VMAssembler 的第一阶段几乎完全由 LEX 和 YACC 处理。文本被转换成代表虚拟指令的 C++ 结构。这些结构被称为 _vinstr_meta 和 _vlable_meta。第二阶段将使用这些结构来验证虚拟指令的存在,并将这些虚拟指令的高级表示编码为解密的虚拟操作数。
4.6.3 第二阶段 - 虚拟指令编码
汇编的虚拟指令编码阶段还验证每个虚拟标签是否存在所有虚拟指令。这是通过比较剖析 VM Handler 名称和虚拟指令名称标记来完成的。如果虚拟指令不存在,则将停止组装。
- if ( !parse_t::get_instance()->for_each( [ & ]( _vlabel_meta *label_data ) -> bool {
- std::printf( "> checking label %s for invalid instructions... number of instructions = %d\n",
- label_data->label_name.c_str(), label_data->vinstrs.size() );
- const auto result = std::find_if(
- label_data->vinstrs.begin(), label_data->vinstrs.end(),
- [ & ]( const _vinstr_meta &vinstr ) -> bool {
- std::printf( "> vinstr name = %s, has imm = %d, imm = 0x%p\n", vinstr.name.c_str(),
- vinstr.has_imm, vinstr.imm );
- for ( auto &vm_handler : vmctx->vm_handlers )
- if ( vm_handler.profile && vm_handler.profile->name == vinstr.name )
- return false;
- std::printf( "[!] this vm protected file does not have the vm handler for: %s...\n",
- vinstr.name.c_str() );
- return true;
- } );
- return result == label_data->vinstrs.end();
- } ) )
- {
- std::printf( "[!] binary does not have the required vm handlers...\n" );
- exit( -1 );
- }
复制代码
一旦所有虚拟指令 IL 得到验证,就可以开始对这些虚拟指令进行编码。在整个编码和加密过程中,必须注意虚拟指令指针前进的顺序,方向决定了操作数和虚拟指令的顺序。
4.6.4 第三阶段 - 虚拟指令加密
与汇编的第二阶段一样,第三阶段也必须考虑虚拟指令指针的前进方向。这是因为操作数必须根据 VIP 的前进方向按顺序加密。如 4.5.1 中详述的那样,最后一个操作数加密产生的加密密钥将用作下一个操作数的起始加密密钥。
这一阶段将对每个标签的每个虚拟指令操作数执行 F-1(e,o) 和 G-1(e,o) 操作。最后,计算从 vm_entry 到第一条虚拟指令的第一个操作数的相对虚拟地址,然后使用用于解密虚拟指令本身的相对虚拟地址的逆变换进行加密。你可以在上一篇文章的 vm_entry 部分找到有关这些变换的更多细节。
4.6.5 第四阶段 - 生成 C++ 头
第四阶段是虚拟指令组装的最后阶段,在这一阶段,将生成 C++ 代码。代码完全自包含,与环境无关。不过,目前的实现有一些限制,最明显的是需要一个 RWX (读、写和可执行) 部分。如果在 Windows 内核驱动程序中使用生成的 C++ 代码,那么该驱动程序将不支持 HVCI 系统。此外,自 2021 年 6 月 19 日起,MSVC 无法编译生成的头文件,因为无论出于何种原因,原始模块的静态初始化器都会导致编译器挂起。如果要使用 VMAssembler 生成的头文件进行编译,必须使用 clang-cl。
4.6.6 例子
使用 VMAssembler 生成 C++ 头文件后,就可以将其包含到项目中,然后使用任何编译器 (非 MSVC 编译器) 进行编译,因为 MSVC 编译器由于某些原因无法处理受保护二进制文件中包含的如此大的静态初始化器,而 clang-cl 编译器则可以处理它。你定义的每个标签都将插入 vm::calls 枚举,每个枚举项的值都是标签虚拟指令的加密相对虚拟地址。
- namespace vm
- {
- enum class calls : u32
- {
- get_hello = 0xbffd6fa5,
- get_world = 0xbffd6f49,
- };
-
- //
- // ...
- //
-
- template < calls e_call, class T, class... Ts > auto call( const Ts... args ) -> T
- {
- static auto __init_result = gen_data.init();
- __vmcall_t vmcall = nullptr;
- for ( auto idx = 0u; idx < sizeof( call_map ) / sizeof( _pair_t< u8, calls > ); ++idx )
- if ( call_map[ idx ].second == e_call )
- vmcall = reinterpret_cast< __vmcall_t >( &gen_data.__vmcall_shell_code[ idx ] );
- return reinterpret_cast< T >( vmcall( args... ) );
- }
- }
复制代码
现在,只需指定 vm::calls 枚举项和标签返回类型作为模板参数,就可以从 C++ 代码中调用任何标签。
- #include <iostream>
- #include "test.hpp"
- int main()
- {
- const auto hello = vm::call< vm::calls::get_hello, vm::u64 >();
- const auto world = vm::call< vm::calls::get_world, vm::u64 >();
- std::printf( "> %s %s\n", ( char * )&hello, (char*)&world );
- }
复制代码
输出
五、VTIL - 入门
目前在 github 上的 VTIL 项目有一些不可告人的需求和依赖项,这些需求和依赖项都没有子模块。我创建了一个 VTIL 的分叉,其中包含子模块的基石和顶石,还描述了继承 VTIL 的项目必须应用的 Visual Studios 配置。VTIL 使用 C++ 2020 功能,如 concept 关键字,因此必须使用最新的 Visual Studios 2019,不支持 vs2017,如果在非 Windows/非 Visual Studios 环境下编译,可以忽略最后一句。
- git clone --recursive https://githacks.org/_xeroxz/vtil.git
复制代码
注意:也许这将成为 VTIL-Core 的一个分支,如果是这样,应该参考官方的 VTIL-Core 代码库。
编译 VTIL 的另一个要求是,必须在包含 Windows.h 之前定义 NOMINMAX 宏,因为 std::numeric_limits 具有静态成员函数 (max 和 min),这些静态成员函数名被视为 min/max 宏,因此会导致编译错误。
- #define NOMAXMIN
- #include <Windows.h>
复制代码
最后一项要求与动态初始化程序导致堆栈溢出有关,为了使包含 VTIL 的编译后可执行文件不会立即崩溃,必须增加初始堆栈大小。由于 VMProfiler 中有大量的动态初始化程序,为了以防万一,我将初始堆栈大小设置为 4MB。
- Linker->System->Stack Reserve Size/Stack Commit Size, set both to 4194304
复制代码
5.1 基本块
vtil::optimizer::apply_all 对 vtil::basic_block 对象进行操作,该对象可以通过调用 vtil::basic_block::begin 来构造。vtil::basic_block 包含以分支指令或 vexit 结尾的 VTIL 指令列表。要添加链接到现有基本块的新基本块,可以调用 vtil::basic_block::fork。
- // Creates a new block connected to this block at the given vip, if already explored returns nullptr,
- // should still be called if the caller knowns it is explored since this function creates the linkage.
- //
- basic_block* basic_block::fork( vip_t entry_vip )
- {
- // Block cannot be forked before a branching instruction is hit.
- //
- fassert( is_complete() );
- // Caller must provide a valid virtual instruction pointer.
- //
- fassert( entry_vip != invalid_vip );
- // Invoke create block.
- //
- auto [blk, inserted] = owner->create_block( entry_vip, this );
- return inserted ? blk : nullptr;
- }
复制代码
注意:vtil::basic_block::fork 将断言 is_complete,以便确保基本块在分叉之前以分支指令结束。
一旦创建了基本程序块,就可以开始将 https://docs.vtil.org/ 中记录的 VTIL 指令附加到基本程序块对象中。使用 WRAP_LAZY 宏为每一条已定义的 VTIL 指令创建一个模板函数。现在,可以在 VM Handler 升降器中轻松 emplace_back 任何 VTIL 指令。
- // Generate lazy wrappers for every instruction.
- //
- #define WRAP_LAZY(x) \
- template<typename... Tx> \
- basic_block* x( Tx&&... operands ) \
- { \
- emplace_back( &ins:: x, std::forward<Tx>( operands )... ); \
- return this; \
- }
- WRAP_LAZY( mov ); WRAP_LAZY( movsx ); WRAP_LAZY( str ); WRAP_LAZY( ldd );
- WRAP_LAZY( ifs ); WRAP_LAZY( neg ); WRAP_LAZY( add ); WRAP_LAZY( sub );
- WRAP_LAZY( div ); WRAP_LAZY( idiv ); WRAP_LAZY( mul ); WRAP_LAZY( imul );
- WRAP_LAZY( mulhi ); WRAP_LAZY( imulhi ); WRAP_LAZY( rem ); WRAP_LAZY( irem );
- WRAP_LAZY( popcnt ); WRAP_LAZY( bsf ); WRAP_LAZY( bsr ); WRAP_LAZY( bnot );
- WRAP_LAZY( bshr ); WRAP_LAZY( bshl ); WRAP_LAZY( bxor ); WRAP_LAZY( bor );
- WRAP_LAZY( band ); WRAP_LAZY( bror ); WRAP_LAZY( brol ); WRAP_LAZY( tg );
- WRAP_LAZY( tge ); WRAP_LAZY( te ); WRAP_LAZY( tne ); WRAP_LAZY( tle );
- WRAP_LAZY( tl ); WRAP_LAZY( tug ); WRAP_LAZY( tuge ); WRAP_LAZY( tule );
- WRAP_LAZY( tul ); WRAP_LAZY( js ); WRAP_LAZY( jmp ); WRAP_LAZY( vexit );
- WRAP_LAZY( vemit ); WRAP_LAZY( vxcall ); WRAP_LAZY( nop ); WRAP_LAZY( sfence );
- WRAP_LAZY( lfence ); WRAP_LAZY( vpinr ); WRAP_LAZY( vpinw ); WRAP_LAZY( vpinrm );
- WRAP_LAZY( vpinwm );
- #undef WRAP_LAZY
复制代码
5.2 提升
以 LCONSTQ 为例。提升器只是添加一条 VTIL PUSH 指令,将 64 位值压入堆栈,请注意使用 vtil::operand 创建 64 位立即值操作数。
- vm::lifters::lifter_t lconstq = {
- // push imm<N>
- vm::handler::LCONSTQ,
- []( vtil::basic_block *blk, vm::instrs::virt_instr_t *vinstr, vmp2::v3::code_block_t *code_blk ) {
- blk->push( vtil::operand( vinstr->operand.imm.u, 64 ) );
- } };
复制代码
VMProfiler 只是循环遍历给定块的所有虚拟指令并应用提升器,一旦所有代码块都用尽,vtil::optimizer::apply_all 就会被调用。这是目前 VTIL 的顶峰,因为其中一些优化过程是针对基于堆栈加工的混淆的。在 vmprofiler 中对 VTIL 进行子建模的目的是为了这些优化,因为我自己编程需要花费数月的研究时间。编译器优化是一个独立的领域,很有趣,但目前我没有时间去追求,所以 VTIL 就足够了。
六、结论
在 VMProtect 2 上花费了很多时间,但是值得的,主要成果是静态地揭示所有虚拟分支并生成清晰的 IL。此外,所有这些工作都是在一个有据可查的开源 C++ 库中完成的,其他研究人员可以进一步继承该库。我不会认为我所做的工作接近完美,这只是朝着去虚拟化的正确方向迈出的一步。
我在所有与 VMProtect 2 工作相关的文档和文章中都避免了去虚拟化,因为对我来说,这始终超出了范围。考虑到我是一名独立的研究人员,虚拟机架构的许多方面无法由一个人在有意义的时间内解决。例如,当指令未被 VMProtect 2 虚拟化时,会发生 vmexit,并且原生指令在虚拟机外部执行。这意味着如果想查看整个例程,则需要跟踪虚拟机中的代码执行,因此 VMEmu 需要更多个月的开发时间来支撑。其他工程师可在我的编写的代码库基础上继续开发,然而即使有详细的文档也没有一点兴趣,每个人都希望有自己的解决方案,这是可以理解的,但从长远来看没有成效。
此外,去虚拟化需要转换回原生 x86_64,为此,必须分析每个 VM Handler,每个 VM Handler 必须为其定义一个 VTIL 提升器,并且每个 VTIL 指令必须映射到原生指令,至少这似乎是我目前所掌握的知识水平所需要的,很可能有一种更优雅的方法来解决这个问题,但现在还没想到。我对去虚拟化的结论是:它不是一个人的工作,因此我的项目的目标从来都不是去虚拟化,它始终是虚拟指令的 IL 可视化,其中 VTIL 提供反混淆伪代码。仅 IL 就足以研究了,VTIL 伪代码相对来说更容易。VMProfiler Qt 与当前存在的 IDA Pro 相结合,可用于分析受 VMProtect 2 保护的二进制文件。它可能不是一个适合初学者的解决方案,但在我看来,它足够了。
我必须指出,如果某人或某团队已拥有针对 VMProtect 2 的全面解决方案,这并不是一个过分的想法。我可以想象一个由比我自己熟练得多的个人组成的团队日复一日地致力于去虚拟化会产生什么结果。除此之外,考虑到 VMProtect 2 公开的时间长度,应有足够的时间来创建此类工具。
最后,在我研究 VMProtect 2 的过程中,有一种微妙的冲动,希望自己以开源的方式重新实现一些混淆和虚拟机功能,以更好地传达 VMProtect 2 的功能。但是,经过深思熟虑,这会更好。创建一个混淆框架是有成效的,该框架将可轻松地创建,一个处理代码分析以及文件格式解析、解构和重建的框架。它的级别低于 LLVM 优化过程,但级别足够高,以至于使用该框架的程序员只需要自己编写混淆算法,甚至不需要知道底层文件格式。该框架仅支持单个 ISA,即 x86。除此之外的细节仍在考虑中:https://githacks.org/llo/
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
x
|