前面的学习过程中,我们了解到程序进入0环后,有一个保存现场的过程,会将3环的各种寄存器都保存到一个叫做_Trap_Frame的结构体中。在3环部分,程序将一个编号存到了eax中,这个编号叫做系统服务号,此外,在保存现场的过程中,程序还让edx指向了3环第一个参数的地址。回忆起这两步,接下来,就可以继续探究执行内核函数的过程了。
系统服务表
在分析代码前,我们先来学习一个结构,系统服务表(System Service Table)
在Windows XP系统下,系统服务表有两张,这两张表存着内核文件的导出函数(不包括内核文件的所有函数,主要是3环函数常用的内核函数)。第一张表导出的内核函数主要来源于ntoskrl.exe,实现大部分3环函数基本功能;第二张表导出的内核函数主要来源于win32k.sys,主要实现图形界面相关功能(例如GDI32.dll的底层实现)。
系统服务表结构
根据示例图,我们先简单认识一下系统服务表,先从结构看起:
- ServiceTable:指向一个函数地址表,通过系统服务号可以在函数地址表中找到指定的内核函数。
- Count:指当前系统服务表被调用的次数。
- ServiceLimit:函数地址表的大小,即系统服务函数的个数
- ArgmentTable:系统服务函数参数的大小,以字节为单位,每个成员大小为1个字节。
系统服务表位置
这个系统服务表位于KTHREAD结构的0xE0偏移处。这样,在进入0环后我们可以通过fs:[0]找到KPCR结构,然后在KPCR->0x124找到当前线程的KTHREAD结构,再根据KTHREAD->0xE0就可以找到当前线程所拥有的系统服务表。
1
| fs:[0] -> KPCR -> KPCR+0x124 -> KTHREAD -> KTHREAD+0xE0 -> 系统服务表
|
系统服务号
系统服务号用来定位所要寻找的系统服务表的函数。
系统服务号只有低13位是有用的
- 下标12:判断去查服务表,0去查第一张表;1去查第二张表
- 下标0~11:函数地址表的索引
SharedCode分析
在前面保存现场的代码分析中,由于进入0环的方式不同,中断进0环(int 2E)和快速调用(sysenter)保存现场的方式也不一样,但是当这两种方式,将寄存器保存到_Trap_Frame结构中以后(保存现场),便会从同一个地放(KiFastCallEntry+0x8D)开始执行,我们把这一部分共同的代码称作SharedCode(引用Joney的文中的称呼),接下来,我们简要分析一下SharedCode到底做了什么事。
这里还是将代码放在一起分析,更有连贯性,具体细节可以到Windbg中动手实现
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
| 804df781 8bf8 mov edi,eax //eax保存的是系统服务号(参考3环部分),edi获 804df781 //取服务号 804df783 c1ef08 shr edi,8 //将edi右移8位,系统服务号只用到13位,这样还 804df783 //剩下5位 804df786 83e730 and edi,30h //将余下的5位与0x30(0011 0000)进行与 804df786 //算,得到的结果只有可能是0x10或者0,这里非常巧 804df786 //妙,因为0x10刚好是16个字节 804df789 8bcf mov ecx,edi //将与后的结果,赋值给ecx 804df78b 03bee0000000 add edi,dword ptr [esi+0E0h] //esi指向KTHREAD,[esi+0xE]就算获取到第一个系 804df78b //统服务表的首地址,然后加上edi。这里如果edi的 804df78b //值是0x10,运算后,edi就会指向第二个系统服务 804df78b //表的首地址,所以上一步的与运算很巧妙 804df791 8bd8 mov ebx,eax //ebx获取服务号 804df793 25ff0f0000 and eax,0FFFh //对服务号进行与运算,因为上面的步骤已经确定了 804df793 //从哪一个系统服务表里找,这里将下标12的位置零 804df793 //,获取在函数地址表里的索引 804df798 3b4708 cmp eax,dword ptr [edi+8] //[edi+8]指向ServiceLimit,这里与系统服务号作 804df798 //比较,防止系统符服务号越界 804df79b 0f8341fdffff jae nt!KiBBTUnexpectedRange (804df4e2) //若越界则跳转异常处理 804df7a1 83f910 cmp ecx,10h //这里判断系统服务号属于哪个表(虽然前面的步骤判 804df7a1 //断过一次了,前面是为了判断服务号是否越界,这 804df7a1 //里主要是找到合适的处理函数) 804df7a4 751a jne nt!KiFastCallEntry+0xcc (804df7c0) //若不等,就跳转到处理查找第一个系统服 804df7a4 //务表的例程 804df7a6 8b0d18f0dfff mov ecx,dword ptr ds:[0FFDFF018h] //若要去查找第二个系统服务表,从这里走,不作详细 804df7a6 //分析 804df7ac 33db xor ebx,ebx 804df7ae 0b99700f0000 or ebx,dword ptr [ecx+0F70h] 804df7b4 740a je nt!KiFastCallEntry+0xcc (804df7c0) 804df7b6 52 push edx 804df7b7 50 push eax 804df7b8 ff1564b25580 call dword ptr [nt!KeGdiFlushUserBatch (8055b264)] 804df7be 58 pop eax 804df7bf 5a pop edx 804df7c0 ff0538f6dfff inc dword ptr ds:[0FFDFF638h] //如果查第一个表,会跳到这里来 [0xffdff638] 804df7c0 //KPRCB结构的0x518偏移处,存的是KeSystemCalls 804df7c0 //,这里自增1(具体用处到APC那块会讲到) 804df7c6 8bf2 mov esi,edx //edx存着3环第一个参数的地址,赋给esi 804df7c8 8b5f0c mov ebx,dword ptr [edi+0Ch] //ebx获取函数参数表地址 804df7cb 33c9 xor ecx,ecx //清空ecx,之前用来判断寻找哪个系统服务表 804df7cd 8a0c18 mov cl,byte ptr [eax+ebx] //cl获取函数参数个数 804df7d0 8b3f mov edi,dword ptr [edi] //另edi直接指向函数地址表首地址 804df7d2 8b1c87 mov ebx,dword ptr [edi+eax*4] //ebx获取到0环实现的内核函数 804df7d5 2be1 sub esp,ecx //提升堆栈,ecx里存的是参数个数的总字节 804df7d7 c1e902 shr ecx,2 //相当于运算ecx/4,方便rep movsd,因为rep 804df7d7 //movsd是4字节运算 804df7da 8bfc mov edi,esp //rep movsd指令用,Copy的目的地 804df7dc 3b35d40b5680 cmp esi,dword ptr [nt!MmUserProbeAddress (80560bd4)]//检测三环参数地址范围是否越界 804df7e2 0f83a8010000 jae nt!KiSystemCallExit2+0x9f (804df990) //若越界,进行异常处理 804df7e8 f3a5 rep movs dword ptr es:[edi],dword ptr [esi] //将参数复制到堆栈 804df7ea ffd3 call ebx //调用0环函数! 804df7ec 8be5 mov esp,ebp 804df7ee 8b0d24f1dfff mov ecx,dword ptr ds:[0FFDFF124h] 804df7f4 8b553c mov edx,dword ptr [ebp+3Ch] 804df7f7 899134010000 mov dword ptr [ecx+134h],edx
|
至此,这块KiFastCallEntry和KiSystemService最终都会执行的一段被我们称作SharedCode的代码段,就分析完了,
SSDT
前文提到了,我们可以通过fs找到KPCR,在通过KPCR找到KTHREAD,然后在KTHREAD+0xE0处找到系统服务表,这里再介绍另一种找到系统服务表的办法,通过SSDT。
SSDT&SSDT Shadow
SSDT(System Services Descriptor Table)系统服务描述符表,在这个结构中包含4个成员,每个成员都是一个系统服务表的结构体,可以在Windbg中通过dd KeServiceDescriptorTable指令进行查看(在程序中可以直接声明全局变量KeServiceDescriptorTable,从而找到找到系统服务表。):
我们可以看到第一个成员的ServiceTable,Count,ServiceLimit,ArgmentTable字段,Windows Xp只使用了2张表,所以第三个和第四个成员的位置是空的,此外,由于SSDT第二个成员是未导出的,所以第二个成员的位置也是空的。这里介绍一个新的指令,dd KeServiceDescriptorTableShadow,通过全局变量KeServiceDescriptorTableShadow可以查看两张完整的系统服务表。
但是,全局变量KeServiceDescriptorTableShadow也是未导出的,在实际写程序时,不能通过直接访问win32k.sys导出的第二张系统服务表的函数地址,因为里面的函数地址都是无效的。原因是,win32k.sys导出的第二张系统服务表只有在当前进程访问GDI相关的API时,里面的函数地址表才会挂载到物理页上。如果进程没有用到GDI相关的API,那么第二张系统服务表里面的函数地址表就不会挂载到物理内存,那么里面的函数也无效。
内核函数查找
有了SSDT表,我们查找3环API对应的内核函数就很简单了,拿之前分析过的3环API函数ReadProcessMemory举例,在进入0环之前,给eax赋值了一个系统服务号0xba,那我们就用这个ba来查看这个这个函数在内核的实现。
通过这张图,可以很清晰的看出来,ReadProcessMemory所实现的功能,在底层是由一个叫做NtReadVirtualMemory完成的。
总结
API函数的调用过程,从3环进入0环,再到找到对应的内核函数,这部分到这就差不多了,当然,真正的调用过程并没有到此结束,因为调用完0环的函数,总得返回3环呀!只是这部分需要用到APC的知识点,因此这里还不能完整实现。此外,在API函数调用这块,还有个小实验,在SSDT表中追加一个函数地址(NtReadVirtualMemory),自己编写API的3环部分调用这个新增的函数(注意:使用2-9-9-12分页,10-10-12会蓝屏),就留到后面补上了
参考教程:https://www.bilibili.com/video/BV1NJ411M7aE?p=41
参考文章:
- https://www.cnblogs.com/joneyyana/p/12585469.html
- https://blog.csdn.net/qq_38474570/article/details/103674271
- https://blog.csdn.net/qq_41988448/article/details/102994374