65.9K
CodeProject 正在变化。 阅读更多。
Home

挂钩中断并调用用户模式下的内核例程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (12投票s)

2009年5月20日

CPOL

9分钟阅读

viewsIcon

78636

downloadIcon

1494

将用户模式例程注入内核空间并执行

引言

这是我 phymem 驱动程序的一个增强版本,请参考 访问物理内存、端口和 PCI 配置空间

本文介绍了一种内核代码注入方法,通过这种方法我们可以将用户模式代码注入到内核空间,并让操作系统内核执行它。我不认为这实际上有什么用,这只是我脑海中的一个想法,我最终让它实现了。源代码可能对驱动程序和内核开发人员有所帮助。假定读者在内核模式驱动程序编码方面有一些经验。

我关于 IDT 管理的代码基于这篇优秀的文章:Windows NT/2000/XP 下的中断挂钩和设备信息检索,作者是Alexander M. 非常感谢。

在用户模式下挂钩 ISR

当然,在用户模式下挂钩 ISR 是不可能的。我真正想说的是,我们开发一个普通的应用程序,并在 phymem 驱动程序的帮助下,将一个特定的例程映射到内核地址,然后用一些 ISR 挂钩这个例程。这通常被称为回调函数,不同之处在于我们的函数是在 CPU DIRQL 级别被中断服务例程调用的。这看起来就像当相应的中断发生时,操作系统会调用我们的应用程序函数。

为了让事情有意义,我们应该找到某种方式与这段代码通信。正常的参数传递肯定不可用。我使用一个指向用户定义结构的上下文指针来实现参数传递,这类似于设备驱动程序使用的 DeviceExtension

我们面临许多障碍

1. 如何获取函数大小

要将特定的代码映射到内核空间,我们必须先获得代码的大小(这段代码占多少字节)。起初我以为只需要在后面放一个 null 函数,然后相减即可。但很快我发现编译器帮了我们太多。它会在调试模式下添加跳转表,并且会偶尔改变函数的顺序,等等。经过长时间的网络搜索和测试,我最终在 VC6.0 SP6 中找到了这个 *可能* 可靠的实现。

#pragma comment(linker, "/incremental:no")
#pragma code_seg("systemcode")
static void __stdcall isr(PISRCONTEXT context)
{
  context->a++;
}
static void __stdcall isrend()
{
}
#pragma code_seg()
DWORD sizeofcode=(DWORD)isrend-(DWORD)systemcode;
  • 禁用链接器的增量链接选项,或将函数声明为 static ,以防止编译器创建跳转表,这会导致函数指针与其实际地址不同。
  • 将两个函数声明在同一个节中,以防止编译器改变它们的顺序。如果定义了两个以上的函数,即使禁用了优化,它们的顺序也可能改变。

2. 如何将代码和上下文映射到内核空间

在我之前的文章 访问物理内存、端口和 PCI 配置空间 中,我介绍了将内核地址映射到用户空间的方法。将用户地址映射到内核空间也非常简单。

  • 使用 IoAllocateMdl 为该用户地址分配一个 MDL
  • 使用 MmProbeAndLockPages ,第二个参数为 “UserMode”,来锁定内存
  • 使用 MmGetSystemAddressForMdlSafe 获取映射后的内核地址

3. 如何挂钩 ISR

关于 ISR 挂钩,我不会多说,因为 Alexander 和他的优秀文章 Windows NT/2000/XP 下的中断挂钩和设备信息检索 已经解释得很清楚了。我的代码也是基于 Alexander 的源代码。

我想说的是,与 Alexander 的示例中新 ISR 是静态链接的 *不同*,我们的 ISR 是在运行时传递给我们的。因此,在链接时我们不知道它的地址。我们可以设置一个跳转表并挂钩 ISR,让它们检查是否安装了新例程,但这效率不高。ISR 应该尽可能快地运行,以提高整个操作系统的性能。

我的方法是在一个缓冲区中动态创建新的 ISR 代码,并将相应的 IDT 条目填充为该缓冲区的地址。缓冲区中实际存放的是代码,而不是数据。如果你知道“缓冲区溢出”是什么意思,那你应该熟悉这种技术。

The ISR C prototype and disassembled codes (Thanks Alexander):
: __declspec(naked) void Isr()
: {
:   __asm { PUSHAD };
00403030 60 pushad
:   __asm { PUSH context };
00403031 68 3C AC 42 00 push context (0042ac3c)
:   __asm { CALL newIsr };
00403036 E8 C5 DF FF FF call newIsr (00401000) //0x401000-0x403036-5
:   __asm { POPAD };
0040303B 61 popad
:   __asm { JMP originalIsr };
0040303C E9 0F E0 FF FF jmp originalIsr (00401050) //0x401050-0x40303c-5

以下是动态创建代码的过程

//pushad
isrCode[0]=0x60; //pushad
//push context
isrCode[1]=0x68; //push
RtlCopyMemory(&isrCode[2], &context, 4); //context address
//call isrAddr
isrCode[6]=0xE8; //call
relativeAddr=(LONG)isrAddr-(LONG)&isrCode[6]-5; //relative address
RtlCopyMemory(&isrCode[7], &relativeAddr, 4);
//popad
isrCode[11]=0x61; //popad
//jmp originalAddr
isrCode[12]=0xE9; //jmp
relativeAddr=(LONG)originalIsr-(LONG)&isrCode[12]-5; //relative address
RtlCopyMemory(&isrCode[13], &relativeAddr, 4);

整个代码大小只有 17 字节,而跳转表将占用 256*4 字节(我懒得计算乘积;编译器会帮我们 :))。

4. 注意事项

请记住,ISR 在操作系统的最低级别运行。虽然你的 ISR 源代码与你的应用程序在一起,但大多数时候,它会在另一个线程的上下文中执行。这意味着当这段代码运行时,包含这段代码的应用程序可能处于睡眠状态。这听起来很奇怪,但这是事实。

不要触碰上下文结构之外的任何东西,不要调用任何子例程。如果你引用全局变量或调用 Windows 库例程,编译器不会报错。但它们的虚拟地址对内核来说是无效的,因此你肯定会遇到蓝屏死机。从中断服务例程调用 MessageBox 已经够糟糕的了。

在用户模式应用程序中调用内核例程

有了前面的知识,我们可以进一步探索直接调用内核例程的方法。我们在应用程序中编写一个调用内核例程(如 READ_PORT_ULONG)的例程,然后将其插入内核,执行它,并检查结果,就像我们编写 *.sys 文件一样,而不是 *.exe 文件。

1. 获取内核例程地址

要调用内核例程,我们必须先找到它的地址。在我花了一个下午写代码、调试并最终成功之后,我偶然发现了一个名为“MmGetSystemRoutineAddress”的内核例程,它的功能几乎一样。我继续使用我的代码,不仅是因为我花费了半天的时间思考和实践,而且我的代码可以改进以查找任何内核例程。MmGetSystemRoutineAddress 只能查找 NTOSKRNL.EXEHAL.DLL 的导出函数。而且,我的代码主要在用户模式下运行。

我一直认为驱动程序开发人员比应用程序开发人员幸运,因为他拥有 2G 以上的所有虚拟地址空间,并且地址区域不会被不同的进程分割。你可以自由访问整个地址区域,而不用担心操作系统是否允许,你就是操作系统本身。特定内核例程的地址在任何线程上下文中始终是相同的。

理解这一点将使查找内核例程更加容易和直接。

  • 获取 phymem 驱动程序中的一个内核例程地址作为参考,并将其返回给用户应用程序。
    例如:DWORD refFuncAddr=(DWORD)IoAllocateIrp
  • 在用户应用程序中,我们可以分析相关文件(NTOSKRNL.EXENTDLL.DLL 等),找到其所有导出函数的入口点和名称。
  • 计算搜索到的函数与其参考函数入口点之间的差值,然后将该值加到 refFuncAddr 上。结果就是搜索到的函数的虚拟地址。

查看示例代码 phymem\pmdll\peexports.cpp,了解如何枚举 DLL 导出函数。它使用了 ImageHlp 库。我最初从 MSDN 示例中复制了代码,但最终发现该示例在某些情况下存在错误。ImageHlp 帮助我们获取导出表,但函数名称是按“提示”(Hint)排序的,而它们的入口点是按“序号”(Ordinal)排序的。我很幸运在 ImageHlp 让我抓狂之前找到了解决方案。

请注意,许多内核例程是宏而不是实际函数。所以我们找不到“IoCopyCurrentIrpStackLocationToNext”的地址,这是我遇到的名字最长的例程。

2. 在我们的应用程序中声明内核例程

我们不能直接从 WDM.HNTDLL.H 中复制函数原型,因为我们需要的是函数指针。我从未真正理解声明函数指针的语法,但我知道如何做到这一点。在最前面加上 “typedef”,然后用括号括起函数名,最后在函数名前面加上一个 “*”。

Kernel function declaration
PVOID MmMapIoSpace(
  PHYSICAL_ADDRESS PhysicalAddress,
  ULONG NumberOfBytes,
  MEMORY_CACHING_TYPE CacheEnable
);
User function pointer declaration
typedef PVOID (__stdcall *MmMapIoSpace)(
  LARGE_INTEGER PhysicalAddress,
  ULONG NumberOfBytes,
  int CacheEnable
);

我们应该对原型做一些修改,因为 WINDOWS.H 缺少内核数据类型。就像示例中所示,我将 PHYSICAL_ADDRESS 改为了 LARGE_INTEGER ,将 MEMORY_CACHING_TYPE 改为了 int。添加 __stdcall 以匹配内核例程的参数传递顺序和堆栈操作。

3. 关于 chkesp()

如果我们在一个函数中调用子例程,编译器会在调试版本中附加 chkesp() 来捕获可能的错误。这对于普通应用程序来说可能很好,但对我们来说非常糟糕,因为 chkesp() 有一个用户模式地址,内核无法访问。/GZ 编译器选项控制是否会自动附加 chkesp() 。删除此选项即可。

微软告诉我们,启用 /GZ 可以在调试版本中捕获发布版本错误。但我遇到的情况恰恰相反。有一次我写了一个程序,在调试版本中运行正常,但在发布版本中失败了。我最终发现我忘记初始化一个局部变量。/GZ 将该变量填充为 0xCC,它恰好是一个合法值,但在发布版本中它是一个随机值,程序因此失败。微软总是倾向于过度帮助我们。我非常害怕新的 Visual Studio,它太庞大了,所以我一直使用 VC6。另一个原因是我的笔记本只有 256MB 内存和 30GB 硬盘,而我的家用电脑有 2GB 内存和 300GB 硬盘,却总是被我的妻子和女儿用来玩俄罗斯方块。

4. 关于测试程序

在测试样本中,我构造了两个例程,一个用于将物理地址映射到用户空间,另一个用于取消映射。在 phymem 驱动程序中,你可以找到标准的内核模式实现。

我调用映射过程,获得指向物理地址 0 的虚拟地址,并在前 1MB 物理内存中搜索 ACPI RSDP 签名“RSD PTR”。最后,取消映射内存。

Using the Code

正如我在开头所说,我不认为我实现的这个东西实际上有什么用,所以我不会浪费时间谈论如何使用这段代码。只需查看源代码。有用的部分在我之前的文章 访问物理内存、端口和 PCI 配置空间 中已经展示了。

免责声明

此代码可能导致系统崩溃,请务必先备份您的工作。如果您仍像我一样使用 VC6,请在运行前将源代码保存到其他地方。VC6 有一个糟糕的 bug,当系统突然崩溃或关闭时,它可能会用二进制数据覆盖您的源文件。我在我的八年旧笔记本上经常遇到这种情况,它有一个随机关机的习惯,而且不事先通知我。我真的很讨厌备份,但微软教会了我备份是必须的,谢谢。

历史

  • 2009 年 5 月 20 日:初始发布
© . All rights reserved.