深入探索Windows用户/内核异常分发器机制
引言
这篇短文旨在创建一段能够监控进程中引发异常的代码(类似于gynvael的ExcpHook,但在用户态),并生成包含异常相关信息的报告。另一个目的当然是深入了解内部机制。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
--Exception detected--
ExceptionRecord: 0x0028fa2c Context: 0x0028fa7c
Image Path: D:\Codes\The Sentinel\tests\divzero.exe
Command Line: ..\tests\divzero.exe divzero.exe
PID: 0x00000aac
Exception Code: 0xc0000094 (EXCEPTION_INT_DIVIDE_BY_ZERO)
Exception Address: 0x00401359
EAX: 0x0000000a EDX: 0x00000000 ECX: 0x00000001 EBX: 0x7ffde000
ESI: 0x00000000 EDI: 0x00000000 ESP: 0x0028fee0 EBP: 0x0028ff18
EIP: 0x00401359
EFLAGS: 0x00010246
Stack:
0x767bc265 0x54f3620f 0xfffffffe 0x767a0f5a
0x767ffc59 0x004018b0 0x0028ff90 0x00000000
Disassembly:
00401359 (04) f77c241c IDIV DWORD [ESP+0x1c]
0040135d (04) 89442404 MOV [ESP+0x4], EAX
00401361 (07) c7042424304000 MOV DWORD [ESP], 0x403024
00401368 (05) e833080000 CALL 0x401ba0
0040136d (05) b800000000 MOV EAX, 0x0
|
因此,我将本文分为两个主要部分:
- 第一部分将讨论理解底层工作原理所需的Windows内部背景知识,
- 最后一部分将讨论Detours以及如何挂钩ntdll!KiUserExceptionDispatcher以实现我们的目的。基本上,该库为程序员提供了一组API来轻松挂钩过程。它还有清晰易读的文档,所以你应该使用它!它通常用于以下情况:
- 热修补错误(无需重启),
- 跟踪API调用(类似API Monitor),
- 监控(有点像我们的示例),
- 伪沙盒(阻止API调用),
- 等等。
目录
- 引言
- 聚焦ntdll!KiUserExceptionDispatcher
- nt!KiTrap*
- 序列Detourer
- 未讲述的故事:Win8和nt!KiFastFailDispatch
- 结论
聚焦ntdll!KiUserExceptionDispatcher
本部分的目的是确保理解异常如何返回用户态,以便由SEH/UEF机制处理(或不处理);尽管我将专注于Windows 7 x86,因为那是我在VM中运行的操作系统。本部分的另一个目标是给你一个大致的了解,我的意思是我们不会深入太多细节,只够后来编写一个工作的异常哨兵PoC。
nt!KiTrap*
当你的用户态应用程序做错了什么时,CPU会引发异常:假设你试图除以零(nt!KiTrap00将处理这种情况),或者你试图获取一个不存在的内存页(nt!KiTrap0E)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
kd> !idt -a
Dumping IDT: 80b95400
00: 8464d200 nt!KiTrap00
01: 8464d390 nt!KiTrap01
02: Task Selector = 0x0058
03: 8464d800 nt!KiTrap03
04: 8464d988 nt!KiTrap04
05: 8464dae8 nt!KiTrap05
06: 8464dc5c nt!KiTrap06
07: 8464e258 nt!KiTrap07
08: Task Selector = 0x0050
09: 8464e6b8 nt!KiTrap09
0a: 8464e7dc nt!KiTrap0A
0b: 8464e91c nt!KiTrap0B
0c: 8464eb7c nt!KiTrap0C
0d: 8464ee6c nt!KiTrap0D
0e: 8464f51c nt!KiTrap0E
0f: 8464f8d0 nt!KiTrap0F
10: 8464f9f4 nt!KiTrap10
11: 8464fb34 nt!KiTrap11
[...]
|
我相信你已经知道,但在x86 Intel处理器中,有一个称为IDT的表,存储了处理异常的不同例程。该表的虚拟地址存储在一个特殊的x86寄存器IDTR中,该寄存器只能通过使用指令sidt(存储中断描述符表寄存器)和lidt(加载中断描述符表寄存器)来访问。
基本上,IDT条目中有两个重要的事情:ISR的地址和CPU应使用的段选择器(记住它只是GDT中的一个简单索引)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
kd> !pcr
KPCR for Processor 0 at 84732c00:
[...]
IDT: 80b95400
GDT: 80b95000
kd> dt nt!_KIDTENTRY 80b95400
+0x000 Offset : 0xd200
+0x002 Selector : 8
+0x004 Access : 0x8e00
+0x006 ExtendedOffset : 0x8464
kd> ln (0x8464 << 10) + (0xd200)
Exact matches:
nt!KiTrap00 (<no parameter info>)
kd> !@display_gdt 80b95000
#################################
# Global Descriptor Table (GDT) #
#################################
Processor 00
Base : 80B95000 Limit : 03FF
Off. Sel. Type Sel.:Base Limit Present DPL AVL Informations
---- ---- ------ --------- ------- ------- --- --- ------------
[...]
0008 0008 Code32 00000000 FFFFFFFF YES 0 0 Execute/Read, accessed (Ring 0)CS=0008
[...]
|
上面的条目告诉我们,对于处理器0,如果引发除零异常,内核模式例程nt!KiTrap00将被调用,使用平面模型code32 ring0段(参见GDT转储)。
一旦CPU进入nt!KiTrap00的代码,它基本上做了很多事情,所有其他nt!KiTrap例程也是如此,但不知何故,它们(或多或少)最终进入内核模式异常分发器:nt!KiDispatchException(记得gynvael的工具吗?他正在挂钩那个方法!)一旦它们创建了与故障关联的nt!_KTRAP_FRAME结构。
现在,你可能已经问过自己,内核如何回到用户态以便通过SEH机制处理异常?
这其实很简单。Windows内核使用的技巧是检查异常发生的位置:如果来自用户模式,内核模式异常分发器将设置陷阱帧结构(传入参数)的eip字段为符号nt!KeUserExceptionDispatcher。然后,nt!KeEloiHelper将使用相同的陷阱帧恢复执行(在我们的例子中是nt!KeUserExceptionDispatcher)。
但猜猜怎么着?该符号保存了ntdll!KiUserExceptionDispatcher的地址,所以这完全合理!
1
2
|
kd> dps nt!KeUserExceptionDispatcher L1
847a49a0 77476448 ntdll!KiUserExceptionDispatcher
|
如果像我一样你喜欢插图,我已经制作了一个WinDbg会话,我将展示我们刚刚讨论的内容。首先,让我们触发我们的除零异常:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
kd> bp nt!KiTrap00
kd> g
Breakpoint 0 hit
nt!KiTrap00:
8464c200 6a00 push 0
kd> k
ChildEBP RetAddr
8ec9bd98 01141269 nt!KiTrap00
8ec9bd9c 00000000 divzero+0x1269
kd> u divzero+0x1269 l1
divzero+0x1269:
01141269 f7f0 div eax,eax
|
现在让我们进一步进入ISR,更精确地说,当nt!_KTRAP_FRAME被构建时:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
kd> bp nt!KiTrap00+0x36
kd> g
Breakpoint 1 hit
nt!KiTrap00+0x36:
8464c236 8bec mov ebp,esp
kd> dt nt!_KTRAP_FRAME @esp
+0x000 DbgEbp : 0x1141267
+0x004 DbgEip : 0x1141267
+0x008 DbgArgMark : 0
+0x00c DbgArgPointer : 0
+0x010 TempSegCs : 0
+0x012 Logging : 0 ''
+0x013 Reserved : 0 ''
+0x014 TempEsp : 0
+0x018 Dr0 : 0
+0x01c Dr1 : 0
+0x020 Dr2 : 0
+0x024 Dr3 : 0x23
+0x028 Dr6 : 0x23
+0x02c Dr7 : 0x1141267
+0x030 SegGs : 0
+0x034 SegEs : 0x23
+0x038 SegDs : 0x23
+0x03c Edx : 0x1141267
+0x040 Ecx : 0
+0x044 Eax : 0
+0x048 PreviousPreviousMode : 0
+0x04c ExceptionList : 0xffffffff _EXCEPTION_REGISTRATION_RECORD
+0x050 SegFs : 0x270030
+0x054 Edi : 0
+0x058 Esi : 0
+0x05c Ebx : 0x7ffd3000
+0x060 Ebp : 0x27fd58
+0x064 ErrCode : 0
+0x068 Eip : 0x1141269
+0x06c SegCs : 0x1b
+0x070 EFlags : 0x10246
+0x074 HardwareEsp : 0x27fd50
+0x078 HardwareSegSs : 0x23
+0x07c V86Es : 0
+0x080 V86Ds : 0
+0x084 V86Fs : 0
+0x088 V86Gs : 0
kd> .trap @esp
ErrCode = 00000000
eax=00000000 ebx=7ffd3000 ecx=00000000 edx=01141267 esi=00000000 edi=00000000
eip=01141269 esp=0027fd50 ebp=0027fd58 iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=0030 gs=0000 efl=00010246
divzero+0x1269:
001b:01141269 f7f0 div eax,eax
kd> .trap
Resetting default scope
|
现在的想法是通过硬件断点跟踪nt!_KTRAP_FRAME.Eip字段的修改,正如我们之前讨论的(顺便说一句,不要尝试直接在VMware上对nt!KiDispatchException设置断点,它只会炸毁我的客户虚拟机):
1
2
3
4
5
6
7
8
9
10
11
12
13
|
kd> ba w4 esp+68
kd> g
Breakpoint 2 hit
nt!KiDispatchException+0x3d6:
846c559e c745fcfeffffff mov dword ptr [ebp-4],0FFFFFFFEh
kd> dt nt!_KTRAP_FRAME Eip @esi
+0x068 Eip : 0x77b36448
kd> ln 0x77b36448
Exact matches:
ntdll!KiUserExceptionDispatcher (<no parameter info>)
|
好的,所以这里我们可以清楚地看到陷阱帧已被修改(请记住WinDbg在实际写入后给你控制权)。这基本上意味着当内核通过nt!KiExceptionExit(或nt!Kei386EoiHelper,两个符号对应同一个地址)恢复执行时,CPU将直接执行用户模式异常分发器。
很好,我认为我们现在有足够的理解来继续文章的第二部分。
序列Detourer
在这一部分,我们将讨论Detours,API的样子,以及如何使用它来构建一个用户态异常哨兵,而不需要太多代码行。以下是我们想要的功能列表:
- 挂钩ntdll!KiUserExceptionDispatcher:我们将使用Detours来实现,
- 生成一个小的可读异常报告:对于反汇编部分,我们将使用Distorm(另一个易于使用的酷库),
- 专注于x86架构:因为不幸的是,express版本不适用于x86_64。
Detours将修改你想要挂钩的API的前几个字节,以便将其执行重定向到你的代码中:这称为内联挂钩。
Detours可以在两种模式下工作:
- 第一种模式是你不动你将要挂钩的二进制文件,你将需要一个DLL模块,你将注入到你的二进制文件的内存中。然后,Detours将在内存中修改你将挂钩的API的代码。这就是我们将要使用的。
- 第二种模式是你修改二进制文件本身,更精确地说是IAT。在这种模式下,你将不需要DLL注入器。如果你对这些技巧的细节感兴趣,它们在安装目录的Detours.chm文件中描述了,请阅读它!
所以我们的哨兵将分为两个主要部分:
- 一个程序,将启动目标二进制文件并注入我们的DLL模块(所有重要的事情都在那里),
- 哨兵DLL模块,将挂钩用户态异常分发器并写入异常报告。
第一个使用DetourCreateProcessWithDll非常容易实现:它将创建进程并注入我们想要的DLL。
用法:./ProcessSpawner <完整路径dll> <可执行文件路径> <可执行文件名> [参数..]
要成功挂钩一个函数,你当然必须知道它的地址,并且你必须实现挂钩函数。然后,你必须调用DetourTransactionBegin、DetourUpdateThread、DetourTransactionCommit,你就完成了,很棒不是吗?
在我们的情况下,唯一棘手的事情是我们想要挂钩ntdll!KiUserExceptionDispatcher,并且该函数有它自己的自定义调用约定。幸运的是,在Detours的示例目录中,你可以找到如何处理这种特定情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
VOID __declspec(naked) NTAPI KiUserExceptionDispatcher(PEXCEPTION_RECORD ExceptionRecord, PCONTEXT Context)
{
/* 取自Excep的detours示例 */
__asm
{
xor eax, eax ; // 在堆栈上创建假返回地址。
push eax ; // (通常,我们由内核调用。)
push ebp ; // 序言
mov ebp, esp ;
sub esp, __LOCAL_SIZE ;
}
EnterCriticalSection(&critical_section);
log_exception(ExceptionRecord, Context);
LeaveCriticalSection(&critical_section);
__asm
{
mov ebx, ExceptionRecord ;
mov ecx, Context ;
push ecx ;
push ebx ;
mov eax, [TrueKiUserExceptionDispatcher];
jmp eax ;
//
// 上面的代码应该永远不会返回。
//
int 3 ; // 中断!
mov esp, ebp ; // 尾声
pop ebp ;
ret ;
}
}
|
以下是挂钩后ntdll!KiUserExceptionDispatcher在内存中的样子:
使用distorm_decode反汇编由CONTEXT.Eip字段指向的一些指令也非常简单:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
if(IsBadReadPtr((const void*)Context->Eip, SIZE_BIGGEST_X86_INSTR * MAX_INSTRUCTIONS) == 0)
{
_DecodeResult res;
_OffsetType offset = Context->Eip;
_DecodedInst decodedInstructions[MAX_INSTRUCTIONS] = {0};
unsigned int decodedInstructionsCount = 0;
res = distorm_decode(
offset,
(const unsigned char*)Context->Eip,
MAX_INSTRUCTIONS * SIZE_BIGGEST_X86_INSTR,
Decode32Bits,
decodedInstructions,
MAX_INSTRUCTIONS,
&decodedInstructionsCount
);
if(res == DECRES_SUCCESS || res == DECRES_MEMORYERR)
{
fprintf(f, "\nDisassembly:\n");
for(unsigned int i = 0; i < decodedInstructionsCount; ++i)
{
fprintf(
f,
"%.8I64x (%.2d) %-24s %s%s%s\n",
decodedInstructions[i].offset,
decodedInstructions[i].size,
(char*)decodedInstructions[i].instructionHex.p,
(char*)decodedInstructions[i].mnemonic.p,
|