阅读预警:这章的内容会非常的难,应该是本专栏开播以来最难的一个地方了。无论你到网上任何地方寻找关于操作系统特权级保护的内容都会非常的复杂。我也是花费了很大功夫呕心沥血的层层推导才完成了归纳整理。但是,如果这章的内容都能啃下来,基本上计算机软件类的书按理没有看不懂的了。
在上一章结束的时候,我曾说如果找一个现成的Window或Linux可执行程序,拆除其格式封装后只抽取出其机器代码,把后缀名改成.hrb,然后放在我们的操作系统中启动,程序能不能成功运行呢?答案是不行的,因为我们目前支持的应用程序还只能是最简单的类型:不带任何数据操作的纯代码段可执行程序。这是因为应用程序目前只有单独的代码段,我们没有给应用它分配没有单独的数据段(通过DS和SS等),所以它要做数据访问操作的时候,就会被错误的指向到操作系统的数据段,这样不但应用程序不行正常运行,还会把操作系统搞瘫。所以,本章我们就来解决这个问题,让我们的操作系统支持完整的应用程序,并提供完整的API。要解决这问题,就必须要引入操作系统的一个核心机制:保护机制。
一、实模式问题
16位的工作模式叫实模式,实模式最大的特点就是编程者可以随心所欲:可以对任何内存段地址做操作。普通用户随便一句如下写内存的指令,都足以可能让操作系统崩溃,因为写的内存地址可能是存放的是操作系统内核程序:
MOV [0x1234],0xff
二、保护模式原理
为了解决实模式的风险问题,计算机便引入了保护模式。虽然我们的操作系统当时是为了支持32位的显卡地址才进入了保护模式的,但其实保护模式更大的作用是为操作系统建立保护机制,这点从“保护”二字就能看出。保护模式的目的是尽最大努力保持操作系统的稳定性。怎么实现保护模式呢?方法是通过段描述符来实现:
在段描述符中,主要将有三类字段来协同参与实现保护功能:
1.段限长---LIMIT。该字段规定了每个定义段的长度,比如你要跳转的程序长度超过了的CS定义的段长度或者要访问的数据内存地址超过了DS的定义的段长度,就会报错。
2.段类型---TYPE。在《计算机自制操作系统(二六):多任务调度设计》中,我们知道,每个段都可以定义了可执行、可读写等类型,如果你要在只读的内存段写数据,无疑会报错。
3.描述符特权级---DPL。这个字段除了存在于普通的段描述符中外,还存在于各类门描述符中。该字段用来表示本段的特权级别,显然操作系统段的特权级最高,应用程序段的特权级最低,如果你要在应用程序中对操作系统的段做写入操作,就会被阻止。
三、保护模式实现
本节就将以《30天》书中的案例来说明保护模式具体实现。
(一)无保护模式
开篇就说了,目前我们的操作系统还不支持有内存数据操作的应用程序。那现在我们就来写一个最简单的有数据操作的应用程序crack1.c,看看它的攻击性在哪里。
void HariMain(void)
{
*((char *) 0x00102600) = 0;
return;
}
这个程序原理很简单,直接往内存地址0x00102600写成0。这个内存地址是什么呢?我们来看操作系统内存分布图:
显然,在内存中软盘数据的开始地址是1MB,而2600是软盘目录数据的偏移地址,因此对内存地址:0x00102600操作实际就是软盘第一个文件名被修改成了0。操作系统看到文件名是0x00开头的,认为是异常(其内核程序是这么设计的),因此再执行任何dir,type或运行任何应用程序都不会有反应,这样就相当于操作系统被坏了。
(二) 初级保护:为应用程序分配专用段
1.分配专用段作用
上面应用程序搞瘫操作系统,最的问题就是操作系统和应用程序的数据段没有做区分所致,操作系统和应用程序的数据段、堆栈段都混为一团,其实就和实模式的管理方法没有本质区别。所以,为应用程序分配独立的段,是最基本的要求。因此,我们重新对段做如下划分:
应用程序的内存空间都是临时申请的,并把这个临时内存空间注册到GDT中就形成了应用程序的代码段和数据段。需要注意的是:代码段的长度是应用程序的实际大小,而数据段的长度由于不好估计,因此这里固定申请为64KB。可以看到,无论是操作系统还是应用程序都只有代码段和数据段,并没有再设置堆栈段。这是因为我们的堆栈寄存器SS一直是和数据类寄存器DSESFSGS保持的同一个值,可以理解为堆栈段就是用的是数据段,那么在同一个程序中,就只需要重新定义ESP栈顶的指针值就可以了,由于这次我们给应用程序申请的数据区长度是64KB,那么我们把ESP栈顶指针设置在这64KB的最顶端最适合了。综上,核心程序如下:
p = (char *) memman_alloc_4k(memman, finfo->size); /*应用程序代码地址*/
q = (char *) memman_alloc_4k(memman, 64 * 1024); /*应用程序数据地址*/
*((int *) 0xfe8) = (int) p;
file_loadfile(finfo->clustno, finfo->size, p, fat, (char *) (ADR_DISKIMG + 0x003e00));
set_segmdesc(gdt + 1003, finfo->size - 1, (int) p, AR_CODE32_ER); /*应用程序代码段*/
set_segmdesc(gdt + 1004, 64 * 1024 - 1, (int) q, AR_DATA32_RW); /*应用程序数据段*/
start_app(0, 1003 * 8, 64 * 1024, 1004 * 8); /*64 * 1024为ESP之值*/
memman_free_4k(memman, (int) p, finfo->size);
memman_free_4k(memman, (int) q, 64 * 1024);
这里用start_app()来启动应用程序,对应参数分别为应用程序启动之后新的EIP值和新的CS,ESP和DS(SS)段地址之值。这样,我们再执行应用程序:*((char *) 0x00102600) = 0的时候,它操作的内存区就不再是操作系统数据段了,而是应用程序的数据段,所以不会对操作系统造成破坏,我们达到了初级保护的目的。
可是,我们在实际运行过程中,还是会报错,这次的故障现象不再是操作系统的运行应用程序不会有反应,而是不断的关机重启,说明是发生了系统性的故障。通过仔细推敲,我们发现原因是应用程序数据段的长度固定为64KB所致的,因为应用程序*((char *) 0x00102600) = 0,翻译到汇编指令是:
mov [0x00102600],0
这条指令默认的数据寄存器是DS:[DS:0x00102600],而我们定义的应用程序数据段长度是64KB,偏移地址0x00102600的长度是>1MB,这样就会发生前面我们介绍的段限长---LIMIT保护错误,会导致处理器异常。我们尝试修改一下,将数据段长度设置为2MB:
q = (char *) memman_alloc_4k(memman, 2048 * 1024);
set_segmdesc(gdt + 1004, 2048 * 1024 - 1, (int) q, AR_DATA32_RW);
......
start_app(0, 1003 * 8, 2048 * 1024, 1004 * 8);
再次运行应用程序*((char *) 0x00102600) = 0就不会宕机了,而且也能正常使用dir命令以及运行其他应用程序,说明操作系统抵挡住了最低级的攻击,具有初级保护能力。原理实际上是由于应用程序分配了专门的数据段,因此它对内存的写操作就只能被限制在它专用的数据段内,而不会对操作系统的段产生影响。
所以现在可以说,我们的操作系统已经可以支持带有数据操作的应用程序了,也即支持完整的应用程序。
2.分配专用段实现
虽然为应用程序分配专用段的作用明显,但是怎么实现还是个问题。最大的困难是由于操作系统和应用程序使用的是不同的代码段和数据段,两个程序之间的切换就会相当的复杂。极端情景是:当应用程序要调用操作系统的API时,调用参数是在应用程序里面的,API实现是在操作系统里面的,那怎么才能实现这个参数传递呢?调用完成之后又怎么回去呢?
2.1 应用程序的启停
我们需要在操作系统里写一个启动应用程序的内核程序start_app(),策略是在应用程序启动之前,通过操作系统的堆栈先保存好操作系统的数据段寄存器(DS和SS等)和操作系统的堆栈指针ESP,再把应用程序的数据段和应用程序的堆栈指针ESP写入相应寄存器,然后开始调用应用程序。应用程序执行完成之后返回之后,操作系统还原之前的各个寄存器值即可。这里为什么不需要将操作系统的代码段CS和EIP入栈保护和恢复呢?这是因为这两个值是通过call和ret等配合调用自动切换的,CPU会自动管理,不需要我们操心。
保存和恢复操作系统普通的段寄存器DS和SS等非常容易,用传统的PUSH和POP即可。但是要保存和恢复操作系统堆栈指针ESP,是不能简单的使用PUSH和POP就行的,关于这点我们在《计算机自制操作系统(二四):开发过程中的痛苦---正确理解编译器也会犯错》中反复强调过:POP ESP 指令不但没有意义,反而它会使程序异常,严重的话计算机会崩溃,应绝对禁用!我们来看书上的程序是怎么解决的:
_start_app: ; void start_app(int eip, int cs, int esp, int ds);
PUSHAD ; 32位寄存器全部保存
MOV EAX,[ESP+36] ; 应用程序EIP
MOV ECX,[ESP+40] ; 应用程序CS
MOV EDX,[ESP+44] ; 应用程序ESP
MOV EBX,[ESP+48] ; 应用程序DS/SS
MOV [0xfe4],ESP ; 操作系统ESP
CLI
MOV ES,BX ; 所有段寄存器切换到应用程序
MOV SS,BX
MOV DS,BX
MOV FS,BX
MOV GS,BX
MOV ESP,EDX ; ESP切换到应用程序
STI
PUSH ECX ; far-CALL启动应用程序的cs
PUSH EAX ; far-CALL启动应用程序eip
CALL FAR [ESP] ; 启动应用程序
MOV EAX,1*8 ; 操作系统DS/SS
CLI
MOV ES,AX ; 恢复操作系统所有段寄存器
MOV SS,AX
MOV DS,AX
MOV FS,AX
MOV GS,AX
MOV ESP,[0xfe4] ;恢复操作系统ESP
STI
POPAD ; 32位寄存器全部恢复
RET
可以看出,程序为了避免使用POP ESP,采用的方法是用了一个固定的内存地址[0xfe4]来暂存和恢复操作系统ESP。这里又有一个比较令人疑惑的问题:程序中既然已经有了PUSHAD和POPAD操作,它的目的其实就是保护和恢复所有的32位寄存器,包括了ESP。那为什么还要在程序中单独对ESP做一次暂存和恢复呢?
原理解释:这个地方PUSHAD和POPAD指令,保证的只是所有32位寄存器(包括ESP)在进入和退出start_app的时候值没有任何改变,但它实现的前提条件是在运行start_app过程中操作系统的堆栈ESP指针必须保持正常的常规操作比如一般的PUSH和POP操作或者手工非常正确准确的出入栈以及挪动ESP指针等。否则,一旦ESP指针错位发生了乱指的情况,堆栈里的数据就全部乱了,POPAD指令是没有办法对所有32位寄存器按序正常恢复的。但是我们恰恰这次就对EPS指针做了“不规范”的操作:在启动应用程序的时候,是把ESP切换到了应用程序所用的,这是一次对ESP的直接赋值修改操作(MOV ESP,EDX),记住了:凡是对ESP做了直接修改操作,如果还要想正常恢复堆栈里的数据,都必须人工再将ESP的值再修改回去。如果不修改回去就会彻底崩溃,所以这次在程序中才必须单独对操作系统的ESP通过内存做一次暂存和恢复操作,这是唯一的方法。
在上面的程序中,应用程序的启停并没有采用我们说的先将操作系统的数据段寄存器(DSES等)推入堆栈,最后再出栈恢复,它采用的方法是在程序的最后直接将所有的寄存器修改成操作系统的指向,其实也可以按我们的思路将这个启停程序start_app修改成如下方式:
_start_app: ; void start_app(int eip, int cs, int esp, int ds);
push ds ;暂存操作系统数据段
push es
PUSHAD
MOV EAX,[ESP+36+8]
MOV ECX,[ESP+40+8]
MOV EDX,[ESP+44+8]
MOV EBX,[ESP+48+8]
MOV [0xfe4],ESP ;暂存操作系统ESP
MOV ES,BX ;切换到应用程序数据段
MOV SS,BX
MOV DS,BX
MOV ESP,EDX ;暂存应用程序ESP
STI
PUSH ECX
PUSH EAX
CALL FAR [ESP] ;启动应用程序
MOV EAX,1*8
MOV DS,AX
MOV SS,AX
MOV ESP,[0xfe4] ;恢复操作系统ESP
STI
POPAD
pop es ;恢复操作系统数据段
pop ds
RET
上面修改过的程序也能成功启停应用程序,我自己对其亲测成功。这样我们就实现了操作系统对应用程序的启停工作,操作系统内核通过调用start_app()就可以了。
2.2 应用程序调用操作系统API
上小节中应用程序的启停就已经不简单了,主要涉及操作系统和应用程序各自的段切换。但是更复杂的情况是应用程序调用操作系统的API,这一小节,我们就来解决这个问题。
回顾一下,我们在上一章中应用程序调用操作系统API的过程:首先是应用程序hello2.nas通过INT 0x40调用操作系统API。
[INSTRSET "i486p"]
[BITS 32]
MOV EDX,2
MOV EBX,msg
INT 0x40
RETF
msg:
DB "hello",0
操作系统中对API调用INT 40响应的中断服务程序是_asm_hrb_api:
_asm_hrb_api:
STI
PUSHAD ;
PUSHAD ;用32位寄存器做调用C程序字符串显示的参数
CALL _hrb_api
ADD ESP,32
POPAD
IRETD
最终hrb_api是用C程序写的字符串显示函数,而这个C函数入口参数是8个32位寄存器。
void hrb_api(int edi, int esi, int ebp, int esp, int ebx, int edx, int ecx, int eax)
{
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
if (edx == 1) {
cons_putchar(cons, eax & 0xff, 1);
} else if (edx == 2) {
cons_putstr0(cons, (char *) ebx);
} else if (edx == 3) {
cons_putstr1(cons, (char *) ebx, ecx);
}
return;
}
通过这个8个32位的寄存器来控制实现不同的API功能:
在上一章中,由于操作系统和应用程序用的是共同的数据段,因此实现调用逻辑简单。但是现在不一样了,我们需要重新编制API调用INT 40响应的中断服务程序_asm_hrb_api,来满足操作系统和应用程序各自独立数据段要求:应用程序调用INT 0x40的时候,所有的段寄存器都是指向的是应用程序,现在进入了操作系统内核代码,那么第一件要做的事情就是把所有的段寄存器从应用程序切换到操作系统,调用完成之后,又还需要再次切换回应用程序。这个过程正好和上一小节相反。我们画个流程图来表示一下整个过程。
asm_hrbapi()程序采用和前面start_app()同样的策略:先通过应用程序的堆栈保存好应用程序的数据段和堆栈指针ESP,然后切换到操作系统的数据段和堆栈指针ESP,操作系统调用完底层服务之后,再恢复应用程序的的数据段和堆栈指针ESP。为什么调用操作系统里面的C程序函数hrb_api必须要先切换到操作系统的段呢?直接用应用程序来调用不行吗?原因很简单,这个C程序函数是操作系统的内核,它的正常工作全部都要依赖于操作系统的段,应用程序是不可能直接能调用的。
_asm_hrb_api:
PUSH DS ;保存应用程序的数据段在应用程序的堆栈区
PUSH ES
PUSHAD ;保存所有32位寄存器(8个hrb_api调用参数)在应用程序的堆栈区
MOV EAX,1*8
MOV DS,AX ; 先将DS切换到操作系统
MOV ECX,[0xfe4] ; 取出操作系统的ESP
ADD ECX,-40 ; 挪动操作系统的ESP指针,
; 准备放8个hrb_api参数和应用程序的ESP,SS
MOV [ECX+32],ESP ; 把应用程序的ESP指针存放在操作系统堆栈区
MOV [ECX+36],SS ; 把应用程序的SS存放在操作系统堆栈区
;下面这段程序的功能是将在应用程序堆栈区8个hrb_api调用参数复制到操作系统的堆栈区
MOV EDX,[ESP ]
MOV EBX,[ESP+ 4]
MOV [ECX ],EDX ; hrb_api参数1
MOV [ECX+ 4],EBX ; hrb_api参数2
MOV EDX,[ESP+ 8]
MOV EBX,[ESP+12]
MOV [ECX+ 8],EDX ; hrb_api参数3
MOV [ECX+12],EBX ; hrb_api参数4
MOV EDX,[ESP+16]
MOV EBX,[ESP+20]
MOV [ECX+16],EDX ; hrb_api参数5
MOV [ECX+20],EBX ; hrb_api参数6
MOV EDX,[ESP+24]
MOV EBX,[ESP+28]
MOV [ECX+24],EDX ; hrb_api参数7
MOV [ECX+28],EBX ; hrb_api参数8
MOV ES,AX ; 将ES也切换到操作系统
MOV SS,AX ; 将SS也切换到操作系统
MOV ESP,ECX ; 将ESP也切换到操作系统
STI
CALL _hrb_api ; 调用操作系统里面C程序写的函数
MOV ECX,[ESP+32] ; 从操作系统的堆栈区取出应用程序的ESP
MOV EAX,[ESP+36] ; 从操作系统的堆栈区取出应用程序的SS
CLI
MOV SS,AX ; 调用结束恢复应用程序的SS
MOV ESP,ECX ; 调用结束恢复应用程序的ESP
POPAD ; 调用结束恢复应用程序所有32位寄存器的值
POP ES
POP DS ; 最后恢复应用程序的数据段
IRETD
这个汇编程序相当的绕,我们静下心来分析一下也能理解,最主要的秘诀就是搞清楚每条指令是把什么数据放到什么地方起到什么作用。这个程序的步骤分为以下几步:
(1) 保存应用程序的所有段。偶先保存数据段DS等,这个和我们之前说的思路是一致的。但是这里需要注意一个问题,就是刚开始进入程序的时候,所有的段和寄存器内容都是应用程序的,所有这些内容也只能暂时存在应用程序的堆栈区。
(2) 切换到操作系统所有段。这个过程里面最复杂的就是操作系统和应用程序之间的栈切换,需要具体来仔细分析。我们最终的目的是要调用操作系统C程序函数hrbapi,由于这个函数hrbapi的8个入口参数是在应用程序里的8个32位寄存器,我们首先需要把8个32位寄存器压入操作系统的栈才行。但现在SS和ESP指向的是应用程序,故应将SS和ESP先切换到操作系统。SS不能直接赋值,只能通过AX等中转,因此我们需要用mov ss, ax指令;要切换到操作系统ESP,方法是从上一节start_app()启动应用程序中暂存在内存地址[0xfe4]中取,所以得采用mov esp [0xfe4]的形式,但是要访问到操作系统的[0xfe4],又要先把DS切换到操作系统:mov ds, 1*8,但是这样的指令是不运行的,必须借助寄存器:mov ds, ax。所以要实现将SS和ESP先切换到操作系统,必须要用以下条指令:mov ax, 1*8, mov ss, ax,mov ds, ax。这样,我们还没来得及将应用程序的8个32位寄存器入到操作系统的栈区,就已经修改了EAX了,所以这个方法不行。我们只有换一种方法:先把应用程序的8个32位寄存器存入应用程序的堆栈区,然后将SS和ESP切换到操作系统,再将这个8个32位寄存器从应用程序的堆栈区复制到操作系统的堆栈区,这个就是应用程序向操作系统传递参数的过程:
(3) 恢复应用程序所有段。当操作系统所有的事情都完成之后,就要恢复所有的应用程序所有段了,注意顺序是先恢复栈类数据SS和ESP,因为8位32的寄存器还全部放在应用程序的栈内呢,程序的最后才恢复数据段。
总算完成了API调用的程序了,这个程序应该是《30天》这本书里最复杂的地方了,如果这里你都能全部掌握,那么就这本书就没有别的地方能难倒你了。我们最后来运行一下应用程序hello2,看程序在新的构架下能否正常运行:
没问题!总结一下:我们的操作系统在应用程序和操作系统分配不同段的情况下,既抵挡住了带数据访问操作的应用程序crack1的攻击,又能完美的支持API调用。但是,这个过程的代价有点重,因为动不动就需要这样复杂的汇编程序绝对不是好事情!幸好,后面我们可以看到,借助于CPU的特权级机制,我们不需要自己来做这么复杂的栈切换,程序也会精简很多。
(三) 中级保护:异常情况处理
上一节中,我们验证了段限长---LIMIT引起的系统异常。虽然我们可以特别小心的注意,但是有时错误是难以避免的,因此有必要加上一种类似看门狗的告警机制:异常处理。为此CPU专门设计了发现这种异常:陷阱门,采用中断处理来对内部异常做出反应。
在《计算机自制操作系统(十六):中断---键盘驱动》中,我们介绍了CPU的内部中断:
可以看到,上一小节内存引用异常就是表中的中断号:13(0x0d),那么我们需要制作一个0x0d的中断服务程序:一旦引发中断,就立即杀掉当前的应用程序,打印出必要的提示信息之后再返回操作系统。打印提示信息的中断服务程序如下:
int inthandler0d(int *esp)
{
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
cons_putstr0(cons, "nINT 0D :n General Protected Exception.n");
return 1;
}
我们再把操作系统对应用程序的数据分配空间设置回64KB,故意引发一个系统错误,运行攻击应用程序crack1.c,最终结果如下:
这个结果表面,操作系统具备了发现应用程序异常并杀死应用程序的中级保护。
(四) 高级保护:特权级检查
虽然我们的操作系统已经为每个应用程序都分配了各自的段,也为这些段建立了异常处理机制,但是如果用户没有使用自己的段,而像下面的攻击程序crack2.nas一样,强行要将操作系统的段给应用程序使用,怎么办?比如下面这个程序,它用汇编程序来强行读写操作系统的内核段内容,也会引起操作系统的崩溃。
[INSTRSET "i486p"]
[BITS 32]
MOV EAX,1*8 ; 操作系统的数据段索引号
MOV DS,AX ; 把它存入应用程序数据段
MOV BYTE [0x102600],0 ; 强行修改操作系统数据段的内容
RETF
- 特权级保护原理
我们必须要阻止这样的应用程序执行,来保护操作系统不被做任何修改,这就需要用到X86保护模式的特权级机制。处理器的断保护机制可以识别4个特权级(或特权层),0级到3级。数值越大,特权越小。下图说明了这些特权级如何能被解释成保护环形式。
处理器利用特权级来防止运行在较低特权级的程序或任务访问具有较高特权级的一个段,除非是在受控的条件下。当处理器检测到一个违反特权级的操作时,它就会产生一个异常中断。
为了在各个代码段和数据段之间进行特权级检测处理,处理器可以识别以下三种类型的特权级:
(1)当前特权级CPL:CPL是当前长在执行程序或任务的特权级。它存放在CS和SS段寄存器的位0和位1中。通常,CPL等于当前代码段的特权级。当程序把控制转移到另一个具有不同特权级的代码段中时,处理器就会改变CPL。
(2)描述符特权级DPL:DPL是一个段或门的特权级。它存放在段或门描述符的DPL字段中。在当前执行代码段试图访问一个段或门时,段或门的DPL会用来与CPL以及段或门选择符中的RPL作比较。
(3)请求特权级RPL:RPL是一种赋予段选择符的超越特权级,它存放在选择符的位0和位1中。处理器会同时检查RPL和CPL,以确定是否运行访问一个段。即使程序或任务具有足够的特权级(CPL)来访问一个段,但是如果提供的RPL特权级不足的话访问也将被拒绝。
任何数据段寄存器重新加载选择子时都会特权级检查,下面来看数据段访问的特权级检查过程:为了访问数据段中的操作数,数据段的段选择符必须被加载进数据段寄存器(DS,ES,FS或GS)或堆栈段寄存器(SS)中。在把一个段选择符加载进段寄存器之前,处理器会进行特权级检查,它会把当前运行程序或任务的CPL、段选择符的RPL和段描述符的DPL进行比较。只有当段的DPL数值大于或等于CPL和RPL时,处理器才会把选择符加载进段寄存器中。否则就会产生一个一般保护异常,并且不加载段选择符。
2.特权级实现
本次面临的攻击程序crack2.nas是利用的数据段访问,通过这2条指令:
MOV EAX,1*8 ; 操作系统的数据段索引号
MOV DS,AX ; 把它存入应用程序数据段
它需要加载数据段的段选择符,我们要通过上一小结介绍的数据段访问特权级检查过程来阻止它:只有当段的DPL数值大于或等于CPL和RPL时,处理器才会把选择符加载进段寄存器中。这里的CPL就是应用程序运行时候的特权级,暂时未知;RPL是应用程序的数据段选择符(这里是AX的最后2位,=0);DPL是目标段特权级,对应到的是操作系统的数据段描述符的(因为AX=1*8)DPL值,操作系统一开始所有的描述符DPL值都是0。
故本次特权级检查是:RPL=0,DPL=0,CPL=?。很明显,我们只有设置CPL=3,才能阻止这个程序的运行。相当于本次应用程序的目标数据段的特权级DPL是最高级,而CPL是最低级,会因为(DPL<CPL)最终会失败并产生一个异常中断。
要点1:必须要让应用程序运行的时候CPL=3。
如何才能办到呢?这个就必须要溯源CPL的由来:由于CPL是存储在CS的最后2位中,它来源于CS成功装载的段选择子,而段选择子的最后2位就是RPL,所以CPL其实就是来源于RPL(当然这句话只针对代码段,因为CPL和数据段的RPL是没有关系的),而且是唯一的来源。当选择子成功装入CS寄存器后,相应的选择子中的RPL就变成了 CPL。因为它的位置变了,已经被装入到CS寄存器中了,所表达的意思也发生了变——原来的要求等级已经得到了满足,就是当前自己的等级。所以设置CPL归根到底就是设置RPL。但是这个RPL的值是在CS成功装载的段选择子之前就要确定的,因此设置RPL的值相当于在跳转到应用程序之前就要完成,于是我们顺着推下去就是:
要点2:必须要让应用程序启动之前RPL=3。
接着看如何启动应用程序:操作系统要跳入应用程序,必须要改变CS的值,也需要做特权级检查,这个检查叫代码段的特权级检查。代码段转移的特权级检查会更加的复杂。
首先,我们应该明白什么情况下,会做代码段的特权级检查。一般的说法都是当有跳转类指令如jmpcall等,实际上这种说法是错误的,就算没有显式的这些指令也有会发生特权级检查。最核心的是:代码段特权级检查会发生在能够改变代码段寄存器 CS值的指令中。
直接调用或跳转到代码段的特权级检查:JMP、CALL和RET指令的近转移形式只是在当前代码段中执行程序控制转移,不改变CS的值,因此不会执行特权级检查。JMP FAR、CALL FAR或RETF指令的远转移形式会把控制转移到另外一个代码段中,处理器一定会进行特权级检查。
我们这次的操作系统到应用程序的调用类型就是直接调用或跳转到代码段(因为目标段是注册在GDT中普通的代码段描述符类型 AR_CODE32_ER)。在这3个检查要素中,已经有2个已知:CPL是操作系统在运行,因此为0;RPL前面推导出为3。故:CPL=0,RPL=3,DPL=?。为了求出这个应用程序的DPL,这里有两个分支:
(1)如果应用程序段是非一致代码段,那么要通过CALL FAR或JMP FAR指令跳转到应用程序,特权级检查要求是:CPL=DPL且RPL<=CPL,显然没有办法满足。
(2)如果应用程序段是一致代码段,那么要通过CALL FAR或JMP FAR指令跳转到应用程序,特权级检查要求是:DPL<=CPL,并不检查RPL。那么此时只能把应用程序的DPL设置成0。但是,这种场景跳转之后,有一个重要的特征是不改变CPL,也即从操作系统CPL=0跳转到应用程序之后的CPL值仍然是0,这个不符合之前推断出的要点1。
因此,最终总结为:操作系统通过CALL FAR 或JMP FAR 指令无法从高特权级转移到低特权等级,可理解为CALL FAR 或JMP FAR 指令不支持操作系统用来发起向应用程序的调用。
我们只有换一种方法,使用RETF指令(后续有详细说明),它的检查条件是:DPL>CPL且RPL>CPL。为了通过检查,DPL唯一的值只能是3。
要点3:必须要让应用程序启动之前DPL=3。
同时这里可以看出:通常CPL都等于当前代码段的DPL,虽然CPL和DPL并没有关系,也不来源于它,但是由上面特权级检查过程中得知,由于RPL的参与,就会造成最终的结果是这样。
那么这个DPL是什么?它就是目标段应用程序的代码段,那我们就需要把它的段描述符DPL设置成最低级(顺便把数据段也一起设置了):
set_segmdesc(gdt + 103, finfo->size - 1, (int) p, AR_CODE32_ER + 0x60);
set_segmdesc(gdt + 104, 64 * 1024 - 1, (int) q, AR_DATA32_RW + 0x60);
加上0x60的目的是,就是设置该描述符的段特权级字段DPL=3,也即最低:
对于RPL的设置,方法是通过段选择子的描述符来设置,在《计算机自制操作系统(十):32位保护模式》中,我们讲过段选择子描述符的格式:
现在需要把RPL设置成3,只需要将两个段寄存器SS和CS的段选择符值的最后2位置3即可,段选择子加载成功之后,CSSS中的CPL值就自动变成3。后面会详细对程序说明。
要点4:利用RETF实现高特权向低特权跳转
通过前面的分析,我们现在就是要在操作系统的0级向应用程序的3级跳转,并使用RETF指令来进行跳转。为什么retf指令能实现呢?它是call far的返回指令,二者方向刚好相反:call far实现代码段跳转,retf则实现代码段返回。
要理解retf的原理,还得先从call far的机制说起。call 和 jmp 指令后接选择子(普通或门调用该都是)作为目标段,以实现从应用程序到操作系统的调用,调用者使用指令之后,相关信息会全部入栈,用于调用结束后的返回:
当被调用者使用retf 指令返回的时候,它会做下面的检查:
- 当处理器执行到 retf 指令,它知道这是远返回,所以需要从 "栈中返回旧栈的地址及返回到低特权级的程序中",这时候它要进行特权级检查。先检查栈中 CS 选择子,根据其 RPL 位,即未来的 CPL,判断在返回过程中是否要该改变特权级。检查通过,则从栈中弹出 EIP_old -> EIP,CS_old -> CS。
- 如果有参数,则增加栈指针 ESP_new 的值,以跳过栈中参数。此时栈指针 ESP_new 指向 ESP_old。
- 如果在第一步中判断出需要改变特权级,从栈中弹出 ESP_old -> SP ,SS_old -> SS。
我们这次是在操作系统中而不是在应用程序中,因此没有调用者,更没有参数,利用的假调用。故我们把应用程序的EIP、CS、ESP和SS全部故意压入操作系统的栈中,用一条指令RETF来触发这些信息出栈,只要它能通过特权级检查,就会自动切换到应用程序,相当于变相的利用调用返回来启动应用程序。
对了由于内容说的有点多了,不要忘记了这个动作是在操作系统启动应用程序start_app()里面的,那么我们最后来看一下完整的程序:
_start_app: ; void start_app(int eip, int cs, int esp, int ds, int *tss_esp0);
PUSHAD ; 32位寄存器全部保持在操作系统的堆栈区
MOV EAX,[ESP+36] ;应用程序的EIP
MOV ECX,[ESP+40] ;应用程序的CS
MOV EDX,[ESP+44] ;应用程序的ESP
MOV EBX,[ESP+48] ;应用程序DS/SS
MOV EBP,[ESP+52] ;tss.esp0内存地址
MOV [EBP ],ESP ;保存操作系统ESP
MOV [EBP+4],SS ;保存操作系统SS
MOV ES,BX
MOV DS,BX
MOV FS,BX
MOV GS,BX
OR ECX,3 ;RPL=3,改变请求特权等级
OR EBX,3 ;RPL=3,改变请求特权等级
PUSH EBX ;应用程序改变特权级之后的SS
PUSH EDX ;应用程序的ESP
PUSH ECX ;应用程序改变特权级之后的CS
PUSH EAX ;应用程序的EIP
RETF ;这条指令执行后再也回不来了
可以看到这个程序,比我们之前在没有引入特权级概念的时候精简了许多,存在如下疑问:
- 在调用完应用程序之后,没有看到将所有数据类段寄存器DSES等指向切回操作系统。
- 调用应用程序前明明看到了中间有保存操作系统ESP和SS的操作,但是最终没见到恢复呢?
- 程序开始之前有PUSHAD,但是结束也没有看到POPAD。
- 最终RETF指令之后,由于程序不会再回来了,那么该往何处?
要点5:不同特权级代码段的栈切换
上面的问题,我们通过分析下面操作系统的API调用中断服务程序便可知道答案:
_asm_hrb_api: ;从应用程序跳转到这里发生了特权级转换,CPU会自动将栈SS/ESP切换到操作系统
;并且要把应用程序的SS/ESP压入操作系统的栈
STI ;以下所有数据入栈都是存在操作系统的堆栈区内
PUSH DS ;保存应用程序的DS
PUSH ES ;保存应用程序的ES
PUSHAD ;保存所有应用程序32位寄存器
PUSHAD ;保存所有应用程序32位寄存器(8个hrb_api调用参数)
MOV AX,SS ;DSESSS指向同一段
MOV DS,AX ;数据寄存器DS切换到操作系统
MOV ES,AX ;数据寄存器ES切换到操作系统
CALL _hrb_api ;操作系统调用底层服务
CMP EAX,0 ;EAX不为0表示是API调用结束
JNE end_app
ADD ESP,32 ;否则是非结束的INT 0x40调用,栈内丢弃8个参数
POPAD ;恢复所有应用程序的32位寄存器
POP ES ;恢复应用程序的DS
POP DS ;恢复应用程序的ES
IRETD ;返回到应用程序(栈内弹出EIP/CS)
;再从操作系统的栈里弹出应用程序的SS/ESP,CPU自动将栈切回应用程序
end_app:
; EAX是tss.esp0内存地址,存放的是操作系统的栈地址SS和ESP
MOV ESP,[EAX] ;应用程序运行全部结束,栈恢复到启动应用程序之前。
POPAD ;恢复所有进入start_app()之前的寄存器
RET ;返回到start_app()的调用者,cmd_app()
它和我们之前的程序相比简单太多了,最大的差异之处就是并没有把栈从应用程序切换到操作系统,更没有把hrb_api调用的8个参数从应用程序的栈区切换到操作系统栈区的繁琐操作。它采用的就是我们之前想用的策略:一开始进来就把所有的参数直接压入操作系统的栈区了!但是奇怪的是,操作系统的栈地址是从哪里获得的呢?因为程序中没有一条指令有所体现,我们之前是一直是通过从内存[0xfe4]来实现操作系统栈区地址传递的。这里就有一个隐形的操作,这就是CPU厉害的地方,因为程序从跳转到asm_hrb_api开始,就代表代码段已经从应用程序转移到了操作系统了。期间发生了特权级3到0的切换,每当调用门用于把程序转移到一个更高级别的代码段时,CPU会自动将堆栈切换到目的代码段的堆栈中去。具体到这里就是要把SS和ESP寄存器的值切换到目的代码段---操作系统的SS和ESP堆栈值,那CPU到哪里去取操作系统的堆栈值呢?答案是TSS中,而且是当前运行任务的TSS,我们知道当前运行任务始终有一个硬件寄存器TR负责指向的,所以这个读取就非常的简单。现在回头再来看看TSS的结构:
这个里面的ESP0SS0,ESP1SS1,ESP2SS2就是用来装载特权切换栈地址的,0-2代表该代码段的运行特权级,为什么没有3呢?由于是程序是从低级别转移到一个更高级别才有这种栈自动切换机制,最低端的转移也就是从3级转到目标2级,因此没有能转到3级的情况。有了这种栈自动切换机制,我们就需要在最开始在调用应用程序之前,往ESP0SS0写操作系统的栈地址初始值,所以在start_app中有一个向内存地址tss.esp0保存操作系统栈地址从操作,从此之后它就代替我们之前的[0xfe4]方案,最重要的是我们再也不用操心操作系统和应用程序之间的栈切换关系了,之前我在梳理这段程序的时候也是费劲。由此不得感慨,设计CPU的人实在太聪明了。
栈从应用程序切换到操作系统的过程算是已经完成了,那么操作系统完成底层调用之后,栈又怎么切回应用程序呢?这个又得从程序调用的过程开始说起(和之前RETF的地方内容是一样的),一旦成功跳转之后,CPU是把应用程序(调用者)的SS和ESP入到操作系统(被调用者)的栈中保存了的:
调用结束,在遇到返回指令(retfiret等)时,由于CPU是检测到即将返回的目标代码特权级要发生变化,因此它会从堆栈里弹出原调用者的SS和ESP,从而切换回调用者(应用程序)的堆栈。当然,如果返回的时候,没有发生特权级变化就不会有这一步弹出操作,比如ret指令。最后,被调用者(操作系统)堆栈的SS和ESP值被丢弃(操作系统被调用的使命已经完成),后面需要切到操作系统的时候,还是去TSS中找SS0和ESP0就是。
特权级转移栈切换机制总结:当发生低特权级(应用程序)向高特权级(操作系统)跳转时,CPU会自动从TSS的SS0和ESP0读出栈值并切换到这个新栈,触发这个切换的指令是CALL XX(调用门)或INT XX(中断门)。当发生高特权级(操作系统)向低特权级(应用程序)跳转时,CPU没有自动切换栈的机制,靠的是利用返回指令RETF或IRET,将应用程序的对栈值SS和ESP从操作系统的栈中弹出(pop)来实现堆栈切换。而其他的所有数据类寄存器DSESFSGS,CPU则完全不会管,需要编程者自己控制它们在发生跳转时的切换关系。
基于上述机制,一般情况下,我们都是只需要对TSS的SS0和ESP0做写操作的,因为需要在进入应用程序之前给它设置好后面要切回操作系统的堆栈值。但是,有一种情况也需要对TSS的SS0和ESP0做读操作:那就是强制结束应用程序时。因为一个应用程序在运行过程中,如果发生意外需要我们强制进行结束时,我们必须要将计算机的状态强制恢复到应用程序调用之前,这样才相当于视本次应用程序的调用根本就没有发生,就不会影响操作系统正常运转状态。那怎么强制恢复到当时的状态呢?无疑是恢复操作系统当时所有寄存器的值,这其中最重要的就是要恢复SS和ESP的值,那这个SS和ESP的值在哪里呢?显然我们当初是把它写入了TSS的SS0和ESP0中,那现在要强制结束应用程序,只需要把它读出来就可以了。由于强制结束应用程序的代码段本身就是在操作系统内核中的,所以DSESFSGSSS的值肯定是属于操作系统的,在从ESP0恢复操作系统的ESP之后,最后就只剩下恢复所有的32位数据类寄存器,用一句POPA就可以实现了。综上分析,我们用于强制结束应用程序的程序就应该这样写:
end_app: ; EAX是tss.esp0内存地址,存放的是操作系统的栈地址SS和ESP
MOV ESP,[EAX] ;应用程序运行全部结束,栈恢复到启动应用程序之前。
POPAD ;恢复所有进入start_app()之前的寄存器
RET ;返回到start_app()的调用者,cmd_app()
要点6.调用门(中断门)的特权级检查
在通过特权级实现上面的API调用过程中,其实我们还漏掉了很重要的一步:重新设置中断门描述符的DPL特权级。
程序转移还可以通过门来实现,调用门和中断门的区别在于:调用门是用户主动发起的调用,如call cs:eip,调用门指令发起的时候,和之前我们学习过的TSS描述符跳转一样,后面的EIP偏移部分会被忽略。而中断门则是计算机发生了中断之后被动接受跳转的,其实就是CPU把中断号当成了普通调用门的目标段索引号,然后再发起一个调用。另外还有一个区别就是,计算机发起中断门调用的时候,有些中断还会比普通调用门多一个入栈的数据:错误编号,该编号可以用来定位计算机更详细的内部错误信息,提供给中断服务程序使用。无论如何,中断门和调用门的调用原理都是一样的,都是由于改变了CS的值而发生了转移,当发生这种类型的转移时,特权级检查还会比直接跳转多一个检查项:门描述符。
由于普通的调用指令只能实现同级别的程序转移,而通过门则实现了程序从低级跳转到高级。所以,这是CPU要引进各种门(调用门、中断门、陷阱门、任务门)的原因。
各种门的特权级检查要求是:DPL_GATE >= CPL >= DPL_CODE&&RPL <= DPL_GATE。
具体到这次的案例发生在API调用的时候:CPL=3,RPL=0,DPL_CODE=0(目标代码就是中断服务程序,具体在操作系统的CS段),要满足以上条件,DPL_GATE只有1个取值:3。本质原因是我们的应用程序的特权级变最低了,那么它通过INT 0x40调用操作系统API的时候,为了能通过特权级检查,我们还需要把该API对应的中断门描述符DPL_GATE也同步调到最低3:
set_gatedesc(idt + 0x40, (int) asm_hrb_api, 2 * 8, AR_INTGATE32 + 0x60);
修改之后,就表明在操作系统这么多的中断门中,只有这种级别的中断门可以被应用程序调用,其它的诸如鼠标、键盘等中断门,都只能被操作系统发起调用。
最终我们将各类特权级保护跳转总结为:
- 普通调用只能实现平级跳转。
- 门调用能实现低级到高级跳转。
- 返回指令RETFRETI能实现低级到高级跳转。
- 数据则只能是高级访问低级。
由于各种调用和转移指令,老是容易搞混,所以我在此还想补充一点内容。纯属自撰,如有错误烦请指出。把以上所有跳转类型具体落实在编程指令上,可以用通用格式:CALL CS:EIP。
- 当指令中CS==当前CS时,这是最普通的调用---段内调用,这种情况下叫近调用,近调用有一个等效写法:CALL EIP,返回用RET。由于不改变CS的值,因此不会特权级检查。
- 当指令中CS不等于当前CS时,CPU应该判断为可能要改变特权级,因为跨段了,这种情况下叫远调用。远调用有一个等效写法:CALL FAR [XX],返回用RETF。这种写法的目标段CS和EIP相当于隐藏了,那它是多少呢?CPU会去内存[XX]地址依次取出6个字节当成目标地址:EIP和CS,故我们可以提前在 [XX]这个地方埋上要跳到的目标地址即可。拿到CS后,CPU会去GDT中检查,该CS对应的描述符是什么类型?
- 非门调用:如果TYPE=1XXX,则这是一个普通的代码段,那这次调用就是普通调用,就会执行非门类转移的特权级检查。非门调用是同级调用,因为它要求:目标CS的特权级==当前CS的特权级。
- 门调用:如果TYPE=1100,则这是一个门描述符,则这次调用就是门调用。门调用指令中指定的EIP值会被废弃,会以门描述符中对应的偏移地址为准。门调用为跨级调用,它会执行门类转移的特权级检查,要求目标CS的特权级>=当前CS的特权级。但是门调用的特权级检查还会有第2次:还要检查门描述符中代码段选择子对应的最终被调用的代码段特权级,要求这个最终被调用的代码段特权级<=当前CS的特权级。
- 在以上特权级检查过程中有任何不通过,则这次调用就失败。
调用门就这些吗?没有结束!调用门还需要传递参数,所以你得指定参数个数。参数和C语言里面的函数调用一样的,都是放在调用者栈内的。所以调用门之前,你得先把这些参数全部放进栈里面,跳转后,CPU自动会负责把你这些参数从你的栈搬到目标段的栈,够周到了吧?
这个过程在前面的图中也有,可能没有引起注意:
3. 把CALL指令换成JMP和INT的话,过程和上面大同小异。一些区别:JMP没有返回指令,如果检查到TYPE=10X1的话是TSS描述符,会触发任务切换,之前我们专门研究过了。INT对应的返回指令是RETI,它在转移的时候,出入栈数据只比RETF多一个EFLAGS。
总结结束,也是累死人了。
经过史上最复杂的历程,我们的操作系统最后运行表明:攻击操作系统内核的程序crack2.nas会被发现并被成功阻止,同时在引入特权级保护之后,各应用程序也运行正常。
本章完成之后,这次自制操作系统最难的地方算是彻底梳理完成了。