Appearance
第十三章:全路径安全
本章使用最初的数据处理任务进行综合分析:
bash
./worker --threads=4 input.txt result.tmp
mv result.tmp result前述章节已经分别讨论控制转移、地址空间、FD、网络等待、多核同步和持久化。本章沿同一执行路径组合这些机制,同时分析安全性、正确性和性能。
xv6 帮助我们逐段看清这些机制,但它不试图抵抗恶意用户、资源耗尽、微架构侧信道或复杂设备故障。本章不再把 xv6 当作可直接部署的安全系统,而把它当作已经展开的机制图,在其上逐项加入现代威胁和防护。
威胁模型
安全性质必须相对于具体上下文定义。对 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)。
攻击面
每一种文件系统、网络协议、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 的架构隔离不自动覆盖微架构资源。
分层输入验证
网络包经过网卡、驱动、协议栈、TLS、应用解析器;文件经过 VFS、文件系统、解压和业务格式。某层验证“长度合法”不代表下一层对整数单位、编码或嵌套深度理解相同。
可靠边界应:
- 使用明确宽度并检查加法、乘法溢出;
- 区分已接收长度、声明长度和缓冲区容量;
- 为嵌套、递归、压缩比和对象数量设预算;
- 在所有权转移时明确谁负责释放;
- 让解析失败保持状态可回滚或直接丢弃整个对象。
“先解析再鉴权”也可能让未授权输入先消耗大量 CPU 和内存。越便宜的拒绝应越靠近入口。
资源耗尽
程序即使不突破内存权限,也可以通过大量创建线程、FD、pipe、异步请求或脏页影响其他工作负载的可用性。cgroup、rlimit、队列上限和超时需要共同限制这类资源消耗。
每个有限资源都应回答:
- 谁被计费?
- 上限在哪里?
- 达到上限时阻塞、失败还是丢弃?
- 请求取消或进程退出后,资源何时真正归还?
- 内核代持资源是否仍算在发起者头上?
还需限制资源放大,例如小型压缩输入触发大量解压内存、单个请求启动大量下游 I/O,或低成本数据包触发高成本内核处理。
fault injection
正常路径测试不能验证崩溃协议,需要在各边界注入失败:
- 系统调用短读、短写、
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 延迟,不用牺牲持久性换虚假性能。
这套方案并非唯一实现,但每一步都明确了保护边界、实施机制和失败语义。
抽象边界
下表汇总主要抽象的接口语义与保证边界:
| 抽象 | 提供的接口语义 | 不自动保证的性质 |
|---|---|---|
| 进程 | 独立执行身份与资源引用集合 | 独占 CPU、cache 和带宽 |
| 虚拟内存 | 连续、受保护的虚拟地址空间 | 固定访问延迟和无限物理内存 |
| 线程 | 地址空间内可独立调度的执行流 | 源码顺序构成全局观察顺序 |
| FD | 对多类内核对象的统一引用接口 | 所有对象具有完全相同的操作语义 |
| 容器 | 独立命名视图与受控资源配额 | 独立内核与独立硬件 |
write() | 内核接受指定字节 | 掉电后数据保持存在 |
| 虚拟机 | 虚拟 CPU、内存和设备接口 | 独占微架构资源与管理面 |
使用操作系统抽象时,需要同时掌握其接口语义和保证边界;超出保证范围的安全、性能与可靠性问题必须结合下层机制分析。
练习
- 如果攻击者只控制 worker,不控制 supervisor,哪些资源不应交给 worker 自己打开?
- “超时”与“取消完成”为什么必须区分?
- 哪些隔离由页表/特权级强制,哪些只是 namespace 中的不同视图?
- 要证明最终文件可靠,需要注入哪些不同于进程 crash 的故障?
- 一项优化如果改变
fsync频率,为什么不能直接和原实现比较吞吐?