Skip to content

第十三章:全路径安全

本章使用最初的数据处理任务进行综合分析:

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、队列上限和超时需要共同限制这类资源消耗。

每个有限资源都应回答:

  1. 谁被计费?
  2. 上限在哪里?
  3. 达到上限时阻塞、失败还是丢弃?
  4. 请求取消或进程退出后,资源何时真正归还?
  5. 内核代持资源是否仍算在发起者头上?

还需限制资源放大,例如小型压缩输入触发大量解压内存、单个请求启动大量下游 I/O,或低成本数据包触发高成本内核处理。

fault injection

正常路径测试不能验证崩溃协议,需要在各边界注入失败:

  • 系统调用短读、短写、EINTRENOSPC
  • 内存分配失败与线程在任意位置被抢占;
  • 网络丢包、重复、乱序、延迟和断连;
  • 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,但要求最终结果在掉电后仍存在”,可以形成一份明确方案:

  1. 由可信 supervisor 启动 worker,使用独立 user/mount/PID/network namespace;
  2. 删除多余 capability,以 seccomp 只允许必要调用,用 cgroup 限 CPU、内存、PID 和 I/O;
  3. supervisor 预先打开输入文件,并将受限的输入 FD 交给 worker,避免授予任意路径访问;
  4. worker 多线程只在进程内共享内存,使用有明确 happens-before 的队列;
  5. supervisor 负责超时和取消,但保留缓冲区直到异步完成真正汇合;
  6. 输出写到同目录临时文件,处理短写并检查所有错误;
  7. fsync 临时文件,原子 rename 到最终名,再 fsync 父目录;
  8. 崩溃恢复只接受完整旧版本或完整新版本,并清理孤立临时文件;
  9. 在 CPU/内存/I/O 压力和随机崩溃下注入故障,验证资源上限与恢复不变量;
  10. 用端到端 trace 测量 run-queue、锁、网络、回写与 flush 延迟,不用牺牲持久性换虚假性能。

这套方案并非唯一实现,但每一步都明确了保护边界、实施机制和失败语义。

抽象边界

下表汇总主要抽象的接口语义与保证边界:

抽象提供的接口语义不自动保证的性质
进程独立执行身份与资源引用集合独占 CPU、cache 和带宽
虚拟内存连续、受保护的虚拟地址空间固定访问延迟和无限物理内存
线程地址空间内可独立调度的执行流源码顺序构成全局观察顺序
FD对多类内核对象的统一引用接口所有对象具有完全相同的操作语义
容器独立命名视图与受控资源配额独立内核与独立硬件
write()内核接受指定字节掉电后数据保持存在
虚拟机虚拟 CPU、内存和设备接口独占微架构资源与管理面

使用操作系统抽象时,需要同时掌握其接口语义和保证边界;超出保证范围的安全、性能与可靠性问题必须结合下层机制分析。

练习

  1. 如果攻击者只控制 worker,不控制 supervisor,哪些资源不应交给 worker 自己打开?
  2. “超时”与“取消完成”为什么必须区分?
  3. 哪些隔离由页表/特权级强制,哪些只是 namespace 中的不同视图?
  4. 要证明最终文件可靠,需要注入哪些不同于进程 crash 的故障?
  5. 一项优化如果改变 fsync 频率,为什么不能直接和原实现比较吞吐?

上一章:内核可扩展性 · 返回课程首页