发现重大安全问题
- 看下面的例子,我们给 TASK A 任务函数中稍微加点东西,加点什么东西呢?我们尝试在 TASK A 任务函数中修改内核中的数据
void TaskAFunc(void) // 任务执行函数 { ... U32* p = (U32*)0xB666; // 这个地址在内核范围内 *p = 0x12345678; // 尝试改动内核数据 printk(">>>>: %x\n", *p); // 查看是否修改成功 ... }
- 其结果,显而易见,我们修改成功了,应用程序居然能够修改内核,后果很严重啊,内核没有一点自我保护能力
内核保护
- 分析一下当前设计的缺陷
- 系统基于平坦内存模型(内核与应用均可以访问内存的任意角落)
- 内核与应用执行与不同的特权级,但并未利用特权级对内核进行保护
- 因此,应用程序能够随意的(恶意的)修改内核空间,进而导致整个操作系统崩溃
- 针对当前的情况,我们于是有了新的需求,那就是当应用程序向内核空间写入数据时,立即将应用程序强行结束
- 解决思路:启动虚拟内存机制(页式内存管理)对内存区域属性进行设置;利用内核与应用特权级的不同,内核(DPL0)可读写内存任意区域,应用(DPL3)不可写入内核所在区域
- 回顾 内存分页机制 中的页表属性,由于物理页的地址必须 4K 字节对齐,我们只使用 32 位 (4 字节)中的高 20 即可表示地址,多出的低 12 位不要浪费,用来描述内存页所具备的属性
- 我们只看 bit1,R/W = 0: 表示在 3 特权级下无法对对应的物理页进行写操作(只读);R/W = 1: 表示在任何特权级下都可以对对应的物理页进行写操作
- 只读内存页的进一步说明:CR0 寄存器中的 WP (bit16) 位用于全局控制内存页是否可以写入
- 当 W/R = 0 时,若 CR0.WP = 1,则任何特权级都无法对内存页写数据;若 CR0.WP = 0,则 0、1、2 特权级下可对内存页写数据
内核保护的代码实现
- 以线性方式建立页表,即 y = f(x) = x, 其中,x 为虚地址,y 为是地址(我们在前面内存分页机制相关章节中就已经实现过)
- 在内核中修改页表属性,使得内核空间在 3 特权级无法写入
- 在第一个任务启动之前启动页表机制
- 第一步就是建立页表,这个工作我们在 开启内存分页 中已经做过,现在只需要把代码 (setup_page 函数)复制过来即可,在全局段描述符表中添加页表对应的描述符,以及添加选择子
; 全局描述符表定义 ... PAGE_DIR_DESC : Descriptor PAGE_DIR_BASE, 4095, DA_DRW + DA_32 PAGE_TAB_DESC : Descriptor PAGE_TAB_BASE, 1023, DA_DRW + DA_32 + DA_LIMIT_4K ; 段选择符定义 ... PAGE_DIR_SELECTOR equ (0x0007 << 3) + SA_RPL0 + SA_TIG PAGE_TAB_SELECTOR equ (0x0008 << 3) + SA_RPL0 + SA_TIG
- 不过其基址要改一下,因为原先的地址处在当前的应用程序范围内,不太合适,现在改到内核中
; 页目录表与页起始地址
PAGE_DIR_BASE equ 0x70000
PAGE_TAB_BASE equ 0x71000
- 还有一些页表属性需要拷贝过来,这里就不再细说了,将其复制到 “inc.asm” 文件中即可
- 拷贝完成,编译运行一下,看看结果,其结果有点出人意料,花屏,并且程序崩溃了
- 深入查找原因,发现我们所创建的页表覆盖了显存区域和应用程序内存,因为二级页表大小为 1M,显然与目前的内存规划冲突
- 合理的解决方案应该是把应用程序往后移,不过我嫌麻烦,直接使用原来的页表地址,放在应用程序内存后面,目前先以实验为主,等以后再重新规划一下应用程序的运行地址吧
PAGE_DIR_BASE equ 0x100000 PAGE_TAB_BASE equ 0x101000
- 修改页表基址后,再次编译运行,程序成功运行,然而应用程序依旧能修改内核数据,这当然是必然的,我们仅仅创建了页表,并没有修改页表属性中的 bit1(R/W) 位
- 下面我们就实现一个修改内核属性 RW 位的函数,遍历应用程序起始地址以下的内存所对应的页表项(我们认为应用程序起始地址以下的内存空间为内核空间),将其中 RW 位(bit1)置 0,在 3 特权级下无法对对应的物理页进行写操作(只读);修改页表后,我们必须更新页表,这一步记得不要遗漏。本函数放到 “page.c” 文件中,该文件的创建与加入工程(修改BUILD.json)就不仔细描述了
void ClearKernelPageWR(void) { U32* TblBase = (U32 *)PAGE_TAB_BASE; // 页表项本身所在的物理内存基地址 U32 index = APP_START_ADDR / 0x1000 - 1; // APP_START_ADDR = 0x80000: APP 加载地址 U32 i = 0; // 遍历应用程序起始地址以下的内存所对应的页表项(我们认为应用程序起始地址以下的内存空间为内核空间) // 将其中 RW 位(bit1)置 0,在 3 特权级下无法对对应的物理页进行写操作(只读) for(i = 0; i < index; i++) { U32 value = *TblBase; value &= 0xFFFFFFFD; *TblBase = value; TblBase++; } // 更改页表后需要刷新页表 asm volatile( "movl %0, %%eax\n" "movl %%eax, %%cr3\n" : : "a"(PAGE_DIR_BASE) // %0 替换成 PAGE_DIR_BASE : ); }
- 代码稍微优化一下,把开启页表从 “laoder.asm” 的 setup_page 函数中删除,合理开启页表机制应该放到初始化完成,开启第一个任务之前
- 取消 “loader.asm” 中如下代码
; 寄存器 cr0 的 PG 位置 1 ; mov eax, cr0 ; or eax, 0x80000000 ; mov cr0, eax
- 改到 “task.c” 中启动第一个任务之前开启内存分页机制
void TaskStart(void) { ... // 寄存器 cr0 的 PG 位置 1,启动内存分页机制 asm volatile( "movl %%cr0, %%eax\n" "or $0x80000000, %%eax\n" "movl %%eax, %%cr0\n" : : : "eax" ); asm volatile("sti"); // 执行任务前必须开中断 SWITCH_TO(current_task); // 切换到空闲任务 taskIdle 执行 }
- 以往我们最后实现的代码都是能成功运行的代码,然而本次实验代码却是不能运行成功的代码
又发现了一个问题
- 由于我们在 TASK A 任务中修改了内核空间的数据,而内核空间又被添加了页表只读属性,那么当 TASK A 修改内核数据时程序崩溃,这也好理解
- 但当我们注释掉 TASK A 中的修改内核数据的代码后,程序依然崩溃,这又是咋回事呢?
void TaskAFunc(void) // 任务执行函数 { ... // U32* p = (U32*)0xB666; // 这个地址在内核范围内 // *p = 0x12345678; // 尝试改动内核数据 // printk(">>>>: %x\n", *p); // 查看是否修改成功 ... }
- 深入思考一下,明明任务中已经没有修改内核数据的代码了,为什么程序依旧崩溃了呢?
- 答案就是:空闲任务,我们很容易忽略这个空闲任务,空闲任务本质上特权级依旧为 3,然而空闲任务中定义的变量以及用到的栈等都在内核空间
- 解决办法:把空闲任务从内核中搬到应用程序中,通过共享内存的方式将内核需要的相关数据传递给内核
- “app.c” 中改动代码如下:
#define IDLE_STACK_SIZE 512 static U08 taskIdle_stack[IDLE_STACK_SIZE]; // 空闲任务私有栈 static void TaskIdleFunc(void) // 空闲任务执行函数 static void TaskIdleFunc(void) // 空闲任务执行函数 { ... } void AppInit(void) { ... // 把应用数据放入共享内存 0xA800 处 ... *((volatile U32*)0xA808) = (U32)taskIdle_stack; *((volatile U32*)0xA80C) = IDLE_STACK_SIZE; *((volatile U32*)0xA810) = (U32)TaskIdleFunc; }
- 在 “share.h” 文件中添加空闲任务相关数据的共享内存地址
#define IDLE_TASK_STACK_ADDR (SHARE_START_ADDR + 0x808) #define IDLE_TASK_STACK_SIZE (SHARE_START_ADDR + 0x80C) #define IDLE_TASK_FUNC_ADDR (SHARE_START_ADDR + 0x810)
- 最后再修改一下 “task.c” 中的代码
void TaskInit(void) { ... U08* IdleTaskStackAddr = (U08 *)(*(U32 *)IDLE_TASK_STACK_ADDR); U16 IdleTaskStackSize = (U16)(*((U32*)(IDLE_TASK_STACK_SIZE))); TASK_FUNC pIdleTaskFunc = (TASK_FUNC)(*(U32 *)IDLE_TASK_FUNC_ADDR); ... // 创建第一个任务(空闲任务) // TaskCreat(&taskIdle.task, TaskIdleFunc, taskIdle_stack, IDLE_STACK_SIZE, "Idle", E_TASK_PRI15); TaskCreat(&taskIdle.task, pIdleTaskFunc, IdleTaskStackAddr, IdleTaskStackSize, "Idle", E_TASK_PRI15); ... }
页异常
- 先回想一下我们的需求,那就是当应用程序向内核空间写入数据时,立即将应用程序强行结束,然而,现在的情况是当应用程序修改内核后,整个系统都崩溃了,这显然不是我们想要的结果,如果我们能捕获到这种异常行为,才能做出处理的动作
- 那么,如何能够捕获到应用程序的异常行为并进一步处理
- 软件有办法捕获这种异常行为吗?很显然,软件没有能力捕获这种异常行为,那么就只能依靠硬件来捕获这种异常行为了
- CPU 提供了 0x0E 号异常保护中断机制,当应用程序向被保护的内存页写入数据时,将触发页异常中断(PageFault)
- 我们可以依靠 CPU 提供的这个异常中断来结束当前应用程序,这样子是不是就能达到我们的目的了呢
异常与中断的细微差异
- 异常也可以理解为中断的一种,只不过与我们前面所学习过的中断稍微有点区别
- 中断是由外部设备触发或者指令主动触发(软件本身并没有错误)
- 异常是指令执行的过程中遇见的错误(由软件错误触发,比如当一个变量除以 0 时就会触发异常)
- 中断处理结束后,返回到被中断的指令下一个指令处执行
- 异常处理结束后,返回异常的指令处重新执行
- 中断不带参数,而异常可能带有参数(错误码)【注意:这与我们软件编写密切相关】
异常发生时栈中的情况
- 异常发生时压栈如下图,与普通中断发生时压栈相比,多了一个错误码或错误类型压栈,出栈时的示意图这里就不再提供了反过来就行
- 因为异常中断多了一个错误码(错误类型)err_code,那么,代码中上下文环境 TASK 结构体类型也要对应新增一个 err_code 元素
- 保存上下文代码也要对应更改一下,在 BeginISR 中第一条增加一条指令 “sub esp, 4”,跳过 err_code,在 EndISR 最后也增加一条指令 “add esp, 4”,其作用也是跳过 err_code
%macro BeginISR 0 ; 保存上下文, ss esp eflags cs eip 这 5 个寄存器已被 CPU 自动入栈保存 sub esp, 4 ; 跳过 err_code pushad ; 保存通用寄存器 push ds ... %endmacro %macro EndISR 0 mov esp, [current_reg] ; 使栈顶 esp 指向上下文数据结构 reg 的起始位置 ; 恢复上下文 ... popad ; 恢复通用寄存器 add esp, 4 ; 跳过 err_code iret %endmacro
- 运行一下,结果程序崩溃了,原来是还有好几个地方忘记修改了,还包括 SwitchTo 函以及
• SWITCH_TO 宏 E_RET SwitchTo(TASK* task) { ... // 恢复上下文 asm volatile( ... "popal\n" // popal(gcc) = popad(nasm) "addl $4, %%esp\n" // 跳过 err_code "iret\n" ... ); } #define SWITCH_TO(t) (void)({ \ ... \ asm volatile( \ ... \ "popal\n" \ "addl $4, %%esp\n" \ "iret\n" \ ... );})
- 因为异常时压栈多一个错误码,导致我们到处修改。编译运一下,程序成功执行
验证页异常中断
- 既然当应用程序向被保护的内存页写入数据时,将触发 0x0E 号页异常中断,那么我们先来简单验证一下,心里有个底
- 先在 “page.c” 文件中写个页异常中断服务函数
void PageFaultHandle(void) { printk("PageFaultHandle\n"); while(1); }
- 修改 “interrupt.asm” 中 0x0E 号中断入口
extern PageFaultHandle Int0x0E_Entry: call PageFaultHandle iret
- 最后再放开 TASK A 中的非法操作
U32* p = (U32*)0xB666; // 这个地址在内核范围内 *p = 0x12345678; // 尝试改动内核数据 printk(">>>>: %x\n", *p); // 查看是否修改成功
- 编译运行,运行结果如下,成功打印出 “PageFaultHandle” 字符串
完善页异常中断
- 涉及相关代码见:interrupt.asm、page.c、task.c
- 前面我们在 “interrupt” 中实现 0x20 以及 0x80 号中断时,在中断服务函数前后都做了保存上下文和恢复上下文的工作,那么 0x0E 号页异常中断呢?
- 毋庸置疑,0x0E 号异常中断也要做这些工作,那么,可以直接使用 BeginISR 和 EndISR 保存上下文和恢复上下文吗?
- 答案,不行,因为异常与中断稍微有些不同,异常多了错误码 err_code
- 那么,我们重新实现专门用于异常的中断的保存上下文 BeginFSR 和恢复上下文 EndFSR
%macro BeginFSR 0 ; 保存上下文, ss esp eflags cs eip 这 5 个寄存器以及 err_code 已被 CPU 自动入栈保存 pushad ; 保存通用寄存器 push ds push es push fs push gs mov esp, KERNEL_STACK ; 重新指定栈顶 esp 到内核栈,以供接下来的逻辑功能代码部分使用 %endmacro %macro EndFSR 0 mov esp, [current_reg] ; 使栈顶 esp 指向上下文数据结构 reg 的起始位置 ; 恢复上下文 pop gs pop fs pop es pop ds popad ; 恢复通用寄存器 iret ; 恢复 ss esp eflags cs eip err_code %endmacro
- 中断入口改动如下
extern PageFaultHandle Int0x0E_Entry: BeginFSR call PageFaultHandle EndFSR
- 代码写到这里,运行结果好像跟之前并没有什么区别,当然,仅从现象上看确实没什么区别
- 接下来我们的工作就是完善 PageFaultHandle 函数,在该函数中实现销毁当前任务的功能
- 很简单嘛,我们直接调用之前实现过的 TaskDestory 函数不就可以了嘛
voidPageFaultHandle(void)
{ TaskDestory(); }
- 然而,理想很美好,现实很骨感,程序还是崩溃了
- 深入查找原因,发现 TaskDestory 函数中仅仅只实现了把当前任务节点从就绪任务队列中取出,当前任务还在运行当中,等当前任务运行时间片达到后才会退出当前任务
- 那么接下来我们的目标就是实现在任务销毁时强行切换到下一个任务执行
- 很简单,从任务调度 schedule 函数中复制一下任务调度相关代码,最后调用 SWITCH_TO 强行切换到新的任务
E_RET TaskDestory(void) { QUEUE_NODE* node = NULL; ... // 从就绪任务队列中取出一个任务节点并执行该任务,再将该任务节点重新添加到就绪任务队列中 node = QueueRemove(&TASK_READY_QUEUE); current_task = (volatile TASK *)&(((TASK_OOP *)QUEUE_NODE(node, TASK_OOP, QueueNode))->task); QueueAdd(&TASK_READY_QUEUE, node); TSS* tss = (TSS*)(*(U32*)TSS_ENTRY_ADDR); // 找到 TSS tss->esp0 = (U32)(¤t_task->reg) + sizeof(current_task->reg); // TSS.esp0 指向任务上下文数据结构 reg 的末尾 current_reg = (U32)(¤t_task->reg); // current_reg 指向任务上下文数据结构 reg 的起始位置 SWITCH_TO(current_task); return E_OK; }
- 一切 OK,成功干掉了 TASK A,并且系统还在执行。展示一下成果
段越界异常保护
- CPU 除了提供页写保护方式还提供了一种段越界异常保护机制,估计都见到过 “Segmentation fault” 这种错误提示吧,现在我们就来实现这种段越界异常保护机制
- 回顾一下当前的内存使用情况,0x80000 以下的内存我们已经实现了页保护,页表本身被放到了 0x100000,我们想办法把应用程序限定在 [0x80000, 0x100000) 这个内存范围,限定应用程序的运行范围
- 想要限定应用程序的执行范围,只能通过 CPU 实现,当发生段越界时,CPU 会触发 0x0D 号异常中断,我们可以在这个中断服务程序中销毁当前任务并调度下一个任务执行
- 有了上面的页异常代码实现的经验,段越界异常实现就比较简单了
- 先实现段异常中断服务函数,暂时放在 “page.c” 文件中
void SegmentFaultHandle(void) { printk("Segmentation fault\n"); TaskDestory(); }
- 然后修改一下中断入口(在 “interrupt.asm” 中)
extern SegmentFaultHandle Int0x0D_Entry: BeginFSR call SegmentFaultHandle EndFSR
- 最后再把 “loader.asm” 中的局部段描述符中的段界限改掉,原先的代码段和数据段都采用了平坦内存模型,范围是整个 4G 内存空间,现在改为 [0, 0xFFFFF]
LDT_CODE_DESC : Descriptor 0, 0xFFFFF, DA_C + DA_32 + DA_DPL3 LDT_DATA_DESC : Descriptor 0, 0xFFFFF, DA_DRW + DA_32 + DA_DPL3
- 代码修改完成,不过想要测试看现象,还需要对 “app.c” 中的 TASK A 稍微修改一下,尝试非法修改段外内存空间
void TaskAFunc(void) // 任务执行函数 { ... U32* p = (U32*)0x100004; // 这个地址超出 app 段界限 *p = 0x12345678; // 尝试改动段外数据 ... }
- 运行结果,成功打印出字符串 “Segmentation fault” 如下图
- 代码见:page.c、interrupt.asm、loader.asm、app.c