Appearance
第十三章:系统安全与可靠性
前面各章分别建立了执行、内存、对象、设备和持久状态模型。本章检查这些模型组合之后的保证。安全问题往往出现在两个机制之间的空隙:页表允许访问不等于业务授权,设备通知完成不等于缓冲区可随时释放,文件系统能恢复不等于应用结果完整。
不同系统机制提供的保证及其边界如下:
| 系统关系 | 已有机制 | 综合分析还要检查什么 |
|---|---|---|
| 执行流与 CPU | 特权入口、调度 | 权限检查、执行预算、抢占与等待依赖 |
| 地址与对象 | 页表、FD、引用计数 | 授权、共享、生命周期与撤销 |
| CPU 与设备 | 请求队列、DMA、通知 | 顺序、取消、迟到完成与缓冲区寿命 |
| 内存与磁盘 | 缓存、日志、同步接口 | 真实故障模型、提交边界与恢复不变量 |
| 不同保护域 | 进程、容器、虚拟机 | 共享内核、管理面、微架构和资源干扰 |
后面的案例用于检验这些关系。xv6 展示基本机制,但没有实现完整的现代权限、资源治理和攻击防护;需要根据实际威胁模型补充保护,不能直接把教学内核的机制当作生产安全保证。
威胁模型与保护边界
从已有抽象能保证什么开始,再确定要抵抗的对手和故障。
抽象边界
下表汇总主要抽象的接口语义与保证边界:
| 抽象 | 提供的接口语义 | 不自动保证的性质 |
|---|---|---|
| 进程 | 独立执行身份与资源引用集合 | 独占 CPU、cache 和带宽 |
| 虚拟内存 | 连续、受保护的虚拟地址空间 | 固定访问延迟和无限物理内存 |
| 线程 | 地址空间内可独立调度的执行流 | 源码顺序构成全局观察顺序 |
| FD | 对多类内核对象的统一引用接口 | 所有对象具有完全相同的操作语义 |
| 容器 | 独立命名视图与受控资源配额 | 独立内核与独立硬件 |
write() | 内核接受指定字节 | 掉电后数据保持存在 |
| 虚拟机 | 虚拟 CPU、内存和设备接口 | 独占微架构资源与管理面 |
使用操作系统抽象时,需要同时掌握其接口语义和保证边界;超出保证范围的安全、性能与可靠性问题必须结合下层机制分析。
威胁模型
安全性质必须相对于具体上下文定义。以读取输入、计算并生成结果文件的 worker 服务为例,至少要明确:
- 资产:宿主内核、其他进程数据、输入凭证、最终结果、服务可用性;
- 对手:恶意 worker、恶意输入、受控远端、低权限本地用户,是否包含管理员;
- 能力:能否反复运行、测量时间、创建线程、耗尽资源、控制崩溃时机;
- 故障:只考虑进程崩溃,还是内核 panic、掉电、介质损坏;
- 目标:保密性、完整性、可用性和持久性分别要求到什么程度。
只有在威胁模型明确后,容器、加密和 fsync 等机制的保证范围才具有可验证含义。
访问控制与攻击防护
多个检查组合时,需要绑定相同对象,并覆盖整个生命周期。
对象授权
典型 TOCTOU 是先检查路径,再按同一路径打开;攻击者可在两步之间替换符号链接或目录。其根因是检查与使用没有绑定同一个稳定对象。
更稳妥的接口会以目录 FD 为起点、限制路径解析方式,并在一次内核操作中完成查找和约束。通用原则是:
对可变名称的检查与使用应合并,或先获取稳定对象引用,再基于该引用实施授权。
FD、句柄和 capability 都在减少“重新解释名字”的窗口。相同原则也适用于 PID、容器名字、设备路径和云资源标识。
Linux 的 openat2() 把这条原则做成了具体 ABI。调用者提供目录 FD 和 struct open_how,并可设置:
RESOLVE_BENEATH:解析结果不能逃出给定目录;RESOLVE_IN_ROOT:把目录 FD 临时视为解析根;RESOLVE_NO_SYMLINKS:拒绝路径中的符号链接;RESOLVE_NO_XDEV:拒绝跨越 mount point。
这些约束与路径遍历在同一次系统调用中执行,避免用户态先 realpath() 检查、再 open() 时被并发替换。具体 flags 见 openat2(2)。
分层输入验证
网络包经过网卡、驱动、协议栈、TLS、应用解析器;文件经过 VFS、文件系统、解压和业务格式。某层验证“长度合法”不代表下一层对整数单位、编码或嵌套深度理解相同。
可靠边界应:
- 使用明确宽度并检查加法、乘法溢出;
- 区分已接收长度、声明长度和缓冲区容量;
- 为嵌套、递归、压缩比和对象数量设预算;
- 在所有权转移时明确谁负责释放;
- 让解析失败保持状态可回滚或直接丢弃整个对象。
“先解析再鉴权”也可能让未授权输入先消耗大量 CPU 和内存。越便宜的拒绝应越靠近入口。
资源耗尽
程序即使不突破内存权限,也可以通过大量创建线程、FD、pipe、异步请求或脏页影响其他工作负载的可用性。cgroup、rlimit、队列上限和超时需要共同限制这类资源消耗。
每个有限资源都应回答:
- 谁被计费?
- 上限在哪里?
- 达到上限时阻塞、失败还是丢弃?
- 请求取消或进程退出后,资源何时真正归还?
- 内核代持资源是否仍算在发起者头上?
还需限制资源放大,例如小型压缩输入触发大量解压内存、单个请求启动大量下游 I/O,或低成本数据包触发高成本内核处理。
攻击面
每一种文件系统、网络协议、ioctl、设备驱动和 eBPF helper 都增加解析器与状态机。即使功能没有主动使用,只要不可信输入能到达,仍可能构成攻击面。
最小化原则可以具体化为:
- 删除不需要的 capability 和系统调用;
- 不挂载不需要的伪文件系统和设备;
- 用最小协议与最小镜像;
- 把复杂解析器放在低权限进程;
- 保持内核、固件和运行时可更新;
- 对跨边界输入做长度、类型、生命周期和状态验证。
内存安全语言能消除一大类越界与 use-after-free,但逻辑授权、资源耗尽、死锁和侧信道仍需设计。
微架构侧信道
页表会阻止用户程序在架构语义上读取内核地址。但 CPU 为性能会推测执行并在确认权限前产生 cache 等微架构影响。即使错误路径最终回滚寄存器,攻击者仍可能通过计时推断秘密。
Spectre 类问题利用错误推测和侧信道跨越软件边界;Meltdown 类问题揭示某些实现中权限检查与推测的危险交互。缓解包括隔离页表、插入 speculation barrier、retpoline、刷新或分区某些状态、更新微码等,往往带来性能成本。
这里也应区分硬件和内核动作。CPU 微码或新指令提供 IBRS、IBPB、STIBP、L1D flush 等控制;Linux 在任务切换、虚拟机切换或特定返回路径中按漏洞类型写控制 MSR、执行 barrier,KPTI 则在用户/内核边界切换不同 CR3。当前机器实际启用了什么可从 /sys/devices/system/cpu/vulnerabilities/ 查看,内核机制见 x86 hardware vulnerabilities。
更一般地说,共享 cache、分支预测器、内存总线、压缩率、功耗和时间都可能泄漏信息。进程、容器甚至 VM 的架构隔离不自动覆盖微架构资源。
故障注入与性能分析
故障注入检验恢复不变量;性能分析还必须保持所比较的接口语义一致。
故障注入
正常路径测试不能验证崩溃协议,需要在各边界注入失败:
- 系统调用短读、短写、
EINTR、ENOSPC; - 内存分配失败与线程在任意位置被抢占;
- 网络丢包、重复、乱序、延迟和断连;
- I/O 超时、设备复位、迟到完成;
- 在
write/fsync/rename各点杀进程或断 VM 电源; - CPU 压力、内存回收与 cgroup throttling。
测试 oracle 应是系统不变量,而非程序没有崩溃。例如:旧结果或新结果至少有一份完整可读;取消完成后设备不再访问缓冲区;重试不会重复执行不可重复业务操作;资源最终得到回收。
并发故障需要系统化调度、sanitizer、模型检查或记录重放。随机 sleep 可能增加复现概率,却不构成正确性证明。
Linux 本身提供可定位到具体路径的 fault-injection 设施:failslab 注入 slab allocation failure,fail_page_alloc 注入页分配失败,fail_make_request 让块 I/O 失败,function error injection 可让标注函数返回错误。它们通过 debugfs 配置 probability、interval、times 和 task filter。使用方法见 fault injection 文档。这比在应用里随意 sleep 更接近真实失败点。
端到端性能
一次请求的延迟可以分解为:
text
排队等待 CPU
+ 用户态计算与锁等待
+ 系统调用与缺页
+ 内核队列
+ 设备服务时间
+ 网络另一端时间
+ 持久化 barrier平均值会隐藏少量极慢请求,分位数又不能直接相加。观测应保留因果关系:一个请求何时创建、在哪些队列等待、由哪个线程/设备完成,而不只是收集各层独立平均指标。
优化前应先确定瓶颈资源。对于主要等待网络 RTT 的请求,减少一次系统调用的收益通常有限;增加异步并发可能提高吞吐,也可能因队列加深而恶化 p99;减少 fsync 频率会改变持久化语义,不能作为等价优化比较。
正确的性能比较必须保持语义相同。
案例:隔离运行与可靠输出
以下方案同时涉及权限、资源额度、共享内存和持久化协议。
假设目标是“不信任 worker,但要求最终结果在掉电后仍存在”,可以形成一份明确方案:
- 由可信 supervisor 启动 worker,使用独立 user/mount/PID/network namespace;
- 删除多余 capability,以 seccomp 只允许必要调用,用 cgroup 限 CPU、内存、PID 和 I/O;
- supervisor 预先打开输入文件,并将受限的输入 FD 交给 worker,避免授予任意路径访问;
- worker 多线程只在进程内共享内存,使用有明确 happens-before 的队列;
- supervisor 负责超时和取消,但保留缓冲区直到异步完成真正汇合;
- 输出写到同目录临时文件,处理短写并检查所有错误;
fsync临时文件,原子 rename 到最终名,再fsync父目录;- 崩溃恢复只接受完整旧版本或完整新版本,并清理孤立临时文件;
- 在 CPU/内存/I/O 压力和随机崩溃下注入故障,验证资源上限与恢复不变量;
- 用端到端 trace 测量 run-queue、锁、网络、回写与 flush 延迟,不用牺牲持久性换虚假性能。
这套方案并非唯一实现,但每一步都明确了保护边界、实施机制和失败语义。
练习
先回答前两题,再用实现细节核对后面的题目。
选择页表、FD、容器、设备队列和日志各一项:分别说明其保证,以及一个超出其保证范围的问题。
应用吞吐提高但减少了持久化频率,为什么不能直接认为同一个系统获得了性能改进?需要先固定哪些语义?
如果攻击者只控制 worker,不控制 supervisor,哪些资源不应交给 worker 自己打开?
“超时”与“取消完成”为什么必须区分?
哪些隔离由页表/特权级强制,哪些只是 namespace 中的不同视图?
要证明最终文件可靠,需要注入哪些不同于进程 crash 的故障?
一项优化如果改变
fsync频率,为什么不能直接和原实现比较吞吐?