L5:隔离机制

lab通关记录

MIT-6.828实验通关记录

第五讲:隔离机制

课程总览

用户/内核隔离,用户/用户隔离
xv6 系统调用作为案例研究

用户/内核隔离,用户/用户隔离

  • 如何选择内核的整体形式?
    许多可能的答案!
    一种极端:
    只是一个设备驱动程序库,与应用程序链接
    直接在硬件上运行应用程序
    快速灵活地用于单一用途的设备
    但通常在一台计算机上执行多项任务

  • 多项任务推动关键要求:
    多路复用
    隔离
    通信

  • 有用的方法:抽象资源而不是原始硬件
    1.文件系统,而不是原始磁盘
    2.进程,而不是原始 CPU/内存
    3.TCP 连接,而不是以太网数据包

  • 抽象通常更容易隔离和共享
    例如程序只会看到一个私有 CPU,不需要考虑多路复用CPU

  • 隔离通常是最严格的要求。

  • 什么是隔离?
    1.强制分离以控制故障的影响
    进程是通常的隔离单元

  • 隔离目的:

    1.防止进程 X 破坏或监视进程 Y
    2.r/w 内存,使用 100% 的 CPU,更改 FD,&c
    3.防止进程干扰操作系统
    4.面对恶意和错误:一个糟糕的进程可能会试图欺骗硬件或内核

  • 内核使用硬件机制作为进程隔离的一部分:
    用户/内核模式标志
    虚拟地址空间
    分时多路复用CPU
    系统调用接口

  • 硬件用户/内核模式标志
    控制指令是否可以访问特权硬件?
    在 x86 上称为 CPL,%cs 寄存器的底部两位
    CPL=0 – 内核模式 – 特权
    CPL=3 – 用户模式 – 无权限

  • x86 CPL 保护许多与隔离相关的处理器寄存器
    I/O 端口访问
    控制寄存器访问(eflags、%cs4、…)
    包括 %cs 本身
    影响内存访问权限,但间接影响
    内核必须正确设置所有这些
    每个微处理器都有某种用户/内核标志

  • 如何进行系统调用——切换CPL
    问:对于用户程序进行系统调用这样的设计是否合适:
    设置 CPL=0
    jmp sys_open
    不合适:CPL=0 的用户指定指令,破坏用户态和内核态的隔离
    问:如何设置 CPL=0 的组合指令,
    但是需要立即跳转到内核中的某个地方?
    不行:用户可能会跳到内核中并破坏系统
    x86 的设计:
    只有几个允许的内核入口点(“中断向量”)
    INT 指令设置 CPL=0 :提升权限并跳转到一个入口点
    但用户代码不能以其他方式修改 CPL 或跳转到内核中的任何其他地方
    系统调用返回在返回用户代码之前设置 CPL=3:这也是一个组合指令(不能单独设置CPL和jmp)

  • 用户与内核的定义需要明确的概念
    CPL=3 并执行用户代码
    或 CPL=0 并从内核代码的入口点执行
    而不是:
    CPL=0 并执行用户代码
    CPL=0 并在用户喜欢的内核中的任何地方执行

  • 如何隔离进程内存?
    想法:“虚拟地址空间”
    给每个进程一些它可以访问的内存
    对于它的代码、变量、堆、堆栈
    阻止它访问其他内存(内核或其他进程)

  • 如何创建隔离的地址空间?
    xv6 在内存管理单元 (MMU) 中使用 x86“分页硬件”
    MMU 翻译(或“映射”)程序发出的每个地址
    CPU -> MMU -> RAM
    |
    页表
    VA -> PA
    MMU 翻译所有内存引用:用户和内核。内核也要页表翻译(防小人也防君子?实际第一次加载内核代码时候ELF给定死了高地址,所以保证虚拟空间分布理论,内核也需要页表翻译)(不同的进程恒等映射公有部分内核地址:虚拟地址进程相同,物理地址更是相同)、指令和数据
    指令仅使用 VA,从不使用 PA

  • 内核为每个进程设置不同的页表:一般都会有多级页表:PTD/PTE来节省内存问题。我们在GDT中,通过CR3的偏移来从页表结构进入进程私有的PTD位置,通过各个划分的虚拟地址作为偏移,进行多级跳转,最终得到物理页地址,和页内偏移一起合成完整的物理地址,当然为了加速访问我们也可以有TLB缓存一部分页表
    每个进程的页表只允许访问该进程的 RAM(SRAM/DRAM)

xv6系统调用实现

系统调用进入内核

  • xv6 进程/堆栈图:
    用户进程;内核线程
    用户堆栈;内核栈
    两种机制:
    在用户/内核之间切换
    在内核线程之间切换
    trapframe
    内核函数调用…
    结构上下文(内核态/用户态)
  • 简化的 xv6 用户/内核虚拟地址空间设置
    FFFFFFFF:
    …内核中进程私有和公有部分
    80000000:内核
    用户堆栈
    用户数据
    00000000:用户说明
    内核将 MMU 配置为仅允许用户代码访问属于用户的下半部分:通过特权级CPL判断是否合法
    每个进程拥有独立地址空间但是每个进程的内核(高)映射都是相同的:因为是内核
  • 系统调用起点:
    在用户空间执行,sh 写出它的提示
    sh.asm, 使用write() 系统接口

    1
    2
    break *0xb90
    x/3i 0xb8b

    其中,eax 中的 0x10 是 write 的系统调用号

    1
    info reg

    ​ cs=0x1b, B=1011 – CPL=3 => 用户模式
    ​ esp 和 eip 是低地址——当然是虚拟地址

    1
    x/4x $esp

    ​ cc1 是返回地址——在 printf 中
    ​ 2 是 fd
    ​ 0x3f7a 是堆栈上的缓冲区地址,作为准备写入的虚拟地址
    ​ 1 是写入字符个数
    ​ 综上,我们可以知道printf中有 wirte(2,0x3f7a,1)这样的调用

    1
    x/c 0x3f7a
  • INT指令,内核入口(建议理解一下CPL,RPL,DPL(current,request,descriptor))

    1
    2
    step i
    info reg

    ​ 从cs=0x8 – CPL=3 =到> 内核模式
    ​ 注意 INT 将 eip 和 esp 更改为高内核地址:jmp和栈都要改

    问题:eip在哪里?

    ​ 在内核提供的向量处jmp——用户只能通过中断才能进入内核。所以用户程序不能跳转到 CPL=0 的内核中的随机位置

    1
    x/6wx $esp

    ​ INT 保存了一些用户寄存器:err,eip,cs,eflags,esp,ss
    问题:为什么 INT 只保存这些寄存器?
    ​ 保护现场:1.会被覆盖2.结束后需要返回
    问题:INT 做了什么:
    ​ 1.切换到当前进程的内核堆栈:由于中断,暂时允许(RPL,CPL >=DPL(int)
    ;RPL,CPL <= DPL(dest))保存寄存器到内核栈
    ​ 2.在内核堆栈上保存了一些用户寄存器
    ​ 3.设置 CPL=0
    ​ 4.开始在内核提供的“向量”处执行
    问题:内核esp 是从哪里来的?
    ​ 创建进程时,得到内核堆栈位置,因此内核位置保存在进程表中

问:为什么 INT 需要保存用户状态,感觉很麻烦?应该保存多少状态?
根据情况比较透明度和速度吧。

​ 对于保护现场而言,给出以下部分例子:

​ 比如你的esp不再像返回时esp-n,而是高低地址的隔离。因此你从栈返回的时候应该有esp值。

​ 再比如你的pc本来是不用特地保存在内核栈,因为可以栈先返回内核态,然后pc得到后代码返回,为了安全也可以一并保存。

  • 然后将其余的用户寄存器保存在内核堆栈上:pushal 压入 8 个寄存器:eax .. edi
    (Trapasm.S alltraps中看)

  • 显示 内核堆栈顶部的 19 个字:

    1
    x/19x $esp
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    ss
    esp
    eflags
    CS
    eip
    err—— INT从这里开始保存?
    trapno//错误码,用来判断中断类型
    ds
    es
    FS
    GS
    eax..edi

    最终会被恢复,当系统调用返回时:现场思考:1.栈切换2.代码切换3.通用寄存器覆盖
    同时内核 C 代码有时需要读取/写入保存的值比如eax返回值这样,就有可能决定是否恢复保存的寄存器。丢弃也是可能的。
    x86.h 中的 struct trapframe

    问题:为什么用户寄存器保存在内核栈中?
    为什么不将它们保存在用户堆栈中?

    因为最后需要从内核态切换:栈和代码段,就像函数返回那样,需要提前保存现场,但是比函数返回更复杂:覆盖的CPU里寄存器更多

中断进入内核

  • 让我们看内核 C 代码
    pushl %esp 为 trap(struct trapframe *tf) 创建一个参数
    现在我们在 trap.c 中的 trap()

    1
    2
    print tf
    print *tf
  • 内核系统调用处理
    设备中断和故障也会进入 trap()
    trapno == T_SYSCALL
    myproc()
    proc.h 中的 struct proc
    myproc()->tf – 所以 syscall() 可以得到系统调用号和参数
    syscall.c 中的 syscall()
    查看 tf->eax 以找出哪个系统调用
    syscalls[] 中的 SYS_write 映射到 sys_write
    sysfile.c 中的 sys_write()
    arg*() 从用户堆栈中读取 write(fd,buf,n) 参数
    syscall.c 中的 argint()
    proc->tf->esp + xxx

  • 恢复用户寄存器
    syscall() 将 tf->eax 设置为返回值
    回到trap()
    完成 – 返回到 trapasm.S

    1
    info reg 

    ​ 仍在内核中,寄存器被内核代码覆盖

    1
    stepi//直到iret
    1
    info reg

    ​ 大多数寄存器保存恢复的用户值
    ​ eax 的 write() 返回值为 1:成功写入的字符个数
    ​ esp、eip、cs 仍然有内核值,还未恢复

    1
    x/5x $esp

    ​ 保存的用户状态:eip、cs、eflags、esp、ss
    IRET 从堆栈中弹出这些用户寄存器
    ​ 从而以 CPL=3 重新进入用户空间

问题:我们真的需要 IRET 吗?否则现场恢复比函数更复杂
我们可以使用普通指令来恢复寄存器吗?不行,见上面
IRET 可以更简单吗?可以,根据系统调用分类,可以放弃有些寄存器恢复

  • 回到用户空间

    1
    2
    stepi
    info reg

fork()系统调用

  • 让我们看看 fork() 如何建立一个新进程
    尤其是如何第一时间让新进程进入用户空间?
    想法:
    fork() 伪造一个内核堆栈,看起来就像它即将从 trap() 返回:直接一开始就进入了内核Iret附近
    顶部有一个伪造的trapframe
    child 开始在内核中执行——在函数返回指令处
    alltraps“恢复”伪造的已保存寄存器
    第一次开始执行子进程

  • 请注意,有两个单独的操作:
    创建一个新进程
    执行新进程

  • break fork
    c
    where//来查看fork的过程
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22

    proc.c 中的 fork()


    - allocproc()
    在 proc.c 开始时查看 proc[]
    关注 p->kstack 的初始内容
    陷阱框架的空间(将是父母的副本)
    指向 trapasm.S 中的 trapret 的伪造保存的 EIP
    “上下文”的内核堆栈空间
    包含*内核*寄存器
    切换到子内核线程时恢复进程
    p->context->eip = forkret 设置子进程在内核中的起始位置
    基本上只是一个函数调用指令

    - 回到 fork()
    (请记住,我们仍然以父级身份执行)
    分配物理内存和页表
    将父母的内存复制给子进程(linux下是写时复制,即只复制地址可以访问,写的时候再真正找内存进行复制)
    复制trapframe
    tf->eax = 0 -- 这将是 fork(): w 的子进程的返回值

    print *np print *np->tf print *np->context x/25x np->context
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    state = RUNNABLE -- 现在我们完成了

    - 新进程的内核堆栈内容:
    trapframe -- 父的副本,但 eax=0

    trapret的地址

    context
    eip = forkret

    - ```
    break forkret
    x/20x $esp
    next
    finish
    (现在在tramasm.S 中的trapret在 sh.S 中的 b6a)
    1
    2
    info reg
    eax 为零——意味着进入子进程
  • 综上,fork子进程拥有父进程的所有内存,寄存器,并且从内核态返回,除了eax=0,紧接着从父进程的代码执行位置继续进行,并且接下来修改内存和父进程基本毫无关系

Donate
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2020-2024 环烷烃
  • Visitors: | Views:

我很可爱,请我喝一瓶怡宝吧~

支付宝
微信