API函数的调用过程

3环部分

API函数即应用程序接口(Application Programming Interface),Windows API主要在C:\Windows\System32\目录下的所有DLL里。

images/download/attachments/1015846/image2022-9-17_16-12-10.png

该目录下有几个重要的DLL:

DLL名称

作用

kernel32.dll

最核心的功能模块,如管理内存、进程和线程

user32.dll

Windows用户界面相关API,如创建窗口、发送消息

gdi32.dll

图形设备接口,如画图、显示文本

ntdll.dll

大多数API通过该DLL进入内核(0环)

分析ReadProcessMemory函数

我们可以通过IDA来看一下ReadProcessMemory在DLL文件中的体现。找到kernel32.dll文件,用IDA打开,在Functions窗口使用Ctrl+F快捷键搜索函数:

images/download/attachments/1015846/image2022-9-18_13-26-27.png

双击函数名,在IDA的反汇编窗口我们可以看到该函数的反汇编指令:

images/download/attachments/1015846/image2022-9-18_14-12-7.png

我们可以看见它的主要作用就是调用导入表的NtReadVirtualMemory函数,我们可以在IDA的Imports窗口里找到该函数(Ctrl+F搜索),并且能知道它来自ntdll.dll模块:

images/download/attachments/1015846/image2022-9-18_14-58-59.png

我们接着使用IDA打开ntdll.dll来看一下该函数,它给了EAX一个值(我们可以理解为是一个编号,也可以称之为系统调用号),并且给了EDX一个内存地址,该内存地址就是一个函数,也就是进入0环的关键:

images/download/attachments/1015846/image2022-9-18_15-3-35.png

所以至此我们也可以得出结论,大部分Windows API的真正实现是在0环的,3环只是一个封装调用。

实现ReadProcessMemory函数

我们已经知道Windows API在3环的表现形式,所以我们可以自己不调用DLL的情况下写一个自己的ReadProcessMemory函数。

我们可以直接按照IDA将kernel32和ntdll的反汇编代码整合一下,如下代码,其中提升栈顶的SUB指令是因为在Windows API本身的调用中是有两次CALL指令的,第一次CALL指令会将栈顶提升0x4,并且存入下一行指令地址,所以在最后一次的CALL调用时,我们要保证hProcess在ESP+4的位置,而不是ESP的位置,在最后的汇编指令中我们也要手动恢复到原来的栈顶位置:

void MyReadProcessMemory(HANDLE hProcess, LPCVOID lpBaseAddress, LPVOID lpBuffer, SIZE_T nSize, SIZE_T *lpNumberOfBytesRead)
{
_asm
{
// ReadProcessMemory
lea eax, [ebp+0x14]
push eax
push [ebp+0x14]
push [ebp+0x10]
push [ebp+0xC]
push [ebp+0x8]
// NtReadVirtualMemory
sub esp, 0x4
mov eax, 0xBA
mov edx, 0x7FFE0300
call dword ptr [edx]
add esp, 0x18
}
}

在代码中去调用,成功执行,结果如下:

images/download/attachments/1015846/image2022-9-18_19-46-11.png

3环进0环部分

我们已经了解API在3环的体现,并且可以手动的去封装一个在3环的应用API。但我们并不知道3环进0环的具体细节,本章节就来一探究竟。

_KUSER_SHARED_DATA

在应用和内核层分别定义了一个_KUSER_SHARED_DATA结构体,用于应用和内核间的一些数据共享。它们使用的是固定的地址值映射,该结构区域在用户和内核层的地址分别为:0x7FFE0000、0xFFDF0000。从地址来看,它们不是一个线性地址,但本质上指向的都是同一个物理页,在用户层对该物理页有只读权限,但在内核层有读、写权限。

在上一章节中我们看到Windows API使用的地址为0x7FFE0300,它就属于_KUSER_SHARED_DATA结构体的一部分,我们可以在Windbg中查看该结构体找到0x300偏移的成员:

images/download/attachments/1015846/image2022-9-18_20-32-45.png

SystemCall

0x7FFE0300就是_KUSER_SHARED_DATA结构体的成员SystemCall,它的作用就是进入0环,而具体进入0环的方式则由CPU决定,当CPU支持SYSENTER、SYSEXIT指令则使用的是ntdll!KiFastSystemCall方式,否则则是ntdll!KiIntSystemCall方式

如果我们自己想要判断是什么方式,可以选择使用CPUID指令,当EAX=1时指向CPUID指令,处理器的特征信息就会存放在ECX和EDX寄存器中,其中EDX包含了一个SEP位(第11位),该位为1则表示CPU支持SYSENTER、SYSEXIT指令,为0则表示不支持

我们可以在OD里执行一下CPUID指令:

images/download/attachments/1015846/image2022-9-18_22-3-16.png

执行之后我们可以看下EDX的第11位,它确实为1,也就表示CPU支持SYSENTER、SYSEXIT指令

images/download/attachments/1015846/image2022-9-18_22-9-43.png

所以在当前CPU下,_KUSER_SHARED_DATA结构体的0x300偏移位存储的就是ntdll!KiFastSystemCall函数的地址。

进入0环的方式

在之前各种“门”的学习后,我们知道当进行权限切换时(3环进0环),CS、SS、ESP、EIP寄存器都会发生变化。有了这些前置的知识了解,我们来看一下KiIntSystemCall和KiFastSystemCall这两个进入0环的方式。

KiIntSystemCall

我们可以通过Windbg打开任意一个PE文件,然后找到KiIntSystemCall函数:

images/download/attachments/1015846/image2022-9-21_10-3-48.png

我们发现它的代码很简单,首先将当前参数的指针存储到EDX寄存器中(系统调用号在EAX寄存器中),然后使用中断门进入0环(所有的API使用中断门进内核时,使用的中断号(IDT表中索引值)都是0x2E)。

调用过程分析

我们来手动分析一下INT 0x2E进0环的过程,首先找到IDT表,然后根据0x2E索引找到对应的门描述符位置:

images/download/attachments/1015846/image2022-10-9_10-45-25.png

该门描述符的高32位的第8位为0,也就表示该描述符为 中断门描述符。通过中断门描述符我们知道了CS(低32位的第16至31位,也就是段选择子)和EIP寄存器(高32位的第16至31位加上低32位的第0至15位,也就是段内偏移),SS和ESP寄存器也就是基于TSS获取的。

我们跟进EIP,就会发现它是一个内核模块的函数KiSystemService,至此我们就大致了解了中断门进0环的简单流程:

images/download/attachments/1015846/image2022-10-9_11-28-35.png

KiFastSystemCall

如下是KiFastSystemCall函数,我们可以看到它跟KiIntSystemCall没睡眠区别,只是它进入0环的方式不是基于中断门,而是使用了SYSENTER指令:

images/download/attachments/1015846/image2022-9-29_15-33-45.png

我们都知道中断门进0环需要提供新的CS、SS、ESP、EIP,CS和EIP在IDT表中,而SS和ESP都在TSS中,这就需要去查内存了,速度相对来说会很慢。所以在这里CPU提供了SYSENTER指令,在执行该指令前操作系统会提前将CS、SS、ESP、EIP的值存储在MSR寄存器中,该指令执行时CPU会将MSR寄存器中的值直接写入到相关寄存器,就减去了读取内存的过程,提升了调用的速度,因此我们可以称之为快速调用

调用过程分析

在MSR寄存器中有三个值即CS段选择子、ESP和EIP寄存器,当我们找到CS选择子时候,将其地址加0x8即可获得SS选择子。当然这些操作都是硬件去完成的,具体的细节可以参考Intel白皮书第二卷中SYSENTER指令的内容。

MSR

地址

IA32_SYSENTER_CS

0x174

IA32_SYSENTER_ESP

0x175

IA32_SYSENTER_EIP

0x176

在操作系统上,我们可以通过RDMSR/WRMST来对MSR寄存器进行读写:

images/download/thumbnails/1015846/image2022-10-9_11-47-15.png

并且我们可以跟进EIP,你就会发现SYSENTER指令进入0环执行的就是内核模块的KiFastCallEntry函数:

images/download/attachments/1015846/image2022-10-9_11-48-15.png

总结

API通过中断门进入0环有一个固定的中断号即0x2E,CS、EIP由中断门描述符提供,ESP、SS由TSS提供,进入0环之后执行的内核函数是nt!KiSystemService。

API通过SYSENTER指令进入0环,CS、ESP、EIP由MSR寄存器提供,SS由CS的值加0x8计算得出,进入0环之后执行的内核函数是nt!KiFastCallEntry。

内核函数所在模块为:ntoskrnl.exe(10-10-12分页模式下使用)、ntkrnlpa.exe(2-9-9-12分页模式下使用)。

保存现场

API进入0环之前是需要将在3环时的寄存器值保存,这样在0环执行切换到新的寄存器执行完成后回到3环时才能恢复原先的寄存器还原现场,那么3环寄存器的值是保存在哪里的,就是本章我们需要学习的保存现场。

结构体

在正式的学习之前我们需要先了解这三个结构体:_Trap_Frame,_ETHREAD,_KPCR。

_Trap_Frame

在Windbg中我们可以使用如下命令查看_Trap_Frame结构体:

dt _KTrap_Frame

结构体中的成员对应的作用见下图中的文字注释,该结构体就是保存现场所需要用到的结构体

images/download/attachments/1015846/image2022-10-9_22-54-55.png

_ETHREAD

与线程相关的结构体就是_ETHREAD,在Windows内核中每一个进程的每一个线程都有着一个_ETHREAD结构体,它存储着线程相关的信息,我们可以使用如下命令在Windbg中查看该结构体:

dt _ETHREAD

images/download/attachments/1015846/image2022-10-10_14-17-41.png

它的第一个成员也是一个结构体_KTHREAD,我们同样可以在Windbg中查看该结构体:

images/download/attachments/1015846/image2022-10-10_14-19-14.png

_KPCR

每个CPU都有自己的控制区,这块控制区我们称之为KPCR。你的CPU有几核就有几个对应的KPCR。

我们可以通过如下指令在Windbg中查看KPCR相关的信息:

dt _KPCR // 查看KPCR结构体
dd KeNumberProcessors // 查看KPCR数量
dd KiProcessorBlock // 查看KPCR位置

images/download/attachments/1015846/image2022-10-10_14-28-11.png

通过KiProcessorBlock我们查看到的KPCR位置实际上是指向KPCR结构体0x120的偏移位,所以如果我们想完整的去查看这个结构体就需要在这个地址上减去0x120:

images/download/attachments/1015846/image2022-10-10_14-48-34.png

它的0x120偏移位指向的是另外一个结构体_KPRCB,我们可以理解为它是一个扩展的KPCR。

KiSystemService保存现场

在了解完基本的结构体之后,我们可以手动来分析一下在0环函数KiSystemService,看一下它到底是如何保存现场的。该函数保存现场的过程实际上就是填充Trap_Frame结构体的过程

我们可以通过IDA打开C:/Windows/System32/ntoskrnl.exe文件,在函数列表中找到_KiSystemService函数:

images/download/attachments/1015846/image2022-10-10_15-0-19.png

我们知道在中断门进入0环时会先压入SS、ESP、EFlags、CS、EIP,也就是将这些值填充到Trap_Frame结构体,所以在_KiSystemService函数执行第一行指令之前是在Trap_Frame结构体的0x68偏移位,但是某些中断情况下如缺页异常就会除了这5个值以外压入第6个值,即Trap_Frame结构体中的ErrCode,所以为了使得堆栈平衡,在_KiSystemService函数第一行指令就压入了一个0x0来对齐,这时候我们就处于0x64偏移位,接着就是依次压入EBP、EBX、ESI、EDI、FS。

images/download/attachments/1015846/image2022-10-11_10-23-44.png
继续读代码,先是向EBX存入一个0x30,接着以0x30为段选择子在GDT表中找到段描述符加载到FS段寄存器中。

images/download/attachments/1015846/image2022-10-11_10-50-7.png

我们可以手动来看一下加载的段描述符到底是谁,首先拆分段选择子取第3至第15位作为索引,接着按索引在GDT表中找到段描述符。

// 0x30 拆分
0000 0000 0011 0000
索引: 0110 -> 0x6
段描述符: ffc093df`f0000001

images/download/attachments/1015846/image2022-10-11_10-55-1.png

接着我们根据段描述符的Base,即0xffdff000,它指向的就是当前CPU的KPCR结构:

images/download/attachments/1015846/image2022-10-11_11-1-7.png

然后就是压入FS段的0x0偏移位的内容,也就是指向的KPCR结构0x0偏移位成员_NT_TIB->ExceptionList,接着再将当前的ExceptionList设为-1,即清空。

images/download/attachments/1015846/image2022-10-11_11-26-49.png

images/download/attachments/1015846/image2022-10-11_11-29-57.png

再继续向下看,将当前CPU执行的线程信息给到了ESI寄存器,即FS段的0x124偏移位的内容,也就是_KPRCB结构体的0x4偏移位成员CurrentThread;它是一个结构体_KTHREAD,接着压入该结构体的0x140偏移位成员,也就是先前模式PreviousMode

images/download/attachments/1015846/image2022-10-11_18-30-3.png

images/download/attachments/1015846/image2022-10-11_18-30-20.png

images/download/attachments/1015846/image2022-10-11_18-31-43.png

接着看下面的代码,首先提升堆栈至Trap_Frame结构体顶部,也就是指向了结构体的第一个成员,然后将3环原来CS的值即Trap_Frame+0x6C+arg_0(0x4)给到EBX,在将CS和1进行与运算判断调用0环函数的来源权限,也就是CPL,然后将与运算的结果给到结构体_KTHREAD的先前模式PreviousMode,这样做的意义是因为有些内核函数可以被3环、0环调用,但由于权限的不同所以执行的内容也不一样,因此需要通过先前模式来决定执行的内容。再接下来就是将栈底指向栈顶的位置,也就是都指向Trap_Frame结构体的第一个成员位置,再将_KTHREAD中的Trap_Frame结构体地址取出,临时存放至当前Trap_Frame结构体的0x3C偏移位,由于Trap_Frame的内容发生了变化,最后再把当前最新的Trap_Frame结构体地址存入_KTHREAD中的Trap_Frame结构体位置(即0x134偏移位)

images/download/attachments/1015846/image2022-10-11_18-45-20.png

如下代码就比较简单了,将3环的EBP、EIP、3环传参指针存入Trap_Frame结构体调试相关的成员当中,判断当前线程是否开启了调试状态,如果开启则进行跳转,跳转过去之后实际上也是填充Trap_Frame结构体中的调试相关的成员。

images/download/attachments/1015846/image2022-10-11_19-10-28.png

images/download/attachments/1015846/image2022-10-11_19-11-3.png

再接着就是跳到某个代码段,但是这个代码段是属于_KiFastCallEntry函数的,所以从本质上来说两个函数最终都是走向同一个地方,无非就是中断门调用会传入5个值需要保存而快速调用不会传入这5个值

images/download/attachments/1015846/image2022-10-11_19-15-55.png

至此,我们就了解了保存现场的过程。

系统服务表

我们知道3环API的本质就是调用0环的函数,那么操作系统是如何根据系统调用号找到要执行的内核函数的,这就需要我们来了解一下系统服务表。

SystemServiceTable

如下图是系统服务表的结构,它有四个成员分别是ServiceTable、Count、ServiceLimit、ArgmentTable。ServiceTable里存储的是一个指针,指向了一张函数地址表,表内存储的是函数地址,表内的每个成员大小为4字节;Count里存储的是当前系统服务表被调用的次数;ServiceLimit里存储这当前系统服务表内存储函数的个数;ArgmentTablee里存储的是一个指针,指向了函数参数表,表内存储的是函数参数的个数,它的单位是字节,表内的每个成员大小为1字节

在函数地址表内存储的并不是所有的内核函数,而是在3环经常使用的内核函数,函数地址表和函数参数表是对应关系,即函数地址表成员1的参数个数就是函数参数表成员1

在Windows XP系统中,系统服务表有两张,下图中绿色、黄色表即体现。它们的结构都是一样的,只是存储的函数、参数不一样,前者存储的是常用的内核函数(Ntoskrl.exe),后者存储的是与图像显示用户界面相关的函数(Win32k.sys)

images/download/attachments/1015846/image2022-10-12_9-53-27.png

表位置

在了解保存现场内容时,我们知道在Windows内核中每一个进程的每一个线程都有着一个_ETHREAD结构体,它存储着线程相关的信息,在这个结构体里有另外一个结构体_KTHREAD,该结构体的0xE0偏移位成员就存储着系统服务表的地址。

images/download/attachments/1015846/image2022-10-12_10-8-56.png

images/download/attachments/1015846/image2022-10-12_10-5-4.png

系统调用号查找函数

3环的API调用就是根据系统调用号找到函数再去使用,虽然系统调用号有32位(4字节)但是真正使用的只有低13位,这13位也分成两部分:第12位值为0则为第一张表(Ntoskrl.exe),值为1则为第二张表(Win32k.sys)低12位(第0至第11位)就是对应的函数地址、参数表索引

images/download/attachments/1015846/image2022-10-12_10-43-46.png images/download/attachments/1015846/image2022-10-12_10-39-35.png


代码分析

在保存现场的学习时我们知道无论是KiSystemService还是KiFastCallEntry方法最终都会走到KiFastCallEntry函数内的某块。这一块代码就是系统调用号查找函数的过程,我们来分析一下。

首先是取3环传入的系统调用号给到EDI,然后因为系统调用号我们实际使用的只有13位,其他位都是0,所以将EDI右移8位之后就还剩下5位,接着这5位又和0x30进行与运算,也就表示运算结果只有两种,即0x10或0x0。继续将运算后的结果给到ECX,因为在之前的KiSystemService函数中ESI指向的是_KTHREAD结构体,所以ESI+0xE0指向的就是系统服务表地址,又将与运算结果和系统服务表地址相加,即表示取的系统服务表是第一张还是第二张(系统服务表的宽度就是16字节,即0x10),因此我们可以知道这里的计算方式是很精巧的。

images/download/attachments/1015846/image2022-10-12_11-40-14.png

其次再取系统调用号给到EBX,然后由于之前已经通过下标为12的位找到了对应的表,所以将系统调用号和0xFFF进行与运算,也就是把下标为12的位设为0,接着EDI+8与EAX进行比较,即系统服务表的ServiceLimit成员与系统调用号,以此来判断传递的函数索引是否越界,如果越界则跳转异常处理。

images/download/attachments/1015846/image2022-10-12_11-42-39.png

再接着就是将之前的与运算结果和0x10进行比较,也就是判断系统调用号对应的表,如果与运算结果是0x10则向下会调用到_KeGdiFlushUserBatch函数(自行查阅手册),如果与运算的结果是0x0则跳转。

images/download/attachments/1015846/image2022-10-12_14-30-16.png

继续向下看,将_KPCR的0x638偏移位的成员值加1,也就是_KPRCB的0x518偏移的成员KeSystemCalls,然后将EDX(即3环参数的指针)给到ESI,接着将系统服务表指向的函数参数表ArgmentTable的地址给到EBX,然后XOR清空ECX。紧接着就是根据系统调用号的索引获取想要调用的函数参数的字节数给到CL,再将系统服务表指向的函数地址表ServiceTable的地址给到EDI,然后再根据系统调用号的索引获取需要调用的函数(这里乘以4是因为存储的函数地址宽度为4字节)给到EBX。

images/download/attachments/1015846/image2022-10-12_15-5-47.png

然后将堆栈按ECX即函数参数字节数进行提升,这样是为了在0环将3环的参数复制进来,接着将ECX即函数参数字节数右移2位也就是除以4获得参数的个数,因为在下面的REP指令中每次复制是4字节的,并且该指令的循环次数是ECX的值,所以我们要除以4。接着将ESP给EDI是为设置要循环复制的目的地址,然后判断3环参数地址范围有没有越界,也就是有没有大于_MmUserProbeAddress,如果越界了则进行跳转。最后,执行完REP指令就调用EBX也就是内核函数。

images/download/attachments/1015846/image2022-10-12_15-56-59.png

SSDT

在上面的学习中我们知道可以通过_KTHREAD结构体的0xE0偏移来会找到系统服务表,除了这个方式以外我们还可以通过另外一种方式来访问,那就是SSDT(System Service Descriptor Table,系统服务描述符表)。

SSDT也有两张表:KeServiceDescriptorTable、KeServiceDescriptorTableShadow。KeServiceDescriptorTable是内核文件导出的,我们可以在内核文件Ntoskrnl.exe的导出表中找到。

images/download/attachments/1015846/image2022-10-12_19-27-19.png

我们可以在Windbg下直接查看这张表,这张表里一共有4个成员,每个成员都是一个系统服务表,所以SSDT的数据宽度为64字节,但是我们知道Windows XP系统使用了两张系统服务表,目前只能看到一张系统服务表,第二张表却无法看见:

images/download/attachments/1015846/image2022-10-12_19-29-21.png

这是因为第二张系统服务表没有放在SSDT中,而是放在了SSDT Shadow(即KeServiceDescriptorTableShadow)里,我们可以通过Windbg查看这张表并且能看到里面有2张系统服务表,但是这张表是未导出的我们想要使用的话,通常采用内存搜索的方式来找到

images/download/attachments/1015846/image2022-10-12_19-36-52.png

实战分析

我们了解了SSTD表后,也就知道了系统服务表的具体成员值,可以根据系统调用号自己来找一下内核函数。如我们之前分析的ReadProcessMemory函数,它的系统调用号就是0xBA。

images/download/attachments/1015846/image2022-9-18_15-3-35.png

我们来手动分析一下,先将第一张系统服务表按成员结构进行拆分:

ServiceTable(函数地址表地址):80504734
Count(系统服务表调用次数):00000000
ServiceLimit(系统服务表内存储函数的个数):0000011c
ArgmentTable(函数参数表地址):80504ba8

接着根据函数地址表地址带入系统调用号找到内核函数的地址,如下图所示我们就找到了ReadProcessMemory最终调用的内核函数NtReadVirtualMemory,它的汇编代码我们也一清二楚:

images/download/attachments/1015846/image2022-10-12_19-48-28.png

同样我们也可以查看该函数的参数总字节数,也就是0x14(20)个字节,按每个参数4字节算,即有5个参数:

images/download/attachments/1015846/image2022-10-12_19-50-55.png