根据前面的介绍,NT内核会把操作系统的代码和数据映射到系统中所有进程的内核空间中。这样,每个进程内的应用程序代码便可以很方便地调用内核空间中的系统服务。这里的“很方便”有多层含义,一方面是内核代码和用户代码在一个地址空间中,应用程序调用系统服务时不需要切换地址空间,另一方面是整个系统中内核空间的地址是统一的,编写内核空间的代码时会简单很多。但是,如此设计也带来一个很大的问题,那就是用户空间中的程序指针可以指向内核空间中的数据和代码,因此必须防止用户代码破坏内核空间中的操作系统。怎么做呢?答案是利用权限控制来实现对内核空间的保护。

2.6.1 访问模式

Windows定义了两种访问模式(access mode)——用户模式(user mode,也称为用户态)和内核模式(kernel
mode,也称为内核态)。应用程序(代码)运行在用户模式下,操作系统代码运行在内核模式下。内核模式对应于处理器的最高权限级别(不考虑虚拟机情况),在内核模式下执行的代码可以访问所有系统资源并具有使用所有特权指令的权利。相对而言,用户模式对应于较低的处理器优先级,在用户模式下执行的代码只可以访问系统允许其访问的内存空间,并且没有使用特权指令的权利。

本书卷1介绍过,IA-32处理器定义了4种特权级别(privilege
level),或者称为环(ring),分别为0、1、2、3,优先级0(环0)的特权级别最高。处理器在硬件一级保证高优先级的数据和代码不会被低优先级的代码破坏。Windows系统使用了IA-32处理器所定义的4种优先级中的两种,优先级3(环3)用于用户模式,优先级0用于内核模式。之所以只使用了其中的两种,主要是因为有些处理器只支持两种优先级,比如Compaq
Alpha处理器。值得说明的是,对于x86处理器来说,并没有任何寄存器表明处理器当前处于何种模式(或优先级)下,优先级只是代码或数据所在的内存段或页的一个属性,参见卷1的2.6节和2.7节。

因为内核模式下的数据和代码具有较高的优先级,所以用户模式下的代码不可以直接访问内核空间中的数据,也不可以直接调用内核空间中的任何函数或例程。任何这样的尝试都会导致保护性错误。也就是说,即使用户空间中的代码指针正确指向了要访问的数据或代码,但一旦访问发生,那么处理器会检测到该访问是违法的,会停止该访问并产生保护性异常(#GP)。

虽然不可以直接访问,但是用户程序可以通过调用系统服务来间接访问内核空间中的数据或间接调用、执行内核空间中的代码。当调用系统服务时,主调线程会从用户模式切换到内核模式,调用结束后再返回到用户模式,也就是所谓的模式切换。在线程的KTHREAD结构中,定义了UserTime和KernelTime两个字段,分别用来记录这个线程在用户模式和内核模式的运行时间(以时钟中断次数为单位)。模式切换是通过软中断或专门的快速系统调用(fast
system call)指令来实现的。下面通过一个例子来分别介绍这两种切换机制。

2.6.2 使用INT 2E切换到内核模式

图2-4展示了在Windows 2000中通过INT 2E从应用程序调用ReadFile() API的过程。因为ReadFile()
API是从Kernel32.dll导出的,所以我们看到该调用首先转到Kernel32.dll中的ReadFile()函数,ReadFile()函数在对参数进行简单检查后便调用NtDll.dll中的NtReadFile()函数。

 

图2-4 通过INT 2E从应用程序调用ReadFile() API的过程

通过反汇编可以看到,NtDll.dll中的NtReadFile
()函数非常简短,首先将ReadFile()对应的系统服务号(0xa1,与版本有关)放入EAX寄存器中,将参数指针放入EDX寄存器中,然后便通过INT n
指令发出调用。这里要说明的一点是,虽然每个系统服务都具有唯一的号码,但微软公司没有公开这些服务号,也不保证这些号码在不同的Windows版本中会保持一致。
ntdll!NtReadFile: // Windows 2000 77f8fb5d b8a1000000 mov eax,0xa1 77f8fb62
8d542404 lea edx,[esp+0x4] 77f8fb66 cd2e int 2e 77f8fb68 c22400 ret 0x24
在WinDBG下通过!idt 2e命令可以看到2e号向量对应的服务例程是KiSystemService ()。KiSystemService
()是内核态中专门用来分发系统调用的例程。
lkd> !idt 2e Dumping IDT: 2e: 804db1ed nt!KiSystemService
Windows将2e号向量专门用于系统调用,在启动早期初始化中断描述符表(Interrupt Descriptor
Table,IDT)时(见第11章)便注册好了合适的服务例程。因此当NTDll.DLL中的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后面的那条指令)。

通过INT 2E进行系统调用时,CPU必须从内存中分别加载门描述符和段描述符才能得到KiSystemService
()的地址,即使门描述符和段描述符已经在高速缓存中,CPU也需要通过“内存读(memory read)”操作从高速缓存中读出这些数据,然后进行权限检查。

2.6.3 快速系统调用

因为系统调用是非常频繁的操作,所以如果能减少这些开销还是非常有意义的。可以从两个方面来降低开销:一是把系统调用服务例程的地址放到寄存器中以避免读IDT这样的内存操作,因为读寄存器的速度比读内存的速度要快很多;二是避免权限检查,也就是使用特殊的指令让CPU省去那些对系统服务调用来说根本不需要的权限检查。奔腾II处理器引入的SYSENTER/SYSEXIT指令正是按这一思路设计的。AMD
K7引入的SYSCALL/SYSRETURN指令也是为这一目的而设计的。相对于INT
2E,使用这些指令可以加快系统调用的速度,因此利用这些指令进行的系统调用称为快速系统调用。

下面我们介绍Windows系统是如何利用IA-32处理器的SYSENTER/SYSEXIT指令(从奔腾II开始)实现快速系统调用的[2]。首先,Windows
2000或之前的Windows系统不支持快速系统调用,它们只能使用前面介绍的INT 2E方式进行系统调用。Windows XP和Windows Server
2003或更新的版本在启动过程中会通过CPUID指令检测CPU是否支持快速系统调用指令(EDX寄存器的SEP标志位)。如果CPU不支持这些指令,那么仍使用INT
2E方式。如果CPU支持这些指令,那么Windows系统便会决定使用新的方式进行系统调用,并做好如下准备工作。

(1)在全局描述符表(GDT)中建立4个段描述符,分别用来描述供SYSENTER指令进入内核模式时使用的代码段(CS)和栈段(SS),以及SYSEXIT指令从内核模式返回用户模式时使用的代码段和栈段。这4个段描述符在GDT中的排列应该严格按照以上顺序,只要指定一个段描述符的位置便能计算出其他的。

(2)设置表2-1中专门用于系统调用的MSR(关于MSR的详细介绍见卷1的2.4.3节),SYSENTER_EIP_MSR用于指定新的程序指针,也就是SYSENTER指令要跳转到的目标例程地址。Windows系统会将其设置为KiFastCallEntry的地址,因为KiFastCallEntry例程是Windows内核中专门用来受理快速系统调用的。SYSENTER_CS_MSR用来指定新的代码段,也就是KiFastCallEntry所在的代码段。SYSENTER_ESP_MSR用于指定新的栈指针(ESP)。新的栈段是由SYSENTER_CS_MSR的值加8得来的。

(3)将一小段名为SystemCallStub的代码复制到SharedUserData内存区,该内存区会被映射到每个Win32进程的进程空间中。这样当应用程序每次进行系统调用时,NTDll.DLL中的残根(stub)函数便调用这段SystemCallStub代码。SystemCallStub的内容因系统硬件的不同而不同,对于IA-32处理器,该代码使用SYSENTER指令,对于AMD处理器,该代码使用SYSCALL指令。

表2-1 供SYSENTER指令使用的MSR(略)

例如在配有Pentium M CPU的Windows XP系统上,以上3个寄存器的值分别为:
lkd> rdmsr 174 msr[174] = 00000000`00000008 lkd> rdmsr 175 msr[175] =
00000000`bacd8000 lkd> rdmsr 176 msr[176] = 00000000`8053cad0

其中SYSENTER_CS_MSR的值为8,这是Windows系统的内核代码段的选择子,即常量KGDT_R0_CODE的值。WinDBG帮助文件中关于dg命令的说明中列出了这个常量。SYSENTER_EIP_MSR的值是8053cad0,检查nt内核中KiFastCallEntry函数的地址。
lkd> x nt!KiFastCallEntry 8053cad0 nt!KiFastCallEntry = <no type information>
可见,Windows把快速系统调用的目标指向内核代码段中的KiFastCallEntry函数。

通过反汇编Windows XP下NTDll.DLL中的NtReadFile
()函数,可以看到SystemCallStub被映射到进程的0x7ffe0300位置。与前面Windows
2000下的版本相比,容易看到该服务的系统服务号码在这两个版本间是不同的。
kd> u ntdll... ntdll!NtReadFile: // Windows XP 77f5bfa8 b8b7000000 mov
eax,0xb7 77f5bfad ba0003fe7f mov edx,0x7ffe0300 77f5bfb2 ffd2 call edx
{SharedUserData!SystemCallStub (7ffe0300)} 77f5bfb4 c22400 ret 0x24 77f5bfb7 90
nop

观察本段下面反汇编SystemCallStub的结果,它只包含3条指令,分别用于将栈指针(ESP寄存器)放入EDX寄存器中、执行sysenter指令和返回。第一条指令有两个用途:一是向内核空间传递参数;二是指定从内核模式返回时的栈地址。因为笔者使用的是英特尔奔腾M处理器,所以此处是sysenter指令,对于AMD处理器,此处应该是syscall指令。
kd> u... SharedUserData!SystemCallStub: 7ffe0300 8bd4 mov edx,esp 7ffe0302
0f34 sysenter 7ffe0304 c3 ret
下面让我们看一下KiFastCallEntry例程,其清单如下所示。
kd> u nt!KiFastCallEntry L20 nt!KiFastCallEntry: 804db1bb 368b0d40f0dfff mov
ecx,ss:[ffdff040] 804db1c2 368b6104 mov esp,ss:[ecx+0x4] 804db1c6 b90403fe7f
mov ecx,0x7ffe0304 804db1cb 3b2504f0dfff cmp esp,[ffdff004] 804db1d1
0f84cc030000 je nt!KiServiceExit2+0x13f (804db5a3) 804db1d7 6a23 push 0x23
804db1d9 52 push edx 804db1da 83c208 add edx,0x8 804db1dd 6802020000 push 0x202
804db1e2 6a02 push 0x2 804db1e4 9d popfd 804db1e5 6a1b push 0x1b 804db1e7 51
push ecx // Fall Through,自然进入KiSystemService函数 nt!KiSystemService: 804db1e8 90
nop 804db1e9 90 nop 804db1ea 90 nop 804db1eb 90 nop 804db1ec 90 nop
nt!KiSystemService: 804db1ed 6a00 push 0x0 804db1ef 55 push ebp
显而易见,KiFastCallEntry在做了些简单操作后,便下落(fall
through)到KiSystemService函数了,也就是说,快速系统调用和使用INT
2E进行的系统调用在内核中的处理绝大部分是一样的。另外,请注意ecx寄存器,mov
ecx,0x7ffe0304将其值设为0x7ffe0304,也就是SharedUserData内存区里SystemCallStub例程中ret指令的地址(参见上文的SystemCallStub代码)。在进入nt!KiSystemService之前,ecx连同其他一些参数被压入栈中。事实上,ecx用来指定SYSEXIT返回用户模式时的目标地址。当使用INT
2E进行系统调用时,由于INT n
指令会自动将中断发生时的CS和EIP寄存器压入栈中,当中断处理例程通过执行iretd返回时,iretd指令会使用栈中保存的CS和EIP值返回合适的位置。因为sysenter指令不会向栈中压入要返回的位置,所以sysexit指令必须通过其他机制知道要返回的位置。这便是压入ECX寄存器的原因。通过反汇编KiSystemCallExit2例程,我们可以看到在执行sysexit指令之前,ecx寄存器的值又从栈中恢复出来了。
kd> u nt!KiSystemCallExit l20 nt!KiSystemCallExit: 804db3b4 cf iretd
nt!KiSystemCallExit2: 804db3b5 5a pop edx 804db3b6 83c408 add esp,0x8 804db3b9
59 pop ecx 804db3ba fb sti 804db3bb 0f35 sysexit nt!KiSystemCallExit3: 804db3bd
59 pop ecx 804db3be 83c408 add esp,0x8 804db3c1 5c pop esp 804db3c2 0f07 sysret

以上代码中包含了3个从系统调用返回的例程,即KiSystemCallExit、KiSystemCallExit2和KiSystemCallExit3,它们分别对应于使用INT
2E、sysenter和syscall发起的系统调用,如表2-2所示。

表2-2 系统调用(略)

图2-5展示了使用sysenter/sysexit指令对进行系统调用的完整过程(以调用ReadFile服务为例)。

 

图2-5 快速系统调用(针对IA-32处理器)

 

 

格物

下面通过一个小的实验来加深大家对系统调用的理解。首先启动WinDBG程序,选择File → Open Crash
Dump,然后选择本书实验文件中的dumps\w732cf4.dmp文件。在调试会话建立后,先执行.symfix
c:\symbols和.reload加载模块与符号,再执行k命令,便得到清单2-4所示的完美栈回溯。

第22章将详细讲解栈回溯的原理,现在大家只要知道栈上记录着函数相互调用时的参数和返回地址等信息。栈回溯是从栈上找到这些信息,然后显示出来的过程,是追溯线程执行轨迹的一种便捷方法。

清单2-4还显示了任务管理器程序(taskmgr)调用NtTerminateProcess系统服务时的执行过程。栈回溯的结果包含4列,第一列是序号,第二列是每个函数的栈帧基地址,第三列是返回地址,第四列是使用“函数名+字节偏移量”形式表达的执行位置。以00栈帧为例,它对应的函数是著名的蓝屏函数KeBugCheckEx,它的栈帧基地址是9796fb9c,它的返回地址是82b1ab51,翻译成符号便是PspCatchCriticalBreak+0x71。

清单2-4 完美栈回溯
# ChildEBP RetAddr 00 9796fb9c 82b1ab51 nt!KeBugCheckEx+0x1e 01 9796fbc0
82a6daa8 nt!PspCatchCriticalBreak+0x71 02 9796fbf0 82a605b6
nt!PspTerminateAllThreads+0x2d 03 9796fc24 8287c87a nt!NtTerminateProcess+0x1a2
04 9796fc24 77da7094 nt!KiFastCallEntry+0x12a 05 001df4dc 77da68d4
ntdll!KiFastSystemCallRet 06 001df4e0 76193c82 ntdll!NtTerminateProcess+0xc 07
001df4f0 00bf57b9 KERNELBASE!TerminateProcess+0x2c 08 001df524 00bf67ec
taskmgr!CProcPage::KillProcess+0x116 09 001df564 00bebc96
taskmgr!CProcPage::HandleWMCOMMAND+0x10f 0a 001df5d8 76abc4e7
taskmgr!ProcPageProc+0x275 0b 001df604 76ad5b7c USER32!InternalCallWinProc+0x23
0c 001df680 76ad59f3 USER32!UserCallDlgProcCheckWow+0x132 0d 001df6c8 76ad5be3
USER32!DefDlgProcWorker+0xa8 0e 001df6e4 76abc4e7 USER32!DefDlgProcW+0x22 0f
001df710 76abc5e7 USER32!InternalCallWinProc+0x23 10 001df788 76ab5294
USER32!UserCallWinProcCheckWow+0x14b 11 001df7c8 76ab5582
USER32!SendMessageWorker+0x4d0 12 001df7e8 74e94601 USER32!SendMessageW+0x7c 13
001df808 74e94663 COMCTL32!Button_NotifyParent+0x3d 14 001df824 74e944ed
COMCTL32!Button_ReleaseCapture+0x113 15 001df884 76abc4e7
COMCTL32!Button_WndProc+0xa18 16 001df8b0 76abc5e7
USER32!InternalCallWinProc+0x23 17 001df928 76abcc19
USER32!UserCallWinProcCheckWow+0x14b 18 001df988 76abcc70
USER32!DispatchMessageWorker+0x35e 19 001df998 76ab41eb
USER32!DispatchMessageW+0xf 1a 001df9bc 00be16fc USER32!IsDialogMessageW+0x588
1b 001dfdac 00be5384 taskmgr!wWinMain+0x5d1 1c 001dfe40 76bbed6c
taskmgr!_initterm_e+0x1b1 1d 001dfe4c 77dc377b kernel32!BaseThreadInitThunk+0xe
1e 001dfe8c 77dc374e ntdll!__RtlUserThreadStart+0x70 1f 001dfea4 00000000
ntdll!_RtlUserThreadStart+0x1b

仔细观察清单2-4中的地址部分,很容易看出用户空间和内核空间的分界,也就是在栈帧04和栈帧05之间。栈帧05中的KiFastSystemCallRet函数属于ntdll模块,位于用户空间。栈帧04中的KiFastCallEntry函数属于nt模块,位于内核空间。栈帧04的基地址是9796fc24,属于内核空间;栈帧05的基地址是001df4dc,属于用户空间。它们分别来自这个线程的内核态栈和用户态栈。WinDBG的k命令穿越两个空间,遍历两个栈,显示出线程在用户空间和内核空间执行的完整过程,能产生如此完美的栈回溯显示了WinDBG的强大。

2.6.4 逆向调用

前文介绍了从用户模式进入内核模式的两种方法,通过这两种方法,用户模式的代码可以“调用”位于内核模式的系统服务。那么内核模式的代码是否可以主动调用用户模式的代码呢?答案是肯定的,这种调用通常称为逆向调用(reverse
call)。

简单来说,逆向调用的过程是这样的。首先内核代码使用内核函数KiCallUserMode发起调用。接下来的执行过程与从系统调用返回(KiServiceExit)类似,不过进入用户模式时执行的是NTDll.DLL中的KiUserCallbackDispatcher。而后KiUserCallbackDispatcher会调用内核希望调用的用户态函数。当用户模式的工作完成后,执行返回动作的函数会执行INT
2B指令,也就是触发一个0x2B异常。这个异常的处理函数是内核模式的KiCallbackReturn函数。于是,通过INT
2B异常,CPU又跳回内核模式继续执行了。
lkd> !idt 2b Dumping IDT: 2b: 8053d070 nt!KiCallbackReturn
以上是使用WinDBG的!idt命令观察到的0x2B异常的处理函数。

2.6.5 实例分析

下面通过一个实际例子来进一步展示系统调用和逆向调用的执行过程。清单2-5显示了使用WinDBG的内核调试会话捕捉到的记事本进程发起系统调用进入内核和内核函数执行逆向调用的全过程(栈回溯)。

清单2-5 记事本进程从发起系统调用进入内核和内核函数逆向调用的全过程
kd> kn # ChildEBP RetAddr 00 0006fe94 77fb4da6 USER32!XyCallbackReturn 01
0006fe94 8050f8ae ntdll!KiUserCallbackDispatcher+0x13 02 f4fc19b4 80595d2c
nt!KiCallUserMode+0x4 03 f4fc1a10 bf871e98 nt!KeUserModeCallback+0x87 04
f4fc1a90 bf8748d4 win32k!SfnDWORD+0xa0 05 f4fc1ad8 bf87148d
win32k!xxxSendMessageToClient+0x174 06 f4fc1b24 bf8714d3
win32k!xxxSendMessageTimeout+0x1a6 07 f4fc1b44 bf8635f6
win32k!xxxSendMessage+0x1a 08 f4fc1b74 bf84a620 win32k!xxxMouseActivate+0x22d
09 f4fc1c98 bf87a0c1 win32k!xxxScanSysQueue+0x828 0a f4fc1cec bf87a8ad
win32k!xxxRealInternalGetMessage+0x32c 0b f4fc1d4c 804da140
win32k!NtUserGetMessage+0x27 0c f4fc1d4c 7ffe0304 nt!KiSystemService+0xc4 0d
0006feb8 77d43a21 SharedUserData!SystemCallStub+0x2 0e 0006febc 77d43c95
USER32!NtUserGetMessage+0xc 0f 0006fed8 010028e4 USER32!GetMessageW+0x31 10
0006ff1c 01006c54 notepad!WinMain+0xe3 11 0006ffc0 77e814c7
notepad!WinMainCRTStartup+0x174 12 0006fff0 00000000
kernel32!BaseProcessStart+0x23

根据执行的先后顺序,最下面一行(帧#12)对应的是进程的启动函数BaseProcessStart,而后是编译器生成的进程启动函数WinMainCRTStartup,以及记事本程序自己的入口函数WinMain。帧#0f表示记事本程序在调用GetMessage
API进入消息循环。接下来GetMessage
API调用Windows子系统服务的残根函数NtUserGetMessage。从第2列的栈帧基地址都小于0x800000000可以看出,帧#12~#0d都是在用户模式执行的。帧#0d执行我们前面分析过的SystemCallStub,而后(帧#0c)便进入了内核模式的KiSystemService。KiSystemService根据系统服务号码,将调用分发给Windows子系统内核模块win32k中的NtUserGetMessage函数。

帧#0a~#05表示内核模式的窗口消息函数在工作。帧#07~#05表示要把一个窗口消息发送到用户态。帧#04的SfnDWORD表示在将消息组织好后调用KeUserModeCallback函数,发起逆向调用。帧#02表明在执行KiCallUserMode函数,帧#01表明已经在用户模式下执行,这两行之间的部分过程没有显示出来。同样,帧#01
和帧#00 之间执行用户模式函数的过程没有完全体现出来。XyCallbackReturn函数是用于返回内核模式的,它的代码很简单,只有如下几条指令。
USER32!XyCallbackReturn: 001b:77d44168 8b442404 mov eax,dword ptr [esp+4]
ss:0023:0006fe84=00000000 001b:77d4416c cd2b int 2Bh 001b:77d4416e c20400 ret 4
第1行把用户模式函数的执行结果赋给EAX寄存器,第2行执行INT 2B指令。执行过INT
2B后,CPU便转去执行异常处理程序KiCallbackReturn,回到了内核模式。

本文摘自《软件调试(第2版)卷2:Windows平台调试(上、下册)》

 

本书是国内当前集中介绍软件调试主题的权威著作。本书第2卷分为5篇,共30章,主要围绕Windows系统展开介绍。第一篇(第1~4章)介绍Windows系统简史、进程和线程、架构和系统部件,以及Windows系统的启动过程,既从空间角度讲述Windows的软件世界,也从时间角度描述Windows世界的搭建过程。第二篇(第5~8章)描述特殊的过程调用、垫片、托管世界和Linux子系统。第三篇(第9~19章)深入探讨用户态调试模型、用户态调试过程、中断和异常管理、未处理异常和JIT调试、硬错误和蓝屏、错误报告、日志、事件追踪、WHEA、内核调试引擎和验证机制。第四篇(第20~25章)从编译和编译期检查、运行时库和运行期检查、栈和函数调用、堆和堆检查、异常处理代码的编译、调试符号等方面概括编译器的调试支持。第五篇(第26~30章)首先纵览调试器的发展历史、工作模型和经典架构,然后分别讨论集成在Visual
Studio和Visual Studio(VS)Code中的调试器,最后深度解析WinDBG调试器的历史、结构和用法。

本书理论与实践结合,不仅涵盖了相关的技术背景知识,还深入研讨了大量具有代表性的技术细节,是学习软件调试技术的珍贵资料。

技术
下载桌面版
GitHub
Gitee
SourceForge
百度网盘(提取码:draw)
云服务器优惠
华为云优惠券
腾讯云优惠券
阿里云优惠券
Vultr优惠券
站点信息
问题反馈
邮箱:[email protected]
吐槽一下
QQ群:766591547
关注微信