I/O 端口嗅探器
一个使用硬件调试寄存器的 I/O 端口嗅探器。
引言
此程序可用于拦截 PC I/O 端口活动。它使用 X86 硬件调试寄存器作为其“耳朵”,并在最低级别进行窃听。假定读者熟悉 Windows 设备驱动程序开发以及操作系统和 Intel X86 架构的一些知识。
我曾想分析一个程序,找出它发送和接收到某些 I/O 端口的数据。令我惊讶的是,我在网上关于 I/O 端口嗅探器的资源很少。我以为这应该是一个常见的实用工具。所以,我不得不自己编写一个。
起初,我编写了一个驱动程序来挂钩原始的 HAL 例程,如 READ_PORT_UCHAR
。虽然大部分情况下都有效,但在目标程序上却失败了。我猜想程序员一定是在其代码中直接使用了“in”和“out”之类的汇编指令。那么我能做什么呢?有没有一种方法可以从底层(硬件级别)拦截 I/O 端口访问?当然有。至少,WinDbg 可以做到。WinDbg 可以在 I/O 端口访问上设置断点。它使用 X86 硬件寄存器作为致命武器。
X86 调试寄存器简介
Intel X86 包含八个 32 位调试寄存器,用于方便地设置硬件断点,DR7 ~ DR0,其中 DR4 和 DR5 不常用。DR7 用作控制寄存器,DR6 用作状态寄存器,DR3 ~ DR0 用于保存断点地址。一次最多只能设置四个不同的硬件断点。我们可以设置硬件断点来触发内存或 I/O 端口访问。当命中硬件断点时,CPU 会在 DR6 中设置相应的位,引发一个调试异常 (#DB),并跳转到 OS 安装的 ISR 处理程序,就像处理普通异常一样。硬件断点异常是一种“陷阱”,这意味着 #DB ISR 处理程序是在 CPU 执行触发异常的代码之后才触发的。
设置或清除 DR7 中相应的位以启用或禁用断点。DR7 的第 13 位称为全局检测位;如果设置了该位,当任何代码尝试访问调试寄存器时,CPU 将引发一个调试异常。这是一个“错误”,意味着 ISR 处理程序是在 CPU 执行异常代码之前触发的。Intel 手册称其用于方便 ICE 调试。起初,我认为它对我没有用,但最终,这个位救了我的命。
有关详细信息,请参考 Intel 系统编程指南。
现在我们有了一个足够强大的武器,可以设置 I/O 端口访问的断点。一旦被任何代码命中,就会引发一个调试异常并调用 ISR 处理程序。所以我们的下一个目标是替换 OS 提供的调试处理程序为我们自己的。
替换调试处理程序
这里有一篇关于如何挂钩中断服务例程的优秀文章。
起初,我尝试“挂钩”OS 提供的 ISR,在我完成工作后,将控制权交给原始代码。但似乎 Windows 的 #DB 处理程序足够健壮,并进行了过多的验证。无论我如何尝试擦除线索,它总是导致蓝屏。所以,我不得不用我自己的代码替换整个程序。
ISR 代码具有以下原型
__declspec(naked) void __cdecl DebugHandler(BYTE *_eip, DWORD _cs, DWORD _eflags)
{
// entry
__asm push ebp
__asm mov ebp, esp
__asm sub ebp, 4
// body ...
// leave
__asm pop ebp
__asm iretd
}
该函数被声明为 __declspec(naked)
,告诉编译器不要为我们生成堆栈和局部参数操作代码。这些操作由我们自己负责。这不是一个由另一个函数调用的常规例程,它是一个 ISR 处理程序。编译器不知道中断处理程序的堆栈内容。但我们可以欺骗编译器,让它认为这只是一个常规函数。
根据 Intel 手册,当引发异常时,CPU 会将当前的 EFLAGS、CS、EIP 推送到内核堆栈(有时还会推送一个错误代码)并跳转到 ISR。"iretd
"指令会将这些值从堆栈中弹出并继续中断的代码。__cdecl
调用约定从右到左推送参数,然后是返回地址。以下是实际堆栈内容与编译器认为应有的内容的比较。
从图中可以看出,堆栈不匹配,因为编译器认为返回地址已被推送到堆栈。因此,编译器无法正确引用这三个参数。第三行代码 "__asm sub ebp, 4
" 解决了这个问题。
EBP 寄存器,也称为堆栈帧指针,被编译器用来引用参数和局部变量。几乎每个函数都以代码 "PUSH EBP
" 然后是 "MOV EBP, ESP
" 开始。因此,第一个参数通过 [EBP+8]
引用,第二个参数通过 [EBP+12]
引用。由于堆栈中缺少返回地址,将 EBP 减去 4 会让编译器满意。现在我们可以使用这三个伪参数,就好像它们确实是由编译器而不是硬件推送的一样。
为了简单起见,我没有声明任何局部变量。所有临时变量都声明为外部全局变量。
防止断点被调度器清除
经过一些测试,我兴奋地发现它起作用了。但它在启动后只能随机地工作很短时间。我发现如果我停留在当前窗口,它会工作更长时间。如果我切换到新应用程序,它会立即停止工作。看起来任务切换会击垮它。新任务会从 TSS(任务状态段)重新加载其上下文;调试寄存器也会被重写吗?搜索后,我沮丧地发现它们确实会被重写。Windows 只允许单个任务使用硬件断点,而不是整个系统。
当我准备放弃时,我幸运地回忆起一篇关于 phrack 杂志的文章。当我第一次阅读这篇文章时,我对调试寄存器知之甚少,所以它并没有给我留下深刻印象。当我再次阅读时,我发现自己很愚蠢。我甚至找到了解决方案并正确地编写了代码,但却因为一个小的错误而放弃了。它如此简单,以至于我无法相信它会起作用。
我曾提到 DR7 有一个称为全局检测的位。如果设置了这个位,任何调试寄存器访问都会引发一个错误并将控制权转移到调试处理程序。如果我们调整保存的 EIP 并让它指向下一条指令,当执行 "iretd
" 时,调试寄存器访问代码将被跳过。
调试寄存器只能通过 "mov
" 到或从通用寄存器(如 EAX)访问。在 X86 上,其操作码大小始终为三个字节。因此,在 ISR 例程 DebugHandler 中,一条简单的语句 "_eip += 3
" 就足够了。这就是为什么我将 _eip
声明为 BYTE*
,而不是 DWORD
。
至于实际编程,有一些技巧可以防止在设置全局检测位时发生无限循环。请参考源代码。我放弃了我的第一个测试,因为我没有仔细关注这个条件。调试这个驱动程序非常困难,因为在我的 #DB ISR 代码中单步执行会再次触发它自身。单步执行也是一个 #DB 异常;代码会在 WinDbg 中一遍又一遍地循环。我不知道是否有任何好的方法来调试调试处理程序。
多处理器兼容
代码现在几乎完成了,并且在虚拟机中运行良好。但当我尝试在我的笔记本上测试时,它表现得很奇怪,有时会导致蓝屏。我并不感到惊讶,因为我曾考虑过多处理器问题。当我将虚拟机 CPU 数量从 1 更改为 2 时,代码就崩溃了。
在多处理器、多核或单核带超线程的平台中,每个逻辑核心都有自己的寄存器集。我的 Intel i5 在单个物理封装中集成了两个处理器核心,每个核心可以同时执行两个线程(超线程,但听起来不如实际那么好)。Windows 告诉我我的笔记本拥有四个强大的“心脏”;当然,这只是 Intel 和 Microsoft 共同玩的一个把戏。
当我们启动一个程序时,操作系统会选择一个处理器来执行程序的主线程。当任务被暂停并通过调度器再次运行时,OS 会根据某种算法选择一个新的处理器来运行代码。大多数时候,新处理器会与之前的处理器不同。由于每个处理器都有自己的调试寄存器和中断描述符表,因此程序只有在幸运地运行在启动它的处理器上时才会工作。
我们可以在任务管理器中设置处理器亲和性,使我们的代码始终在指定的核心上运行。但我们正在设计一个设备驱动程序,并且我们的 #DB 处理程序将在任意线程上下文中被调用,这意味着所有任务都应该被设置为在同一个处理器上运行。显然,这是一个愚蠢的想法。
因此,我们需要一种方法来写入所有处理器的寄存器集。一些聪明人已经通过使用 DPC(延迟过程调用)找到了解决方案,DPC 可以设置为在指定处理器上运行。这项技术在《Rootkits: Subverting the Windows Kernel》一书中有所介绍。但我们不必那么激进,因为我们可以通过文档化的 API 从用户空间来实现。
"SetThreadAffinityMask
" 可以用来将一个线程绑定到一个处理器。我们可以先枚举所有逻辑处理器。然后为每个处理器调用一个线程。在线程中,调用驱动程序代码来写入相关的处理器的寄存器集,并在退出时清理垃圾。浏览源代码可以找到如何枚举逻辑处理器以及如何使用 SetThreadAffinityMask
。
源代码和演示
演示包含一个驱动程序 "IOSniffer.sys" 和一个控制台应用程序 "test.exe"。在驱动程序中,我为 I/O 端口 0x60 和 0x64 设置了硬件断点,所以它可以被认为是最低级别的键盘钩子(USB 键盘将无法工作)。我还没有实现任何 IOCTL 代码,断点已硬编码在源代码中。如果有人能将其改进为一个真正有用的工具,我将非常高兴。我只是缺乏耐心。
演示仅使用 "DbgPrint" 与外部通信。所以您必须安装 DbgView 来查看输出消息。
这是我在笔记本上的示例输出。显然,Microsoft Windows 是一个良好的 SMP 系统,因为处理器使用非常均衡。如果我们深入挖掘,当我们捕获系统每次进行任务切换时,就可以窥探到一些系统内部的奥秘。
该代码在 Windows XP SP3 32 位上进行了测试。它不能在 64 位版本上工作。请自行承担使用风险。
该驱动程序是在 WDK 7600.16385.1 下构建的,测试应用程序是在 VC10.0 下开发的。它们可以在较低版本的开发工具下构建,只需少量甚至无需修改。
该驱动程序包含一些内联汇编代码。与 ARM 相比,我根本不熟悉 X86 汇编。您可能会发现我的代码很笨拙,因为我总是使用通用寄存器来访问内存,就像在 RISC 平台上一样,尽管 X86 拥有更丰富的寻址模式和更少的通用寄存器。我向 Intel 申请了打印的指令集手册,但他们告诉我永远无法提供。所以我不得不恳求我的妻子打印手册。但当她知道需要多少纸时,她踢了我屁股。结果是我 X86 汇编编程技能仍然停留在十年前上学时的水平,甚至更糟。