要点回顾
在调用门、中断门与陷阱门中,一旦发生权限切换,那么就必有堆栈的切换。而且,由于CS的CPL发生改变,也导致了SS也必须要切换。这时问题来了,我们知道EIP和CS的值都可以通过门描述符获得,那么ESP和SS是哪来的?这就是引出了今天的内容:TSS(Task-state segment),任务状态段。
TSS设计初衷
想要学习一类知识,首先要了解它被设计出来的目的,这样就能找到方向,更好的了解它。CPU在运行时会频繁的切换任务,每次切换任务时,还没执行完的任务怎么办?总不能下次重新执行吧,于是需要保存上一个任务的上下文环境,于是,TSS诞生了,TSS是一块大小为104(0x68)个字节的内存,没错,TSS不是什么寄存器,就是一段内存,或者说是一个段,这段内存可以保存32-Bit下所有通用寄存器以及段寄存器的值,这样CPU就可以切换到新任务时,仍然保留上一个任务的环境,方便新任务执行完后,能够完好的回到先前的任务继续执行。
注意,TSS是一个段,有段就有段寄存器和段描述符哟!比如我们熟悉的CS,CS就是段寄存器,它描述的是代码段,同时,它会通过段选择子从GDT表的代码段描述符中载入段的相关信息,通常情况下,代码段的范围是0~FFFFFFFF(大小是4GB);这样一对比,就可以理解了,TSS也是如此,因为TSS也是一个段(大小是104字节),所以应该存在一个描述TSS的段寄存器从一个段描述符里加载TSS相关信息。这就是今天会依次介绍的TR寄存器和TSS段描述符。
这里补充一点知识,尽管Intel设计TSS的初衷的为了方便任务切换(CPU层面叫做任务切换,操作系统层面叫做线程切换),但是Windows认为这个TSS设计的不好,因此并没有采用这个结构进行线程切换,并且Linux也没有采用TSS进行线程切换,这俩操作系统用的都是堆栈进行线程切换的。那么Windows用到了这个结构没有,当然是用到了,但仅仅用到了ESP0和SS0这两个值。
TSS结构
先来看看TSS的结构
大部分都应该比较熟悉,这里介绍几个较为陌生的字段:
- Previous Task Link:这里保存的是上一个TSS的段选择子,比如任务发生了切换,新任务执行完后,如何才能找到先前未能执行完的任务呢?就得依靠这个值了(前面不是说了Windows不用TSS进行线程切换嘛,是呀,但是这里讲解的这个字段的作用,设计初衷就是为了任务切换)
- ESP0/SS0:当发生提权时,0环的ESP和SS的值就是从这里取的
- CR3:有人会问,我哪知道取哪个TSS的ESP0和SS0呢?就是这个值的作用了,这个值帮我们确定当前位于哪个线程,之后在页的篇章中会学到CR3的相关内容。
- LDT:这个值通常都是0,Windows没有用到LDT表,因为LDT表只对当前的线程有用
- I/O Map:这个位置涉及到硬件IO了,值一般是固定的
TSS段描述符
TSS段描述符只能存在GDT表中,不能存到LDT或者IDT中,所以它的结构和之前介绍的段描述符是类似的,区别在于个别位的不同
- G位:在代码段/数据段描述符中,这个位置通常位1,因为这两个段的范围通常是4GB,而TSS的大小是104字节,单位是字节,因此这个值为0
- Type域:这个值为1011或1001,其中B位是Busy位,置1时说明该TSS是否被载入或者嵌套,载入说明CPU正在执行该任务;嵌套则表明该任务处理了一半,切换到了另一个任务中去,但该任务并未执行完。
- Base/Limit:Base确定TSS段的起始位置,Limit确定TSS段的大小,Base~Base+Limit就是TSS段的范围。
TR寄存器
在一开始的探究段寄存器的文章中提过,CPU共有8个段寄存器,TR就是其中之一。先前提到过,TR寄存器的工作原理和其它段寄存器一样,通过段选择子加载GDT表的TSS段描述符中的信息,方便CPU找到TSS的位置,具体工作原理如下:
这里介绍两个操作TR段寄存器的指令LTR和STR:
- LTR:这是一个特权指令,只有0环的程序才能调用;作用是将段选择子写入TR寄存器
- STR:STR不是特权指令,这个指令3环程序也可以调用,所用是读取TR寄存器的值
需要注意的一点,修改TR寄存器的值,只是会载入新的TSS段描述符信息,并不会对修改前的TSS段造成影响。
实现任务切换
虽然说了Windows并没有将TSS用来进行线程切换,但是我们仍然可以手动实现任务的切换。
直接修改TR寄存器是不能做到任务切换的,但是可以通过JMP FAR或者CALL FAR来加载TSS段描述符来实现。下面分别使用两种方法来实现(两种实现方法细节有很多差别)
一般情况下,任务切换发生在下列四种情况之一:
- 当前程序,任务或者进程执行JMP/CALL语句,且参数是位于GDT表中的TSS段描述符
- 当前程序,任务或者进程执行JMP/CALL语句,且参数是位于GDT表或者当前LDT表中的任务门描述符
- 一个中断或者异常触发了在IDT表中的任务门描述符
- 当前任务执行IRET指令,且EFLAGS寄存器的NT位为1时
CALL FAR实现
本次CALL FAR实现采用第一种任务切换的情况。
设计一个TSS,存储任务切换时必要的信息,其中包括ESP0,SS0,CR3,EIP,ESP,段寄存器,位图控制。
- ESP0:也就是任务切换后的堆栈,可以自己创一个空数组,然后写入数组的首地址,就可以作为堆栈使用。
- 段寄存器及SS0:这些值,在0环通常都一样,可以进入Windbg参考其它TSS的值,这里使用的是SS/SS0 = 0x10,ES/DS = 0x23,CS = 0x8,FS = 0x30,GS = 0x0
- EIP:这就是要跳转后执行的地方,可以写一个裸函数来验证是否切换成功,直接取裸函数地址即可,我这里的值为0x401020(每个人机器可能不一样)
- CR3:这个值,需要在执行前,中断到Windbg寄存器中,通过!process 0 0指令获取。
- 位图控制:这是一个默认值为0x20AC0000
构造完的TSS如下:
1 | DWORD tss[0x68] = { |
然后我们需要根据这个TSS的地址,来构造我们的TSS段描述符
地址为0x12fd70
因此TSS段描述符为:0000e912`fd700068,e->DPL=3:是为了3环的程序可以访问这个段描述符,0x68就是104字节,9说明未被载入。然后让我们填入段描述符
接着运行程序,需要采集Cr3的值,先中断到Windbg,再通过!process 0 0指令获取,取最后的一个值
在程序中填入cr3的值后回车,发现可以成功取到任务切换后ESP,CS,SS,并且均为我们设定的值,实验成功。
完整代码如下:
1 |
|
以上是通过Call Far实现的任务切换。还有另一种方法,是通过JMP FAR来实现,而且JMP FAR实现会更加困难一些。这里简要概括一下,当使用CALL FAR时,CPU会自动帮你用当前任务的段选择子填写你TSS的Previous Task Link字段,同时给你的Eflags的NT位置1,这个NT位有什么用呢,就是关系到iret这个指令的意义,当Elfags的NT为1时,iret表示根据Previous Task Link的值,从当前任务返回到前一个任务中去,当NT为0时,这是一个中断返回指令。而当你使用JMP FAR实现时,你需要手动给Pervious Task Link字段赋上前一个任务的段选择子,此外你需要手动给Eflags的NT位置1,当然这可以通过
1 | pushfd |
来实现,此外,还需要确保前一个TSS段的段描述符Busy位的值为1,这样才能确保该任务处在嵌入的状态。
总结
这是开博客以来,最艰难的一篇了,看视频楞是没看明白,然后又去翻Intel白皮书,看明白了然后开始代码实现,CALL FAR的任务切换实现的还算顺利,但是JMP FAR的问题就比较大了,一下午都没整出来,蛋疼啊~ 不想再弄了,看了群友的代码,感觉自己好像也没写错,就是一直死。明天打算整一整任务门吧,完了就到页的知识了,那边掌握的还算不错,可以轻松一阵子了,坚持呀!
参考资料:《Intel白皮书卷3-第七章》