本篇位置 全课收尾。回到第 1 篇立的那个框架,看它的极限在哪
运行环境 容器里的 Linux,Python 3

一、这门课一直在说的那句话

第 1 篇立了一个框架:操作系统在你和真实资源之间加一层中间人,然后在这一层里撒一个善意的谎。 而每个谎都要付两笔钱,第二笔是——

谎言总会在某个地方漏出来。

前面二十九篇里,漏点一直是副作用:程序莫名变慢(第 1、13 篇)、断电丢数据(第 27 篇)、缓存冷了(第 4 篇)。

这一篇看的是有人专门盯着这些漏点的时候会发生什么。

二、三层边界,各自靠什么撑着

先把这门课建起来的三道墙摆出来,以及每一道究竟由谁保证

边界 靠什么 打穿它意味着
用户态 / 内核态 CPU 的电路(第 4 篇那个 SIGILL) 拿到整台机器
进程 / 进程 MMU 和页表(第 12 篇) 读到别人的内存
容器 / 容器 ⚠️ 内核里的软件检查(第 29 篇) 拿到宿主机

前两道由硬件撑着,第三道只由软件撑着。 这个差别是本篇后面所有内容的根。

第 4 篇那个演示——用户态执行一条特权指令,被 CPU 直接判 SIGILL——那是硬件在拒绝,不是 Linux 检查出来的。而容器的隔离,是内核里成千上万行 C 代码在检查。代码会有 bug,电路不会。

三、第三道墙是怎么被打穿的

容器逃逸的路径,几乎都归到同一句话:⭐ 容器和宿主机共用一个内核,所以内核里的任何一个提权漏洞,都是一次逃逸。

攻击面:系统调用

第 4 篇说过,系统调用是用户态进入内核的唯一一道门。Linux 有三百多个。每一个都是攻击面——只要其中任何一个在处理畸形参数时有内存错误(第 9 篇那五种),攻击者就可能在内核态里写数据。

Dirty COW(2016)就是一例:它利用的是写时复制的一个竞态——也就是第 3 篇 fork 用的、第 29 篇 overlayfs 用的那个机制。一个为了省内存的优化,被做成了任意文件写。

⭐ 而它的形状你在第 21 篇见过:检查和使用之间被插了一脚(TOCTOU)。同一个 bug 形状,从应用代码一路复现到内核。

三层减法

于是就有了三种"把攻击面切小"的机制:

Capabilities:把 root 那一整包权力拆成几十个独立的小权限。第 4 篇那个演示跑的就是它——容器里是 root,但 mount、改实时优先级、reboot 全部 Operation not permitted,因为对应的 capability 被丢掉了。

seccomp:⭐ 直接封掉一部分系统调用。 Docker 的默认配置就封掉了几十个(kexec_loadopen_by_handle_at 等等)。少一个系统调用,就少一片攻击面。

User namespace:⭐ 最根本的一个。它把容器里的 uid 0 映射成宿主机上一个普通的非特权用户。这样即使攻击者拿到了容器里的 root,逃出去之后他也只是个 nobody

⚠️ 但这一层至今不是所有地方都默认开着——因为它和很多东西(挂载、文件属主、镜像构建)配合起来很麻烦。

⭐ 这三样合起来的思路是同一条:你没法保证内核没有漏洞,那就尽量减少能被够到的部分,并且让够到之后的收益变小。 这和第 6 篇 MLFQ 的"选一个攻击者没法便宜地伪造的判据"是同一种思维方式——都是在承认"防不住"之后,去改变收益结构。

四、⭐ 侧信道:不打穿墙,隔着墙听

上面说的都是"打穿"。还有一类完全不同:墙好好的,一点没破,但信息还是过来了。

因为共享资源本身就是信道。你和邻居共用缓存、共用页缓存、共用分支预测器——而**“他用过没用过"这件事,你能通过时间或状态观察到**。

这不需要什么高深的手段。看一个只用标准接口、不需要任何特权的例子。

mincore() 是一个普通的系统调用,它告诉你一个文件的哪些页此刻在页缓存里。它本来的用途很正当:让程序知道自己 mmap 进来的数据在不在内存里,好决定要不要预取。

def cached_pages(path):
    fd = os.open(path, os.O_RDONLY)
    size = os.fstat(fd).st_size
    addr = libc.mmap(None, size, PROT_READ, MAP_SHARED, fd, 0)
    n = (size + PAGE - 1) // PAGE
    vec = (ctypes.c_ubyte * n)()
    libc.mincore(ctypes.c_void_p(addr), ctypes.c_size_t(size), vec)
    return [i for i in range(n) if vec[i] & 1], n

场景:一个 64 MB 的共享文件,攻击者和受害者都能访问它。攻击者想知道受害者读了这个文件的哪一部分——注意他要的不是文件内容(那他自己就能读),是受害者的行为

  文件共 16384 页。攻击者一开始看到 0 页在缓存里
  受害者读了一小段之后,攻击者看到 20 页在缓存里
  攻击者据此推断:受害者碰的是第 5000–5019 页
  ★ 受害者的真实读取范围是第 5000–5019 页

一页不差。

⭐ 请注意这里没有任何东西被攻破。文件权限是对的,进程隔离是对的,攻击者读到的每一个字节都是他有权读的。泄露的不是文件内容,是"受害者访问了哪里"这个行为。

而在很多场景里,行为就是内容:他查了哪条数据库记录、他访问了哪个用户的目录、他用的加密密钥的哪一位是 1——这些都能被还原成秘密本身

⚠️ 这就是为什么侧信道这么难防:你要防的不是"别人读了你的数据”,而是"别人观察到了你的行为"。 而只要有共享,行为就一定会留下痕迹。

⚠️ 交代一下这个演示的前提

按这门课第 1 篇立的规矩,得说清楚上面这次到底是在什么条件下跑出来的。

2019 年之前,这个攻击对"只有读权限的人"就成立——那才是它当年被当成漏洞报上去的原因。而我上面这次演示,攻击者和受害者是同一个用户(容器里都是 root),所以它跑通了。

因为 Linux 5.0 打了个补丁(mm/mincore.c 里那个 can_do_mincore):现在只有当你拥有这个文件、或者你对它有写权限时,mincore 才会告诉你真话,否则它只报你自己映射了的页。所以在今天的内核上,一个真正"只有读权限"的攻击者,用这个办法已经问不出东西了

但请仔细看这个补丁修的是什么。

它没有消除信道——页缓存依然是共享的,“受害者碰过哪一页"这个事实依然留在系统里。它只是把问路的那扇窗户关小了一点:以前有个系统调用直接告诉你答案,现在不告诉了。

而答案还在那儿。你完全可以换个问法:去读一页,掐表看它花了多久。 在缓存里是几百纳秒,不在是几十微秒(第 15 篇那五个数量级)。门槛高了,信道还在。

⚠️ 侧信道的防御几乎全是这个形状——不是"修好了”,是"变贵了"。这和前面三层减法(capabilities、seccomp、user namespace)是同一种承认:“我防不住,但我可以让它不划算。”

五、Meltdown 和 Spectre:连硬件那两道墙也漏了

2018 年那两个漏洞之所以震动整个行业,是因为它们打穿的是前两道墙——那两道由硬件撑着的墙。

机制大致是这样:

  1. 现代 CPU 会推测执行——分支还没判完,它先猜一个方向往下跑,猜错了再回滚。
  2. 回滚会撤销架构状态(寄存器、内存),但不会撤销缓存状态
  3. 于是攻击者可以诱导 CPU 在推测执行中读一个它无权读的地址(内核内存),并用那个值去访问一个数组。
  4. 架构上这次访问被撤销了,什么都没发生
  5. 但那个数组的某一行进了缓存。 攻击者挨个测每一行的访问时间,就知道刚才读到的值是多少。

权限检查一直是对的,那次非法访问确实被拒绝了、被回滚了。漏的不是数据,是"CPU 曾经短暂地看见过这个数据"在缓存上留下的影子。

这和上一节的 mincore 是同一件事:不是墙破了,是共享资源的状态本身构成了信道

代价极其现实:修补的办法(KPTI——把内核页表和用户页表彻底分开)让每次系统调用都要多做一次页表切换。⚠️ 第 23 篇实测的那个"每次系统调用 260 纳秒",里面就含着这个补丁的钱

⭐ 这是这门课里最贵的一次"谎言泄漏":为了修复一个信息泄露,所有人的每一次系统调用都变慢了。

六、代价与取舍

隔离换来了什么: 多租户。你的代码和陌生人的代码跑在同一块硅上,而你们互相看不见——整个云计算行业建立在这句话上

它花了什么:

  • 每一道边界都有性能代价:上下文切换(第 4 篇)、TLB 清空(第 13 篇)、二级地址转换(第 29 篇)、KPTI(本篇)。
  • 完整性无法证明。 你只能说"目前还没被打穿"。

它放弃了什么:“隔离是二元的"这个假设。

隔离不是"有"或"没有”,它是一个强度谱

同一进程内的两个函数
  < 同一台机器上的两个进程
    < 同一内核上的两个容器
      < 同一台物理机上的两个虚拟机
        < 两台物理机

越往右越强,也越贵。没有任何一档是绝对的——最右边那档还有网络攻击和供应链攻击。

所以正确的问题从来不是"这样安全吗",而是"这样能挡住谁,代价是多少"。 处理不同客户的代码用虚拟机,处理自己的微服务用容器,处理不可信的用户输入用进程隔离——这是在这条谱上选位置,不是在"安全"和"不安全"之间选。

七、全课收尾

这门课从头到尾在说同一件事。

操作系统的每一层都在撒一个善意的谎——CPU 是你的、内存是你的、你已经写进硬盘了、这台机器只有你。每一个谎都换来了巨大的便利:你写代码时不用认识隔壁的进程。

而每一个谎都要付两笔钱。

第一笔是中间人自己的开销,这门课有一半篇幅在讲怎么把它省下来——TLB 之于页表(第 13 篇)、多级页表之于稀疏地址空间(第 14 篇)、日志之于 fsck(第 27 篇)、近似计数器之于全局锁(第 18 篇)。

第二笔是泄漏。 你的程序莫名变慢,是虚拟化在漏(第 1、13 篇);断电丢了数据而程序没报错,是持久化在漏(第 1、27 篇);top 里的数字和 cgroup 的限额对不上,是容器在漏(第 29 篇);而这一篇里,有人专门坐在漏点旁边等着。

所以这门课真正想给你的,不是三十个机制的名字。

是这个:看到一个新系统时,先问它在哪一层撒了什么谎,然后问这个谎在哪里会漏。 你会发现这套问法在操作系统之外一样好用——数据库的事务隔离级别、分布式系统的一致性模型、CDN 的缓存、编译器的优化,全都是"加一层中间人,撒一个便利的谎,然后处理它漏的地方"。

机制的名字会变,这个形状不会。

八、小结

  • 三道边界,前两道由硬件撑着(CPU 特权级、MMU),第三道只由软件撑着(内核里的检查)。代码会有 bug,电路不会。
  • 容器逃逸的路径几乎都是:共用一个内核 → 内核提权漏洞就是逃逸Dirty COW 利用的正是写时复制的一个 TOCTOU 竞态——和第 21 篇讲的应用层 bug 是同一个形状
  • 三层减法:capabilities(拆开 root)、seccomp(封掉系统调用)、⭐ user namespace(容器里的 root 映射成宿主机的 nobody)。思路都是承认"防不住",然后改变收益结构。
  • 侧信道:墙一点没破,信息照样过来。 实测:攻击者只用一个普通的 mincore精确还原出受害者读了哪 20 页,一页不差泄露的不是内容,是行为——而在很多场景里行为就是内容。
  • ⚠️ 那个演示的前提要交代清楚:2019 年前这对只有读权限的人就成立,今天 Linux 要求你拥有该文件或有写权限(我的演示满足后者)。⭐ 但补丁没有消除信道,只是把问路的窗户关小了——换成"读一页掐表"照样问得出来。侧信道的防御几乎全是这个形状:不是修好了,是变贵了。
  • Meltdown/Spectre 打穿的是硬件那两道墙:权限检查是对的、访问被回滚了,但缓存上留下了影子。⚠️ 补丁(KPTI)的代价含在第 23 篇实测的"每次系统调用 260 纳秒"里——为了堵一个泄露,所有人的每次系统调用都变慢了。
  • 隔离不是二元的,是一个强度谱(函数 < 进程 < 容器 < 虚拟机 < 物理机),越强越贵,没有一档是绝对的。正确的问题是"能挡住谁、代价多少"。
  • 全课的一句话:每一层都在撒一个善意的谎,谎要付两笔钱——中间人的开销,和泄漏。看到新系统先问:它在哪一层撒了什么谎,这个谎在哪里会漏。

思考题

  1. 第四节末尾说,mincore 被限制之后还可以"读一页掐表"。但这个替代办法有一个 mincore 没有的副作用,会让攻击变得不那么干净——是什么?(提示:你为了观察,自己也碰了那一页。)
  2. seccomp 封掉系统调用能减少攻击面。那能不能干脆只放开程序真正用到的那几十个?这在工程上有什么困难?
  3. 第五节说 KPTI 让每次系统调用变慢。有没有办法在不牺牲性能的前提下防住 Meltdown?(提示:这个问题的答案在硬件那边,而且已经实现了。)
  4. 第六节那个强度谱里,“同一进程内的两个函数"是最弱的一档。但确实有技术想在这一档上做出隔离(WebAssembly 沙箱、语言级的能力系统)。它们靠什么?为什么它们的保证比 MMU 弱?

延伸