保护模式的特权级
保护模式内存访问检测
使用选择子访问段描述符表时 , 索引值的合法性检测
当索引值越界时 , 引发异常
判断规则 : 索引值 * 8 + 7 <= 段描述表界限值
内存段类型合法性检测
- 具备可执行属性的段( 代码段 ) 只能加载到CS寄存器
- 具备可写属性的段( 数据段 ) 才能加载到SS寄存器
- 具备可读属性的段才能加载到DS,ES,FS,GS寄存器
代码段和数据段的保护
处理器每访问地址都要确认该祖不超过界限值
判断规则 :
代码段 : IP + 指令长度 <= 代码段界限
数据段 : 访问起始地址 + 访问数据长度 <= 数据段界限
保护模式的特权级
- x86 架构中的保护模式提供了 4 个特权级( 0,1,2,3 )
- 特权级从高到底分別是 0,1,2,3 ( 数字越大特权级越低 )
为了安全,让应用程序运行在低特权级,不能访问高特权级的数据代码段
特权级的表现形式
- CPL(CurrentPrivilege Level)当前可执行代码段的特权级,由 CS寄存器最低2位定义
- DPL(Descriptor Privilege Level)内存段的特权级,在段描述符表中定义
- RPL(Request Privilege Level)选择子的特权级,由选择子最低2位定义
DPL和CPL
段描述符中的 DPL 用于标识内存段的特权级 ; 可执行代码访问内存段时必须满足一定特权级(CPL)即CPL<=DPL
,否则,处理器将产生异常
CPL 和 DPL 的关系
保护模式中,每一个代码段都定义了一个DPL
当处理器从A代码段成功跳转到B代码段执行
跳转之前:CPL = DPLA
跳转之后:CPL = DPLB
保护模式中 , 每一个数据段都定义了一个 DPL
当处理器执行过程中需要访问数据段时 :CPL < = DPLdata
inc.asm添加以下标识符以便设置相应特权级
结论:
- 处理器进入保护模式后CPL= 0(最高特权级)是无法直接跳转到DPL=3的代码段的
- 处理器不能直接从高特权级转换到低特权级执行
- 选择子RPL大于对应段描述符的 DPL时 , 产生异常
引出的问题
- 如何在不同特权级的代码段之间跳转执行 ?
- 高特权级代码为什么不能使用低特权级栈段 ?
- 选择子的 RPL 具体有什么用?
门描述符
通过门描述符在不同特权级的代码间进行跳转
根据应用场景的不同,门描述符分为:
- 调用门( Call Gates )
- 中断门( Interrupt Gate )
- 陷阱门 ( Trap Gate )
- 任务门( Task Gate )
门描述符的内存结构
- 每一个门描述符占用 8 字节内存
- 不同类型门描述的内存含义不同
内存结构和段描述符内存结构是相似的,8-15位各个字段和段描述符完全一致。
调用门call Gates描述符的定义
1 | ; 门 |
调用门描述符的工作原理
通过调用门选择子就可以访问到门描述符,这又可以根据门描述符内的段描述符的选择子去访问一个段描述符,拿到段基址和段界限,再和门描述符内的偏移地址组合就可以得到确定的内存地址
省略中间过程就成了通过调用门选择子就可以直接跳转到某个固定的地址执行
1 | [section .gdt] |
主函数中
1 | mov ax, 2 |
那么问题 来了: 既然,想调用某个函数借助门选择子:0 改为 借助目标段选择子:函数偏移地址,执行效果相同,那为什么我们还需要调用门呢?
- 1)我们需要用门来实现不同特权级的代码间的转移, 因为单纯地通过CPL、RPL和RPL进行比较 来进行不同特权级代码段间的转移的话,有诸多限制;
- 2)有特权级变换的转移的复杂之处, 不但在于严格的特权级检验,还在于特权级变换的时候,堆栈也要发生变化;处理器利用调用门的机制避免了高特权级的过程由于栈空间不足而崩溃;
汇编语言中的跳转方式
- 段内 : call,jmp
参数为相对地址,函数调用时只需要保存当前偏移地址 - 段间跳转: call far, jmp far
参数为选择子和偏移地址
函数调用时需要同时保存段基地址和地址
1 | [section .func] |
单步调试,寄存器出现了我们的预期结果
结论:
- 门描述符是一种特殊的描述符 , 需要注册于段描述表
- 调用门可以看作一个函数指针( 保存具体函数的入口地址 )
- 通过调用门选择子对相应的函数进行远调用( callfar )
- 可以直接使用选择子 : 偏移地址的方式调用其它段的函数
- 使用调用门时偏移地址无意义 , 仅仅是语法需要( 为什么? )
历史遗留问题:那么保护模式下的不同段之间如何进行代码复用( 如 : 调用同一个函数 ) ?
- 将不同代码段需要复用的函数定义到独立的段中( retf )
- 计算每一个可复用函数的偏移量( FuncName - $$ )
- 通过段选择子 : 偏移地址的方式对目标函数进行远调用
小结:
- 门描述符是一种特殊的描述符 , 需要注册于段描述符表
- 门描述符分为 : 调用门 , 中断门 , 陷阱门 , 任务门
- 调用门可以看作一个函数指针( 保存具体函数的入口地址 )
- 调用门选择子对应的函数调用方式为远调用( call far )
特权级跳转retf
- 调用门只支持从低特权级跳转到高特权级执行
- 无法利用调用门从高特权级跳转到低特权级执行
ret指令用于函数返回,返回到函数调用的地方,而ret的本质用法是跳转指令,跳转到调用函数的地方。
retf的本质就是 调用门当发生权限切换的时候.堆栈会保存 SS ESP CS EIP(返回地址)
retf本质就是将这些堆栈值进行恢复. 并不是说调用门非要使用retf才可以. 你如果自己进行POP也是可以的.
调用门的特级跳转
- 通过远调用( call far ) : 低恃权级->高特权级
- 通过远返回( retf ) : 高特权级->低特权级
远返回(retf)能够实现高恃权级到低特权级的代码跳转,那么,考虑如何利用其机制完成这个跳转?
函数调用的过程( 近调用 )
再论函数调用的过程( 远调用 )
因此retf跳转关键在于这个栈,然而栈必须要知道的事实:
- x86 处理器对于不同的特权级需要使用不同的栈
- 每一特权级对应一个私有的栈(最多4个栈)
- 特权级跳转变化之前必须指定好相应的栈
高特权级->低特权级的解决方案:指定栈和目的地址
- 指定目标栈段选择子( push )
- 指定栈顶指针位置( push )
- 指定目标代码段选择子( push )
- 指定目标代码段偏移( push )
- 跳转( retf )
高特权级->低特权级显然是远跳转,因此需要retf
测试实验:
设置code32代码段特权级为3,如果直接jmp dword Code32Selector : 0
则报错
选择子的RPL也应修改为3
相同特权级跳转可以不额外指定一个新的栈
1 | ; 5. jump to 32 bits code |
特权级转移
初识任务状态段( Task State Segment )
- 处理器所提供的硬件数据结构,用于解决多任务解决方案
- TSS中保存了关键寄存器的值以及不同特权级使用的栈(切换任务的时候就要保存这些任务上下文)
TSS中保存不同特权级的栈信息
在TSS中只保存了3个栈的信息
- 特权级0:ss0,esp0
- 特权级1:ss1,esp1
- 特权级2:ss2,esp2
特权级转移时的栈变化
- 低特权级–>高特权级( 调用门 )
从 TSS 获取高特权级目标栈段(获取指定SShigh和ESPhigh的值)
将低特权级栈信息压入高特权级栈中( SSlow 和 ESPlow压入高特权级栈中用于返回 ) - 高特权级–>低特权级( retf )
将低恃权级栈信息从高特权级栈中取出并恢复到 ss 和 esp
为什么TSS只保存了3个栈的信息?
理应而言是4个栈的信息啊,但是仔细想想TSS只有在调用门从低转移到高特权级的时候才会使用,去寻找高特权级的栈信息,而特权级3已经是最低的特权级了,自然无需存储
低特权级<->高特权级实验
任务目标:
- 定义 32 位核心代码段和数据段( Privilege = 0 )
- 定义 32 位任务代码段和数据段( Privilege = 3 )
- 由核心代码段跳转到任务代码段执行( 高 -> 低 )
- 在任务代码段中调用高特权级代码段打印字符串( Call Gate )
流程图:
从实模式转换到保护模式,刚转换到保护模式特权级仍然是0,然后进行一系列初始化工作,通过远返回指令retf转移到任务代码段,任务代码段需要通过调用门调用系统函数,然后系统函数在内核态执行,执行完后远返回指令retf返回到任务代码段
注意事项:
- 特权级转移时会发生栈的变换( 如何变换 ? )
- 栈的变化需要在 TSS结构体中预先定义
- TSS 结构体作为一个独立段定义( 描述符 , 选择子 )
- 在核心代码段中加载具体的 TSS结构体( Itr TSSSelector )
1 | ; TSS段 |
call FunctionSelector : PrintString
段选择子+段内偏移地址调用函数,调用都为特权级0的代码段,没问题
跟踪调试可以看到cs低二位都是0,CPL(CurrentPrivilege Level)当前可执行代码段的特权级都是0,并没有发生特权级的改变
retf就会实现特权级的改变,特权级从高特权级0变为了低特权级3
任务代码通过调用门调用系统函数call FuncPrintStringSelector : 0
调用门选择子会有特权级的变化,观察到cs低二位变为了00,提高到特权级0
另外出现了一个问题,通过调用门调用PrintString时报错,mov [gs:edi], ax
,ds源内存(ds属于用户内存,用户态特权级为3)拷贝到gs(gs属于系统内存,内核态特权级为0)中,这会造成内核数据的破坏
因此在全局GDT中将gs即显存段设置特权级也为3,这样在低特权级下printString向显存段写入也不至于报错
RPL
- 位置意义:选择子或段寄存器的最低2 位
- 请求意义:资源请求的特权级( 不同于当前特权级CPL )
当需要请求获取某种资源时,处理器通过CPL,RPL 和 DPL共同确定请求是否合法 !
特权级检查
数据段的访问规则(数据段是无可执行属性的内存段)
- 访问者权限( CPL ) 高于或等于数据段特权级( DPL )
- 请求特权级( RPL )高于或等于数据段特权级( DPL )
- 即 : (CPL <= DPL) && (RPL <= DPL)
CPL=2,RPL=1,DPL=3 ?合法
合法,就不贴图了
CPL=0,RPL=3,DPL=2 ?不合法
bochs爆出了这样的错误,这意思是需要满足CPL <= DPL && RPL <= DPL(bochs报错信息稍微不友好)
CPL=0,RPL=1,DPL=2 ?合法
中途报了这样一个错,给ss段寄存器加载选择子时报错,这是因为栈选择子RPL不为0和栈描述符DPL不为0
对于栈段,当给SS寄存器赋值时,使用规则(CPL==RPL)&&(CPL==DPL)保证特权级的匹配,不仅如此,降特权级跳转(retf)的时候,目标代码段特权级与目标栈段特权级必须完全相同!即**(SS.RPL == CS.RPL)&&(SS.DPL == CS.RPL)**
将栈段修改好后,能成功运行
1 | ; 需要访问的数据段DPL |
选择子被段寄存器加载时 , 会进行保护模式的检查
- 检查选择子的下标是否合法( 段描述符的合法性 )
- 检查特权级是否合法( CPL& RPL <= DPL )
- 检查特权级时CPL 和 RPL 之间不会进行比较
代码段的分类
非系统段(S=1)包括一致性代码段和非一致性代码段
系统段(S=0)LDT,TSS,各种门结构
非系统段的分类
- 一致性代码段 : X = 1,C = 1
- 非一致性代码段 : X = 1,C = 0
代码段之间的跳转规则( 不借助门描述符,使用call或者jmp )
- 非一致性代码段
代码段之间只能平级转移( CPL == DPL , RPL <= DPL ) - 一致性代码段
支持低特权级代码向高特权级代码的转移( CPL >= DLP ),否则就是不合法的
虽然可以成功转移高特权级代码段 , 但是当前特权级不变,因此栈也不会变化
数据段只有一种,没有一致性和非一致性的区分;并且,数据段不允许被低特权级的代码段访问。
特权级降低转移时 , retf 指令会触发栈段的特权级检査
一致性代码段和非一致性代码段中的代码并没有本质区别,两种代码仅仅在于跳转时使用的合法性判断规则不同,因此一致性代码段到非一致性代码段的直接同级跳转是合法的
深入理解调用门
- 调用门用于向高特权级的代码段转移( CPL > = DPLobject )
- 调用门描述符的特权级低于当前特权级( CPL <= DPLgate )
门的作用类似于“蹦床”
- 调用门支持特权级同级转移,但是不支持降级跳转
- 调用门同级转移被处理为普通函数调用或直接跳转
- call 通过调用门能提升特权级,jmp 通过调用门只能同级转移且jmp“有去无回”不会在栈留下返回信息
通过调用门降特权级返回(retf)时
- 对目标代码段以及栈段特权级检查即(SS.RPL == CS.RPL)&&(SS.DPL == CS.RPL)
- 对相关段寄存器强制清零( 指向高特权级数据的段寄存器, )
1 | mov ax, Data32Selector0 |
函数返回前设置ds,gs为0特权级的数据,这样内核数据就不安全了
特权级与内核安全实例
用户程序想要访问获取操作系统内核中的私密教据 !
漏洞分析
攻击者可以用非法手段获得了内核函数段选择子和偏移地址,然后非法手段构造调用门描述符和选择子,这样可以拷贝内核数据到用户指定的es处
1 | ; Call Gate,非法手段获得调用门描述符和选择子 |
可以看到拷贝成功了
初步解决方案:获取目的选择子中RPL的值
判断 RPL 的值是否为 SA_RPL0
- true检查通过 , 可继续访问数据
- false 特权级较低 , 触发异常
小技巧- 通过下标为 0 的段描述符触发异常
1 | mov ax, 0 ;使用0选择子 |
类似于
1 | int* fs = 0; |
最终实现检查RPL是否为0:
1 | KMemCpy: |
用户程序可以通过“伪造”选择子中的RPL值,从而绕开安全检查的机制( CheckRPL )。UserData32Selector equ (0x0004 << 3) + SA_TIG + SA_RPL0
特权级修改为0,这样仍然存在漏洞,需要继续打补丁
解决思路:追踪真实的请求者
攻击者通过非法手段获得了内核函数段选择子和偏移地址,通过调用门来调用,意味着返回地址cs和eip会入栈,因此可以通过cs和eip获取攻击者的信息
- 在栈中获取函数远调用前CS寄存器的值( 攻击者 )
- 从之前CS寄存器的值中获取真实的RPLcr ( 攻击者特权级 )
- 用RPLcr更新到数据缓冲区对应的段寄存器中
- 使用 CheckRPL对段寄存器进行安全检查
进行反汇编得到通过选择子调用函数的地址,打上断点0x929d,然后看上面反编译0x929d的下一条指令地址是0x92a4相减就可以得到值是0x7,
然后运行到断点这里,查看寄存器的值,可以看到eip的值为0x0019,cs的值为0x000a因此推算出如果进入了函数之后栈应当存储eip值为0x0019+0x7=0x0020存储cs值应为0x000a
1 | <bochs:1> break 0x929D |
然后单步执行观察栈顶状态
可以看到栈顶依次存储了CS(0x000a),EIP(0x0020),ESP(0x0032),SS(0x03ff),是符合预期的
1 | <bochs:5> s |
因此可以用mov cx, [esp + 4]
取出先前cs寄存器的值,这样也可以取得调用者的真实的RPL的值
最终实现如下:
1 | ; KernelData拷贝到es:di --> data buffer |