函数监视器可以监视代码中对外部函数的调用情况,通过这些调用信息可以让我们方便理解程序的意图。

类似的实现思路可以用 dll 转发或者微软的 detour 库实现 api hook。

最近我自己写了一个函数监视器,可以监视进程内所有模块导出函数的调用情况,并且可以自行选择需要监视和设置断点的函数。

方案确定

既然是监视调用情况,那么必须在目标函数被调用前将控制权转交给监视器。常见思路有:

  1. 中断。即在入口设置断点,这样调用时被会产生软件异常。随后可以选择在进程内处理异常或者通过调试器接管异常。
  2. 中转。其实就是常说的 hook。通过改变代码的执行流程达到获取控制权的效果。可以直接 hook 入口代码,也可以 hook 导出表。

两种方法都各有优缺点。但总的来说中转法的处理效率较高,而且不会搞出异常来干扰调试。我的函数监视器也是使用 hook 的方案。

因为 hook 必然是在目标进程内处理的。因此也有不同的注入方式:

  1. 模块注入。注入自己的 dll 模块来执行代码,处理函数的 hook。
  2. 代码注入。不通过加载额外模块的形式,不显眼一些。

如果用模块注入的话在开发过程中可以比较方便的调试,代码注入就麻烦多了。此外如果用模块注入,因为模块是由系统加载的,根据实现的不同,系统可能在加载模块时已经加载了其他模块,这样可能会错过对这些模块的函数调用的监视。因此要监视系统对外部函数的调用情况就要用代码注入的方式。这里我也用了代码注入的方式。

接下来确定工作方式。因为监视器要有交互和输出,所以可以选:

  1. 内挂。在目标进程中创建窗口,输出数据,同时函数监视的工作也在目标进程内完成。
  2. C/S模式。把目标进程当作客户端,另实现一个用于操作和输出数据的程序,两者通过 ipc 进行交互。

显然 C/S 模式的好处更明显。

因为使用了代码 hook,所以使用汇编是不可避免的,但是剩下大部分的功能还是可以使用高级语言来开发。界面部分其实我是想偷懒用了 MFC,也可以用其他 UI 框架来实现。我只处理了 32 位程序的监控,64 位的还不支持。

具体实现

主要分为两个工程,即注入部分和监控部分,分别称为 PayLoad 和 ApiMonitor。PayLoad 是注入目标进程执行的,因此要将 crt 修改为静态链接。ApiMonitor 则没有什么特殊的需求。UI 的布局如下,左侧列出进程中的模块、导出函数和断点等相关数据,右侧记录了函数的调用信息,如调用序号、调用线程、返回地址、函数名等,因为调用记录相当的多,界面上应该用虚表避免刷新效率低下。另外可以看到系统 dll 就有一堆,我简单地用配置文件记录了各个模块 api 的 hook 情况和断点设置,可以在勾选后通过菜单选项保存到磁盘上,避免一次次浪费时间确认要 hook 哪些函数。PS:左侧函数 va 和弹窗里的不同是因为左侧的是 kernel32 里的函数。

启动目标进程并注入 PayLoad 模块

为了抢占最好的代码注入时机,所以使用监视器来启动目标进程,等到程序运行后再注入就比较受限,也会错过许多函数的调用监视。设定好目标进程的路径、启动参数和启动目录等参数就可以了,当然根据 CreateProcess 的参数我们也可以相当提供更多的设置项,这里我就放了个目标路径。随后以 CREATE_SUSPEND 方式启动,然后就可以注入代码了。注入代码需要自己实现重定位,偷懒的话直接注入到默认加载地址 0x10000000 上应该也行,毕竟进程空间还是空荡荡的。因为 ntdll 在同一个 session 中的基址是固定的,所以一些数据可以直接从监控端自己进程中的 ntdll 来获得。另外一些必要的参数也可以在创建目标进程后由监控端直接写入进程的固定地址,简化操作。

hook

静态依赖的 dll 是在进程启动时由 LdrpInitializeProcess 解析 dll 的导入表,并由 LdrpLoadDependentModule 递归加载,加载到进程空间时会调用 NtMapViewOfSection 进行映射,可以选择在这里做 hook。因为动态加载 dll 时也会经过 LdrLoadDll 来到 NtMapViewOfSection,所以只要在这里拦住就可以处理所有的 dll 了。另外,在所有依赖的 dll 都映射到内存空间后就会根据顺序通过 LdrpCallInitRoutine 逐个调用他们的入口函数。

; 递归加载静态依赖的 dll
00 02f9e710 77853631 ntdll!NtMapViewOfSection
01 02f9e768 7785340f ntdll!LdrpMinimalMapModule+0x93
02 02f9e790 7785549d ntdll!LdrpMapDllWithSectionHandle+0x15
03 02f9e7c0 778604eb ntdll!LdrpLoadKnownDll+0xf4
04 02f9e7e4 77847925 ntdll!LdrpFindOrPrepareLoadingModule+0x80
05 02f9ee00 77846700 ntdll!LdrpLoadDependentModule+0x1175
06 02f9ee48 77853565 ntdll!LdrpMapAndSnapDependency+0x190
07 02f9ee70 7785549d ntdll!LdrpMapDllWithSectionHandle+0x16b
08 02f9eea0 778604eb ntdll!LdrpLoadKnownDll+0xf4
09 02f9eec4 77847925 ntdll!LdrpFindOrPrepareLoadingModule+0x80
0a 02f9f4e0 77846700 ntdll!LdrpLoadDependentModule+0x1175
0b 02f9f528 778b91f2 ntdll!LdrpMapAndSnapDependency+0x190
0c 02f9f788 77861dc1 ntdll!LdrpInitializeProcess+0x1b32
0d 02f9f7e0 77861cb1 ntdll!_LdrpInitialize+0xba
0e 02f9f7ec 00000000 ntdll!LdrInitializeThunk+0x11

; LdrpLoadDll 的路径,加载 dll 最终也会通过 NtMapViewOfSection 进行映射
00 02f9f228 77853631 ntdll!NtMapViewOfSection
01 02f9f280 7785340f ntdll!LdrpMinimalMapModule+0x93
02 02f9f2a8 7785549d ntdll!LdrpMapDllWithSectionHandle+0x15
03 02f9f2d8 778604eb ntdll!LdrpLoadKnownDll+0xf4
04 02f9f2fc 77845029 ntdll!LdrpFindOrPrepareLoadingModule+0x80
05 02f9f358 77854e8c ntdll!LdrpLoadDllInternal+0xbd
06 02f9f498 77854db3 ntdll!LdrpLoadDll+0x7e
07 02f9f518 778b8d85 ntdll!LdrLoadDll+0x93

hook 导出函数则通过 hook 导出表来实现,这样可以避免修改原始函数,稍微隐蔽一些。hook 导出表也有两种,一种是直接替换原始导出表中 AddressOfFunctions 表的 rva 地址。在 32 位程序中,可以利用整数溢出来跳转到任何地址,也就是说我们的中转函数地址可以小于模块的基址;64 位程序就要用其他办法了,比如模块重定位之类的。另一种则是新建一个导出表,然后替换 PE 头里导出表的地址为新表的地址。甚至还可以在 RtlImageDirectoryEntryToData 中替换获取到的导出表地址。因为像 GetProcAddress 会使用这个 api 来定位导出表。接管这个函数我们就可以实现无模块修改的导出表 hook。除非是那些自己实现了 GetProcAddress 的程序,这样可能会遗漏掉。

通信

通信采用命名管道。另外,还要定义收发消息的数据结构。这里简单地采用一个枚举作为消息的类型,并自行做消息体的封包和解包。

struct ModuleFilter {
    Allocator::string name;
    bool              filter;

    std::vector<char, Allocator::allocator<char>> Serial() {
        std::vector<char, Allocator::allocator<char>> vec;
        SerialInit(vec);
        SerialItem(vec, name);      // 逐个附加字段即可
        SerialItem(vec, filter);
        CalFinalLength(vec);
        return vec;
    }
    void Unserial(std::vector<char, Allocator::allocator<char>>& str) {
        size_t idx = GetFirstItemIndex(str);
        idx = ExtractItem(str, idx, name);   // 保持相同顺序,逐个附加字段即可
        idx = ExtractItem(str, idx, filter);
    }
};

这里有一点,虽然我在 PayLoad 里启动了读写线程来进行简单的批量收发数据和缓存,但实际上实现的还是服务端被动回复模式,如果要求支持更多的操作,实现全双工模式的话,就需要修改服务端的实现,从而进行主动发送消息。PayLoad 端也需要修改,比如使用完成端口等更加高效的方式,这会增加了不少工作量;另外这在目标进程里的动作也更大了,可能要考虑是否会被保护代码检测到。总之,现在就是 PayLoad 主动发消息,监控端处理并只在此时发送回复消息。

消息

主要设置了以下几个事件:发送模块导出函数消息、回复函数过滤消息、函数调用消息这三个。监控端收到模块导出函数消息后会在 UI 上提供是否 hook 相应函数的选择,同时也可以设置断点,然后一并回复给 PayLoad。目标进程在调用了监控的函数时则发送函数调用消息给监控端,这里也利用了 PayLoad 会等待监控端回复的特点,如果触发了“断点”,监控端可以先不回复,那么目标线程就会一直等待消息,此时可以做附加调试器等操作了。

要注意的一些点

  • 因为系统 dll 的 hook 发生在进程早期,crt 的设施都没有初始化,要使用容器的话需要自行创建堆,实现 stl 的内存分配器。
  • 初期 kernel32 的上层接口不一定可用,最好是用 ntdll 的 ntapi。
  • 在调试的时候因为附加调试器也会触发系统 dll 的加载,观察到的执行流程和没有调试器时相比会有变化,不过对代码执行影响倒不大,该崩的时候还是得崩。。。。作为对比,可以通过命令行的 windbg 来加载,比如可以用 windbg.exe -xe cpr notepad.exe 创建进程观察一番。



通过上面介绍的几个要点就能建立一个可用的监视器了,但要使软件更加易用仍需要大量的开发工作,比如 UI 的可操作性,监视器设置的保存载入,提高函数过滤的稳定性等。比方说如果目标进程有大量线程在输出日志,其中一个命中了断点开始等待监控端回复,但是其他线程仍在大量输出,而监控端没能及时收走,这就可能会使管道的缓存爆满,进而数据丢失、消息破坏。又比如我在监控一个大一点的软件时 hook 的函数太多,软件还没启动完就收到了 100w+ 的记录,爆内存了。。。虽说实际使用中只会 hook 必要的函数,但是实现上的缺陷还是需要避免的。因此要达到工具级别就需要更多枯燥的编码和测试工作,工作量至少 x9,因此这里也就仅仅实现了一些必要的功能。

代码见这里