也说X86虚拟机(CPU仿真)
作 者: wht0395

    注:本人不善言辞,而且写此文也仅算是对本人近期编码和思考的一个总结,不会太注意用词的。
    前面linxer前辈有写过《ring 3级32位x86 cpu仿真》,对于x86CPU的仿真的做过一些构想和阐述。可惜,我是在完成了X86的基本指令集的解释引擎后才注意到此文章。拜读之后,觉得自己与前辈的实现思想有些许差别,所以就随便写下。

目标优先级:
    1)可维护性。
    2)执行效率。
    3)可移植性。本引擎并非设计为在桌面平台使用

一、CPU仿真

1、CPU CONTEXT。
    因为一些原因,在CPU相关的结构上,有借鉴linxer前辈的思想。

    此处要提到的是,本人对于所有寄存器和寄存器索引,是按照IA32手册上的标准定义的。linxer前辈在EFLAG寄存器的标识位索引定义时并未按照标准,并且注释为了“效率”,本人到现在仍然没有理解这个“效率”问题。


2、内存寻址和访问接口。
    因为模块化编程的原因,此处对于内存的操作是单独提取出来的接口。当前版本是使用一个映射接口来完成,其声明如下:

DWORD32  MmMapAddress(pVM,                     /*虚拟机结构*/
                     WORD16  Seg,             /*段地址/段选择子*/
                     DWORD32 Address,         /*线性地址。在16位寻址模式下仅仅使用低16位*/
                     DWORD32 Size,            /*需要映射的区域大小*/
                     DWORD32 Privilege);      /*映射的权限(读、写、执行等)*/

    每当VCPU(我们仿真的虚拟CPU)要访问访问内存时,调用此接口,映射得到一个本地指针,可以对其进行相应读写操作。
    在内存映射模块的内部,基本的思想是跟linxer前辈一样,区别如下
    a)效率问题,对地址部分采用了HASH处理。
    b)根据Privilege检查内存块属性,不符合时会触发VCPU GP类异常

   
    备注:1)我们并未对VCPU的读写进行函数级封装,效率问题
          2)内存映射部分并非本人所作,这里说的仅仅是我们的原始设计
          
    本人其实会更倾向于对于读写进行函数级封装,虽然效率低,但可以对页属性进行较为完善的仿真。

3、指令解析部分
    这个部分本人和linxer前辈区别较大。
    事实上,如果看下INTEL IA32手册的(OPCODE MAP)部分,你会发现指令解析的方法和方式相当清晰:建表。
    IA32体系结构下的机器码其实就是由几张表来完成的“One-byte Opcode Map”、“Two-byte Opcode Map”、“Opcode Extensions for ..."以及浮点协处理器的指令解析表格。
    所以,指令解析框架很简单:建立如下的“One-byte Opcode Map”(256项)
     ONE_OPCODE_TABLE_ENTRY One_byte_Opcode_Map[]={
/*0x00*/  Function1_ADD_00                  ,  /*ADD      */
/*0x01*/  Function1_ADD_01                  ,  /*ADD      */
/*0x02*/  Function1_ADD_02                  ,  /*ADD      */
/*0x03*/  Function1_ADD_03                  ,  /*ADD      */

    其中FuncOne_ADD_00等都是对应的解析函数。
    解析过程如下:
    通过指令第一个字节查询"One-byte Opcode Map",直接CALL对应的解析函数
    a)如果这条指令是单字节指令(如0x00 add),则这个解析函数其实就是这条指令的实现函数
    b)否则,这个解析函数其实是另外一个分派函数(将会查询另外一个表格),此解析函数根据指令第二个字节查询“Two-byte Opcode Map” “Opcode Extensions for ..." 或者 浮点协处理指令解析表,调用下一级指令解析函数
    以此类推,到最低层解析函数时,指令所对应的参数和格式已经最大程度地明确了,实现起来很简单。


    选择这种解析方式的理由:
    a)结构简单,利于维护。添加和修改指令都只需写/更改相应指令解析函数,然后填入对应位置即可。
    b)解析速度会比switch快些。虽然switch case结构在编译后其实也可能用表格来实现(也可能用DEC/JZ之类跳转实现),但其更加依赖于编译器优化。
    c)假如对这些解析表和相应的X86指令序列进行随机置乱的话,可以成为一个简单的保护代码执行引擎。

    这种结构的弊端也很简单:前期工程量会稍大点,因为实现基本指令就要几百个解析函数,另外要建立好几个表格,比较累(还好可以写一些辅助工具完成)
    
4、中断仿真
    对于中断的仿真可以以如下方式完成:
     a)在指令产生异常/中断(GP/DE/DB等等)时,指令会填充CPU CONTEXT的一个INTERRUPT_INFOMATION结构,把相应的异常/中断信息保存,然后逐层返回到最高层的解析函数(查询
"One-byte Opcode Map"的那个函数)。
     b)根据中断号调用相应中断处理程序。中断处理程序会根据INTERRUPT_INFOMATION结构的内容执行相应的操作。SEH之类的模拟,可以在这里完成的。

5、指令仿真
    因为是采用标准C来实现指令仿真,所以有些标志位处理起来比较麻烦。
    用内联汇编的方法实现指令很简单,没必要说什么。这里主要说下用C来实现实现指令模拟时会遇到的问题
   a)AF位的处理。这个标志主要影响调整指令,比较麻烦,我的当前版本暂时回避了此标志,下个版本中将使用使用算法来解决此标志。
   b)CF/OF位。由于暂时没有找到统一的处理办法,当前版本中,我是通过做有符号/无符号两种运算来分别设置的。
   c)PF位。使用计算海明码码重的算法(LINUX源码中可以找到),起码比逐位遍历要高效不少
   d) ModR/M SIB位。本部分一样是使用查表(参见IA32手册INSTRUCTION FORMAT部分)来完成解析的,解析结果是地址,保存到如下结构中
struct xxxxxxxxxxxxxxxxxxxxxx
{
  /*
  The total length of ModR/M、SIB(if exist) and Displacement(if exist).
  Filled by GetInstructionArgs()
  */
  UCHAR Length;

  /*The reg field of the ModR/M byte*/
  UCHAR Reg_Opcode;

  /*The r/m field of the ModR/M byte*/
  /*
  Is EffectiveAddress a memory address or general register ?
  1 -> Register. the RegIndex field is the index of the general register.
  0 -> Memory address.
  */
  UCHAR bIsEAReg;
  union
  {
    UCHAR RegIndex;
    ULONG Address;
  }EffectiveAddress;

}

6、用于提高解析效率的指令CACHE
   当前版本并未实现CACHE,但为了解析效率计划加上。实现CACHE的麻烦在于自修改指令的处理,不过因为对内存访问使用单独的接口,所以,只要映射内存时将Address、Size、Privilege与指令Cache对比即可准确判断是否要更新CACHE



7、指令模拟的正确性测试
   利用微软的DEBUG API接口写测试工具,单步,内存/寄存器 对比。



二、PE运行环境的模拟
   这部分我并未实际编码,所以说的估计都是错的

1、内存管理
   没什么好说的,链表/数组什么的,就是要高效点。
   另外,要把常用的DLL文件的地址空间、TEB/PEB也仿真了

2、API仿真
   对于截获API调用并不需要在在CALL/JMP/RET等里面进行单独处理,我的思路是对于被仿真的API的虚拟地址维护一张表,在解释一条指令之前,对比EIP与API地址表,如果是API调用,则执行仿真API,否则正常解析





胡言乱语:
指令/编码层次对抗仿真的手段
1)将浮点运算/BCD码运算的结果融入到正常指令流中
2)使用一些特殊的指令参数。例如:ENTER/CPUID等虽然是常用指令,但个别类型的入口参数仿真起来很繁琐。起码不使用内联X86汇编的情况下,我是觉得很烦。
3)未公开指令。这个思路其实有点扯淡,你能得到的东西,仿真者也能得到。
3)调用不常用的API,这个是最容易想到的。
4)API的仿真是不可能把KERNEL32真的加载进去的,所以,如果读取这些地址,比如API的前N个字节,应该会导致仿真失败。

  • 标 题:答复
  • 作 者:linxer
  • 时 间:2007-08-27 14:57

楼主 不错,好强大~~~

EFLAG寄存器  分开写的原因是 当初没有用sahf  lahf来操作仿真标志位 如果用这两条指令的话 分开写的方法是低效的

指令识别和指令解析部分 要高效的话,还的确是用opcode作哈希,在发x86机器码识别一文的时候  笨笨雄大哥就说过设个问题, 不过这个还的确比较麻烦,又是体力活

关于cache的引入,还真要注意用smc技术的一些程序,否则cache一旦失效,虚拟CPU没有觉察到,后果严重,如果CPU要效率比较高 是应该加入cache功能,甚至可以引入2级cache

对抗虚拟机:
其实这里说的虚拟机是很脆弱的,不堪一击,只要你乐意攻击
1. 没有OS支持,要穿透很easy
2. 对未公开指令,关键是要善于发掘这些指令,目前发现这种指令也就那么几条,仿真上就OK了
3. 对故意引入的错误指令,虚拟CPU支持捕获这类异常就可以了