1. 操作系统模型
大多数操作系统中,都会把应用程序和内核代码分离运行在不同的模式下。内核模式访问系统数据和硬件,应用程序运行在没有特权的模式下(用户模式),只能使用有限的API,且不能直接访问硬件。当用户模式调用系统服务时,CPU执行一个特殊的指令以切换到内核模式(Ring0),当系统服务调用完成时,操作系统切换回用户模式(Ring3)。
Windows与大多数UNIX系统类似,驱动程序代码共享内核模式的内存空间,意味着任何系统组件或驱动程序都可能访问其他系统组件的数据。但是,Windows实现了一套内核保护机制,比如PatchGuard和内核模式代码签名。
内核模式的组件虽然共享系统资源,但也不会互相访问,而是通过传参数的方式来访问或修改数据结构。大多数系统代码用C写的是为了保证可移植性,C语言不是面向对象的语言结构,比如动态类型绑定,多态函数,类型继承等。但是,基于C的实现借鉴了面向对象的概念,但并不依赖面向对象。
2. 系统架构
下图是简化版的Windows系统架构实现:
首先注意那条横线将用户模式和内核模式分开两部分了。横线之上是用户模式的进程,下面是内核模式的系统服务。服务进程和用户程序之下的“子系统DLL”。在Windows下,用户程序不直接调用本地Windows服务,而是通过子系统DLL来调用。子系统DLL的角色是将文档化的函数翻译成调用的非文档化的系统服务(未公开的)。
内核模式的几个组件包括:
- Windows执行实体,包括基础系统服务,比如内存管理器,进程和线程管理器,安全管理,I/O管理,网络,进程间通信。
- Windows内核,包括底层系统函数,比如线程调度,中断,异常分发,多核同步。也提供了一些routine和实现高层结构的基础对象。
- 设备驱动,包括硬件设备驱动(翻译用户I/O到硬件I/O),软件驱动(例如文件和网络驱动)。
- 硬件抽象层,独立于内核的一层代码,将设备驱动与平台的差异性分离开。
- 窗口和图形系统,实现了GUI函数,处理用户接口和绘图。
下表中是Windoows系统核心组件的文件名:
文件名 组件 Ntoskrnl.exe 执行体和内核 Ntkrnlpa.exe(32位才有) 支持PAE Hal.dll 硬件抽象层 Win32k.sys 子系统的内核模式部分 Ntdll.dll 内部函数 KERNEL32.DLL,KERNELBASE.dll,USER32.dll, GDI32.dll 核心子系统的组件
在一个安装完成的Windows操作系统中可见并有效的内核实现文件是:
C:\Windows\System32\ntoskrnl.exeC:\Windows\System32\ntkrnlpa.exe
请注意有两个内核文件,其中第二个比第一个的名字少了os多了个pa,省去的os没有任何意义,但是多出来的pa所代表的意思是PAE(物理地址扩展),这是X86CPU的一个硬件特性,Windows启动之后根据当前系统设置是否开启了PAE特性会自动选择把其中一个作为内核加载到内存中。
为什么加了这么多限定词,因为ntoskrnl.exe这个文件名并不一定是这个文件的真实名称,可以从文件属性中看到:
ntoskrnl.exe原始文件名为可能为ntoskrnl.exe或者ntkrnlmp.exentkrnlpa.exe原始文件名为可能为ntkrnlpa.exe或者ntkrpamp.exe
可以发现其中的不同之处就是mp,mp就是Multi-processor(多处理器,也可以理解为多核,因为IA-32架构对多核处理器的编程和多处理器的编程是相似的机制)。为什么会出现这中情况呢?因为这完全是由计算机硬件的不同配置导致的。当安装Windows操作系统的时候,Windows安装程序会自动检测机器的CPU特性,根据CPU的核心数来确定使用哪一套内核。如果是单核心就只复制ntkrnlpa.exe和ntoskrnl.exe到系统目录下,如果是多核心就复制ntkrnlpamp.exe和ntoskrnlmp.exe到系统目录下,所以如果你有一台单核心CPU的机器,有一天你换了双核的CPU却没有重新安装操作系统,那么你就不会在看到熟悉的Windows启动画面了。类似这两个文件的还有一个文件C:\Windows\System32\hal.dll,这是Windows的硬件抽象层程序文件,这个就不做具体介绍了。
注意:由于在跟踪分析系统内核调用的时候需要导入相应的符号文件以及对函数偏移位置等进行分析,因此需要知道自己系统上内核文件的原始文件名。
3. 系统服务调用机制
对于应用程序进程来说,操作系统内核的作用体现在一组可供调用的函数,称为系统调用(也成"系统服务")。
从程序运行的角度来看,进程是主动、活性的,是发出调用请求的一方;而内核是被动的,只是应进程要求而提供服务。从整个系统运行角度看,内核也有活性的一面,具体体现在进程调度。
系统调用所提供的服务(函数)是运行在内核中的,也就是说,在"系统空间"中。而应用软件则都在用户空间中,二者之间有着空间的间隔(CPU运行模式不同)。
综上所述,应用软件若想进行系统调用,则应用层和内核层之间,必须存在"系统调用接口",即一组接口函数,这组接口运行于用户空间。对于windows来说,其系统调用接口并不公开,公开是的一组对系统调用接口的封装函数,称为windowsAPI。
用户空间与系统空间所在的内存区间不一样,同样,对于这两种区间,CPU的运行状态也不一样。
在用户空间中,CPU处于"用户态";在系统空间中,CPU处于"系统态"。
CPU从系统态进入用户态是容易的,因为可以执行一些系统态特有的特权指令,从而进入用户态。
而相反,用户态进入系统态则不容易,因为用户态是无法执行特权指令的。
所以,一般有三种手段,使CPU进入系统态(即转入系统空间执行):
① 中断:来自于外部设备的中断请求。当有中断请求到来时,CPU自动进入系统态,并从某个预定地址开始执行指令。中断只发生在两条指令之间,不影响正在执行的指令。② 异常:无论是在用户空间或系统空间,执行指令失败时都会引起异常,CPU会因此进入系统态(如果原先不在系统空间),从而在系统空间中对异常做出处理。异常发生在执行一条指令的过程中,所以当前执行的指令已经半途而废了。
③ 自陷:以上两种都是CPU被动进入系统态。而自陷是CPU通过自陷指令主动进入系统态。多数CPU都有自陷指令,系统调用函数一般都是靠自陷指令实现的。一条自陷指令的作用相当于一次子程序调用,子程序存在于系统空间。
4. Windows使用系统调用的方法
4.1. 通过自陷实现系统调用
Windows API如果设涉及到系统调用就要由RING3进入RING0,这就牵扯到了X86保护模式下有特权级变化的控制转移。在早期的CPU中(Pentium II之前),没有快速系统调用这个机制,所以能用来进行特权级变化的控制转移的机制只有通过自陷实现(很多书或网络上也经常称为中断方式),保护模式下的中断的实现方式是通过IDT表来实现,IDT表中存放的是一种特殊的X86段描述符——门描述符,门描述符的格式如下:
可以看到其中有一个Selector字段和一个Offset字段,并且是不连续的,这里只介绍这两个字段的含义,其他字段的含义这里不再赘述,有兴趣的话可以自己去看下保护模式相关资料。说到底这个门描述符的作用就是描述一个程序段,对我们来说重要的就是Selector和Offset字段了,因为Selector可以帮我们找到它所描述的程序的【段】,Offset就是程序在【段】内的【偏移】,有了【段】和【偏移】就可以确定程序的线性地址。
在Win10 X64操作系统中IDT表的结构又有些不一样,具体的结构可以用WinDbg获得,具体指令及结果如下:
kd> dt_KIDTENTRY64ACPI!_KIDTENTRY64
+0x000 OffsetLow : Uint2B
+0x002 Selector : Uint2B
+0x004 IstIndex : Pos 0, 3 Bits
+0x004 Reserved0 : Pos 3, 5 Bits
+0x004 Type : Pos 8, 5 Bits
+0x004 Dpl : Pos 13, 2 Bits
+0x004 Present : Pos 15, 1 Bit
+0x006 OffsetMiddle : Uint2B
+0x008 OffsetHigh : Uint4B
+0x00c Reserved1 : Uint4B
+0x000 Alignment : Uint8B
kd> r idtr
idtr=fffff801b88ca070
kd> dt_KIDTENTRY64 fffff801b88ca070
ACPI!_KIDTENTRY64
+0x000 OffsetLow : 0x7500
+0x002 Selector : 0x10
+0x004 IstIndex : 0y000
+0x004 Reserved0 : 0y00000 (0)
+0x004 Type : 0y01110 (0xe)
+0x004 Dpl : 0y00
+0x004 Present : 0y1
+0x006OffsetMiddle : 0xb6d5
+0x008 OffsetHigh : 0xfffff801
+0x00c Reserved1 : 0
+0x000 Alignment : 0xb6d58e00`00107500
在使用这种机制的windows系统中,系统调用2E号中断,进入了系统内核。一般在中断调用前都会初始化一个系统服务号;也叫做分发
ID,该 ID
需要在执行 int 2Eh
前,加载到EAX 寄存器,以便在切换到内核模式的时候调用相应的内核函数来完成相应的功能。粗略地讲,INT 指令在内部涉及如下几个操作:
1) 清空陷阱标志(TF),和中断允许标志(IF);2) 依序把(E)FLAGS,CS,(E)IP 寄存器中的值压入栈上;
3) 转移到 IDT 中的中断门描述符记载的相应 ISR(中断服务例程)的起始地址;
4) 执行 ISR,直至遇到 IRET 返回。
最关键的第3步涉及“段间”转移,通过中断门描述符,能够引用一个 Ring0 权限代码段,该代码段对应的 64 位段描述符(存储在 GDT 中)中的 DPL 位,即特权级位等于0(0=Ring0;3=Ring3,即便由 Intel 规定的段描述符的 DPL 位有4种取值,但 Windows 仅使用了其中的最高特权级 Ring0 与最低特权级 Ring3,总体而言,用户模式应用程序位于 Ring3 代码或数据段;内核与设备驱动程序则位于 Ring0
代码或数据段 ),再结合段描述符中的“基址”与中断门描述符中的“偏移”,就能计算出 ISR在 Ring0 代码段中的起始地址。下表是64位段描述符的格式,取自 Intel 文档,自行添加了翻译:我们知道了系统调用了2E号中断,从而进入了系统内核,知道了中断号下面我们要做的就是找到这个中断的服务程序,也就是RING3进入到RING0之后的第一条指令在哪里。下面就进入内核调试模式。由于IDT是由IDTR指定的,这里用WINDBG进行手工分析:
1) 在X86模式下:
0: kd> r idtridtr=8003f400
这个IDT有多大呢?
0: kd> r idtl
idtl=000007ff
其实大小就是这个数加一。地址找到了,大小找到了,关键是这个是啥结构,IDT长啥样呢?
0: kd> dt _KIDTENTRY
ntdll!_KIDTENTRY
+0x000Offset : Uint2B
+0x002Selector : Uint2B
+0x004Access : Uint2B
+0x006ExtendedOffset : Uint2B
就是这个结构的数组。
下面看看第一个成员。
0: kd> dt _KIDTENTRY 8003f400ntdll!_KIDTENTRY
+0x000Offset : 0x3360
+0x002Selector : 8
+0x004Access : 0x8e00
+0x006ExtendedOffset : 0x8054
这个结构的具体的含义,请看前面对中断门描述符的解释或查看Intel的手册及者相关的资料。经过计算得出地址是:0x80543360
验证的方式之一:
1 |
0: kd> u 0x80543360 |
看到了吧!显示的是正确的。
另一个办法是:
1 |
0: kd> !idt -a |
2) 在X64模式下:
首先查看IDTR和IDTL
kd> r idtridtr=fffff801b88ca070
kd> r idtl
idtl=0fff
在64位系统中使用的结构是_KIDTENTRY64
kd> dt _KIDTENTRY64
ACPI!_KIDTENTRY64
+0x000 OffsetLow : Uint2B
+0x002 Selector : Uint2B
+0x004 IstIndex : Pos 0, 3Bits
+0x004 Reserved0 : Pos 3, 5Bits
+0x004 Type : Pos 8, 5Bits
+0x004 Dpl : Pos 13,2 Bits
+0x004 Present : Pos 15,1 Bit
+0x006 OffsetMiddle : Uint2B
+0x008 OffsetHigh : Uint4B
+0x00c Reserved1 : Uint4B
+0x000 Alignment : Uint8B
kd> dt _KIDTENTRY64 fffff801b88ca070
ACPI!_KIDTENTRY64
+0x000 OffsetLow : 0x7500
+0x002 Selector : 0x10
+0x004 IstIndex : 0y000
+0x004 Reserved0 : 0y00000(0)
+0x004 Type : 0y01110(0xe)
+0x004 Dpl : 0y00
+0x004 Present : 0y1
+0x006 OffsetMiddle : 0xb6d5
+0x008 OffsetHigh :0xfffff801
+0x00c Reserved1 : 0
+0x000 Alignment :0xb6d58e00`00107500
查看IDT服务表
kd> !idtDumping IDT: fffff801b88ca070
00: fffff801b6d57500nt!KiDivideErrorFault
01: fffff801b6d57600nt!KiDebugTrapOrFault
02: fffff801b6d577c0nt!KiNmiInterrupt Stack =0xFFFFF801B88E5000
03: fffff801b6d57b40nt!KiBreakpointTrap
04: fffff801b6d57c40nt!KiOverflowTrap
05: fffff801b6d57d40nt!KiBoundFault
06: fffff801b6d57fc0nt!KiInvalidOpcodeFault
07: fffff801b6d58200nt!KiNpxNotAvailableFault
08: fffff801b6d582c0nt!KiDoubleFaultAbort Stack =0xFFFFF801B88E3000
09: fffff801b6d58380nt!KiNpxSegmentOverrunAbort
0a: fffff801b6d58440nt!KiInvalidTssFault
0b: fffff801b6d58500nt!KiSegmentNotPresentFault
0c: fffff801b6d58640nt!KiStackFault
0d: fffff801b6d58780nt!KiGeneralProtectionFault
0e: fffff801b6d58880nt!KiPageFault
10: fffff801b6d58c40nt!KiFloatingErrorFault
11: fffff801b6d58dc0nt!KiAlignmentFault
12: fffff801b6d58ec0nt!KiMcheckAbort Stack =0xFFFFF801B88E7000
13: fffff801b6d59540nt!KiXmmException
1f: fffff801b6d52890nt!KiApcInterrupt
20: fffff801b6d56c10nt!KiSwInterrupt
29: fffff801b6d59700nt!KiRaiseSecurityCheckFailure
2c: fffff801b6d59800nt!KiRaiseAssertion
2d: fffff801b6d59900nt!KiDebugServiceTrap
2f: fffff801b6d52b60nt!KiDpcInterrupt
30: fffff801b6d52d90nt!KiHvInterrupt
31: fffff801b6d530f0nt!KiVmbusInterrupt0
32: fffff801b6d53440nt!KiVmbusInterrupt1
33: fffff801b6d53790nt!KiVmbusInterrupt2
34: fffff801b6d53ae0nt!KiVmbusInterrupt3
35: fffff801b6d51718hal!HalpInterruptCmciService (KINTERRUPT fffff801b7425cb0)
50: fffff801b6d517f0USBPORT!USBPORT_InterruptService (KINTERRUPT ffffd001fee5c640)
60: fffff801b6d51870VBoxGuest+0x1290 (KINTERRUPT ffffd001fee5cb40)
70: fffff801b6d518f0storport!RaidpAdapterInterruptRoutine (KINTERRUPT ffffd001fee5cc80) HDAudBus!HdaController::Isr(KINTERRUPT
ffffd001fee5c780)
80: fffff801b6d51970i8042prt!I8042MouseInterruptService (KINTERRUPT ffffd001fee5c8c0)90: fffff801b6d519f0i8042prt!I8042KeyboardInterruptService (KINTERRUPT ffffd001fee5ca00)
a0: fffff801b6d51a70serial!SerialCIsrSw (KINTERRUPT ffffd001fee5c500)
b0: fffff801b6d51af0ACPI!ACPIInterruptServiceRoutine (KINTERRUPT ffffd001fee5cdc0)
b1: fffff801b6d51af8dxgkrnl!DpiFdoLineInterruptRoutine (KINTERRUPT ffffd001fee5c3c0)
ce: fffff801b6d51be0hal!HalpIommuInterruptRoutine (KINTERRUPT fffff801b74266b0)
d1: fffff801b6d51bf8hal!HalpTimerClockInterrupt (KINTERRUPT fffff801b74264b0)
d2: fffff801b6d51c00hal!HalpTimerClockIpiRoutine (KINTERRUPT fffff801b74263b0)
d7: fffff801b6d51c28hal!HalpInterruptRebootService (KINTERRUPT fffff801b74261b0)
d8: fffff801b6d51c30hal!HalpInterruptStubService (KINTERRUPT fffff801b7425fb0)
df: fffff801b6d51c68hal!HalpInterruptSpuriousService (KINTERRUPT fffff801b7425eb0)
e1: fffff801b6d53e30nt!KiIpiInterrupt
e2: fffff801b6d51c80hal!HalpInterruptLocalErrorService (KINTERRUPT fffff801b74260b0)
e3: fffff801b6d51c88hal!HalpInterruptDeferredRecoveryService (KINTERRUPT fffff801b7425db0)
fd: fffff801b6d51d58hal!HalpTimerProfileInterrupt (KINTERRUPT fffff801b74265b0)
fe: fffff801b6d51d60hal!HalpPerfInterrupt (KINTERRUPT fffff801b74262b0)
可以看到在X64环境下INT 2E中断服务表已经没有可以导出的服务了,在使用!idt
–a指令查看,可以考到2E中断服务表的内容为:
2e: fffff801b6d516e0nt!KiIsrThunk+0x170
通过下一个章节的分析可以看到在Win10 X64系统中实际是使用快速系统调用机制。
4.2. 使用快速系统调用机制
从Pentium II系列开始的CPU引入了快速系统调用这一特性,增加了两条指令SYSENTER和SYSEXIT(AMD CPU中的指令为SYSCALL和SYSRET,在Intel 64 CPU中也用SYSCALL和SYSRET),Windows 10 X64系统使用的是SYSCALL和SYSRET指令。这一机制的实现就是专门用于解决操作系统的系统调用的性能问题的,这种机制实现的控制转移比中断系统要快很多,因为转移的目标地址是存放在MSR寄存器内,而中断实现的系统调用目标地址存放在内存中的IDT中,所以能提高执行速度。
windows 10 x64 版本会使用 processor 提供的 syscall/sysret 指令来构造一个快速的调用系统服务例程机制。实际上他就是在上一节自陷模式中调用int 2Eh/IRET指令的地方使用了syscall/sysret指令,同时在调用用指令前对相应的寄存器进行现场保护和初始化。其中在EAX寄存器中存放了系统服务号。Syscall指令有一个统一的内核服务程序入口,他的内核服务程序入口存放在MSR_LSTAR 寄存器中,我们要想看
syscall 指令进入哪里,可以查看 MSR_LSTAR 寄存器的值,在 windbg 的内核调试模式下,使用 rdmsr 命令观察 MSR_LSTAR 寄存器值,例如:
|
上面的结果显示 fffff801`b6d59d00 就是 MSR_LSTAR 里的值,它是 syscall 指令的进入点。当用户模式的程序执行Syscall指令后CPU将切换到内核模式,并开始执行内核服务程序入口的指令,同时根据系统服务号调用内核相应的服务函数,完成后返回到用户模式。实际上入口函数为nt!KiSystemCall64:
kd> uffffff801`b6d59d00Flow analysis wasincomplete, some code may be missing
nt!KiSystemCall64:
fffff801`b6d59d000f01f8 swapgs
fffff801`b6d59d03654889242510000000 mov qword ptr gs:[10h],rsp
fffff801`b6d59d0c65488b2425a8010000 mov rsp,qword ptr gs:[1A8h]
fffff801`b6d59d15 6a2b push 2Bh
fffff801`b6d59d1765ff342510000000 push qword ptr gs:[10h]
fffff801`b6d59d1f4153 push r11
fffff801`b6d59d216a33 push 33h
fffff801`b6d59d2351 push rcx
fffff801`b6d59d24498bca mov rcx,r10
fffff801`b6d59d274883ec08 sub rsp,8
fffff801`b6d59d2b55 push rbp
fffff801`b6d59d2c4881ec58010000 sub rsp,158h
fffff801`b6d59d33 488dac2480000000 lea rbp,[rsp+80h]
fffff801`b6d59d3b48899dc0000000 mov qword ptr [rbp+0C0h],rbx
fffff801`b6d59d424889bdc8000000 mov qword ptr [rbp+0C8h],rdi
fffff801`b6d59d494889b5d0000000 mov qword ptr [rbp+0D0h],rsi
fffff801`b6d59d50c645ab02 mov byte ptr [rbp-55h],2
fffff801`b6d59d5465488b1c2588010000 mov rbx,qword ptr gs:[188h]
fffff801`b6d59d5d0f0d8b90000000 prefetchw[rbx+90h]
fffff801`b6d59d640fae5dac stmxcsr dword ptr [rbp-54h]
fffff801`b6d59d68650fae142580010000 ldmxcsr dword ptrgs:[180h]
fffff801`b6d59d71807b0300 cmp byte ptr [rbx+3],0
fffff801`b6d59d7566c785800000000000 mov word ptr [rbp+80h],0
fffff801`b6d59d7e0f849a000000 je nt!KiSystemServiceUser+0xce(fffff801`b6d59e1e) Branch
前面提到,执行 SYSCALL 前,需要加载一个系统服务号到 EAX 寄存器,系统服务号的作用就是提供给 KiSystemCall64 ()在 KeServiceDescriptorTable 或 KeServiceDescriptorTableShadow中索引相应系统服务的入口地址并调度执行。
在WindowsNT系列操作系统中,有两种类型的系统服务,一种实现在内核文件中,是常用的系统服务;另一种实现在win32k.sys中,是一些与图形显示及用户界面相关的系统服务。这些系统服务在系统执行期间常驻于系统内存区中,并且他们的入口地址保存在两个系统服务地址表KiServiceTable和Win32pServiceTable中。而每个系统服务的入口参数所用的总字节数则分别保存在另外两个系统服务参数表(ArgumentTable)中。
系统服务地址表和系统参数表是一一对应的,每个系统服务表(一下简称SST)都指向一个地址表和一个参数表。在Windows 2000/xp/7系统中,只有两个SST。一个SST指向了KiServiceTable,而另一个SST则指向了Win32pServiceTable.
所有的SST都保存在系统服务描述表(SDT)中。系统中一共有两个SDT,一个是ServiceDescriptorTable,另一个是ServiceDescriptorTableShadow。ServiceDescriptor中只有指向KiServiceTable的SST,而ServiceDescriptorTableShadow则包含了所有的两个SST。SSDT是可以访问的,而SSDTShadow是不公开的。
windows内核文件导出了一个公开的变量KeServiceDecriptorTable,它指向了SSDT。在内核程序中可以直接使用这个变量,通过数据结构之间的关系,找到KiServiceTable,然后从KiServiceTable中查找任何一个系统服务的入口地址。
下面是关于这些数据结构的示意图:
例如:在用户模式下用户应用程序调用CreateFile这个系统api,在Win10 X64系统中通过指令SYSCALL切换至内核模式,同时在EAX寄存器中设置系统服务号为0x55h,在内核模式下通过KiSystemCall64查找SSDT服务表,并调用相应的内核函数,在Windbg下可以看到如下内容:
kd> rdmsrc0000082msr[c0000082] =fffff802`7f7d4d00
kd> ufKiSystemCall64
Flow analysis wasincomplete, some code may be missing
nt!KiSystemCall64:
fffff802`7f7d4d000f01f8 swapgs
fffff802`7f7d4d03654889242510000000 mov qword ptr gs:[10h],rsp
fffff802`7f7d4d0c65488b2425a8010000 mov rsp,qword ptr gs:[1A8h]
fffff802`7f7d4d156a2b push 2Bh
fffff802`7f7d4d17 65ff342510000000 push qword ptr gs:[10h]
fffff802`7f7d4d1f4153 push r11
fffff802`7f7d4d216a33 push 33h
fffff802`7f7d4d2351 push rcx
fffff802`7f7d4d24498bca mov rcx,r10
fffff802`7f7d4d274883ec08 sub rsp,8
fffff802`7f7d4d2b55 push rbp
fffff802`7f7d4d2c4881ec58010000 sub rsp,158h
fffff802`7f7d4d33488dac2480000000 lea rbp,[rsp+80h]
fffff802`7f7d4d3b48899dc0000000 mov qword ptr [rbp+0C0h],rbx
fffff802`7f7d4d424889bdc8000000 mov qword ptr [rbp+0C8h],rdi
fffff802`7f7d4d494889b5d0000000 mov qword ptr [rbp+0D0h],rsi
fffff802`7f7d4d50c645ab02 mov byte ptr [rbp-55h],2
fffff802`7f7d4d5465488b1c2588010000 mov rbx,qword ptr gs:[188h]
fffff802`7f7d4d5d0f0d8b90000000 prefetchw[rbx+90h]
fffff802`7f7d4d640fae5dac stmxcsr dword ptr[rbp-54h]
fffff802`7f7d4d68 650fae142580010000 ldmxcsr dword ptr gs:[180h]
fffff802`7f7d4d71807b0300 cmp byte ptr [rbx+3],0
fffff802`7f7d4d7566c785800000000000 mov word ptr [rbp+80h],0
fffff802`7f7d4d7e0f849a000000 je nt!KiSystemServiceUser+0xce(fffff802`7f7d4e1e)
---------------------------------------省略若干代码------------------------------------
nt!KiSystemServiceRepeat:
fffff802`7f7d4e444c8d1535292300 lea r10,[nt!KeServiceDescriptorTable(fffff802`7fa07780)]
fffff802`7f7d4e4b4c8d1dee282300 lea r11,[nt!KeServiceDescriptorTableShadow(fffff802`7fa07740)]
fffff802`7f7d4e52f7437840000000 test dword ptr [rbx+78h],40h
fffff802`7f7d4e594d0f45d3 cmovne r10,r11
fffff802`7f7d4e5d423b441710 cmp eax,dword ptr [rdi+r10+10h]
fffff802`7f7d4e620f83ef020000 jae nt!KiSystemServiceExit+0x1ac(fffff802`7f7d5157)
如上红色代码。在KiSystemServiceRepeat里面找到了KeServiceDescriptorTable ,之后我们看一下KeServiceDescriptorTable:
kd> dq KeServiceDescriptorTablefffff802`7fa07780 fffff802`7f94a150 00000000`00000000
fffff802`7fa07790 00000000`000001bc fffff802`7f94af34
fffff802`7fa077a0 00000000`00000000 00000000`00000000
fffff802`7fa077b0 00000000`00000000 00000000`00000000
fffff802`7fa077c0 00000000`00000000 00000000`00f54d67
fffff802`7fa077d0 00007ffb`a8178b70 ffffe001`65e87d30
fffff802`7fa077e0 ffffe001`65e92dc0 00000000`00000000
fffff802`7fa077f0 00000000`00000000 00000000`00000001
kd> ddfffff802`7f94a150
fffff802`7f94a150 fdbeb004 fe0f4600 01930742 0365ad00
fffff802`7f94a160 01530300 fe832200 01258905 01477b06
fffff802`7f94a170 0126ce05 01a6d001 01ac7600 01307e80
fffff802`7f94a180 01992b00 0128df00 01550300 0111da00
fffff802`7f94a190 01a65601 01497a01 01368700 013c1302
fffff802`7f94a1a0 015b9700 01b7bf80 013f3801 013fbb02
fffff802`7f94a1b0 011ad302 0130e201 01a84801 01a02545
fffff802`7f94a1c0 01694e00 01359b43 0118cf00 035d5b00
KeServiceDescriptorTable基地址:fffff802`7fa07780,ServiceTable的基地址为**KeServiceDescriptorTable = fffff802`7f94a150
公式:
ServiceTableBase = **KeServiceDescriptorTableServiceTableBase[Index] = ServiceTableBase + Index * 4
ServiceAddress = ServiceTableBase[Index] >> 4 + ServiceTableBase
通过公式,我们可以推算CreateFile这个服务调用的服务地址为(CreateFile的服务号为0x55h):
1) ServiceTableBase[Index] = 0xfffff802`7f94a150 + 0x55 * 4= 0x FFFFF8027F94A2A4
2) ServiceTableBase[Index] >> 4= [0xFFFFF8027F94A2A4] >>4 = 0x01367507 >> 4 = 0x0136750
kd> ddFFFFF8027F94A2A4fffff802`7f94a2a4 01367507 01b3c141 035d77c2 0135fd80fffff802`7f94a2b4 01b7944c 03a756c0 01a60001 01a52600fffff802`7f94a2c4 01a00a00 fd94b700 01f70b41 010c0202fffff802`7f94a2d4 fe17e440 fe336b03 fe0f19c7 ff3873c7fffff802`7f94a2e4 038ee8cc 038ef34d 018c3640 03ad5b40fffff802`7f94a2f4 03ad5d40 0211e2c2 0283e24c 03808c40fffff802`7f94a304 03809d00 01258280 01ab9b00 00f056c0fffff802`7f94a314 0364a140 01ce0180 019480c5 019a7800
3) ServiceAddress = ServiceTableBase[Index] >> 4 + ServiceTableBase= 0x0136750 + 0xfffff802`7f94a150
= 0xFFFFF8027FA808A0
kd> uFFFFF8027FA808A0nt!NtCreateFile:fffff802`7fa808a04881ec88000000 sub rsp,88hfffff802`7fa808a733c0 xor eax,eaxfffff802`7fa808a94889442478 mov qword ptr [rsp+78h],raxfffff802`7fa808aec744247020000000 mov dword ptr [rsp+70h],20hfffff802`7fa808b689442468 mov dword ptr [rsp+68h],eaxfffff802`7fa808ba4889442460 mov qword ptr [rsp+60h],raxfffff802`7fa808bf89442458 mov dword ptr [rsp+58h],eaxfffff802`7fa808c38b8424e0000000 mov eax,dword ptr [rsp+0E0h]kd> xnt!NtCreateFilefffff802`7fa808a0nt!NtCreateFile (<no parameter info>)
可以看到通过SSDT服务表的查找,内核正确的找到了服务函数。
5. Win10 X64系统调用跟踪分析
5.1. 研究测试系统环境搭建
本文主要研究Windows 10 X64系统的系统调用框架,主要使用Visual Studio 2015及WinDbg工具(通过VS2015集成环境使用),具体的环境搭建参考本人的另外一篇博文《Win10下VS2015(WDK10)驱动开发环境配置》,链接地址:http://blog.csdn.net/liuyez123/article/details/50857621,环境准备就绪后,在VS2015中新建一个项目syscalltest(Visual
C++-->Windows-->空白应用通用Windows),准备一段VC测试代码,代码中通过使用Windows API来实现系统调用,我们通过VS2015的调试功能来跟踪系统调用实现的流程及框架。代码的内容如下:
main.cpp内容:#include "stdafx.h"
#include <windows.h>
#include "iostream"
using namespace std;
int APIENTRY _tWinMain(HINSTANCE hInstance,
HINSTANCEhPrevInstance,
LPTSTR lpCmdLine,
int nCmdShow)
{
HANDLE hFile= CreateFile("Hello.txt", GENERIC_READ, 0, NULL, OPEN_ALWAYS, 0,NULL);
if ( hFile ==INVALID_HANDLE_VALUE )
{
OutputDebugString("无法打开文件!\n" );
return -1;
}
charbuffer[1024];
DWORDdwActualRead = 0;
BOOL bRet =ReadFile(hFile, buffer, 1024, &dwActualRead, NULL);
if ( !bRet )
{
OutputDebugString("无法读取文件!\n" );
return -1;
}
CloseHandle(hFile);
buffer[dwActualRead]= 0;
MessageBox(NULL,TEXT(buffer), TEXT("Hello"), 0);
return 0;
}
通过跟踪代码中使用的CreateFile系统API来发现Windows 10 X64系统是怎么调用内核实现的过程。
注意:
1) 在工具菜单—》选项窗口中设置调试属性窗中不要勾选--启用“仅我的代码”,如果此项勾选则在单步跟踪时不能够步入到windowsAPI内部。2) 勾选“源代码不可用时显示反汇编”
3) 设置好符号文件位置
5.2. 分析跟踪过程
5.1.1. 用户模式的调用过程
1) 在VS2015 IDE环境中将刚才准备的syscalltest项目设置为X64,Debug方式,然后编译生成项目。
2) 开始调试项目,在语句HANDLE hFile = CreateFile("Hello.txt", GENERIC_READ, 0, NULL,OPEN_ALWAYS, 0, NULL);处设置断点,程序运行到此断点停下,打开调试菜单,在窗口项目上打开反汇编窗口,然后按F11键,单步步入API函数的汇编代码,调试界面如下(L注意:由于WIN10的系统保护机制,每次程序的加载地址可能都不一样)。
继续按F11键单步运行,到第一个Call qword ptr[_imp_CreateFileA(地址xxxxxh)],从截图中标注点可以看出这里还是实在应用程序空间,按F11键单步步入。
3) 从下面的截图可以看出这时程序已经步入到应用层的KERNEL32.DLL系统函数空间。
通过DLL Export Viewer工具打开KERNEL32.DLL文件,可以看到在KERNEL32.DLL文件中CreateFileA方法的相对偏移地址为0x0002d8a0 ,在VS2015的模块窗口中可以看到本次KERNEL32.DLL的基地址为0x00007FFF836A0000,则CreateFileA在内存中的地址为:0x00007FFF836A0000 + 0x0002d8a0 = 0x00007FFF836CD8A0, 这和VS汇编窗口中的汇编指令行地址是一致的。我们继续F11单步跟踪步入。
4) 这个时候程序又跳到应用层的KERNELBASE.DLL系统函数空间。
通过DLL Export Viewer工具打开KERNELBASE.DLL文件,可以看到在KERNELBASE.DLL文件中CreateFileA方法的相对偏移地址为0x00060d90 ,在VS2015的模块窗口中可以看到本次KERNELBASE.DLL的基地址为0x00007FFF80D90000,则CreateFileA在内存中的地址为:0x00007FFF80D90000 + 0x00060d90 = 0x00007FFF80DF0D90,
这和VS汇编窗口中的汇编指令行地址是一致的。我们继续F10单步运行,直到汇编指令Call CreateFileW(0x00007FFF80DAA0F0h),在按键F11单步跟踪步入。这个时候程序又跳到KERNELBASE.DLL动态库的CreateFileW函数空间,通过DLL Export Viewer工具打开KERNELBASE.DLL文件,可以看到在KERNELBASE.DLL文件中CreateFileW方法的相对偏移地址为0x0001a0f0 ,在VS2015的模块窗口中可以看到本次KERNELBASE.DLL的基地址为0x00007FFF80D90000,则CreateFileA在内存中的地址为:0x00007FFF80D90000
+ 0x0001a0f0 = 0x00007FFF80DAA0F0, 这和VS汇编窗口中的汇编指令行地址是一致的。继续按F10键单步运行,直到汇编语句call CreateFileInternal,按F11键单步跟踪步入调用函数,CreateFileInternal是KERNELBASE.DLL动态库的内部函数,他是在为进行内核系统调用初始化相应的数据,我们可以一直F10单步运行到汇编指令会运行到call
qword ptr[__imp_NtCreateFile (07FF922EFC1A8h)] (注意:每次跟踪可能模块加载的地址会都不一样)。再按F11键单步跟踪步入函数,这次指令来到了NTDLL.DLL动态库的地址空间。并且看到了syscall指令和int
2Eh指令,也就是说我们来到了用户模式和内核模式交界的地方,在NTDLL的NtCreateFile函数内初始化EAX为0x55H系统服务号,同时判断运行的系统环境是要使用INT
2Eh中断调用方式,根据跟踪在我的系统上使用的是SYSCALL系统调用的方式。通过DLL Export Viewer工具打开NTDLL.DLL文件,可以看到在NTDLL.DLL文件中NtCreateFile方法的相对偏移地址为0x000a5b60 ,在VS2015的模块窗口中可以看到本次KERNELBASE.DLL的基地址为0x00007FFF844F0000,则NtCreateFile在内存中的地址为:0x00007FFF844F0000
+ 0x000a5b60 = 0x00007FFF84595B60, 这和VS汇编窗口中的汇编指令行地址是一致的。这个时候可以将窗口视图切换到调用堆栈视图,这时可以看到当前的函数调用堆栈,效果如下图:
到此我们已经跟踪了全部用户模式下API的调用过程,SYSCALL指令后系统将切换到内核模式,后面我们将分析在内核模式下系统调用过程是怎么样工作的。
5.1.2. 内核模式的调用过程
内核模式下我们建议采用双机调试方式或虚拟机的方式进行调试,具体的环境搭建参考本人的另外一篇博文《Win10下VS2015(WDK10)驱动开发环境配置》,链接地址:链接地址:http://blog.csdn.net/liuyez123/article/details/50857621。
环境准备就绪后,在上面用户模式调试使用的程序源代码中的CreateFile调用前增加一句MessageBox(NULL,TEXT(buffer),
TEXT("Hello"), 0)语句; 并重新生成项目。运行生成的运行文件,这时在测试计算机上弹出了一个消息窗口。在主计算机的VS2015的调试菜单中—》选择附加到进程--》在传输(P)下拉框中选择Windows Kernel Model Debugger,在限定符(Q)下拉框中选择刚才配置的测试目标主机名称—》在可用进程中选择Kernel—》最后点击附加按钮。
在VS2015打开调试窗口后,点击工具栏上调试菜单中的全部中断按钮(菜单),在命令行中运行以下命令:
kd> .symfixkd> !sym noisy
noisy mode - symbolprompts off
kd>.reload /f
这时符号加载成功,接下来我们需要找到syscalltest.exe进程的一些信息,以确保我们只在这个进程里断下,而不是在每个进程都断下。而我们需要寻找的信息是一个指向EPROCESS结构的指针。该EPROCESS结构是用来表示一个进程的主内核数据结构。你可以看到包含“DT _EPROCESS”(在EPROCESS结构dump类型)的信息。为了找到一个给定的过程的EPROCESS结构,我们可以调用!Process扩展命令。该扩展命令打印目标系统中当前活动进程的信息。我们过滤筛选出syscalltest.exe的进程,并且只显示最低限度的信息:
kd>!process 0 0 syscalltest.exePROCESS
ffffe000eb1a1300SessionId: 1 Cid: 11e0 Peb: 00266000 ParentCid: 0b60
DirBase: 2e466000 ObjectTable: ffffc0008a560dc0 HandleCount: <Data Not Accessible>
Image:syscalltest.exe
该EPROCESS的指针为蓝色突出“PROCESS”字段,我们接下来就会用到这个值。
我们要设置在内核中设置NtCreateFile断点。这是系统的调用,所有用户模式调用CreateFile的api最终内核都会调用该函数。通过在此处设置断点,我们可以看到系统用户模式切换到内核模式后运行的过程。所以我们将使用上述EPROCESS值,要求从我们选择的进程上下文中断NtCreateFile函数。我们可以使用命令如下:
kd>bp /p ffffe000eb1a1300 nt!NtCreateFilekd> g
这就只在我们的进程中设置(通过/ P使用我们的EPROCESS值)nt!NtCreateFile(NT为内核模块的名称)断点。在测试计算机上点击OK按钮,消息对话窗口消失,测试程序继续运行,当断点命中时主计算机的VS调试窗口会切换到断点处,并且系统也进入断点中断状态。
通过程序调用堆栈窗口可以看到系统的调用情况,但是由于系统采用多线程方式,因此看到的调用堆栈信息不完整。通过lmi命令可以可到内核加载的模块信息,我们可以看到调用的nt!NtCreateFile函数地址fffff800`b08838a0是落在内核模块ntkrnlmp.exe的地址空间fffff800`b048b000 fffff800`b0c57000内的。
上文我们已经讲到内核的函数调用时通过SSDT系统服务描述表来分发调用内核文件的,内核导出的SSDT是具有以下格式符号KeServiceDescriptorTable的结构:
typedef struct _KSERVICE_DESCRIPTOR_TABLE {PULONG ServiceTableBase; // Pointer to function/offset table (the table itself is exported as KiServiceTable)
PULONG ServiceCounterTableBase;
ULONG NumberOfServices; // The number of entries in ServiceTableBase
PUCHAR ParamTableBase;
} KSERVICE_DESCRIPTOR_TABLE,*PKSERVICE_DESCRIPTOR_TABLE;
在Windows的32位版本,ServiceTableBase是一个指向函数指针数组的指针。在64位中稍微有点复杂,ServiceTableBase指向数组偏移值为32位处,全部都是相对于KiServiceTable在存储器中的表的位置,这使得可视化使用常用的内存显示命令(如dds)是不可能的。相反,我们将不得不使用一些WinDbgs更高级的命令在列表中迭代,数据操纵到一个更合适的形式。
让我们先来看看在内存中是如何偏移,我们可以用dd(显示DWORD)命令列出数组偏移值。使用/c 1选项指示调试器每行显示一个DWORD:
kd> dqKeServiceDescriptorTablefffff800`b080a780 fffff800`b074d150 00000000`00000000
fffff800`b080a790 00000000`000001bc fffff800`b074df34
fffff800`b080a7a0 00000000`00000000 00000000`00000000
fffff800`b080a7b0 00000000`00000000 00000000`00000000
fffff800`b080a7c0 00000000`00000000 00000000`00f54d53
fffff800`b080a7d0 00007ff8`e0758b70 ffffe000`e8485dc0
fffff800`b080a7e0 ffffe000`e8487dc0 00000000`00000000
fffff800`b080a7f0 00000000`00000000 00000000`00000001
kd> dd /c 1KiServiceTable
DBGHELP:SharedUserData - virtual symbol module
fffff800`b074d150 fdbeb004
fffff800`b074d154 fe0f4600
fffff800`b074d158 01930742
fffff800`b074d15c 0365ad00
fffff800`b074d160 01530300
fffff800`b074d164 fe832200
fffff800`b074d168 01258905
fffff800`b074d16c 01477b06
fffff800`b074d170 0126ce05
fffff800`b074d174 01a6d001
fffff800`b074d178 01ac7600
……
这些值通过左移4位并和其他数据编码,最终至少显示四位。为了形成我们需要的每个值的绝对存储器地址,需要右移4位移,并加上KiServiceTable的地址。我们希望在表的每个入口点都这样做,并输出与绝对地址相关联的符号。要做到这一点,我们可以使用.foreach命令迭代,使用.printf显示符号。下面是一个命令的实现,会对每个部分进行解释和说明:
.foreach /ps 1 /pS 1 ( offset {dd /c 1 nt!KiServiceTable L poi(nt!KeServiceDescriptorTable+10)}){ .printf "%y\n", ( offset >>> 4) + nt!KiServiceTable }
.foreach——该步骤指定每个令牌(在我们的例子中,我们使用dd命令来提供令牌)。这些参数/ PS 1和/ PS 1使得foreach每秒跳过一个令牌。我们这样做是因为dd命令输出<地址><值>,我们只对当前值感兴趣。这些选项每次跳过令牌地址。
偏移——声明一个名为offset变量,该变量保存当前foreach迭代的令牌(当前的偏移值)
dd
——运行dd命令显示DWORD的偏移列表,这些将被.foreach进行迭代。/C 1确保每行只输出一个DWORD。Nt!KiServiceTable是我们将要显示的地址(这是偏移数组)。”L poi(nt!KeServiceDescriptorTable+10)”描述了要显示多少个值。在这种情况下,我们从指向我们的结构体NumberOfServices的KeServiceDescriptorTable开始处取出16个字节(10H),POI(),然后间接引用实际地址存储的值,例如表中有效入口的值。.printf
——printf命令让我们执行格式化的打印。这里我们使用格式化字符串%y向给定的内存地址打印符号。当我们传递一个参数“(offset>>>4)+!NT KiServiceTable”,这是当前偏移值右移4位,并添加到KiServiceTable的地址。我们使用>>>operator而不是>>operator,来保持的符号位,因为一些值是代表负偏移。如果标志设置正确,上述命令的输出结果应该是这样:
kd> .foreach /ps1 /pS 1 ( offset {dd /c 1 nt!KiServiceTable Lpoi(nt!KeServiceDescriptorTable+10)}){ .printf "%y\n", ( offset>>> 4) + nt!KiServiceTable }nt!NtAccessCheck(fffff800`b050bc50)
nt!NtWorkerFactoryWorkerReady(fffff800`b055c5b0)
nt!NtAcceptConnectPort(fffff800`b08e01c4)
nt!NtMapUserPhysicalPagesScatter(fffff800`b0ab2c20)
nt!NtWaitForSingleObject(fffff800`b08a0180)
nt!NtCallbackReturn(fffff800`b05d0370)
nt!NtReadFile(fffff800`b08729e0)
nt!NtDeviceIoControlFile(fffff800`b0894900)
nt!NtWriteFile(fffff800`b0873e30)
nt!NtRemoveIoCompletion(fffff800`b08f3e50)
nt!NtReleaseSemaphore(fffff800`b08f98b0)
nt!NtReplyWaitReceivePort(fffff800`b087d938)
nt!NtReplyPort(fffff800`b08e6400)
nt!NtSetInformationThread(fffff800`b0875f40)
nt!NtSetEvent(fffff800`b08a2180)
nt!NtClose(fffff800`b085eef0)
……
nt!NtCreateFile(fffff800`b08838a0)
nt!NtQueryEvent(fffff800`b0900d64)
nt!NtWriteRequestData(fffff800`b0aaa8cc)
nt!NtOpenDirectoryObject(fffff800`b0883128)
nt!NtAccessCheckByTypeAndAuditAlarm(fffff800`b0904a94)
……
结果应该显示可供用户态代码的主要内核系统调用一个合适的SSDT功能列表。
6. 总结
Win10 X64系统在用户层使用的系统调用,将通过KERNEL32.DLL—》KERNELBASE.dll—》NTDLL.DLL的流程,然后在NTDLL动态库中通过SYSCALL或INT 2EH指令切入内核层的KiSystemCall64函数,再通过SSDT服务表派发到内核ntkrnlmp.exe模块的相应函数,最终在调用底层的驱动或模块。