构建内核HOOK框架
分析漏洞或者提取病毒行为,调试器是必不可少的,不过面对rootkit或者其它有驱动辅助的程序,OD或者windbg硬上可能会蓝屏,驱动级的anti还是不容小觑的;此外不少企业或者团队还希望有自己的模拟器,可以去半自动跑一些样本。不管怎么说,内核HOOK框架都是必不可少的。
内核HOOK需要关注的类型并不多:SSDT、ShadowSSDT、IDT内核关口HOOK;文件系统过滤,可以自建控制设备和卷设备过滤,也可以使用MiniFilter;网络控制,TDI/WPF驱动必不可少,甚至需要自己的NDIS驱动;重点当然是内核入口,现在的主流是KiFastCallEntry,我们也是处理了这个点。
单纯的HOOK这些点以及加一些过滤是没有意义的,重要的还是人为地参与和控制。首先需要重载内核,即加载磁盘文件,而不使用系统正在使用的内核,考虑到磁盘文件可能也有问题,可以根据操作系统版本号加载自己的文件;调试框架也需要自己重新构建,最好自己定义结构采集进程信息,仿造windows的DebugPort;文件和注册表重定位,这个是比较重要的,也是记录行为的关键;驱动和应用层通信,通信可以有很多种方式,可以简单实用DeviceIoControl。
作为一个HOOK框架,要定义好一些回掉函数,由另外的驱动来实现功能并向我们的HOOK框架注册。下面详细说一下使用的技术和实现细节。
内核入口
内核入口实现ring3调用到ring0实现功能。Ring3的所有调用,只要经过ntdll,都会调用ntdll.KiFastSystemCall:
lkd> uf ntdll!ZwCreateFile
ntdll!NtCreateFile: 7c92d0ae b825000000
mov eax,25h 7c92d0b3 ba0003fe7f
mov edx,offset SharedUserData!SystemCallStub (7ffe0300) 7c92d0b8 ff12
call dword ptr [edx] 7c92d0ba c22c00
ret 2Ch
这个SharedUserData是可以内核层和应用层都可以访问的区域,在应用层被映射到虚拟地址7FFE0000,内核层映射为FFDF0000:
lkd> dt _KUSER_SHARED_DATA
ntdll!_KUSER_SHARED_DATA
+0x300 SystemCall : Uint4B
+0x304 SystemCallReturn : Uint4B
这个结构在系统启动的时候被初始化,SystemCall和SystemCallReturn被指定。Ring0和ring3都可以访问这片区域,所以可以通过上述两个函数实现环境切换。
ntdll!KiFastSystemCall:
7c958458 8bd4 mov edx,esp
7c95845a 0f34 sysenter
7c95845c c3 ret
eax保存SSDT中的索引,edx指向ring3栈基址。Sysenter使用几个MSR特殊寄存器设置EIP、CS、ESP、SS等寄存器,这时进入内核。IA32_SYSENTER_EIP指定执行的地址。正是KiFastCallEntry。KiFastCallEntry实现具体的服务例程分发,即调用eax索引指定的SSDT中的函数。
所以KiFastCallEntry是所有应用层调用必经入口,我们只要在这里实现HOOK,就可以拦截所有调用(理论上)。
kd> u 8053e614
nt!KiFastCallEntry+0xd4:
8053e614 8b5f0c mov ebx,dword ptr [edi+0Ch]
8053e617 33c9 xor ecx,ecx
8053e619 8a0c18 mov cl,byte ptr [eax+ebx]
8053e61c 8b3f mov edi,dword ptr [edi]
8053e61e 8b1c87 mov ebx,dword ptr [edi+eax*4]
8053e621 2be1 sub esp,ecx
8053e623 c1e902 shr ecx,2
8053e626 8bfc mov edi,esp
恰好五个字节,可以做个jmp。这时我们已经控制了调用的入口。
SSDT和ShadowSSDT
前面提到KiFastCallEntry用于实现系统服务分发,nt导出一个全局变量(64位的不再导出)KeServiceDescriptorTable,它类似如下结构:
#pragma pack(1)
typedef struct ServiceDescriptorEntry {
unsigned int *ServiceTableBase;
unsigned int *ServiceCounterTableBase;
unsigned int NumberOfServices;
unsigned char *ParamTableBae;
} SSDT_ENTRY;
#pragma pack()
__declspec(dllimport) SSDT_ENTRY KeServiceDescriptorTable;
Windbg可以看具体的内容:
kd> x nt!KeSer*
80553f60 nt!KeServiceDescriptorTableShadow = <no type information>
80553fa0 nt!KeServiceDescriptorTable = <no type information>
kd> dd 80553fa0 l4
80553fa0 80502b8c 00000000 0000011c 80503000
kd> dds 80502b8c l5
80502b8c 8059a948 nt!NtAcceptConnectPort
80502b90 805e7db6 nt!NtAccessCheck
80502b94 805eb5fc nt!NtAccessCheckAndAuditAlarm
80502b98 805e7de8 nt!NtAccessCheckByType
80502b9c 805eb636 nt!NtAccessCheckByTypeAndAuditAlarm
这正是我们需要重点关注的内容,大量的文件、注册表、进程、线程等操作都会调用SSDT中的函数。内核线程结构KTHREAD的ServiceTable成员指向KeServiceDescriptorTable。另一个与SSDT类似的结构是ShadowSSDT。
Windows的图形操作由子系统实现,内核部分表现为win32.sys,实现窗口管理和GDI调用,它导出的Shadow SSDT表向用户层提供系统服务,只有GUI线程才用得到,而GUI线程的ServiceTable指向KeServiceDescriptorTableShadow,所以要HOOK这个结构并不像SSDT那么直接,因为调用DriverEntry的system进程并不会加载win32k.sys,一般都通过搜索内存查找它的地址。比较成熟的方式是在KeAddSystemServiceTable附近查找:
nt!KeAddSystemServiceTable+0x1a:
8059779e 8d88603f5580 lea ecx,nt!KeServiceDescriptorTableShadow (80553f60)[eax]
805977a4 833900 cmp dword ptr [ecx],0
805977a7 7546 jne nt!KeAddSystemServiceTable+0x6b (805977ef)
SSDT是不可写的,可以通过修改CR0的标记,HOOK之后再改回来;也可以通过MDL映射一份可写区域。具体代码如下:
NTSTATUS MAKEMyMDL() {
MyMDL = MmCreateMdl(NULL,
KeServiceDescriptorTable.ServiceTableBase,
KeServiceDescriptorTable.NumberOfServices*4
);
if (!MyMDL) return STATUS_UNSUCCESSFUL;
MmBuildMdlForNonPagedPool(MyMDL);
MyMDL->MdlFlags |= MDL_MAPPED_TO_SYSTEM_VA;
NewServiceDescriptorTable = MmMapLockedPages(MyMDL, KernelMode);
return STATUS_SUCCESS;
}
然后就可以将系统调用替换为我们的函数,可以在里头做一些捕获工作。主要用到几个宏:
#define SYSTEMSERVICE(func) \
KeServiceDescriptorTable.ServiceTableBase[ *(PULONG)((PUCHAR)func + 1)]
#define SYSTEMINDEX(func) \
*(PULONG)((PUCHAR)func + 1)
#define HOOKFUNC(func, new, old) \
old = (PVOID)InterlockedExchange( (PULONG) \
&NewServiceDescriptorTable[SYSTEMINDEX(func)], (ULONG)new)
#define UNHOOKFUNC(func, old) \
InterlockedExchange( (PULONG) \
&NewServiceDescriptorTable[SYSTEMINDEX(func)], (ULONG)old)
重载内核
为什么要重载内核呢?因为有可能系统内核已经被病毒或者各种杀软搞坏了。首先根据系统版本,将ntoskrnl.exe或ntkrnlpa.exe文件加载到内存,这里需要对PE结构比较了解,因为要实现重定位,并且修复导入表。这时我们有了一个干净的内核,就可以在自己的内核上做SSDT和ShadowSSDT Hook了。KiFastCallEntry的hook也会重定位到我们这里。
重定位过程可以参考windows加载dll时做的工作:
NTSTATUS FixReloc(PVOID lpBase, PVOID OrigBase)
{
ULONG count, i;
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)lpBase;
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)(pDosHeader->e_lfanew + (PUCHAR)lpBase);
ULONG RelocOffset = (ULONG)OrigBase - pNtHeaders->OptionalHeader.ImageBase;
PULONG newAddr;
IMAGE_DATA_DIRECTORY ImageDataDirectory = pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
PIMAGE_BASE_RELOCATION pImageBaseRelocation = (PIMAGE_BASE_RELOCATION)(ImageDataDirectory.VirtualAddress + (PUCHAR)lpBase);
if (!pImageBaseRelocation)
{
DbgPrint("FixReloc: pImageBaseRelocation error!\n");
return STATUS_UNSUCCESSFUL;
}
while (pImageBaseRelocation->SizeOfBlock)
{
count = (pImageBaseRelocation->SizeOfBlock - 8)/2;
for (i=0; i<count; i++)
{
if ((pImageBaseRelocation->TypeOffset[i] >> 12) == 3)
{
newAddr =(PULONG)((pImageBaseRelocation->TypeOffset[i] & 0xFFF) + pImageBaseRelocation->VirtualAddress + (ULONG)lpBase);
*newAddr = *newAddr + RelocOffset;
}
}
pImageBaseRelocation = (PIMAGE_BASE_RELOCATION)((ULONG)pImageBaseRelocation + pImageBaseRelocation->SizeOfBlock);
}
return STATUS_SUCCESS;
}
IDT HOOK
DOS时代学好中断就可以声称掌握系统了,但是现在的windows中,中断作用虽然不再那么明显,但还是值得把关的地方。IDT是中断描述符表的简称,当中断发生时,通过中断号引用中断描述符表的项,从而执行特定中断服务例程(ISR)。IDTR寄存器保存了IDT有关的信息,SIDT指令可以读取到下面的一个结构:
typedef struct _IDTINFO
{
WORD IDTLimit;
WORD LowIDTBase;
WORD HiIDTBase;
} IDTINFO;
记录了IDT的基址和大小。IDT中的每一项是如下的64位结构:
#pragma pack(1)
typedef struct _IDTENTRY
{
WORD LowOffset;
WORD selector;
BYTE unused_lo;
unsigned char unused_hi:5;
unsigned char DPL:2;
unsigned char P:1;
WORD HiOffset;
} IDTENTRY;
#pragma pack()
这里的selector指向GDT(全局描述符表)中的一个描述符。每个描述符都用来记录一个段的信息。GDT记录了所有描述符,包括段的基地址和大小,权限信息等。GDTR寄存器保存了GDT的基址,可以通过SGDT获取。
kd> !pcr
KPCR for Processor 0 at ffdff000:
Major 1 Minor 1
NtTib.ExceptionList: b286b664
InterruptMode: 00000000
IDT: 8003f400
GDT: 8003f000
TSS: 80042000
kd> dqs 8003f400 l5
8003f400 80538e00`0008f19c
8003f408 80538e00`0008f314
8003f410 00008500`0058113e
8003f418 8053ee00`0008f6e4
8003f420 8053ee00`0008f864
!pcr指令可以查看IDT和GDT,找一下int 3中断的处理函数:段选择子(GDT的索引)为8,说明是内核模式段,基址为0(这里不懂的可以看一下分页模式和分段模式)。ISR就是8053f6e4。如果我们修改这里,就可以在一定程度上控制调试过程。
IDT HOOK通过修改中断服务例程控制中断发生时执行的函数。还可以在IDT表中找一个空白项,实现我们自己的中断处理例程。有一个很好的模板用于实现IDT的inline hook。
IDTINFO IdtInfo;
IDTENTRY *IdtEntries;
DWORD old[256];
DWORD count[256];
BYTE* tables;
#define MIN_IDT 0
#define MAX_IDT 0xFF
char template[] = {
0x90, //nop, debug
0x60, //pushad
0x9C, //pushfd
0xB8, 0xAA, 0x00, 0x00, 0x00, //mov eax, AAh
0x50, //push eax
0x9A, 0x11, 0x22, 0x33, 0x44, 0x08, 0x00, //call 08:44332211h
0x58, //pop eax
0x9D, //popfd
0x61, //popad
0xEA, 0x11, 0x22, 0x33, 0x44, 0x08, 0x00 //jmp 08:44332211h
};
void __stdcall NewISR(DWORD nouse)
{
unsigned long *index;
unsigned long i;
__asm mov eax,[ebp+0Ch]
__asm mov i, eax
i = i & 0xFF;
index = &count[i];
InterlockedIncrement(index);
}
void HookIDT()
{
int i, offset = 0;
char* entry;
for (i = MIN_IDT; i < MAX_IDT; i++)
{
old[i] = MAKELONG(IdtEntries[i].LowOffset, IdtEntries[i].HiOffset);
entry = tables + offset;
memcpy(entry, template, sizeof(template));
entry[4] = (BYTE)i;
*((DWORD*)(&entry[10])) = (DWORD)NewISR;
*((DWORD*)(&entry[20])) = (DWORD)old[i];
__asm cli
IdtEntries[i].LowOffset = (WORD)entry;
IdtEntries[i].HiOffset = (WORD)((DWORD)entry >> 16);
__asm sti
offset += sizeof(template);
}
}
注册官方回调函数
监控进程创建、模块加载,一方面可以HOOK相关的函数,另一方面可以利用windows提供的回调机制,注册回掉函数。实际上在64不能修改内核时,商业软件的做法都是通过注册回调来实现原来的功能。
主要是三个函数:
PsSetCreateProcessNotifyRoutine
PsSetCreateThreadNotifyRoutine
PsSetLoadImageNotifyRoutine
具体的函数类型可以查看MSDN。
VT技术
这里的VT专指Intel的硬件虚拟化技术(Hardware Enabled Virtualization, HEV)。VT技术主要是为了解决虚拟机效率问题而开发的技术,在硬件层面支持虚拟化。简单来说,利用intel的VT技术,可以让自己处于一个全新的状态,人们一般戏称ring -1层;在这一层之上可以有多个操作系统,操作系统,各个操作系统都可以互不影响的进行IO访问、系统调用、产生中断等,但是与单个操作系统不同的时这些行为都会被ring-1捕获。VT可以一劳永逸解决各种HOOK可以解决的问题。VT实际上让CPU处于一个被监控状态。
具体化一下之前提到的概念:VMM(Virtual Machine Monitor)即虚拟机监控器,会捕获虚拟机的操作,比如执行某些特权指令,然后模拟执行后返回给虚拟机。使用VT后CPU进入全新的VMX模式,该模式下CPU会有两种状态:VMX root和VMX non-root状态。VMX non-root状态所有指令都可以执行,省去软件模拟的过程,但是某些特权指令还是会被捕获,触发一次#VMExit事件,进入VMX root模式。
为了实现这些功能,intel增加了一些VMX指令:
VMXON
VMXOFF
VMLAUNCH
VMCALL
VMRESUME
VMPTRLD
VMPTRST
VMCLEAR
VMREAD
VMWRITE
INVEPT
INVVPID
VMXON指令使得CPU进入VMX模式,然后通过VMPTRLD、VMPTRST、VMCLEAR、VMREAD、VMWRITE这些指令初始化一个VMCS,保存状态切换所需的信息,主要有CR*寄存去、段寄存器、通用寄存器 、MSR寄存器、段描述符相关的寄存器(LDTR、GDTR)、EFLAG寄存器等,前面提到过KiFastCallEntry地址保存在IA32_SYSENTER_EIP寄存器中,如果我们可以控制这个寄存器的值,就没必要去hook了。初始化完成之后执行VMLAUNCH指令,进入虚拟机的执行过程。运行期间只要捕获#VMExit事件即可。捕获之后可以通过查看VMCS区域,可以确认是执行什么指令导致的#VMExit。
可以看到VT技术是非常强大的,如果是自己来用,可以完全发挥它的作用,轻松监控和调试所有程序。不能商业化的原因主要还是考虑到一些老旧的CPU不支持。不过迟早VT技术会用到各个领域。
构建好我们的内核HOOK框架之后,就可以编写程序实现具体的功能了。监控软件行为和调试最新漏洞都不是问题。