找回密码
 立即注册→加入我们

QQ登录

只需一步,快速开始

搜索
热搜: 下载 VB C 实现 编写
查看: 3347|回复: 0

【处理器】简介AMD64体系的系统调用

[复制链接]
发表于 2020-5-31 15:59:29 | 显示全部楼层 |阅读模式

欢迎访问技术宅的结界,请注册或者登录吧。

您需要 登录 才可以下载或查看,没有账号?立即注册→加入我们

×
早年的x86处理器没有标准的系统调用,于是操作系统的编写者们选择用中断指令(Interrupt)或者远调指令(Far Call)来实现系统调用。比如20世纪的Windows就用int 2e指令实现系统调用。中断和远调指令的效率很低,其主要原因来自于分段机制的检查。然而那时基于x86的操作系统都是采用平坦分段模型,真的没必要做特别完整的分段检查。本文不讨论如何用中断指令实现系统调用,因为我们写操作系统时似乎真的没必要去兼容那种老掉牙的处理器。(似乎也捡不到那样的垃圾


低效的系统调用方式很明显不适应新时代,于是Intel在21世纪搞出了sysenter和sysexit指令,目的就是用于加速系统调用。
在引进sysenter和sysexit指令的同时,新增了三个MSR寄存器:SYSENTER_CS(Index=0x174),SYSENTER_ESP(Index=0x175)和SYSENTER_EIP(Index=0x176)。
这三个寄存器表示sysenter指令执行时,所装载的cs段选择子,esp寄存器值,eip寄存器值。那么操作系统层面上,就是:
1. 在GDT中设置好用于系统调用的cs段和ss段,然后将cs段选择子放进SYSENTER_CS这个MSR中。
2. 设置好系统调用的栈,然后将栈指针放进SYSENTER_ESP这个MSR中。
3. 将系统调用的处理函数的函数地址放进SYSENTER_EIP这个MSR中。
sysenter指令保存调用之前的esp和eip寄存器,因此需要自行实现保存。保存esp简单,但如何处理eip就属于写汇编的智慧了,稍后再说。
当执行sysenter指令时:
处理器读取SYSENTER_CS的值,并赋值到cs段选择子上,同时令ss=cs+8,但不装载GDT上的cs和ss段性质,而是令段基址为零,段限为4GiB。
处理器读取SYSENTER_ESP和SYSENTER_EIP的值,并赋值到esp和eip寄存器上。
然后处理器开始执行系统调用的处理函数了。
接下来谈操作系统如何编写处理函数。我们需要关注的就是两点:用户栈在哪,返回给谁。有了用户栈,就能知道系统调用的参数。用户栈不被sysenter指令保存,因此一般而言操作系统的编写者会把esp保存到一个寄存器上,顺延到sysenter指令之后再使用。比方说系统函数可以这么写:
  1. mov edx,esp
  2. sysenter
复制代码

这段代码将用户栈保存到edx寄存器上,在sysenter后,处理函数通过edx的值来读取参数。
将参数读取出来后,操作系统的处理函数再交给专门的系统函数处理之。
专门的系统函数?哪个专门的系统函数?这就是编写操作系统的考量:用一个编号表示去调用哪个系统函数。在Windows操作系统中,所有的系统调用函数地址都放置在SSDT(System Service Descriptor Table,系统服务描述符表)中。而用户模式的函数将这个编号放进eax寄存器中,在sysenter指令后进入内核,处理函数根据eax的值判断是哪个函数,判断这个函数有多少个参数,把参数复制到内核栈上,然后调用之。
当系统调用结束时,操作系统应当调用sysexit指令。Intel规定sysexit指令把ecx寄存器的值赋值到esp寄存器上,edx寄存器的值赋值到eip寄存器上,并将SYSENTER_CS+16的值赋值到cs段选择子,SYSENTER_CS+24的值赋值到ss段选择子上。
其实esp,cs,ss都好办,唯独eip很难办,因为处理器不帮你保存下一条指令的eip的值。而且也不能用mov指令保存eip的值。那eip怎么办呢?办法有两个:
1. 在sysenter前用lea指令,方法如下:
  1. lea ecx,[0]
复制代码

这条指令将下一条指令的地址赋值到ecx寄存器中,考验x86指令手动编码水平的时候来了:
按照AMD64手册,lea reg32,mem的机器码是8D /r,那么lea ecx,[0]这条指令的机器码整体布局就是:操作码 ModRM 32位立即数。总计6个字节。
因为lea ecx,[0]这条指令的源操作数只是32位长的立即数,则根据AMD64手册中操作数寻址的规则,ModRM.mod=00,ModRM.r/m=101,而目标寄存器是ecx,因此ModRM.reg=001。又因为ModRM.mod在高二位,ModRM.r/m在低三位,而ModRM.reg在中三位,所以ModRM=00_001_101=0b1101=0xD。
那么lea ecx,[0]的机器码按照Little-Endian排序就是8D 0D 00 00 00 00。各位可以自己用反汇编器验证一遍,或者干脆跑一下。
用在调用系统的函数可以这么写:
  1. mov eax,index
  2. mov edx,esp
  3. lea ecx,[2]
  4. sysenter
  5. ret
复制代码

这里用lea ecx,[2]而不是lea ecx,[0]的原因就在于sysenter指令上,它是一条两个字节长的指令,机器码为0F 34,这样一来,ecx的值就是ret指令的地址了。
2. 用一个专门的地址作为sysexit的返回地址,Windows就是用的这个方法。这里直接举32位Windows中系统调用的例子来进行说明。以下反汇编代码取自32位Windows 7 SP1的某个系统函数,复制自WinDbg的反汇编结果:
  1. 0:003> uf ntdll!NtTerminateProcess
  2. ntdll!NtTerminateProcess:
  3. 773b68c8 b872010000      mov     eax,172h
  4. 773b68cd ba0003fe7f      mov     edx,offset SharedUserData!SystemCallStub (7ffe0300)
  5. 773b68d2 ff12            call    dword ptr [edx]
  6. 773b68d4 c20800          ret     8
复制代码

可以注意到这里有个call dword ptr [edx]的调用,而地址就在SharedUserData!SystemCallStub,那我们就查看一下这个地址:
  1. 0:003> dps SharedUserData!SystemCallStub l2
  2. 7ffe0300  773b70b0 ntdll!KiFastSystemCall
  3. 7ffe0304  773b70b4 ntdll!KiFastSystemCallRet
复制代码

也就是说这个指令调用的是KiFastSystemCall函数,这个函数的反汇编如下:
  1. 0:003> uf ntdll!KiFastSystemCall
  2. ntdll!KiFastSystemCall:
  3. 773b70b0 8bd4            mov     edx,esp
  4. 773b70b2 0f34            sysenter
  5. 773b70b4 c3              ret
复制代码

可以看到这里把esp赋值到了edx,但并没有对下一条eip进行处理。不过你可以注意到有个函数叫KiFastSystemCallRet,它的地址就是KiFastSystemCall的ret指令的地址。我们可以作出推测:KiFastSystemCallRet的函数地址就是sysexit的返回地址。
可是,KiFastSystemCallRet是个用户态的地址,它位于ntdll.dll中,内核里如何快速获取这个地址呢?答案在于用户态与内核态之间共享的内存。我们可以发现,我们在调用KiFastSystemCall函数时,提到了SharedUserData!SystemCallStub,其中这个SharedUserData正是用户态和内核态之间共享的内存。但说来说去,我们还是需要去查证,判定一下内核到底是不是从这个地方取地址的。这里我们打开WRK v1.2的源码,找到base\ntos\ke\i386\trap.asm文件,其中第1166行的代码如下:
  1. push    dword ptr ds:[USER_SHARED_DATA+UsSystemCallReturn] ; push return address
复制代码

这行代码对应Windows 7 x86内核中这一行反汇编代码:
  1. 83e5d0e8 ff350403dfff    push    dword ptr ds:[0FFDF0304h]
复制代码

这个0FFDF0304h像是个内核地址,我们查看一下这个地址有些啥:
  1. kd> dps ffdf0304 l1
  2. ffdf0304  76f670b4 ntdll!KiFastSystemCallRet
复制代码

这就一目了然了吧。这样一来,将来就会有一个pop edx指令将KiFastSystemCallRet的地址赋到edx寄存器上。
讲到这,虽然不能说讲解透彻到尘埃落定,但也差不多了,只剩下最终的一记实锤了。要实锤之,我们就需要分析代码,可是分析反汇编很蛋疼。但也有简便方法,那就是在调试器中恰当地下断点。跳过所有不必要的代码。我们只需要下两个断点,一个是刚才提到的push指令,另一个是sysexit指令之前的最后一个pop edx指令。我们需要找到sysexit指令。我们同样可以搜索WRK,发现sysexit指令位于KiSystemCallExit2函数中,而函数位于kimicro.inc文件中:
  1. _KiSystemCallExit2:

  2.         test    dword ptr [esp+8], EFLAGS_TF
  3.         jne     short _KiSystemCallExit

  4.         pop     edx                 ; pop EIP
  5.         add     esp, 4              ; Remove CS
  6.         and     dword ptr [esp], NOT EFLAGS_INTERRUPT_MASK ; Disable interrupts in the flags
  7.         popfd
  8.         pop     ecx                 ; pop ESP

  9.         sti                         ; sysexit does not reload flags

  10.         iSYSEXIT
复制代码

可以看到有个pop edx指令,这一段对应WinDbg中的反汇编:
  1. nt!KiSystemCallExit2:
  2. 83e5d340 f744240800010000 test    dword ptr [esp+8],100h
  3. 83e5d348 75f5            jne     nt!KiSystemCallExit (83e5d33f)

  4. nt!KiSystemCallExit2+0xa:
  5. 83e5d34a 5a              pop     edx
  6. 83e5d34b 83c404          add     esp,4
  7. 83e5d34e 812424fffdffff  and     dword ptr [esp],0FFFFFDFFh
  8. 83e5d355 9d              popfd
  9. 83e5d356 59              pop     ecx
  10. 83e5d357 fb              sti
  11. 83e5d358 0f35            sysexit
复制代码

然后我们下刚才提到的两个断点,然后开始运行,你就会发现,push时的esp和pop时的esp刚好相差4。这就表示pop指令所弹出栈到edx的值就是那条push指令所压入栈的值。
于是,尘埃落定,彻底实锤了Windows是如何处理sysexit返回到eip的方法了。
相比第一种方法,第二种方法可以去掉所有系统调用的lea指令。如果系统中有2000个系统调用函数,那么就可以省下12000个字节,这个节省的量可谓是相当可观了。(Windows 7大约有1200个系统函数)
到此,Intel的sysenter/sysexit系快速系统调用就到此为止了。


时间来到2003年,这时超威半导体有限公司提出了基于x86架构而扩展的64位体系架构——也就是现如今PC平台以及服务器平台上最为主流的AMD64架构,同时还提出了新的一套快速系统调用体系。
先给大家几秒钟思考超威半导体为什么要提出一个新的快速系统调用体系。先说明:答案绝不是因为Intel那一套无法适配到64位上。此外,AMD提出的新的体系也可以适配在32位模式上。
答案在于x86架构的调试体系。在x86架构中,单步调试时需要将eflags.tf位置位,此位置位后,每执行一条指令便会触发#DB异常,也就是调试异常。在这种情况下,sysenter指令进入内核时,eflags.tf位仍然被置位,因此进入内核后,仍然会触发#DB异常。如果内核不对此特殊处理,就会导致调试器能直接本机单步调试内核,这是很可怕的。说到这,你似乎能想到怎么处理了:sysenter指令进入内核后,触发#DB异常,内核将eflags.tf位复位后,再返回到系统调用处理函数继续执行,不过在sysexit之前需要再将eflags.tf位置位。如果你观察刚才贴出的WRK源码,就会注意到调用sysexit之前有个popfd指令,这个指令就是用于恢复eflags的。而篇幅所限,就不予引证了。在开头提到过,Intel搞出新的快速系统调用体系,就是为了去掉中断指令造成的延迟,可如果因为调试体系而导致系统调用的中断又来了,那么优化的功夫可以说是白给了一大半。正因如此,超威半导体才提出一套新的快速系统调用体系,这个新体系引出了三条新指令:syscall,sysret,swapgs指令以及五个新MSR寄存器:STAR(C0000081),LSTAR(C0000082),CSTAR(C0000083),SF_MASK(C0000084)和KernelGSbase(C0000102)。
先从新MSR讲起。首先是STAR寄存器,这个寄存器的全名叫System Target Address Register,低32位是syscall指令在32位模式下所装载的eip值,而高16位是任何模式下sysret指令所装载的cs段选择子的值,中16位是任何模式下syscall指令所装载的cs段选择子的值。
然后则是LSTAR寄存器,这个寄存器是给长模式下的用户程序用的,所以叫LSTAR。这个寄存器记录系统调用处理的函数地址。
接着是CSTAR寄存器,这个寄存器是给兼容模式下的用户程序用的,所以叫CSTAR。所谓兼容模式即32位程序运行在长模式时的子模式。但在64位Windows中,32位程序在系统调用时以远跳等方式从兼容模式进入长模式,把参数搬到微软的64位调用约定的形式,再调用syscall指令走LSTAR这个MSR进入内核。换言之,64位Windows选择直接弃用了CSTAR寄存器。
而重头在于SF_MASK寄存器,这是一个掩码型的寄存器,其目的就是为了掩掉rflags寄存器的部分位。当SF_MASK中某个位置位时,syscall指令会将rflags中对应的位复位后再进入内核。那就显而易见了,64位Windows必然会把rflags.tf给掩掉。
挂上调试器,我们读一下这个MSR寄存器:
  1. kd> rdmsr c0000084
  2. msr[c0000084] = 00000000`00004700
复制代码

其中第8,9,10,14位置位。也就是说,执行syscall指令时,处理器会将rflags中的TF,IF,DF和NT位复位。如此一来,就可以减少单步调试用户态程序时内核的性能损失了。为什么选择用掩码而非直接复位呢?那当然是为了能允许单步调试咯。
而KernelGSbase暂且忽略,这和swapgs指令直接关联,但和syscall/sysret指令无关。
和sysenter不相似的是,在调用syscall指令时,不会改掉rsp寄存器,因此需要操作系统自行切换栈,优点在于不需要由用户态的程序保存栈了。
syscall指令也会修改cs和ss的段选择子,值来自于STAR寄存器,和sysenter同样的,ss=cs+8。
syscall指令还会保存下一条指令的地址到rcx寄存器上。这样一来,编写操作系统就可以省心了,不必再去折腾返回地址了。
而sysret指令则将rcx寄存器赋值到rip上,从STAR寄存器上取得返回到用户模式后的cs,ss段选择子,最终返回到用户模式。
再谈谈KernelGSbase这个MSR以及关联的swapgs指令,这个MSR的作用在于swapgs指令上。swapgs指令会切换当前gs段基址和KernelGSbase的值。在64位Windows中,用户模式下的gs段基址是当前线程的TEB结构体的地址,而内核模式下的gs段基址是描述处理器核心的KPCR结构体的地址。因此切换gs段基址的作用就不言而喻了,而实际上,32位Windows在sysenter的处理中,也切换了fs段。在32位Windows中,fs段的作用和64位Windows中gs段的作用相似。超威半导体搞出swapgs这个指令显然是研究过主流操作系统是如何处理系统调用的。
那栈怎么办?总不能让内核去用用户模式的栈吧。当然不会,既然说了内核能用swapgs切换gs段基址,那在这个地址上,可以保存切换用的栈指针,比方说处理函数的函数头可以这么写:
  1. swapgs
  2. xchg rsp,qword ptr gs:[10h]
复制代码

其中gs:[10h]就是内核gs段中用于切换栈指针的位置。在sysret前再用一次xchg指令把rsp还原回来即可。
由于syscall/sysret系的快速系统调用比较直观,也就不引用Windows的源码进行举例说明了。


CVE-2012-0217漏洞
解释这个漏洞之前,我先说一句:Intel垃圾!因为AMD就没这破毛病,所以AMD YES!!!
这个漏洞攻击的是sysret指令。这个漏洞与CVE-2018-8897不一样,后者是Intel搞出了奇怪的逻辑,别的x86厂商只好跟风;而sysret是AMD设计的指令,AMD没事,Intel却出事了。
处理器执行sysret指令的过程里有两步值得注意:一是将Ring0降低至Ring3,二是检查返回的目标地址是否合法(即最高位统一问题)。
而Intel却把顺序颠倒为先二后一,这就意味着,如果返回的目标地址不合法,产生#GP异常之前不会从Ring0降低至Ring3,于是进入了#GP异常的时候,压到栈上的RPL=0,但是栈指针却指向用户栈(因为一般来说OS都会在sysret之前切换到用户栈上)。
这就导致#GP处理函数莫名其妙的就使用起用户栈了,这在本质上就是Ring3向Ring0的提权。
Intel并不打算修正这个逻辑错误,反而是在手册里提如何修复漏洞:
  • OS在执行sysret指令之前自行检查rcx寄存器的值。
  • 用IST机制强制在出现#GP时切换栈。(我推荐这个方法)



结语
很明显,syscall优于sysenter,而sysenter优于int。我们也没理由在支持syscall的情况下还去用sysenter了。但是复杂的情况来了:
超威半导体显然不喜欢sysenter这个指令,于是直接在长模式中取消了对这个指令的支持。在长模式中使用sysenter指令会产生#UD异常。而在32位模式下保留了syscall(前文也提到过AMD64体系下32位syscall的处理方式)的支持。
sysenter_amd.PNG
而彼时要引用AMD64架构的Intel对此显然有所不满,有意不支持syscall指令来和超威半导体对着干。然而64位Windows却早就设计使用syscall指令了,生米煮成熟饭,无奈之下,Intel只好选择在x64处理器上引入超威半导体的syscall。由于Windows设计了兼容模式下进行系统调用时先进入长模式再进行syscall,所以Intel在报复超威半导体时捡了个漏:Intel选择在非64位模式下故意不支持syscall指令,这包括长模式的子模式——兼容模式。
syscall_intel.PNG
于是这年头的32位Windows就统一用sysenter指令,64位的Windows统一用syscall指令。其他的操作系统大概也是这样的吧。
如此一来,其他x86的供应商就会站队,分Intel系和AMD系。Intel系有威盛电子股份有限公司和上海兆芯集成电路有限公司,而AMD系有成都海光集成电路设计有限公司。
这个派系放到支持硬件虚拟化的x86处理器供应商上也是同理的。
最后的最后,在Intel上一定要小心CVE-2012-0217漏洞。
回复

使用道具 举报

本版积分规则

QQ|Archiver|小黑屋|技术宅的结界 ( 滇ICP备16008837号 )|网站地图

GMT+8, 2024-12-27 00:04 , Processed in 0.040738 second(s), 31 queries , Gzip On.

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表