CTF真(太)好(难)玩(了)

0%

Windows系统调用学习笔记(2)—— 3环进0环

根据上一节的例子,我们实现了不通过Windows的API函数,直接构造一个函数实现ReadProcessMemory的功能。在这个过程中,最关键的一步就是INT2E,通过INT2E软中断自陷,我们从3环进入了0环实现了ReadProcessMemory的功能。本节主要讨论的就是INT2E软中断自陷这个操作过程中更底层的原理,和3环进入0环了另一种方法(Systementer快速调用)

0x01 中断门进0环

何谓CPU进入系统空间

用户空间与系统空间所在的内存区间不一样,所以对于这两种区间,CPU的运行状态也不一样。在用户空间中,CPU处于”用户态”;在系统空间中,CPU处于”系统态”。那么这二者有什么区别呢?何谓CPU进入系统空间?

  1. CPU的运行状态从用户态转为系统态,拥有了执行”特权指令”的能力。

  2. CPU进入系统态后,可以访问内存中的系统区(内核所在的区域),而在用户态下是无法访问的。

  3. 当前进程使用的堆栈,从用户态切换到系统态。堆栈原先的内容(用户态),以及用户空间的堆栈指针,被压入系统空间堆栈。同时被压入的,还有EFLAGS、CS、EIP的内容。

  4. 关闭中断,依照中断向量从IDT中找到相应的表项,并根据表项提供的程序入口进入相应的中断服务程序。

CPU进入系统态的手段

CPU从系统态进入用户态是容易的,因为可以执行一些系统态特有的特权指令,从而进入用户态。而相反,用户态进入系统态则不容易,因为用户态是无法执行特权指令的。所以,一般的,有3种手段可以使CPU进入系统态(即转入系统空间执行)。

  1. 中断:来自于外部设备的中断请求。当有中断请求到来时,CPU自动进入系统态,并从某个预定地址开始执行指令。中断只发生
    在两条指令之间,不影响正在执行的指令。
  2. 异常:无论是在用户空间或系统空间,执行指令失败时都会引起异常,CPU会因此进入系统态(如果原先不在系统空间),从而
    在系统空间中对异常做出处理。异常发生在执行一条指令的过程中,所以当前执行的指令已经半途而废了。
  3. 自陷:以上两种都CPU被动进入系统态。而自陷是CPU通过自陷指令主动进入系统态。多数CPU都有自陷指令,系统调用函数一般都是靠自陷指令实现的。一条自陷指令的作用相当于一次子程序调用,子程序存在于系统空间。

INT2E中断与KiSystemService函数

而INT2E就是自陷指令,即通过自己主动的触发陷阱的方法进入系统态。相信大家应该都知道,IDT表其实就是一个索引表,每个中断门描述符对应着一个中断处理函数,那么我们同样来分析一下这个门描述符 (对门描述符分析有疑问的参见Windows保护模式学习笔记(2)—— 调用门&中断门&陷阱门

  1. 门描述符:804dee00`0008e7d1
  2. CS:门描述符的段选择子部分(0008)
  3. SS:从段选择子指向的TSS段描述符指向的TSS表中取出
  4. ESP:从段选择子指向的TSS段描述符指向的TSS表中取出
  5. EIP:804de7d1

然后我们查看一下这个EIP对应的地址空间

可以看到,INT2E号中断对应着KiSystemService函数,我们可以看到这个函数前面带着 nt! 的字样,这个关键字说明这个函数是内核函数,即其存在于0环空间

通过自陷指令调用系统服务流程

下面我们用一个例子(ReadFile函数)来看一下通过中断门进0环的一个整体的调用过程

Windows将2e号向量专门用作系统调用,在启动早起初始化中断描述表时便注册好了适合的服务例程。因此当NtDll中的NtReadFile发出int 2e指令后,cpu便会通过idt表找到KisystemService函数。因为KiSystemService函数是位于内核空间的,所以cpu在把执行权交给KiSystemService函数前,会做好从用户态换到内核态的各种工作,包括:

  1. 权限检查 即检查源位置和目标位置所在的代码段权限,核实是否可以转移。

  2. 准备内核态使用的栈,为了保证内核安全,所有线程在内核态执行时都必须使用位于内核的内核栈(kernel stack),内核栈的大小一般为8KB或者12KB.

KiSystemService会根据服务ID从系统服务分发表(System Service Dispatch Table)中查找到需要的服务函数地址和参数描述,然后将参数从用户态复制到该线程的内核栈中,最后KiSystemService调用内核中真正的NtReadFile()函数,执行读文件操作,操作结束后会返回到KiSystemService,KISystemService函数会将操作结果复制回线程用户态栈,最后通过IRET指令将执行权交回给NtDll.dll中的NtReadFile()函数,继续执行INT 2E后面的那条指令。

其实,纵观ntdll.dll,大部分的函数都是这个样式的。所有windows基本系统调用的中介函数存在于ntdll.dll中,这些中介函数拥有相同的样式(都是执行自陷指令,然后调用相应的内核函数),所以reactOS用工具自动生成这些中介函数。它们的函数名、系统调用号、参数个数,来自文件sysfuncs.lst中。

0x02 快速调用进0环

从ring0到ring3最开始是用的int2E(中断门方式),此模式切换过程设计很多次内存访问(从IDT中取CS和EIP,从TSS中取SS和ESP),还有两次查表操作机访问权限的检查,这导致模式切换的开销很大。因此,从PentiumII 处理器开始,Inter引入了新的指令sysenter/sysexit,来实现快速的模式切换。但同样的,并不是所有处理器都支持快速调用!

判断CPU是否支持快速调用

使用 CPUID 指令可以从处理器厂商里获得关于处理器的详细信息,该指令是从 Intel 486 处理器以后开始加入支持(这个版本实在太过古老,暂不考虑不支持该指令的情况)。通过该条指令获取到的SEP位可确认是否支持快速调用

1
2
3
4
5
6
MOV EAX, 1
XOR ECX, ECX
XOR EDX, EDX
CPUID
# EDX = 0xBFF = 1011 1111 1111
# 第11位为SEP位

SEP=1,说明当前CPU支持 sysenter / sysexit 指令

_KUSER_SHARED_DATA

学习快速调用还需要补充一些前置知识。系统在 User 层和 Kernel 层分别定义了一个 _KUSER_SHARED_DATA 结构区域,用于 User 层和 Kernel 层共享某些数据。它们使用固定的地址值映射,_KUSER_SHARED_DATA结构区域在User层和Kernel层地址分别为:

User:0x7ffe0000

Kernnel:0xffdf0000

可以看到,这两个地址虽然指向的是同一个物理页,但在User层是只读的,而在Kernnel层是可写的

KiFastSystemCall

前面我们说过,系统在初始化的时候根据CPU是否支持快速系统调用而使_KUSER_SHARED_SYSCALL(0x7ffe0300)指针指向KiIntSystemCall()或KiFastSystemCall(),所以我们这里看一下快速调用时调用的函数KiFastSystemCall()

可以看到,同样的,这个函数也只有简单的两行代码。第一行将当前栈顶(esp)的值放入edx中,第二行执行了sysenter指令。同样的,可以看到,在执行KiIntSystemCall函数前,编号已被写入EAX

sysenter指令

CPU如果支持sysenter指令,操作系统会提前将CS/SS/ESP/EIP的值存储在MSR寄存器中,sysenter指令执行时,CPU会将MSR寄存器中的值直接写入相关寄存器,没有读内存的过程,所以叫快速调用,但其与中断门调用进入0环的本质是一样的。 在执行sysenter指令之前,操作系统必须指定0环的CS段、SS段、EIP以及ESP,其中,CS段、EIP以及ESP来自MSR寄存器,下面看一下该寄存器的结构(该寄存器非常庞大,这里只列出三个最重要的值)

可以在内核模式下通过 rdmsr/wrmsr 指令来读写这3个寄存器。

  1. kd> rdmsr 174 //查看CS
  2. kd> rdmsr 175 //查看ESP
  3. kd> rdmsr 176 //查看EIP

查看EIP所在地址的反汇编,可以看到nt表示当前函数为内核函数

然后这里有个注意点,在执行sysenter指令时,只有CS、ESP、EIP三个寄存器的值可从MSR寄存器中获得,其中并不包括SS!

  • SS= IA32_SYSENTER_CS + 8

0x03 参考资料

  1. lzyddf师傅的博客

  2. 系统调用,从用户态进入系统态(_KiSystemService() _KiFastCallEntry)

  3. Int 2e 与 Sysenter区别

  4. sysenter/sysexit 原理