管理程序内存自省(HVMI)依赖于分析内存访问以确定它们是否合法。例如,通过分析旧的存储值和新存储的值,HVMI可以决定是否允许修改。然而,这带来了需要对修改受保护内存的每条指令进行深入分析的复杂性。与RISC体系结构不同,x86有大量的指令可以通过复杂的读-修改-写(RMW)方式访问存储器,并使用复杂的寻址方案。为了简化指令解码和分析,我们创建了一个专用的x86指令解码器,能够提供完整的指令信息,从而减轻了HVMI模块对x86指令格式的需要。这篇博客文章将详细介绍一些bddisasm内部机制,以及如何使用它,同时强调为什么它是HVMI的关键部分。此外,我们还将介绍x86指令编码的一些特殊性。主要的bddisasm项目位于此处,可以在此处访问文档。
Bddisasm是一个用C编写的独立库,使用一些Python生成内部解码表。该库构建得很快,同时提供尽可能多的关于已解码指令的信息-这一点很重要,因为其他使用bddisasm的项目可以依赖它来提供关于指令的完整而准确的信息。在考虑其他解码库时,只有一些功能相似:
Intel Xed是由Intel编写和维护的,因此以某种方式使其成为标准的x86解码器;虽然它不是最快的,但它提供了关于解码指令的丰富信息;
Zydis,就功能而言可以与Xed相媲美,而且轻量级(然而,在内部创建bddisasm时,Zydis还不存在);
用其他语言(如Rust或C#)编写的其他解码器或反汇编程序(只提供指令的文本输出,而不提供实际解码的指令信息)不被考虑。考虑到当时Xed和Capstone似乎很难合作,我们决定创建自己的轻量级解码器,考虑到以下目标:
轻量级-完全用C语言编写,没有外部依赖,没有分配内存,设计上是线程安全的;
速度--虽然我们需要一个能够提供尽可能多的指令信息的解码器,但我们仍然认为速度是一个重要因素;
弹性-解码器应该能够处理各种格式错误的指令,以及包含冗余前缀或非典型编码的有效指令;
完整-解码器必须支持所有现有的x86指令,包括AVX;此外,扩展对新指令的支持应该尽可能简单;
易于使用-单头文件,单API库,它提供了输出解码指令中所有可能的信息,而不需要调用额外的函数来从解码指令中提取信息;
我们不会对bddisasm和其他解码库进行比较,我们将把这篇博客的重点放在如何使用bddisasm以及它在HVMI中的用处。
使用解码库很容易:包括bddisasm.h头文件,链接到bddisasm.lib(Windows)或libbddisasm.a(Linux),然后调用解码API!Bddisasm使用单一API解码方案,其中NdDecode API提供一个包含有关指令的所有可能信息的输出INSTRUX结构。INSTRUX结构中唯一没有包括的是指令的文本反汇编,它必须使用NdToText API单独生成。典型的使用场景可能如下所示:
#INCLUDE";bddisasm/bddisasm.h";int main(){INSTRUX ix;unsign char ins[2]={0x33,0xC0};NDSTATUS Status;Status=NdDecodeEx(&;ix,ins,sizeof(Ins),ND_CODE_64,ND_DATA_64);如果(!ND_SUCCESS(状态)){printf(";解码失败,错误0x%08x!\n";,状态);return-1;}printf(";解码的指令长度为%d!\n";,ix。长度);}。
请注意,输出INSTRUX ix结构将包含有关已解码指令的所有信息。有关您可以找到的信息类型的综合列表:
重复解码的前缀信息,例如指令是否使用锁定,是否启用xAcquisition/xRelease或跟踪CET;
长度信息,包括关于指令本身和指令的不同字段的信息,例如立即字段或移位;
关于指令的每个构成字段的偏移量信息(指令中每个字段的位置);
CPUID特征标志,其指示必须查询的叶和子叶,以及指示对该特定指令的支持的寄存器和位;
操作数信息,包括:操作数类型、大小、访问(读、条件读、写、条件写)和详细信息;
全内存操作数详细信息:段、基数、索引、小数位数、压缩位移、堆栈、字符串、位库、VSIB等);
关于如何从INSTRUX中提取不同类型的信息的例子可以在官方文档页面上找到。
使用NdToText函数,可以将解码的INSTRUX转换为可以打印的文本反汇编。NdToText函数仅支持英特尔风格的语法,因此以下指令33C0将被解码为XOR eax,eax,而4833C0将被解码为XOR rax,rax。NdToText函数的典型用法是:
//为该指令创建文本反汇编。字符文本[ND_MIN_BUF_SIZE];NdToText(&;ix,0,sizeof(Text),text);printf(";说明:%s\n";,text);
分流是HVMI技术的关键部分。如前所述,解码和分析指令非常重要,因为HVMI处理的绝大多数事件都违反了EPT(内存引用指令)。
当EPT违规发生时,HVMI做的第一件事就是对违规指令进行解码。由于访问客户内存通常很慢(因为它涉及将客户线性地址转换为客户物理地址,并映射进程中的每个物理页面-页表和实际页面),因此一旦指令被解码,它就会被内部缓存。来自同一指令指针的后续访问将产生已解码的高速缓存指令,从而加速这一过程(当然,高速缓存指令还意味着必须监视包含它们的页的修改,以便在指令被修改时使高速缓存无效)。
一旦指令被解码,HVMI将对其进行剖析,以确定所访问的每个存储器位置。这一点很重要,因为一条指令可以直接或间接访问多个内存位置,在允许指令继续之前,我们必须分析每次访问。因为bddisasm提供了完整的操作数信息(包括隐式操作数),所以HVMI基本上会遍历所有指令操作数,并检查它们是否为内存。然后,对于每个内存操作数,它将确保它访问不受HVMI监视的内存区域。访问多个地址的指令示例可能包括:
调用[mem]-它读取[mem]操作数,并写入堆栈;如果启用了CET影子堆栈,它还可以访问影子堆栈;
使用VSIB寻址的AVX指令-多个地址可由VSIB操作数访问,例如[rax+xmm0*8];
还有一些事件(主要是异步事件)可能会导致EPT冲突,即使当前指令不执行任何类型的内存访问也是如此;此类事件包括:
发送中断或异常,该中断或异常将读取中断描述符表(IDT),并将中断帧写入堆栈;
作为任务切换或中断传送的一部分的任务状态段(TSS)内的访问;
页表内的访问,作为页面遍历的一部分(尽管这些访问是使用VMCS EPT违规退出资格内的专用位明确表示的);
这些类型的事件不包括在INSTRUX结构中,因为它们可能在正常指令执行期间以异步方式发生。
一旦错误指令被解码,Introcore内部的多个模块就会对其进行分析,以确定它是否为合法修改(这与提取的其他类型的信息一起使用,例如指令属于哪个模块)。典型的验证是将旧内存值与即将存储的新值进行比较-如果它们相同,则允许指令执行通常是安全的。提取旧值可以通过对指令进行简单的模拟来完成-因为bddisasm已经提供了所有必要的信息,所以可以相当容易地计算出新值(而且由于这不是一个全面的模拟,所以在这一点上不需要进行检查)。
对访问内存的指令执行的另一个常见任务是计算显式内存操作数访问的客户线性地址。对于典型的modrm编码内存操作数,计算该值所需的步骤包括:
查询段寄存器,并获取寻址中使用的段的基址;所得到的线性地址可以被初始化为该段基值,或者不使用段的0;
如果内存操作数是直接的(例如在A01111111111111111中,其解码为MOVAL,字节PTR[0x11111111111111]),则可以将地址添加到线性地址,并且不需要进一步处理;
如果内存操作数使用基址寄存器,则将其值与线性地址相加;
如果内存操作数使用索引寄存器,则对其进行缩放并将其添加到线性地址;
如果内存操作是RIP相关的,则添加当前指令的长度,后跟当前RIP;
如果内存操作数使用位基寻址(BT、BTS、BTR、BTC指令),则计算源操作数的位偏移量,并将其与结果线性地址相加;
如果操作是堆栈推送,则从得到的线性地址减去堆栈操作的大小;
上面列出的步骤将产生指令操作数使用的线性地址,可以对其进行进一步处理以提取更多信息。
Bddisasm是以可扩展的方式创建的,这使得添加新指令变得微不足道,只要它们不使用新的编码方案或新的寄存器。说明数据库包含在bddisasm repo的isagenerator/Instructions项目内的几个.dat文件中。虽然自述文件中已经提供了关于该项目的足够信息,但我们将只演示如何向bddisasm添加新的说明。
让我们首先选择一个当前没有被任何指令0F04使用的编码(尽管应该注意,它是由LoadAll在286上使用的)。如果我们尝试解码此指令,我们将看到bddisasm失败:
查看disasmstatus.h内部,我们看到错误代码0x80000002表示ND_STATUS_INVALID_ENCODING-因此没有有效的编码,这很好,因为我们想创建自己的编码。
现在让我们假设我们要创建的指令是一个简单的模编码指令,有两个操作数-第一个是通用寄存器,第二个是通用寄存器或内存。让我们将此指令称为BDDISASM,让我们看看几种可能的形式:
如前所述,该指令的操作码将是(当前未分配的)0F04。第一个操作数将是16位、32位或64位通用寄存器,具体取决于操作数大小。第二个操作数将是16、32或64位通用寄存器或内存位置,具体取决于modrm.mod和操作数大小。第一个操作数是读写的,而第二个操作数是只读的。没有隐式操作数。现在我们可以在TABLE_0F.dat中描述基本指令:
第二个元素GV表示第一个操作数是以modrm.reg(G)编码的通用寄存器,其大小取决于操作数大小(V),大小为16、32或64位;
第三个元素ev表示第二操作数是以modrm.rm(E)编码的通用寄存器或存储器,其大小取决于操作数大小(V),大小为16、32或64位;
下一个元素描述指令访问的隐式操作数;因为我们没有任何操作数,所以我们指定为nil;
下一个元素是最重要的,它描述了编码;它列举了所有操作码字节-0F和04,后跟/r,表明指令使用modrm编码;
最后一个元素是每个指令操作数的访问映射,并使用关键字w:指定。第一个操作数是读写(RW),而第二个操作数是读(R)。
现在,让我们通过在VisualStudio中构建isagenerator项目,或者运行make,然后重建bddisasm库和disasmtool来重建解码树。现在让我们再次尝试对指令进行解码:
现在我们得到一个不同的错误:0x80000001,表示ND_STATUS_BUFFER_TOO_SMALL-这表明存在用于此编码的有效指令,但我们没有提供足够的字节来解码它。事实上,我们没有这样做,因为我们只指定了操作码,而没有modrm字节。让我们再试一次,这次也使用modrm字节:
如您所见,指令已被成功解码!使用不同的编码会产生预期的指令:
C:\>;disasm-b64-h 0F0400660F0400480F0400670F0400F30F04000000000000000000 0f0400 BDDISASM eax,双字PTR[rax]0000000000000003 660f0400 BDDISASM ax,word PTr[rax]0000000000000007 480f0400 BDDISASM rax,qword PTR[rax]000000000000000B 670f0400 BDDISASM eax,dword PTR[eax]000000000000000F f30f0400 BAX。
如果我们愿意,我们可以在我们的指导中添加更多的信息。例如,我们现在可以假设指令修改了标志-它总是设置进位标志(CF)。为了指定这一点,我们必须首先指示指令使用隐式操作数fv,它代表标志寄存器,然后我们必须使用f:关键字告诉它它修改了哪些标志:
C:\>;Disasm-b64-h 0F0400-exi0000000000000000 0f0400 BDDISASM eax,双字PTR[rax]DSIZE:32,ASIZE:64,Vlen:-ISA Set:UNKNOWN,INS CAT:UNKNOWN,CET TRACKED:无标志访问CF:1,有效模式R0:YES,R1:YES,R2:YES,R3:YES REAL:YES,V8086:YES,PROT:YES,COMPAT:YES,VMX OFF:YES有效前缀rep:no,REPcc:no,lock:no,HLE:no,仅XACQUIRE:no,仅XRELEASE:no BND:no,BHINT:no,DNT:no Operand:0,ACC:RW,Type:Register,Size:4,RawSize:4,Ending:R,RegType:General Purpose,RegSize:4,RegID:0,RegCount:1 Operand:1。原始大小:4,编码:s,RegType:标志,RegSize:4,RegID:0,RegCount:1。
您可以看到,在标志访问部分中列出了CF:1,这意味着它始终设置为1。标志的其他可能值包括m(表示根据结果修改标志)、0(表示标志已清除)、t(表示标志已测试)和u(表示标志未定义)。标志访问部分中缺失的标志根本不会被触及。
最后,让我们讨论一下在处理指令编码时一些不太为人所知的情况。当然,所有这些信息都有文档记录,但在SDM中查找这些信息可能需要一些时间,因此以下是最相关信息的快速列表:
可以对看似有效的指令进行编码,这些指令的长度超过15字节的最大限制,但CPU无论如何都会在这些指令上生成#GP;
REX前缀必须始终是操作码字节之前的最后一个字节,否则将被忽略:48F333C0将解码为XOR eax,eax,而不是XOR rax,rax,因为F3前缀在REX 48前缀之后;
如果指令中同时存在F2和F3前缀,则CPU将考虑最后出现的前缀:F2F2F2F3A6将解码为REPZ cmpsb,因为F3是最后出现的前缀,并且所有的F2前缀都将被忽略;
同样,考虑最后出现的段前缀,但请注意,在64位模式下,只接受fs和gs覆盖:65642e2e3300将在32位模式下解码为XOR eax,dword PTR cs:[EAX],但在64位模式下,它将解码为xor eax,dword PTR fs:[rax];
被忽略和冗余的前缀是指令的一部分,因此它们被计入指令长度:F2F2F2F2F2F2F2F2F2F2F2F2F2F290作为NOP被解码和执行,但是再增加一个F2前缀将使指令16字节长,从而产生#GP,并导致解码错误;
RSP不能用作SIB寻址的索引,它将被忽略:3304E0将解码为XOR eax,dword PTR[rax]而不是xor eax,dword ptr[rax+rsp*8];
在64位模式下使用modrm.mod==0(内存)和modrm.rm==5(RBP)时,将使用RIP相对寻址而不是直接寻址;但是,通过使用带有modrm.mod==0(内存)、modrm.rm==4(RSP、SIB)、SIB.base==5(RBP)和SIB.index==4(RSP)的SIB寻址,即使使用SIB寻址,也可以直接寻址到绝对32位地址。
在64位模式下,堆栈操作默认使用64位操作数(即使没有REX.W前缀);然而,这意味着使用32位操作数编码PUSH/POP是不可能的,尽管您仍然可以使用16位操作数编码PUSH/POP:6650即使在64位模式下也将解码为PUSH AX,无法编码PUSH EAX;
在64位模式下,在Intel CPU上,间接分支总是使用64位操作数:FF20、48FF20、66FF20都将在64位模式下解码为JMP qword PTR[rax];
移入/移出控制/调试寄存器将始终使用modrm.mod==3,即使实际modrm.mod为0、1或2:0F2000将解码为mov rax,CR0,而不是mov[rax],cr0;
移入/移出modrm.mod==1或modrm.mod==2的控制/调试寄存器(带位移),将忽略位移:0F2040将解码为mov rax,CR0,即使正常情况下modrm.mod==1需要1个字节的位移;
在AMD上,可以使用带mov cr指令的锁定前缀F0来访问cr8寄存器:F00F22C0解码为mov cr8,eax;
在这篇博客文章中,我们展示了一个全面的、编写良好的指令解码器如何在处理指令时极大地改善开发人员的生活。当然,bddisasm是HVMI的一个非常重要的部分,因为很多自省逻辑都围绕着分析指令,并且可以选择使用简单、快速和准确的解码器来实现这一点,从而极大地提高了代码质量。
我们将在未来的一篇博客文章中看到bddisasm如何帮助创建简单的指令模拟器,届时我们将深入研究另一个重要的项目-BitDefender Shellcode Emulator-bdshemu。