挂钩中断并调用用户模式下的内核例程
将用户模式例程注入内核空间并执行
引言
这是我 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.EXE 和 HAL.DLL 的导出函数。而且,我的代码主要在用户模式下运行。
我一直认为驱动程序开发人员比应用程序开发人员幸运,因为他拥有 2G 以上的所有虚拟地址空间,并且地址区域不会被不同的进程分割。你可以自由访问整个地址区域,而不用担心操作系统是否允许,你就是操作系统本身。特定内核例程的地址在任何线程上下文中始终是相同的。
理解这一点将使查找内核例程更加容易和直接。
- 获取 phymem 驱动程序中的一个内核例程地址作为参考,并将其返回给用户应用程序。
例如:DWORD refFuncAddr=(DWORD)IoAllocateIrp
- 在用户应用程序中,我们可以分析相关文件(NTOSKRNL.EXE、NTDLL.DLL 等),找到其所有导出函数的入口点和名称。
- 计算搜索到的函数与其参考函数入口点之间的差值,然后将该值加到
refFuncAddr
上。结果就是搜索到的函数的虚拟地址。
查看示例代码 phymem\pmdll\peexports.cpp,了解如何枚举 DLL 导出函数。它使用了 ImageHlp
库。我最初从 MSDN 示例中复制了代码,但最终发现该示例在某些情况下存在错误。ImageHlp
帮助我们获取导出表,但函数名称是按“提示”(Hint)排序的,而它们的入口点是按“序号”(Ordinal)排序的。我很幸运在 ImageHlp
让我抓狂之前找到了解决方案。
请注意,许多内核例程是宏而不是实际函数。所以我们找不到“IoCopyCurrentIrpStackLocationToNext
”的地址,这是我遇到的名字最长的例程。
2. 在我们的应用程序中声明内核例程
我们不能直接从 WDM.H 或 NTDLL.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 日:初始发布