本节将深入介绍Intel i386处理器架构,以及现代系统中更常用的AMD64架构(i386架构的扩展)的内部机制。虽然这两种架构最初由不同公司设计,但现在,这两家供应商已经实现了对方的设计。因此,尽管我们可能依然会在Windows文件和注册表键中看到这些后缀,但目前普遍用x86(32位)和x64(64位)指代这两种架构。
本节将讨论段(segmentation)、任务、Ring级别等与关键机制相关的概念,以及陷阱(trap)、中断(interrupt)和系统调用(system call)等概念。
诸如C/C++和Rust等高级编程语言会被编译为机器代码,通常可称之为汇编语言或汇编代码。借助这种低级语言可直接访问处理器寄存器。通常程序可访问以下三种主要类型的寄存器(调试代码时可见):
● 程序计数器(Program Counter,PC),在x86/x64架构中可将其称为指令指针(Instruction Pointer,IP),由EIP(x86)和RIP(x64)寄存器所代表。该寄存器始终指向正在执行的汇编代码行(某些32位ARM架构存在例外情况)。
● 栈指针(Stack Pointer,SP),由ESP(x86)和RSP(x64)寄存器所代表。该寄存器会指向保存了当前栈位置的内存位置。
● 其他通用寄存器(General Purpose Register,GPR),包括但不限于EAX/RAX、ECX/RCX、EDX/RDX、ESI/RSI及R8、R14等寄存器。
虽然这些寄存器可包含指向内存的地址值,但在访问内存位置时还需要其他寄存器的介入,这是一种称为受保护模式段(protected mode segmentation)的机制。为此需要检查各种段寄存器,此类寄存器亦可称为选择器(selector):
● 所有针对程序计数器的访问首先需要检查代码段(Code Segment,CS)寄存器。
● 所有针对栈指针的访问首先需要检查栈段(Stack Segment,SS)寄存器。
● 对其他寄存器的访问由段重写(Override)决定,段重写所用的编码方式可强制针对特定寄存器进行检查,如数据段(Data Segment,DS)、扩展段(Extended Segment,ES)或F段(F Segment,FS)。
这些选择器位于16位段寄存器中,可在一种名为全局描述符表(Global Descriptor Table,GDT)的数据结构中进行查找。为了定位GDT,处理器还会用到另一个CPU寄存器:GDT寄存器,也就是GDTR。这些选择器的格式如图8-1所示。
图8-1 x86段选择器的格式
段选择器中的偏移量可以在GDT中查看,除非TI位设置为使用另一种名为本地描述符表(Local Descriptor Table,LDT)的数据结构,该数据结构由LDTR所确定,但现代Windows操作系统中已不再使用该数据结构了。因为这种工作方式会造成这样一种结果:在被发现的段项(或者无效段项)中产生一般性保护错误(#GP)或段错误(#SF)异常。
这个段项在现代操作系统中通常被称为段描述符,主要提供两种关键用途:
● 对于代码段,它给出运行这个段选择器所加载的代码即将执行的Ring级别,也叫代码特权级别(Code Privilege Level,CPL)。Ring级别的范围介于0到3之间,会被缓存至实际选择器的最低两位,如图8-1所示。Windows操作系统会使用Ring 0来运行内核模式组件和驱动程序,并使用Ring 3运行应用程序和服务。此外在x64系统中,代码段还可体现出这是一个长模式还是兼容模式的段。前者允许x64代码以原生方式执行,后者可激活与x86的遗留兼容模式。x86系统中也存在类似机制,据此可将段标记为16位段或32位段。
● 对于其他段,它给出访问这些段所需的Ring级别,也叫描述符特权级别(Descriptor Privilege Level,DPL)。虽然在当今现代操作系统中已经算是一项过时的检查,但处理器(以及应用程序)依然会强制要求正确设置该段。
最后,在x86系统中,段项也可以使用32位基址,该值会被添加到已载入(使用重写引用该段的)寄存器的其他任意值中。随后会使用相应的段限制来检查底层寄存器的值是否超过某个固定上限。因为在大部分操作系统中,该基址会被设置为0(且限制为0xFFFFFFFF),所以x64架构代码摒弃了这个概念,但FS和GS选择器除外,它们的工作方式略有差异,如下:
●
如果代码段为长模式,那么会从FS_BASE这个特殊模块寄存器(Model Specific Register,MSR)中的0C0000100h处获得FS段的基址。对于GS段,则查看当前的Swap状态,该状态可通过swapgs指令修改,随后则会载入GS_BASE MSR(0C0000101h)或GS_SWAP MSR(0C0000102h)。
如果FS或GS段选择器寄存器中设置了TI位,则会从LDT项相应的偏移量处获得对应的值,该值只能采用32位基址。这样做是为了保证与某些忽略32位基址限制操作系统的兼容性。
● 如果代码段为兼容模式,那么会照常从相应的GDT项(如果TI位已设置,则会从LDT项)读取基址。该限制会强制实施,并且会通过段重写后寄存器中的偏移量进行验证。
FS和GS段这种有趣的行为可被Windows等操作系统用于实现某种类型的线程本地寄存器效果,借此,段基址可指向某种特定的数据结构,进而以简单的方式访问其中的特定偏移量/字段。
例如,Windows会将线程环境块(Thread Environment Block,TEB)的地址存储在x86系统的FS段或x64系统的GS(已交换)段中(TEB已在卷1第3章中进行了详细介绍)。随后,当在x86系统中执行内核模式代码时,该FS段会被手动修改为一个不同的段项,该段项包含内核处理器控制区(Kernel Processor Control Region,KPCR)的地址,而在x64系统中则是由GS(未交换)段存储该地址。
因此,段可在Windows上实现这两种效果:在处理器级别下编码并强制实施可供代码片段执行的特权级别,并分别为用户模式和内核模式代码提供对TEB和KPCR数据结构的直接访问。请注意,由于GDT是由CPU寄存器(GDTR)指向的,因此每个CPU都可以有自己的GDT。实际上,Windows正是借此保证了每个GDT都加载相应的每个处理器KPCR,并且在当前处理器上,当前执行线程的TEB同样会保存在自己的段中。
实验:在x64系统中查看GDT
在进行远程调试或分析崩溃转储文件(都需要用到LiveKD)时,我们可以使用dg这个调试器命令查看GDT的内容,包括所有段的状态及其基址(如果相关)。该命令可接收起始段和终止段,也就是下文范例中的10和50:
0: kd> dg 10 50 P Si Gr Pr Lo Sel Base Limit Type l ze an es ng flags ---- ----------------- ----------------- ---------- - -- -- -- -- -------- 0010 00000000`00000000 00000000`00000000 Code RE Ac 0 Nb By P Lo 0000029b 0018 00000000`00000000 00000000`00000000 Data RW Ac 0 Bg By P Nl 00000493 0020 00000000`00000000 00000000`ffffffff Code RE Ac 3 Bg Pg P Nl 00000cfb 0028 00000000`00000000 00000000`ffffffff Data RW Ac 3 Bg Pg P Nl 00000cf3 0030 00000000`00000000 00000000`00000000 Code RE Ac 3 Nb By P Lo 000002fb 0050 00000000`00000000 00000000`00003c00 Data RW Ac 3 Bg By P Nl 000004f3
此处的关键段为10h、18h、20h、28h、30h和50h(上述输出结果有省略,删除了与本话题无关的项)。
在10h(KGDT64_R0_CODE)中可以看到一个处于Ring 0的长模式代码段,该代码段在PI列下显示数字“0”,在Long列下显示字母“Lo”,其类型为Code RE。类似地,在20h(KGDT64_R3_CMCODE)中可以看到一个处于Ring 3的Nl段(Nl代表Not Long,也就是兼容模式),该段可用于在WoW64子系统中执行x86代码。而在30h(KGDT64_R3_CODE)中可以看到一个等价的长模式段。随后请注意18h(KGDT64_ R0_DATA)和28h(KGDT64_R3_DATA)段,它们对应栈、数据和扩展段。
还有最后一个段50h(KGDT_R3_CMTEB),除非我们在转储GDT时在WoW64下运行某些x86代码,否则该段的基址通常为零。根据上文的介绍,在兼容模式下运行时,该段通常会存储TEB的基址。
要查看64位TEB和KPCR段,我们需要转储相应的MSR。在进行本地或远程内核调试时,可通过下列命令进行转储(这些命令无法用于崩溃转储):
lkd> rdmsr c0000101 msr[c0000101] = ffffb401`a3b80000 lkd> rdmsr c0000102 msr[c0000102] = 000000e5`6dbe9000
我们可以将这些值与@$pcr和@$teb的值进行对比,随后应该能看到相同的值,例如:
lkd> dx -r0 @$pcr @$pcr : 0xffffb401a3b80000 [Type: _KPCR *] lkd> dx -r0 @$teb @$teb : 0xe56dbe9000 [Type: _TEB *]
实验:在x86系统中查看GDT
在x86系统中,虽然GDT包含类似的段,但分别位于不同的选择器中。此外,由于使用了双FS段来替代swapgs功能,并且缺乏长模式,因此选择器的数量也会有所差异,如下所示:
kd> dg 8 38 P Si Gr Pr Lo Sel Base Limit Type l ze an es ng flags ---- -------- -------- ---------- - -- -- -- -- -------- 0008 00000000 ffffffff Code RE Ac 0 Bg Pg P Nl 00000c9b 0010 00000000 ffffffff Data RW Ac 0 Bg Pg P Nl 00000c93 0018 00000000 ffffffff Code RE 3 Bg Pg P Nl 00000cfa 0020 00000000 ffffffff Data RW Ac 3 Bg Pg P Nl 00000cf3 0030 80a9e000 00006020 Data RW Ac 0 Bg By P Nl 00000493 0038 00000000 00000fff Data RW 3 Bg By P Nl 000004f2
此处的关键段为08h、10h、18h、20h、30h和38h。在08h(KGDT_R0_CODE)
中可以看到一个处于Ring 0的代码段。类似地,在18h(KGDT_R3_CODE)中会看到一个Ring 3的段。随后请注意10h(KGDT_R0_DATA)和20h(KGDT_R3_DATA)段,它们对应栈、数据和扩展段。
在x86系统中,可以在30h(KGDT_R0_PCR)段中看到KPCR的基址,并在38h(KGDT_R3_TEB)段中看到当前线程TEB的基址。此类系统的段不使用MSR。
根据上文有关段的描述和相关值的介绍,在x86或x64系统中调查DS和ES段的值可能会有“惊喜”:它们的值未必会与相应Ring级别所定义的值相匹配。例如,一个x86用户模式线程可能包含下列段:
CS = 1Bh (18h | 3) ES, DS = 23 (20h | 3) FS = 3Bh (38h | 3)
然而,在Ring 0的系统调用中,可能会看到如下段:
CS = 08h (08h | 0) ES, DS = 23 (20h | 3) FS = 30h (30h | 0)
类似地,内核模式执行的x64线程也可以将自己的ES和DS段设置为2Bh(28h | 3)。造成这种差异的原因在于一项名为延迟段加载(lazy segment loading)的功能。此外,这种差异体现在平面内存模型下运作的系统中,如果当前代码特权级别(CPL)为0,那么数据段的描述符特权级别(DPL)将毫无意义。由于更高位的CPL始终可以访问更低位DPL的数据(但无法反向访问),因此在进入内核时将DS和ES段设置为各自“适当”的值后,还需要在返回用户模式时将这些值还原。
虽然10h处的MOV DS指令看似无关紧要,但在遇到该指令后,处理器的微码需要执行一系列选择器正确性检查,这会为系统调用和中断处理增加大量处理成本。因此,为避免增加这些成本,Windows始终会使用Ring 3数据段值。
除了代码和数据段寄存器,x86和x64架构中还有另一种特殊寄存器:任务寄存器(Task Register,TR),这也是GDT中充当偏移量的另一个16位选择器。然而,此时的段项并不与代码或数据相关联,而是与任务相关联。这意味着,对于处理器的内部状态而言,当前执行的代码片段会调用任务状态(task state),在Windows中所调用的为当前线程。现代x86操作系统会使用这些由段代表的任务状态(即任务状态段,Task State Segment,TSS)构建各种可关联至关键处理器陷阱(下文将详细介绍)的任务。在最基本的情况下,TSS可代表一个页目录(借助CR3寄存器),如x64系统中的PML4(有关分页的详细信息请参阅卷1第5章),也可代表代码段、堆栈段、指令指针,甚至最多可代表四个栈指针(每个Ring级别一个指针)。此类TSS主要用于如下场景:
● 在未出现特定陷阱时,可代表当前执行状态。如果处理器当前正运行在Ring 3级别下,那么随后处理器可从该TSS加载Ring 0栈,以便正确地处理中断和异常。
● 解决处理调试错误(#DB)时的架构竞争条件,这需要有包含自定义调试错误处理程序和内核栈的专用TSS。
● 代表在出现双重错误(#DF)陷阱时需要加载的执行状态。借此可在安全(备份)内核栈而非当前线程的内核栈上切换至双重错误处理程序,而后者可能也是出现错误的原因。
● 代表在出现不可屏蔽的中断(#NMI)时需要加载的执行状态。类似地,该TSS可用于在安全内核栈上加载NMI处理程序。
● 对于会在计算机检查异常(#MCE)中使用的其他类似任务,出于相同原因,它们也可以在专用的安全内核栈中运行。
在x86系统中,可以在GDT的028h选择器中找到主要的(当前)TSS,这也解释了在Windows的正常执行过程中TR会位于028h的原因。此外,#DF TSS位于58h,NMI TSS位于50h,#MCE TSS位于0A0h,#DB TSS位于0A8h。
在x64系统上,由于TSS功能已被降级为主要执行在专用内核栈上运行的陷阱处理程序,因此删除了系统具有多个TSS的功能。目前只使用一个TSS(在Windows中位于040h),它使用了一个由八个可能的栈指针组成的数组,该数组名为中断栈表(Interrupt Stack Table,IST)。先前遇到的每个陷阱都会关联至IST索引,而不再关联至自定义TSS。在下一节内容中,随着我们转储几个IDT项,你就会直观感受到x86和x64系统以及它们处理这些陷阱的方法上的差异。
实验:在x86系统中查看TSS
在x86系统中,我们可以使用上一个实验中用过的dg命令在28h处查看系统范围内的TSS:
kd> dg 28 28 P Si Gr Pr Lo Sel Base Limit Type l ze an es ng flags ---- -------- -------- ---------- - -- -- -- -- -------- 0028 8116e400 000020ab TSS32 Busy 0 Nb By P Nl 0000008b
上述命令将返回KTSS数据结构的虚拟地址,随后可使用dx或dt命令对其创建转储:
kd> dx (nt!_KTSS*)0x8116e400 (nt!_KTSS*)0x8116e400 : 0x8116e400 [Type: _KTSS *] [+0x000] Backlink : 0x0 [Type: unsigned short] [+0x002] Reserved0 : 0x0 [Type: unsigned short] [+0x004] Esp0 : 0x81174000 [Type: unsigned long] [+0x008] Ss0 : 0x10 [Type: unsigned short]
请注意,上述指令只设置了Esp0和Ss0字段,因为Windows绝不会在上文介绍的陷阱之外的其他情况下使用基于硬件的任务切换。因此这个TSS的唯一用途是在硬件中断期间加载相应的内核栈。
正如在“陷阱调度”一节中所述,对于不会受到“Meltdown”处理器架构漏洞影响的系统,这个栈指针也是当前线程的内核栈指针(基于卷1第5章介绍过的KTHREAD
结构);但对于受此漏洞影响的系统,这个栈指针会指向处理器描述符区域内部的过渡栈。同时,栈段将始终设置为10h,即KGDT_R0_DATA。
如上文所述,计算机检查异常(#MC)使用了另一个TSS。我们同样可以通过dg命令查看:
kd> dg a0 a0 P Si Gr Pr Lo Sel Base Limit Type l ze an es ng flags ---- -------- -------- ---------- - -- -- -- -- -------- 00A0 81170590 00000067 TSS32 Avl 0 Nb By P Nl 00000089
不过这一次我们会使用.tss命令而非dx命令,该命令可格式化KTSS结构中的不同字段,并以类似于在当前执行线程中那样的方式显示任务。本例中的输入参数为栈选择器(A0h)。
kd> .tss a0 eax=00000000 ebx=00000000 ecx=00000000 edx=00000000 esi=00000000 edi=00000000 eip=81e1a718 esp=820f5470 ebp=00000000 iopl=0 nv up di pl nz na po nc cs=0008 ss=0010 ds=0023 es=0023 fs=0030 gs=0000 efl=00000000 hal!HalpMcaExceptionHandlerWrapper: 81e1a718 fa cli
请留意段寄存器的设置方式与上文“延迟段加载”中所提到的方式是一致的,并且程序计数器(EIP)指向了#MC的处理程序。此外,为了不受内存错误影响,该栈被配置为指向内核二进制库中的一个安全栈。最后,尽管并未显示在.tss的输出结果中,但CR3实际上被配置为系统页目录。在“陷阱调度”一节,我们还将使用!idt命令重新查看这个TSS。
实验:在x64系统中查看TSS和IST
很不幸,x64系统中的dg命令存在Bug,无法正确显示64位基址,因此,为了获取TSS段(40h)的基址,我们需要对两个段创建转储,并将高位、中位和低位基址的数据结合在一起:
0: kd> dg 40 48 P Si Gr Pr Lo Sel Base Limit Type l ze an es ng flags ---- ----------------- ----------------- ---------- - -- -- -- -- -------- 0040 00000000`7074d000 00000000`00000067 TSS32 Busy 0 Nb By P Nl 0000008b 0048 00000000`0000ffff 00000000`0000f802 <Reserved> 0 Nb By Np Nl 00000000
因此在本例中,KTSS64位于0xFFFFF8027074D000。作为获取该地址的另一种方式,请注意每个处理器的KPCR都包含一个名为TssBase的字段,其中也包含一个指向KTSS64的指针:
0: kd> dx @$pcr->TssBase @$pcr->TssBase : 0xfffff8027074d000 [Type: _KTSS64 *] [+0x000] Reserved0 : 0x0 [Type: unsigned long] [+0x004] Rsp0 : 0xfffff80270757c90 [Type: unsigned __int64]
请留意,此处看到的虚拟地址与GDT中看到的地址是相同的。此外我们还会发现,除RSP0之外,其他所有字段都是零,与x86架构类似,RSP0包含(在不受“Meltdown”硬件漏洞影响的计算机上)当前线程内核栈的地址,或包含处理器描述符区域过渡栈的地址。
执行该实验所用的系统配备了一个第10代Intel处理器,因此RSP0等于当前内核栈:
0: kd> dx @$thread->Tcb.InitialStack @$thread->Tcb.InitialStack : 0xfffff80270757c90 [Type: void *]
最后,查看中断栈表会看到关联至#DF、#MC、#DB和NMI陷阱的各种栈,在“陷阱调度”一节我们还将进一步查看中断调度表(Interrupt Dispatch Table,IDT)是如何引用这些栈的:
0: kd> dx @$pcr->TssBase->Ist @$pcr->TssBase->Ist [Type: unsigned __int64 [8]] [0] : 0x0 [Type: unsigned __int64] [1] : 0xfffff80270768000 [Type: unsigned __int64] [2] : 0xfffff8027076c000 [Type: unsigned __int64] [3] : 0xfffff8027076a000 [Type: unsigned __int64] [4] : 0xfffff8027076e000 [Type: unsigned __int64]
在讨论了GDT中Ring级别、代码执行以及某些关键段之间的关系后,我们将通过下文的“陷阱调度”一节一起看看不同代码段(及其Ring级别)之间实际的过渡过程。但在讨论陷阱调度前,我们先分析在易受熔断(Meltdown)硬件旁路攻击影响的系统中TSS配置是如何变化的。