今天开始学习各种门与门描述符

先解决一下上节课的存疑。

段选择子的检验

尝试将段选择子装入 CS 或 SS 时,会进行检查,通常会产生一个保护异常。而装入其它的段寄存器不会立即检查,会在尝试访问的时候检查权限。


前面提到,段描述符当 s=0 时,是一个系统段,而系统段根据 TYPE 域的变化有如下的区别

其中就有各种各样的门描述符,包括调用门、中断门、陷阱门,门描述符的结构如下所示

长调用和短调用,长跳转与短跳转

在学习调用门之前,先来了解一下长跳(jmp far),短跳(jmp)与长调用(call far),短调用(call)的区别。我们平时使用的比较多的指令其实都是短调用和短跳转,几乎很少用到长跳和长调用。长调用和长跳事实上会修改段寄存器 CS,CS不能通过一般的赋值指令修改,只能通过长调用或长跳修改。

长跳不会改变进程 CPL,长调用会

短跳:

1
2
jmp 立即数/寄存器/内存
//仅修改 EIP

长跳:

1
2
jmp far cs:eip
//修改 EIP 和 CS

事实上,长调用在现实情况中也很少见。

短调用:

1
2
call cs:eip
//修改 esp,eip和内存

长调用:

1
2
call far cs:eip (eip参数在这里不发挥作用)
//修改 esp eip cs和内存,返回使用 retf

跨段不提权

这里编写代码测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
int __declspec(naked)func() {
__asm {
retf;
}
}
int main()
{
printf("%p\n", func);
char bufcode[] = { 0x00,0x00, 0x00, 0x00, 0x23,0x00 };
*(int32_t*)&bufcode[0] = (int32_t)func;
__asm {
nop;
call fword ptr bufcode;
}
printf("%d\n", 1);
}

下断点调试,进入之后可以发现在执行 call 的时候不但压入了 EIP,同时将段寄存器 CS 也压入栈中。

在 retf 之后,同样会将 EIP 和 CS 一块弹出来,恢复栈。

跨段提权

通常是改变了进程自身的权限,至于前面提到了跨段之后提权,自然是会有一些校验的。对于用户层来说,我们跨段提权仅仅是修改 CS 和 EIP。如果我们修改了 CS 提权,同时能够任意指定 EIP,那将会产生很严重的安全漏洞。

因此,跨段调用提权需要通过调用门来进行,我们看到调用门描述符结构

  • ParamCount 有五位,可以指定该调用门最多拥有31个参数。
  • TYPE=C 表明是调用门。
  • P=1表示有效。
  • S=0表示为系统段。
  • DPL指示了该调用门需要请求的权限。
  • 调用门还存储了新的段选择子指示该调用门调用之后 CS 新的值。
  • 其余 32 位数据指示了新的 EIP 的位置。

因此我们说跨段提权的情况中,EIP 被废弃,我们只需要指示 CS 为目标调用门即可,而目的地址则在对应的门描述符中,因此我们通过调用门来进行跨段提权无法指示目标代码的执行位置(至少在用户层)。

调用门

跨段提权具体过程

对于一致代码段而言,低级别的程序可以在不通过提升 CPL 的情况下访问。对于非一致代码段而言,禁止不同级别进行访问,要想访问则必须通过调用门先提升 CPL 再调用。

从上面分析我们可以总结调用门的执行过程(个人理解,难免有误,敬请指正),在调用 call far 的时候会进行如下操作:

  1. 根据 CALL 给出的 CS 在 GDT 中找到对应的对描述符,CPU验证该段描述符是一个调用门描述符。
  2. 检查 CPL 与调用门 DPL 是否满足调用条件
  3. 将 SS 和 ESP 压栈。
  4. 取出调用门当中指定的新段选择子,给CS,当前 CS,EIP 压栈。
  5. SS 会变为当前 CS+8 的值,此时栈会被切换。
  6. 栈切换之前,会根据 ParamCount 字段获取原栈中参数个数,并将它压入新的栈中。
  7. 将调用前的 CS,EIP也压入新的栈中。
  8. 根据 CS 新的段选择子的 Base + 调用门指定的 Offset 将 EIP 设置到指定的位置。

这样就完成了跨段提权的过程。

调用门权限检查

它会检查这几个字段

  • CPL
  • 门选择子 RPL(也就是你尝试 CALL 的所指定的门选择子的低2位)
  • 调用门描述符 DPL
  • 目标代码段 DPL

同时还要检查目标代码段的一致性位。

与前面访问数据差不多,CALL 调用门权限要满足以下条件:

  • CPL<=调用门DPL
  • RPL<=调用门DPL
  • 目标代码段DPL<=CPL。

这里会发现有点奇怪,为什么 CPL >= 目标代码段 DPL,这是为了防止通过调用门以高特权级去执行用户代码。也就是说,使用 call far 尝试调用调用门时,只允许向高特权级的代码去转移而不能向低特权级的代码转移。相对应的,call far 有对应的 retf 指令与之对应,但是 retf 只能向同权限或者低权限去转移。

对于 JMP 来说,除了满足前两个条件外,如果目标是非一致代码段,则不允许低权限的 CPL 进来访问,只允许同级访问,因为 jmp far 不会改变当前进程的 CPL。对于一致代码段,访问并不会受限,此时也不会改变当前进程的特权级。

retf 指令它能确保跨段提权之后,恢复所有栈的情况,包括提权之后可能压入的 CS,EIP,SS,ESP以及参数的清理等。

中断门与陷阱门

中断

中断(Interrupt)指的是当出现需要的时候,CPU放弃处理当前运行的程序转而去处理的过程。比如常见的除零(0号中断),断点(3号中断),系统调用(0x2e中断,Linux使用0x80中断)以及异常处理都会引发中断,调用相应的处理例程去处理中断事件。而操作系统需要维护这样的一个例程表,于是就有了 IDT。

中断门与调用门类似,也会指定新的段选择子和一个中断处理程序,所以中断门也可以用于提权,提权的规则与检测与调用门几乎相同,在某些细节有略微的差异。

中断描述符表

与gdt一样,同样有一个寄存器 IDTR 维护了一张中断描述符表(IDT),同样,该寄存器是 48 位的寄存器,存储了 IDT 表的位置(4字节)和大小(2字节),IDT的长度字段默认是 0x7FF,也就是IDT总长为 2048 字节,能够存入 256 个中断描述符。

IDT 主要存储三种门描述符

  • 任务门描述符
  • 中断门描述符
  • 陷阱门描述符

中断描述符结构如下图所示

其中,TYPE 域的 D 位决定了它是 16 位(0)还是 32 位(1)的中断门。除了 ParamCount 字段,其余字段跟调用门几乎是一致的。

IDT可以存入以下三种描述符:

  1. 任务门描述符:用于任务切换,里面包含用于选择任务状态段(TSS)的段选择子。可以使用JMP或CALL指令通过任务门来切换到任务门所指向的任务,当CPU因为中断或异常转移到任务门时,也会切换到指定任务。
  2. 中断门描述符:用于描述中断例程的入口。
  3. 陷阱门描述符:用于描述异常处理例程的入口。

中断门调用过程

通过中断门进入处理程序有很多种方法,通过 int xx 指令进入应该属于熟知的方式,除此之外,以下行为均会通过中断门进入中断处理过程:

  • int 指令
  • 外部设备中断
  • 软件异常

同样,如果是跨段提权,那么需要进行栈切换,保存原 CS 等等操作,除此之外,需要额外保留原来的 EFLAGS,栈的情况如下:

1
2
3
4
5
|   SS   |   <- 用户模式的栈段选择子
| ESP | <- 用户模式的栈指针
| EFLAGS | <- 标志寄存器
| CS | <- 代码段选择子
| EIP | <- 被中断的指令地址

对应的,从中断处理程序返回使用 iret 指令返回到被中断的位置。iret 指令和 retf 一样,

可以完美地还原压栈的参数。

这里需要注意的是,iret 指令并不单单还原栈的参数,还会做一件事情,就是开中断。因为通过中断门调用进去之后,会屏蔽其它中断( eflags.TF=0)。如果仅仅使用 retf 4 进行长返回的话,会导致应用层程序的 eflags.TF=0,如果此时它出现其它异常,会导致无法中断这个程序从而出现蓝屏。并且,eflags.TF 标志位无法在用户层修改,只能通过特权指令 cli(关中断)和 sti(开中断)去修改。

关中断指令只能屏蔽可屏蔽中断,电源掉电等不可屏蔽中断(NMI)CPU无法屏蔽,其它硬件也可以向CPU报告紧急事件,通过CPU的NMI引脚去触发,CPU一旦收到必须立刻处理。

陷入

与中断几乎一样,唯一的区别是陷阱门调用之后不会关中断,也就是说它可以被其它中断打断。

参考文献