System Protection

  《30天自制操作系统》差不多都过了一遍。感觉操作系统和一般程序的最大的区别是可以随意操作和控制内存。而作为操作系统,最重要的是兼容性和稳定性。稳定的最基本的条件就是安全。所以操作系统作为最灵活的“程序”必须在安全方面做好工作。《30天自制操作系统》这本书上也讲了很多关于保护操作系统的知识,让我记忆深刻。下面就来总结一番。
  保护操作系统是一项工程量十分巨大的活,有时候一个死角就能够然操作系统运行错误。所以要想保护操作系统,必须对操作系统的运行的每个流程都十分清楚,这样才能够找到操作系统的弱点,并做好防护措施。下面就来回顾一下操作系统的运行流程以及涉及的相关代码。

  • 1.BIOS找到启动区,执行启动区的代码。这段代码所在文件是ipl10.nas,是用汇编写的,引导区只有512字节,不足以存放操作系统,这段代码的主要作用就是设定分区格式,把操作系统读入内存,然后跳入到操作系统核心代码。说简单一点就是引导操作系统。

  • 2.启动区之后直接跳入HariMari函数开始执行。相关代码在bootpack.c。这部分代码主要是初始化键盘、鼠标等硬件设备设备,初始化GDT、IDT、PIC,开放中断,初始化内存、初始化任务并运行,然后进入到了一个循环,在这个循环里面接收键盘和鼠标的数据,并控制数据的发送。

  • 3.操作系统会启动一个终端。相关代码在console.c中。终端是当前的操作系统最重要的交互工具。主要功能是执行相关命令和外部程序。如果当前选中终端,输入字符在HariMari中被接收之后,会发送到终端做相关的显示,如果发送回车键之后,终端会判断是命令还是程序,并执行。

  • 4.如果执行的是外部程序,则操作系统则会跳到程序的主函数执行其代码。为了程序的方便,程序可以调用操作系统所提供的公开API。

  分析上面的内容。前三个部分是操作系统所提供的服务。这些部分是不能够被外部程序所更改的,只需要给操作系统关键部分文件加上一定的修改权限就可以了。比如在win7操作系统上,不能够删除系统关键文件,这应该是一个道理。这部分代码是设计和编写操作系统的人编写的,只要这部分代码稳定,能够保证运行正常功能不会出错就可以了。
  第四个部分的代码是可以由其它程序员编写的,所以这部分代码可能会存在bug,或者是恶意代码,如果没有做好一定的防护,操作系统可能会崩溃。

  下面再来看一下外部文件被操作系统调用的流程。
  用户通过键盘向电脑输入相关外部文件名字,然后回车。HariMari函数接收到键盘数据,发送给终端,终端判断输入是否是命令,如果不是命令,则查找是否存在该文件,如果存在文件则通过file_loadfile载入内存,注册gdt,然后跳转执行。
  现在假设操作系统没有任何防备,看看应用程序能够做些什么来破坏操作系统。

  • 1.语言直接修改内存数值。
      这是C语言的特性,在操作系统没限制的条件下,可以通过指针修改任意内存的值。
    这个本来应该是操作系统的权力。在实现操作系统的时候可以通过在一些内存中写入来保存数据,把数据当全局变量来用。这个在之前的内容中也用到过。但是如果是外部的软件,当然不可能给予它这么大的权限,所以需要把所有软件中的非法内存操作给禁止。
      解决方法:专门给应用程序分配数据段,限制应用程序修改的数据范围。如果应用程序需要调用系统函数,则在执行系统函数之前先把段地址切换到操作系统所在段再执行。执行好之后回到应用程序段,切换段以及执行api的操作由操作系统提供,只需要再对应用程序对api给的参数进行合法性校检就可以了。

  • 2.用汇编写第三方程序,忽略操作系统指定的数据段。
      这个是上面的解决方法的漏洞。c语言编译之后会用系统提供的段,但是汇编更强大,可以自己选定段进行数据修改。
      解决方法:让应用程序无法访问操作系统的段。在软件的数据段和代码段上的访问权限加上0x60,这样可以将段设置为应用程序用。该段中的代码如果要访问操作系统所在段代码是非法的。这个0x60是x86架构中提供的,在现在的操作系统中只需要设定好久行了,不需要编写额外的代码。处于好奇,我去查了一下arm,里面有一个MMU,也可以设定访问权限,功能与x86类似。看来这部分内容在操作系统中至关重要,在处理器架构上面就已经把这个问题考虑清楚了。
      申明段为应用程序段之后还有其它的一系列好处,比如应用程序不能够直接执行IN和OUT指令,这样可以防止应用程序更改定时器的周期,让定时器运行缓慢。除了这个,在应用程序中,也不能够执行CLI、STI和HLT之类的指令,这些指令在操作系统看来都是危险的,所以0x60的设置极大地提高了系统的安全性。

    1
    2
    3
    4
    //代码段
    set_segmdesc(gdt + 1003, finfo->size - 1, (int) p, AR_CODE32_ER + 0x60);
    //数据段
    set_segmdesc(gdt + 1004, segsiz - 1, (int) q, AR_DATA32_RW + 0x60);
  • 3.破坏应用程序所在段中的数据。
      应用程序不能够对操作系统的段进行修改,所以篡改操作系统是没用的了。但是可以篡改应用程序所在段的内容,让其它应用程序无法正常运行。对于CPU来说,应用程序访问应用程序所在段应该是理所当然的。但是这的确西药改进,如果操作系统中的应用程序很容易受到其它应用程序的干扰,而操作系统对此也不能够做些什么,那这个操作系统也是失败的操作系统。
      解决方法:由gdt可以得到一些启示,可以动态修改gdt设置,把正在运行的程序gdt设置为应用程序段,把不在运行的程序设置为操作系统段。这样的话如果运行的应用程序想要破坏其它应用程序,那也只能够破坏和它同时运行的应用程序,大大降低了危害。但是这样有很多缺点,比如说一个程序就要用好几个gdt,而且每次更改起来也比较麻烦。
    其实对于这个问题,CPU已经准备好了解决的方案。就是LDT。我们知道,GDT是全局段描述符表,而LDT则是局部段描述符表。每个LDT都是互相独立的,不能够访问各自的资源。所以只需要把应用程序设置在LDT中,这样其它应用程序就不能够访问该应用程序的段,保证了数据的安全。设定LDT也很简单,只需要在任务的tss中给成员变量ldtr赋相应值就可以了。

    1
    2
    //设定ldtr
    taskctl->tasks0[i].tss.ldtr = (TASK_GDT0 + MAX_TASKS + i) * 8;

  最后来谈一下对一些破坏性程序的处理。
  作为一个可用的操作系统,如果应用程序具有破坏性,那么运行之后就应该做到适当的提示,以及能够强制关闭应用程序,不要让它继续占用内存和CPU。这样操作系统才能够更加稳定。
  对于一些恶意代码,比如在应用程序中修改操作系统段的数据,或者在应用程序中执行STI等操作,由于我之前设定过0x60的段权限,应用程序会自动产生0x0d中断(异常中断),在这个中断中,我可以写一些代码来关闭应用程序做好善后工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//0x0d中断之后调用asm_inhandler0d函数
set_gatedesc(idt + 0x0d, (int) asm_inthandler0d, 2 * 8, AR_INTGATE32);
;中断调用汇编函数
_asm_inthandler0d:
STI
PUSH ES
PUSH DS
PUSHAD
MOV EAX,ESP
PUSH EAX
MOV AX,SS
MOV DS,AX
MOV ES,AX
;调用inthandler0d显示异常
CALL _inthandler0d
CMP EAX,0
JNE _asm_end_app
POP EAX
POPAD
POP DS
POP ES
; INT 0x0d 中需要这句
ADD ESP,4
;返回操作系统
IRETD
//_asm_inthandler0d调用
int *inthandler0d(int *esp)
{
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
struct TASK *task = task_now();
char s[30];
//在终端中显示异常
cons_putstr0(cons, "\nINT 0D :\n General Protected Exception.\n");
sprintf(s, "EIP = %08X\n", esp[11]);
cons_putstr0(cons, s);
//强制结束程序
return &(task->tss.esp0);
}

  现在再来回顾一下操作系统,只要其内部稳定,基本上就很难被外部程序给干扰。因为操作系统和程序已经隔离。应用程序不能够破坏操作系统数据,如果要高权限操作,应用程序可以调用操作系统公开的api来完成。同时应用程序和应用程序间也独立,互不干扰。这样的操作系统才能经受住考验。