状态切换、权限与安全
前置:进程(用户态/内核边界、syscall 的调用链)、线程(执行流 = 一组寄存器)。
实践:syscall lab(gdb 看陷入、
interpose沙箱、attack 偷秘密)。traps lab 会把本篇第 3 节的切换过程亲手再走一遍。宿主机实验(Ubuntu 26.04,内核 7.0):
cd local/system/code gcc -O2 seccomp.c -o seccomp && ./seccomp # Linux 版的 interpose gcc -O2 -static seccomp.c -o seccomp-static && ./seccomp-static
01-process 从用户的角度看这扇门:发请求,拿结果。这一篇站到门的另一侧,回答三个问题:
- 权限是什么、从哪来:为什么内核能做用户做不了的事?
- 怎么切换:一条
ecall之后,CPU 的哪些状态变了、谁来改? - 怎么守住:内核拿到的请求全是用户给的,怎么保证用户骗不了内核、也碰不到别人?
1. 名词速查
| 名词 | 全称 / 意思 | 在 xv6 / RISC-V 里是什么 |
|---|---|---|
| 特权级 / mode | privilege level | U(user)、S(supervisor,内核)、M(machine,固件)。CPU 里的一个状态位,不是代码的属性 |
| trap / 陷入 | 一切"从当前执行流跳到内核"的事件 | 三种来源:ecall(系统调用)、异常(缺页、非法指令)、设备中断 |
| CSR | control and status register | 只有 S 态以上能读写的控制寄存器,下面几个都是 |
stvec |
supervisor trap vector | 陷入后 CPU 跳去的地址,由内核事先设置 |
sepc |
supervisor exception PC | 陷入时被打断的那条指令的地址 |
scause |
陷入原因 | 8 = 用户态 ecall,13 = 读缺页,15 = 写缺页 |
stval |
附加信息 | 缺页时是出错的地址 |
sstatus.SPP |
supervisor previous privilege | 陷入前在哪个模式:0 = U,1 = S。sret 按它返回 |
satp |
supervisor address translation and protection | 当前页表的物理地址。换它就是换地址空间 |
| trapframe | struct trapframe(kernel/proc.h) |
每个进程一页,存陷入时的 32 个用户寄存器 + 内核需要的几个值 |
| trampoline | 跳板页 | kernel/trampoline.S,在用户页表和内核页表里映射在同一个虚拟地址,负责换页表 |
PTE_U |
页表项的 user 位 | 有它,U 态才能访问这一页;内核用它判断"这是不是用户的内存" |
2. 权限从哪来:CPU 的一个状态位
权限不是代码的属性,而是 CPU 当前的状态。 同一段机器码,CPU 处在 S 态时执行就"有权限",处在 U 态时执行就会被硬件拦下。所谓"内核",就是 CPU 处在 S 态时执行的那批代码。
S 态能做、U 态做不了的事情(U 态做了会触发非法指令异常):
| 能力 | 为什么必须是特权 |
|---|---|
写 satp(换页表) |
能换页表就能看到任意物理内存,隔离全失 |
写 stvec(设陷入入口) |
能设入口就能让下一次陷入跳进自己的代码,并且是以 S 态执行 |
写 sstatus、执行 sret |
能控制"返回到哪个模式"就能把自己升到 S 态 |
访问没有 PTE_U 的页 |
内核的数据结构(包括别的进程的 struct proc)都在这样的页里 |
| 关中断 | 能关中断就能霸占 CPU,调度器再也抢不回来 |
U 态升到 S 态,只有一种办法:陷入。 而陷入跳到哪里(stvec)是内核事先设好的。所以用户能"获得权限",但只能在内核指定的入口、执行内核写的代码。这就是整套安全的根:
提权的唯一入口由被保护的一方控制。
类比:交易所的撮合引擎不对外开放,你只能往网关发订单。网关地址是交易所定的,订单进来以后怎么处理也是交易所的代码说了算。
3. 一次切换:哪些状态变了、谁改的
一个常见误解是 ecall 让 CPU "切换到内核",好像一切都自动完成了。实际上硬件只做最少的几件事,剩下的全靠内核代码自己搭起来。
进入:ecall 之后
| 步骤 | 谁做 | 改了什么 | 代码 |
|---|---|---|---|
| 1 | 硬件 | sepc ← pc,scause ← 8,sstatus.SPP ← 0(来自 U),关中断(SIE → SPIE),模式 ← S,pc ← stvec |
无,是 ecall 指令的语义 |
| 2 | 软件 | 借 sscratch 腾出一个寄存器,把 32 个用户寄存器存进 TRAPFRAME |
trampoline.S uservec |
| 3 | 软件 | 从 trapframe 取出内核栈指针、hartid、usertrap 地址、内核页表 |
同上 |
| 4 | 软件 | csrw satp:换到内核页表 |
同上 |
| 5 | 软件 | 跳进 C 代码 usertrap();把 stvec 改成 kernelvec(之后的陷入是"内核里的陷入",要走另一条路) |
trap.c |
| 6 | 软件 | 存 sepc 到 trapframe->epc;是 ecall 就 epc += 4;这时才开中断;调 syscall() |
trap.c |
几个关键点:
- 硬件不换页表,也不换栈。 刚进
uservec时,CPU 已经是 S 态,但用的还是用户页表、用户的sp。所以uservec这段代码必须在用户页表里也能找到。这就是 trampoline 的用处:它在两张页表里映射在同一个虚拟地址(TRAMPOLINE = MAXVA - PGSIZE),换页表的那条指令前后,PC 都指向有效的代码。 - trampoline 页没有
PTE_U。 它映射在用户页表里,但 U 态读不了、也跳不进去。只有陷入之后、CPU 处于 S 态时才能执行它。 - 为什么要先关中断、晚开中断:中断也是陷入,会覆盖
sepc、scause、sstatus。在把它们保存好之前来一次中断,返回地址就丢了。所以硬件进入时自动关中断,usertrap保存完sepc才intr_on()。 - 为什么
epc += 4:sepc指向ecall本身。不加 4,返回后会再执行一次ecall,死循环。缺页异常则正好相反:不加,返回后重新执行那条出错的指令(这是 cow、lazy 分配能工作的前提)。
返回:sret 之前
prepare_return() + userret 把上面的过程倒着做一遍:
stvec ← uservec:下次陷入走用户入口。- 把内核页表、内核栈、
usertrap地址写进 trapframe,留给下一次进入时用。 sstatus.SPP ← 0(sret回 U 态)、SPIE ← 1(回去后开中断);sepc ← trapframe->epc。userret:换回用户页表,从 trapframe 恢复 32 个寄存器(a0里是系统调用的返回值),sret。
sret 是唯一一条"降权"指令:它按 SPP 切回 U 态,跳到 sepc。
状态存在哪
| 状态 | 存在哪 | 谁写 |
|---|---|---|
| 被打断的用户 PC | sepc → trapframe->epc |
硬件 → usertrap |
| 用户的 32 个寄存器 | trapframe |
uservec |
| 陷入前的模式 | sstatus.SPP |
硬件 |
| 内核在这个进程里的执行流 | 进程自己的内核栈 kstack |
内核代码 |
| 切到别的进程时内核自己的寄存器 | p->context |
swtch(见 线程 §2) |
两套寄存器存在两个地方:trapframe 存的是"用户态被打断时"的现场,context 存的是"内核态被调度走时"的现场。一个进程在内核里睡眠时,两份都在。
gdb 里看到的就是这些
syscall lab 第 1 题在 syscall() 打断点,观察的正是第 6 步之后的状态:调用栈、trapframe 里的调用号、sstatus 的 SPP 位。对照上面两张表,就能自己读出它们各说明了什么。
4. 边界上的信任:用户给的一切都要检查
进到内核以后,内核手里的所有输入(trapframe 里的寄存器)都是用户随便填的。内核必须假设用户是恶意的。
数字:先查范围
syscall() 先检查 num > 0 && num < NELEM(syscalls) && syscalls[num],再查表。少了这个检查,用户填一个很大的 a7,内核就会从 syscalls[] 后面读一个任意值当函数指针来调用,等于让用户选择内核执行哪里的代码。fd 也一样,argfd 要查 0 <= fd < NOFILE 且 ofile[fd] != 0。
指针:先拷贝进内核,再使用
用户传来的指针是用户虚拟地址,而内核运行在内核页表下,不能直接解引用。xv6 的做法是 copyin / copyinstr / copyout:
- 用这个进程的页表翻译地址(
walkaddr); - 要求页表项有
PTE_V和PTE_U(vm.c:143)。用户递来内核的地址,翻译不出来,拒绝; - 地址不能超过
p->sz; - 失败就返回 -1,而不是让内核缺页崩溃。
先拷贝、再检查、再使用拷贝。 要检查用户传来的路径,就先把它拷进内核,检查的是这份拷贝。如果检查的是用户内存里的原件,在"检查"和"使用"之间原件可能被改掉,这叫 TOCTOU(time of check to time of use)。
- xv6 用户进程没有线程,也没有共享内存,所以这个窗口在 xv6 里不存在:进程在内核里时,没人能改它的内存。
- Linux 有线程,所以真有这个问题。这也是为什么 seccomp-BPF 只能看寄存器的值,不能顺着指针去读路径:它就算读了,读到的也不一定是
openat稍后真正使用的那份。按路径做限制要靠别的机制(Landlock、LSM),它们在内核已经把路径拷进来、解析完之后才检查。
错误:返回 -1,而不是崩溃
| 出错的位置 | 后果 | 原因 |
|---|---|---|
| 用户程序读了非法地址 | usertrap → 杀掉这个进程 |
只影响它自己 |
| 内核读了非法地址 | kerneltrap → panic,整台机器停 |
内核坏了,没有更高一级的人来收拾 |
| 用户传了非法指针给系统调用 | copyin 返回 -1 → 系统调用返回 -1 |
内核检查过,没有真的去读 |
syscall lab 第 1 题让内核故意去读地址 0,结果是整台机器 panic。这说明了为什么上面那些检查不能省:任何一个能被用户输入触发的内核崩溃,都是一个安全漏洞(拒绝服务)。
5. 安全策略放在哪:引用监视器
有了特权和边界,就可以在边界上实施策略:"这个进程能不能做这件事"。经典理论叫引用监视器(reference monitor),要求三条:
| 性质 | 意思 | Linux seccomp 怎么满足 |
|---|---|---|
| 总被调用(complete mediation) | 没有绕过检查的路 | 在系统调用入口统一检查,任何系统调用都要先过过滤器 |
| 防篡改(tamperproof) | 被管的人改不了策略 | 过滤器存在内核里,进程只能再加,不能改也不能删 |
| 可验证(verifiable) | 小到能看懂、能检查 | BPF 程序没有循环、长度有上限,装进内核前先经过校验 |
syscall lab 的 sandbox 题要自己设计一个满足这三条的检查:状态放在哪、检查放在哪、什么操作能改它。
沙箱要满足的几条性质
问一句"被关在里面的进程有什么办法出来",就能逐条推出来:
| 逃逸办法 | 堵法 | Linux seccomp |
|---|---|---|
| 再调一次,把限制放松 | 单调收紧 | 过滤器只能叠加,不能卸载 |
| fork 一个子进程 | fork 继承 | 过滤器随 fork / clone 继承 |
| exec 一个新程序 | exec 保留 | 过滤器跨 execve 保留 |
| 进程的内核状态被复用时带着上一个进程的限制 | 释放时清零 | 内核负责 |
| exec 一个 setuid 程序,让它在受限环境下做错事 | 不许再提权 | 必须先设 no_new_privs 才能装过滤器 |
最后一行值得多想一下:沙箱让某些调用失败,这本身也可能被用来攻击。比如一个 setuid root 的程序调用 setuid() 去降权,如果这个调用被沙箱弄失败了,而程序又没检查返回值,它就会继续带着 root 权限运行。所以 Linux 规定,普通用户装过滤器之前必须承诺"之后 exec 的程序也不会获得更多权限"。
xv6 的沙箱接口:mask 和 path
syscall lab 要加的系统调用是 interpose(mask, path),两个参数各管一件事:
- mask:第 n 位为 1,就禁止第 n 号系统调用。被禁的调用返回 -1,进程不会被杀。
- path:在 mask 上开的一个例外。被禁的
open或exec,如果路径参数逐字节等于 path,就放行。
所以 path 只在两个条件同时成立时才起作用:调用的是 open 或 exec(只有它们的第一个参数是路径),并且这个调用已经被 mask 禁了。没被禁的调用本来就能通过,用不到 path。
用户态的 sandbox 命令把命令行原样传进去:sandbox <mask> <path> <command...>,先调 interpose,再 exec 后面的命令。评分里的几条命令:
| 命令 | 结果 | 原因 |
|---|---|---|
sandbox 32768 - cat README |
cat 打不开文件 | 32768 = 1 << 15,15 是 SYS_open,即禁 open;path 是 "-",没有叫 - 的文件,等于不开例外 |
sandbox 32768 README grep xv6 README |
正常输出 | open 被禁,但打开的正是 "README",放行 |
sandbox 32768 README grep xv6 x |
grep 打不开 x | "x" ≠ "README" |
sandbox 32768 x grep xv6 x |
grep 能打开 x | 这次的例外是 "x" |
几点容易误解的地方:
-不是特殊值:内核收到的就是字符串"-",只是没有文件叫这个名字。- 只禁 open 不影响 exec:exec 在内核里查找程序文件走的是
namei,不经过 open 这个系统调用。上面几条命令里grep都能正常启动。 - path 是"放行",不是"禁止":
user/sandbox.c开头的注释写的是 "disallowing … system calls that are using path",读起来像是 path 用来额外禁止什么。实际语义正好相反。 - path 也必须遵守单调收紧:如果第二次调用能把例外换成另一个路径,被关在沙箱里的程序就能自己把例外挪到它想读的文件上。
为什么要有这个例外,下面的实验给了答案;为什么按字符串放行不够安全,见路径名不等于文件。
宿主机实验:code/seccomp.c
把 interpose 用 Linux 的 seccomp 写一遍:禁掉 openat,然后依次检查 fork、exec、尝试放松。先猜输出,再运行。
1. before sandbox open: ok
2. after sandbox open: Operation not permitted
getpid: 488378 (其他调用照常)
3. child after fork open: Operation not permitted
.../seccomp: error while loading shared libraries: libc.so.6: cannot open shared object file: Operation not permitted
4. exec'd program 子进程退出码 127
5. try to loosen 再装一个禁 getpid 的过滤器: ok(只能更严)
5. still open: Operation not permitted
getpid: -1 (EPERM)
第 4 步最有意思:动态链接的程序根本起不来。exec 之后,动态加载器 ld.so 要先 openat("libc.so.6"),这一步就被沙箱拦了,main 都没运行。换成静态链接版(-static)就能跑到 main,然后在 open 上失败(4. after exec open: Operation not permitted)。
这正是 xv6 的 interpose 要有 path 这个例外的原因:完全禁掉 open/exec 的沙箱几乎没法用,程序连启动都需要打开文件。真实沙箱(Chrome、Docker 的默认 seccomp 配置)的难点都在这里:禁得太多程序跑不了,禁得太少挡不住攻击。
另一个小坑也在代码里:fork 之前、exec 之前都要 fflush(stdout)。fork 会把还没刷出的 stdio 缓冲区复制一份,导致输出两次;exec 会换掉整个用户内存,没刷出的缓冲区就直接丢了。这是 fork/exec 状态表 的直接推论。
路径名不等于文件
interpose 的 path 例外按字符串比较。但字符串只是名字,它指向哪个文件取决于解析时的状态:
- 相对路径看 cwd:
"secret"从当前目录解析(namex里idup(myproc()->cwd))。进程如果先chdir到别处,再open("secret"),打开的就是另一个文件,而且检查照样放行。 - 别名:
"./secret"、"//secret"、"a/../secret"指的是同一个文件,但字符串不同。精确比较会拒绝它们。这是 fail-closed,方向安全,只是不方便。 - 符号链接(fs lab 会加):名字一样,指向的东西可以被换掉。
所以更好的做法是按对象授权,而不是按名字授权:先在受信任的状态下打开一个目录,拿到 fd;之后只允许"相对于这个 fd"去打开文件(Linux 的 openat(dirfd, ...) + Landlock,FreeBSD 的 Capsicum)。fd 就像一把只开某扇门的钥匙,这就是能力(capability)模型。fd 那篇说过"用户手里只有一个编号",这里它的安全意义就出来了。
6. 隔离还有时间维度:资源复用前必须清干净
页表保证的是同一时刻两个进程看不到彼此的内存。但物理页、proc 槽位、寄存器都会被先后复用。一个资源从一个安全域交给另一个时,如果里面还留着旧数据,隔离照样会被打破。
syscall lab 的 attack 题就建立在这一点上:那个分支故意去掉了内存分配路径上的清零。
同一条规则在内核里到处出现:
| 被复用的资源 | 不清会泄漏什么 | 谁负责清 |
|---|---|---|
| 物理页 | 上一个进程的数据 | uvmalloc 清零;Linux 给用户的匿名页一律是零页,还有 init_on_alloc / init_on_free |
proc 槽位 |
上一个进程的 pid、名字、状态 | freeproc |
| 寄存器 | 内核运行时留在寄存器里的指针、数据 | userret 恢复全部 32 个用户寄存器,没有内核的值能漏回用户态 |
copyout 出去的内核结构体 |
结构体里没赋值的字段、对齐填充(padding) | 拷出前整个清零(Linux 里大量 CVE 是这一类) |
规则只有一句:"新的"必须是零。 分配器对调用者的承诺,同时也是一条安全不变式。
7. 代价
一次陷入要做的事:存 32 个寄存器、换两次页表(还要刷 TLB)、跑分发、再全部倒回来,大约几百到上千个周期。这就是为什么:
- Linux 的
gettimeofday走 vDSO:内核把时间映射到一页只读的用户内存里,读时间不用陷入。pgtbl lab 的ugetpid是同一个思路。 - 高性能网络、交易系统的热路径避免系统调用:忙轮询、kernel bypass。
- Meltdown 之后,Linux 用 KPTI 让用户页表里几乎不映射内核,于是每次陷入都要真的换页表,成本又涨了一截。xv6 一直就是这么做的(两张独立的页表),所以需要 trampoline。
8. 一张表
| 问题 | 机制 | xv6 在哪 |
|---|---|---|
| 权限是什么 | CPU 的特权级状态位 | U / S 模式 |
| 怎么升权 | 只有陷入,入口由内核设定 | stvec = uservec |
| 怎么降权 | sret,按 SPP 返回 |
userret |
| 用户现场存哪 | trapframe | uservec、struct trapframe |
| 换地址空间 | 写 satp,在两边都映射的跳板页里做 |
trampoline.S |
| 用户输入怎么检查 | 查范围、拷贝后再用、失败返回 -1 | syscall()、argstr、copyinstr、walkaddr |
| 策略放哪 | 唯一入口处的引用监视器 | syscall lab 自己实现 |
| 策略怎么不被绕开 | 单调收紧,fork/exec 继承 | syscall lab 自己实现 |
| 时间上的隔离 | 资源复用前清零 | kalloc、uvmalloc、freeproc |
思考题(先自己答)
uservec开头为什么要把a0存进sscratch,而不是直接存进 trapframe?(提示:存 trapframe 需要一个寄存器装它的地址。)- 如果用户程序自己把
stvec写成自己的函数会怎样?如果内核忘了在usertrap里把stvec改成kernelvec呢? - trampoline 页如果带了
PTE_U,用户能做什么? interpose允许路径"secret"。构造一个序列,让沙箱里的进程打开另一个叫secret的文件。怎么改interpose才能防住?- 为什么"被禁的系统调用返回 -1"比"直接杀掉进程"更难做对?(提示:调用者如果不检查返回值呢?想想
no_new_privs。)