从前端角度理解 CVE-2026-31431
如果你是写前端的,第一次看到 CVE-2026-31431 这种内核漏洞,大概率会觉得跟自己没关系------里面提到的 splice、Page Cache、加密模块、容器逃逸,没一个是日常会碰的。
但把这些术语剥掉,它讲的事情你其实很熟:
一份本来只能读的共享数据,被某个函数当成自己的私有数据原地改了。后面所有读这份数据的人,拿到的都已经是被改过的版本。
这种坑前端里随处可见。下面我就用前端的方式把它讲一遍。
先看一张总览图,整个漏洞的因果链就是这样:

起点:引用传递
JS 里改对象属性,不会复制对象:
js
const config = { role: 'guest' };
const ref = config;
ref.role = 'admin';
config.role; // 'admin'
ref 看起来是个新变量,其实和 config 指的是同一块内存。你改 ref,config 跟着变。
小代码里这没什么,大型应用里它是 bug 之源:
- 子组件直接改了 props
- 工具函数偷偷改了你传进去的对象
- 全局缓存被随手
push了一个元素 - 第三方库拿到对象后偷塞了点状态进去
这一类 bug 调起来都烦:你看自己的代码,"我明明没动它",实际上别人通过同一个引用动了。
CVE-2026-31431 是同一回事,场景换到了 Linux 内核,"对象"换成了 Page Cache。
Page Cache 是什么
Linux 读文件不会每次都跑去敲磁盘。第一次读完之后,文件内容会留一份在内存里,下次再读直接从内存拿。这块"留在内存里的文件内容"就是 Page Cache。
它和浏览器缓存的逻辑很像,只是住在内核里、给整台机器所有进程共用。你打开 /usr/bin/su,我打开 /usr/bin/su,容器里另一个进程打开 /usr/bin/su------读到的可能是同一份内存。
| 前端里的 | 内核里的 |
|---|---|
| 浏览器缓存 | Page Cache |
| 对象引用 | 内存页引用 |
| 深拷贝 | 复制一份内存页 |
| 改了共享对象 | 改了缓存页 |
| 全局状态被污染 | Page Cache 被污染 |
如果这份缓存里装的只是普通文本文件,被改坏顶多是读出来的数据不对。但要是装的是 su、sudo、passwd 这种和权限有关的程序------后面谁执行它,跑的就不是磁盘上那份了。
splice 想干什么:少拷贝一次
数据从文件流到 socket,按部就班的写法是这样:
text
磁盘 → Page Cache → 用户态 buffer → 再写回内核 → socket
中间从内核往用户态搬一次,又从用户态搬回内核一次。数据量大的时候这两次拷贝很贵。
splice() 想省掉这趟来回。它让数据在内核里几个对象之间直接"挪位置",不经过用户态:
text
Page Cache ──splice──▶ 管道 / socket / 加密模块 ...
但"挪"在工程上往往不是真挪,而是传引用------下游拿到的是 Page Cache 那块内存的引用,不是它的副本。
性能是上去了。代价是:下游必须老老实实把这块内存当只读数据。它一旦写一笔,写的就不是自己的副本,是全机器共用的那份缓存。
正常路径和漏洞路径的区别就在这一步:
漏洞点:下游写了不该写的内存
CVE-2026-31431 的事故就在这里。
splice 把 Page Cache 的引用交给加密模块去处理。加密模块在某条路径上没把这块内存当只读,原地改了一笔。
写成 JS 大概是这种感觉:
js
const cache = Object.freeze({ bin: 'su 的内容' });
function encrypt(buf) {
buf.bin = '...'; // 它以为 buf 是自己的
return buf;
}
encrypt(cache);
只不过 Object.freeze 在 JS 里会拦住你,内核里没这层保护------加密模块觉得自己拿到的就是普通缓冲区,写下去就真写下去了。
写完之后世界变成这样:
- 磁盘上的
/usr/bin/su一字节没动 - 内存里的 Page Cache 已经是攻击者想要的内容
- 后面任何进程执行
su,加载到内存里的是被改过的版本
为什么这种污染特别难发现
很多安全检测的思路是算文件哈希:
text
/usr/bin/su 的 SHA256 还对得上吗?对得上 → 没事
这套逻辑在这次漏洞面前直接失灵:
- 磁盘文件没动,哈希一直对
- 但运行时谁也不会再去磁盘读,大家都从 Page Cache 拿
- Page Cache 已经被改了
也就是"看起来正常,跑起来不对"。前端里偶尔也会遇到类似的事------源码没改,但运行时 store 被某个地方污染了,行为就是不对。区别是这里被污染的不是 Redux store,是操作系统底层。
用图来看更直观:
攻击链长什么样
具体怎么打不展开,只说轮廓:
text
低权限进程
→ 准备一段特殊数据
→ 走 splice 进 zero-copy 路径
→ 引用指向某个敏感程序的 Page Cache
→ 触到加密模块那条会写内存的路径
→ 缓存里的程序内容被改
→ 谁再执行这个程序,行为就不是原来的了
翻译成前端语言:
text
一个低权限的小脚本
→ 拿到全局 store 的引用
→ 调了一个有副作用的工具函数
→ store 被它改了
→ 后面所有组件读到的都是脏数据
→ 应用按攻击者的意图在跑
前端里这种 bug 顶多让页面崩或者数据错;内核里同样的 bug,能让一个普通用户跑出 root 权限。
顺带把容器一起带进来
容器有个常见的误解:以为它是虚拟机,里面外面完全隔离。
不是。容器和宿主机共用同一个内核,Page Cache 也归这个内核管。容器里读 /usr/bin/su,宿主机读 /usr/bin/su,背后很可能是同一份缓存。
这就意味着:容器里一个进程把 Page Cache 改坏了,宿主机和别的容器再去读,读到的就是被改过的内容。容器隔的是用户空间,没法隔内核------这也是为什么内核漏洞老和容器逃逸一起出现。
低权限进程] -->|触发漏洞| PC PC -->|缓存被污染| C2[容器 B] PC -->|缓存被污染| C3[容器 C] PC -->|缓存被污染| HOST[宿主机进程] style C1 fill:#fde2e2,stroke:#c0392b style PC fill:#fde2e2,stroke:#c0392b style C2 fill:#fff4cc,stroke:#b8860b style C3 fill:#fff4cc,stroke:#b8860b style HOST fill:#fff4cc,stroke:#b8860b
和前端 bug 同根
把术语去掉,这次漏洞踩的几个坑前端里全见过。
信任边界没守住。 前端是把用户输入当 HTML 渲染了;这里是把只读缓存当可写 buffer 用了。都是"这个数据应该当什么对待"没想清楚。
为了性能省了一步该做的事。 前端常见的是不深拷贝、复用对象、缓存计算结果,省下来的代价是状态被污染。splice 的 zero-copy 是同样的思路,省的是内核那次拷贝,代价是下游必须自觉只读。
表面没变,行为变了。 前端:源码没动,运行时 store 已经脏了。内核:磁盘文件没动,Page Cache 已经脏了。两边都让人查半天查不出来。
页面错乱] end subgraph 内核 K1[Page Cache] --> K2[加密模块] K2 -->|原地写入| K3[缓存被污染] K3 --> K4[程序读到改过的内容
权限被突破] end F2 -.同一种 bug.-> K2 F3 -.同一种后果.-> K3 style F3 fill:#fde2e2,stroke:#c0392b style K3 fill:#fde2e2,stroke:#c0392b style F4 fill:#fff4cc,stroke:#b8860b style K4 fill:#fde2e2,stroke:#c0392b
修起来的思路
修这种 bug 没什么花活,就一句:
下游可能写这块内存,就别给它共享引用,先复制一份给它。
JS 写出来是这样:
js
// 不行:直接改共享对象
function process(cfg) {
cfg.temp = true;
return cfg;
}
// 行:先复制一份
function processSafely(cfg) {
const copy = structuredClone(cfg);
copy.temp = true;
return copy;
}
内核修起来麻烦得多,要判断哪条路径下游会写、哪条不会、哪些场景能继续 zero-copy、哪些必须降级到普通拷贝。但思路就是这个:这块内存归谁、能不能写,得说清楚。
