[转载]自动验证 Windows NT 系统服务描述表的完整性
文章作者:Flier Lu <[email]flier@nsfocus.com[/email]>经历过DOS时代人朋友一定还记得,在内存开始处那个神奇的ISR中断服务程序映射表,其实Windows NT内部维护的系统服务描述表(System Service Descriptor Table)也起着类似的作用。用户态程序在调用系统核心的服务时,通过某种 Trap 机制(NT/2K是使用INT 2EH,XP/2003改为SYSENTER/SYSCALL指令,因为 P4 的中断调用效率太低 :P )进入核心态,然后核心根据系统服务号查表获取并调用相应服务函数。因此这个描述表实际上起到NT系统服务分配接口的作用,故而也被诸多Hook系统调用的软件(如Rootkit)所关注,呵呵
下面将给出实现一个自动验证 Windows NT 系统服务描述表的完整性的程序技术路线,并针对其中的技术难点的相关知识进行分析。
实现思路很简单:
1.定位内核的ntoskrnl.exe模块
2.获取系统服务描述表KeServiceDescriptorTable符号的地址
3.读取KeServiceDescriptorTable内容并定位服务描述表地址
4.打印描述表,对每个入口地址通过调试符号库查询响应函数名
1.定位内核的ntoskrnl.exe模块
最简单的定位方法莫过于使用NTDLL提供的Native API中的ZwQuerySystemInformation函数查询SystemModuleInformation信息,一次性将内核态所有模块的信息读出,然后找到所需模块的信息。后面查询函数入口地址时还会用到这些信息。
NTSYSAPI NTSTATUS NTAPI ZwQuerySystemInformation(
IN SYSTEM_INFORMATION_CLASS SystemInformationClass,
OUT PVOID SystemInformation,
IN ULONG SystemInformationLength,
OUT PULONG ReturnLength OPTIONAL);
typedef struct _SYSTEM_MODULE_INFORMATION { // Information Class 11
ULONG Reserved[2];
PVOID Base;
ULONG Size;
ULONG Flags;
USHORT Index;
USHORT Unknown;
USHORT LoadCount;
USHORT ModuleNameOffset;
CHAR ImageName[256];
} SYSTEM_MODULE_INFORMATION, *PSYSTEM_MODULE_INFORMATION;
此函数的使用方法网上讨论很多,请google之或参考《NT Native API》一书,这儿就不再罗嗦。
2.获取系统服务描述表KeServiceDescriptorTable符号的地址
获取系统服务描述表KeServiceDescriptorTable符号的地址途径也很多,大概说来有三种:
1) 通过 ntoskrnl.exe 中 export 的符号获取
2) 通过 ntoskrnl.pdb 中的调试符号获取
3) 通过线程核心对象KTHREAD的字段获取
最简单的方法莫过于从 ntoskrnl.exe 中 export 的符号获取,但可怜的是我在折腾出后两种方法后,才知道可以直接从 ntoskrnl.exe 中获取,呵呵 (感谢 JIURL 的提醒)
首先可以通过ZwQuerySystemInformation函数查询到 ntoskrnl.exe 在核心中的内存基址,然后通过LoadLibrary函数在用户态载入一份 ntsokrnl.exe 的实例,再直接用GetProcAddress函数获取用户态 ntoskrnl.exe 实例中导出的 KeServiceDescriptorTable 符号地址,减去用户态载入模块的基址获得 KeServiceDescriptorTable 符号在模块中的相对偏移,最后加上核心态载入的基址,就可以很容易地获取核心态中 KeServiceDescriptorTable 符号地地址。示例代码如下:
LPVOID TServiceTableApplication::GetpKeServiceDescriptorTableAddress(void) const
{
const AnsiString strModuleName = "ntoskrnl.exe";
const TKernelModule *pKernelModule = TModuleManager::Default().FindModule(strModuleName);
if(!pKernelModule)
{
std::cerr << "Can't locate " << strModuleName << " module" << std::endl;
return NULL;
}
HMODULE hDll = ::LoadLibrary(strModuleName.c_str());
LPVOID lpKeSvcTbl = ::GetProcAddress(hDll, "KeServiceDescriptorTable");
::FreeLibrary(hDll);
return (LPVOID)(pKernelModule->Base + (DWORD)lpKeSvcTbl - (DWORD)hDll);
}
复杂一些的方法是通过 MS 提供的 DbgHelp 库,载入安装的调试符号库,查询符号信息。
首先用SymInitialize函数初始化一下符号库引擎,与之对应最后也需要用SymCleanup函数析构;然后用SymSetSearchPath函数设置符号库搜索路径,也可以使用_NT_SYMBOL_PATH环境变量指定;对需要载入符号库的模块,调用SymLoadModule函数载入对应符号库,并使用SymGetModuleInfo函数获取符号库信息;再使用SymEnumSymbols函数枚举模块中的符号;对我们需要的符号,可以通过SYMBOL_INFO.Address得到符号在内存中的地址。
更复杂一些的方法是创建一个线程;此线程可以选择是否调用任意 GDI 函数,以转换为内核态的 GUI 线程;然后通过此线程句柄信息获取内核态线程对象地址;读取线程对象内存结构中保存的 KeServiceDescriptorTable (普通线程) 或 KeServiceDescriptorTableShadow (GUI 线程) 地址。有兴趣的朋友可以具体参看我BLog的一篇文章 《另一种获取系统服务描述表入口地址的方法》,这里就不再详细讨论。
3.读取KeServiceDescriptorTable内容并定位服务描述表地址
KeServiceDescriptorTable符号指向一个KSERVICE_TABLE_DESCRIPTOR结构,定义系统服务描述表的函数入口表和参数表的地址:
typedef struct _KSERVICE_TABLE_DESCRIPTOR {
PULONG_PTR Base;
PULONG Count;
ULONG Limit;
PUCHAR Number;
} KSERVICE_TABLE_DESCRIPTOR, *PKSERVICE_TABLE_DESCRIPTOR;
Limit存储此服务描述表中有多少项;
Base指向函数入口表地址,每个表项是一个DWORD,表示一个函数入口地址;
Number指向函数参数表地址,每个表项是一个UCHAR,表示一个函数参数长度。
但因为此结构存储在2G以上地址空间中,而核心态内存是无法在用户态直接访问的,所以我们必须想办法直接读取核心态内存。首先我们需要将KeServiceDescriptorTable符号指向的虚拟地址(Virtual Address)转换为物理地址(Physical Address),然后通过操作\Device\PhysicalMemory设备Section 将物理内存页映射到用户态空间;最后读取映射后的内存。
上述步骤所用技术中,虚拟地址向物理地址转换的原理《Inside Win2K》一书中和webcrazy的《小议Windows NT/2000分页机制》一文中有很精彩的论述;读取 \Device\PhysicalMemory 设备 Section 的方法在 crazylord 的《玩转Windows /dev/(k)mem》一文中做了很详细的分析。因此这儿就不再对实现原理多做罗嗦。
值得一提的是在用户态进行完整的虚拟地址向物理地址转换以前并没有什么好办法。因为NT系统中,对0xA0000000以上地址的内存是通过一个两级页表(启用AWE模式或64位模式下则为三级)来实现映射的。因此要真正访问 0xA0000000 以上虚地址内存,必须读取进程的 PDE/PTE 表。但问题是PDE页表一般存放在虚地址 0xC0300000,PTE 页表则从虚地址 0xC0000000 开始。也就是说这些页表本身,就存放在通过查询页表才能访问的虚地址上。要查PDE/PTE表进行虚地址向物理地址转换,首先要知道 0xC0300000 和 0xC0000000 映射在哪个物理页上。这样一来就变成了先有鸡还是先有蛋的问题了,呵呵。NT 内核本身,则可以通过进程 CR3 寄存器中保存的 PDE 页表起始物理地址直接访问,但不巧的是,访问 CR3 需要有 Ring 0 的状态。
因此大多数讨论地址转换的文章中只提供了在核心态完成转换的算法,如webcrazy的文章中的算法就直接使用了PDE地址;或者提供一个经验函数,只处理0x80000000-0xA0000000访问内的核心内存访问,因为这段内存为提高运行效率被直接映射到连续的物理页面上,可以通过PA = VA & 0x1FFFF000的经验函数转换,如crazylord的文章中的算法就是一个经验函数。
既然无法知道 PDE/PTE 的物理地址,也不能直接访问 CR3,就得尝试其他迂回的途径。
我们知道 PDE/PTE 是进程相关的。因为每个进程都有自己的虚拟空间,因此必须有一整套独立的页表备查。核心在进行线程切换时,如果两个线程在一个进程内,无需做页表的切换;如果两个线程跨越了进程,就必须将目标线程所在进程的Context载入,这其中就包括我们所需要的 CR3 的内容。而每个进程的 CR3 内容被保存到 EPROCESS::KPROCESS::DirectoryTableBase[0] 字段中。这个数组分别保存了 PDE 页表物理地址和 hyber space PTE 页表物理地址。
于是实现思路就清晰了:
1) 取当前进程的 EPROCESS
2) 读取 PDE 页表物理地址
3) 通过分解虚地址获取 PDE/PTE 表的索引
4) 查页表获得目标物理页地址,读取物理页内容。
1) 取当前进程的 EPROCESS
当前进程的 EPROCESS 地址在 ntoskrnl.exe 的空间中,因此可以通过直接映射内存访问。使用OpenProcess打开当前进程,获得句柄;使用NTDLL::ZwQuerySystemInformation函数获取所有核心句柄表,线性搜索到进程句柄,其指向的内核对象就是 EPROCESS。
2) 读取 PDE 页表物理地址
PDE 页表物理地址在 EPROCESS 结构的偏移 0x18 处,这个偏移从NT4到Windows 2003都没有改动,估计以后也不会变动。:D
3) 通过分解虚地址获取 PDE/PTE 表的索引
将目标虚地址分解为三部分:
DDDDDDDDDDTTTTTTTTTTBBBBBBBBBBBB
01234567890123456789012345678901
高10位是PDE索引;中间10位是PTE索引;末尾12位是页内字节索引。
4) 查表获得目标物理页地址,读取物理页内容。
每个PDE/PTE表项都是一个DWORD,其高20位定义下一级的索引。
typedef struct _MMPTE_HARDWARE {
ULONG Valid : 1;
ULONG Write : 1; // UP version
ULONG Owner : 1;
ULONG WriteThrough : 1;
ULONG CacheDisable : 1;
ULONG Accessed : 1;
ULONG Dirty : 1;
ULONG LargePage : 1;
ULONG Global : 1;
ULONG CopyOnWrite : 1; // software field
ULONG Prototype : 1; // software field
ULONG reserved : 1; // software field
ULONG PageFrameNumber : 20;
} MMPTE_HARDWARE, *PMMPTE_HARDWARE;
获取页表地址的示例代码如下:
const PHYSICAL_ADDRESS TPhysicalMemoryManager::GetPDEAddress(void)
{
HANDLE hProcess = ::OpenProcess(PROCESS_QUERY_INFORMATION, TRUE, ::GetCurrentProcessId());
Win32Check(hProcess != NULL);
::THandleTable tblHandles; // 使用 Native API 获取系统句柄表
const TSystemHandle *pHandle = tblHandles.FindHandle(::GetCurrentProcessId(), hProcess);
Win32Check(::CloseHandle(hProcess));
assert(pHandle);
PVOID pObj = pHandle->Object; // 句柄指向对象地址就是 EPROCESS 结构的地址
assert(pObj);
PHYSICAL_ADDRESS addr = { 0, 0 };
ReadVirtualMemory((LPCVOID)((DWORD)pObj + 0x18), &addr.LowPart, sizeof(addr.LowPart));
return addr;
}
具体查表转换算法如下
#define GetPdeAddress(base, va) (LPCVOID)((base) + (((ULONG)(va) >> 22) << 2))
#define GetPteAddress(base, va) (LPCVOID)((base) + ((((ULONG)(va) >> 12) & 0x3FF) << 2))
const PHYSICAL_ADDRESS TPhysicalMemoryMapping::LinearAddressToPhysicalAddress(LPCVOID lpVirtualAddress)
{
PHYSICAL_ADDRESS addr = { 0, 0 };
if((DWORD)lpVirtualAddress >= 0x80000000L && (DWORD)lpVirtualAddress < 0xA0000000L)
{
addr.QuadPart = (DWORD)lpVirtualAddress & 0x1FFFF000;
}
else
{
MMPTE_HARDWARE PDE, PTE;
m_pManager->ReadPhysicalMemoryPage(GetPdeAddress(m_pManager->PTE.LowPart, lpVirtualAddress), &PDE, sizeof(PDE));
m_pManager->ReadPhysicalMemoryPage(GetPteAddress(PDE.PageFrameNumber << PAGE_SHIFT, lpVirtualAddress), &PTE, sizeof(PTE));
addr.LowPart = (PTE.PageFrameNumber << PAGE_SHIFT) + BYTE_OFFSET(lpVirtualAddress);
}
return addr;
}
知道了目标页面的物理地址,就可以通过读取 \Device\PhysicalMemory 直接获取了。
读取物理内存实际上就是使用NtOpenSection函数打开NT内建\Device\PhysicalMemory设备Section,然后将此Section中需要访问的内存页用NtMapViewOfSection函数映射到用户态空间中,就可以直接读取。最后再相应调用NtUnmapViewOfSection函数和NtClose函数关闭映射。
4.打印描述表,对每个入口地址通过符号库查询响应函数名
将描述表读取后,就可以根据每个函数入口地址,先反向定位到某个核心模块,再使用DbgHelp提供的SymFromAddr函数定位到某个函数。如果某个系统服务入口被Hook,则在通过系统服务描述表入口中的地址可以反向定位到进行Hook的驱动模块。也可以编写代码进一步检查每个函数入口处的几个字节,是否调用了模块外部的地址,检测此类 Hook 系统服务的方法。
参考资料:
1.《Windows NT/2000 本机 API 参考手册》, (美)Gary Nebbett
[url]http://www.china-pub.com/computers/common/info.asp?id=4015[/url]
2.《另一种获取系统服务描述表入口地址的方法》,Flier Lu
[url]http://flier_lu.blogone.net/?id=1442740[/url]
3.《玩转Windows /dev/(k)mem》, crazylord 著, Refdom 译
[url]http://www.xfocus.net/articles/200208/430.html[/url]
4.《Windows 2000 内部揭密》,(美)David A.Solomon Mark E.Russinovich
[url]http://www.china-pub.com/computers/common/info.asp?id=3725[/url]
5.《小议Windows NT/2000分页机制》, webcrazy
[url]http://cn.geocities.com/cntsu00/page.htm[/url]
6.《自动获取 NT 系统服务描述表与函数名映射表》,Flier Lu
[url]http://flier_lu.blogone.net/?id=1404224[/url]
7.《在用户态进行虚拟空间地址向物理空间地址的转换》,Flier Lu
[url]http://flier_lu.blogone.net/?id=1428057[/url]
页:
[1]