本文旨在学习 PE64 运行相关机制所以不会进行完美模拟, 以高精度为目标进行模拟实际上只需要模拟操作系统底层对所有退出的处理, 而所有除了退出时我们模拟的操作系统需要的数据结构之外其它的本质都是一堆字节, 不需要任何解读方式, 属于模拟器也就是
VCPU范畴文章主要涉及两方面:
- 上面提到的运行相关机制
- 模拟器设计 ( 不包括VCPU, VCPU直接使用
unicron-engine提供的qemu核心 )
纯 unicorn 模拟到什么时候会退出?
模拟一个可执行文件时, 在已经分配映射主 PE 和栈空间的情况下大概率是一次IAT调用导致了退出, 本质上是一次未映射内存访问.
模拟执行入口在实际的模块加载过程中不算最早期的那一批, 被跳过的流程包括了一步导入表解析. 在操作系统解析导入表并填充IAT之前其中的地址是没有意义的 ( 实际上和.bss一样属于虚拟段, 无对应的文件内存, 在被使用前不映射 ), 此时call [IAT]相当于解引用无效指针导致退出.
导入表解析
导入目录中包含多个导入描述器, 以导入的模块 (.dll) 为单位, 每个描述器包含该导入模块的
- 模块名
- (时间戳, 转发串)
- 导入名称表地址
- 模块的导入表地址 ( 这意味着同一个模块的导入函数是连片的 )

其中的转发串字段在现代Windows上已经废弃
实际导入的过程就是加载器通过模块名找到模块并加载 ( 然后递归该模块依赖的模块 ), 在它的导出目录中搜索拿到真正的地址填入IAT
导出表解析
导出目录包含的信息和导入表对应:
- …
- 本模块名
- 导出函数数量 ( 只是字段名, 导出实际全局变量也并列, 并且不是导出函数数量而是通过最大序号来限制导出目录范围 )
- 导出函数数量
- 导出符号名偏移表
- 导出符号名索引 =>
EAT索引映射表 - 导出函数偏移表 ( i.e. 导出表,
EAT)

为什么会出现映射表?
作为最常用于索引导出函数用的符号名是严格字典排序的:

这样一来大端序取待搜索的符号名字节就能够进行二分搜索提升效率了, 找到后拿着名下标映射到EAT下标, 这样就可以在导出表中找到想要的导出符号的rva了.
问题来了, 为什么不直接y = x然后省掉映射表呢? 这涉及到先前提到的存在通过序号导出的符号. 就例如这个模块:

而通过序号导出不一定保证导出的顺序上符号名还是严格字典序, 可能写这个动态链接库的人就这顺格或者胡闹编程:

实际上逆向过程中确实遇到过很多直接通过序号来导入 API 的, 可能是这些序号在每个版本的库上都固定了相同的导出函数, 这个过程的伪代码如下:
1 | let name_idx = address_of_names.binsearch(API_name) |
转发导出
导入表解析中提到导入表有已经被废弃的转发串模式, 而转发串在导出表中至今仍然经常应用, 例如kernel32.dll大量偷了kernelbase.dll的接口:

加载器区分转发导出的唯一方法就是在通过序号找到rva后发现落在了符号名表范围内时进行判定, 而这些转发串的rva是不会出现在符号名偏移表中的.
代理注入的原理就是转发受害者的所有导出函数然后改名替代受害者dll, 这样目标在加载依赖模块时就会自动加载待注入的dll ( 哇, 还有替身文学
这部分的模拟器设计比较简单, 主要是 PE 文件解析, 而现在已经有很多相关的第三方库 ( e.g. pelite ) 可以使用了, 唯一难点是对加载模块入口的模拟, 加载器在完成模块加载的前置工作后会执行DllEntry(DLL_PROCESS_ATTACH), 这也揭示了后续模拟器设计始终绕不过的一座大山:
注入一次 guest call?
这个功能的设计直接影响后续所有涉及到退出时模拟器的处理方法, 所以需要根据模拟器:
- 是否从运行中退出
- 跳转目标是否有完整函数序
- 是否需要保留跳转前上下文
- 跳转目标返回后继续执行 guest 逻辑还是模拟器逻辑
来对注入的 call 进行分发, 对于先前的IAT就比较简单, 模拟器只充当转发层 / 模拟 API 层, guest 退出仅用于监控 API 调用, 而这里对于模块加载时入口点的调用就有两种情况:
- 通过导入表解析依赖进行导入, 此时模拟器还未启动, 入口点返回后需要继续模拟器逻辑
- 运行过程中通过
LoadLibraryA/W[Ex]导入, 此时模拟器已经启动, 入口点返回后继续 guest 逻辑
为了一次性解决很多问题, 我们引入Sentinel Poll, 一个按地址增长的槽位表, 通过BTreeMap联系到带类型的 handler, 限制哨兵池对 guest 的执行权限, 这样一来所有进入哨兵池的rip都会因为访存异常退出由模拟器接管, 此时就可以对rip做分发, 即 Page hook思路.
以较难的运行时导入的模块入口点调用为例:
- guest 踩中
IAT中的哨兵, 退出到模拟器并根据踩中哨兵分发到LoadLibrary的模拟函数 - 模拟器执行映射流程, 将映射基址压入一个模拟器状态变量中
- 将
rip和参数寄存器调整好后使用一个对应类型Rewind的 handler 替换call压入的返回地址并让模拟器返回 guest 继续执行 - guest 执行完模块入口点后返回踩中哨兵回到模拟器
- 模拟器根据哨兵索引到一个
Rewind类型的 handler, 进入分发器中对应分支 - 从模拟器状态中寄存的模块基址弹出赋给 guest 的
rax, 模拟器返回 guest 拿到LoadLibrary结果继续执行
这样的复杂乒乓流程在后面的异常处理中还会高频用到
实际加载器处理加载模块还涉及PEB中Ldr等数据的变化, 这就是模拟器过度干涉执行的副作用, 如果只模拟最底层的系统调用就能让系统 dll 自动处理这些数据
处理完IAT之后下一个退出点会出现在哪里呢? 大概率还是无效地址导致的访存异常, 因为当只加载了主PE和第一个 dll 两个模块的情况下静态的 guest 内存映射才能满足, 即大部分 EXE / DLL 选的默认基址140000000 / 180000000, 当再加载下一个模块时如果还是使用NT Header中的默认基址则大概率会覆盖掉先前映射的 dll, 那么就为模拟器新增一个状态变量表示使用的内存, 每次映射后新增映射的大小进行动态基址映射(~ASLR), 这带来了另一个问题, 我们模拟的加载器漏了重定位这一步
重定位
模块中被代码引用的数据大部分都不会被动态基址影响 – 它们大部分都不是绝对地址访问而是rip相对地址访问, 这样一来只要这些数据和代码的偏移不变就能正常访问, 而被数据引用的数据或代码则几乎必定会被影响 ( 除了通过rva访问的 ), 一个编译时就有值的指针是以NT header中的默认基址 + 偏移的方式计算出来的, 为了在不同的基址都能保证指向同一个地址编译器会构造这种数据的重定位表,
重定位表以每个页为一级索引, 编译器收集该页中需要重定位的数据相对该页的偏移以及重定位的Type, x64上重定位类型基本上是IMAGE_REL_BASED_DIR64 (Ah), 无论是哪种类型计算新地址的公式都相同, 只是数据类型有区别, 重定位伪代码如下:
1 | let delta = mapped_base - image_base |