Aller au contenu

AST : désobfuscation de JavaScript

Les outils d’obfuscation JavaScript modernes (comme javascript-obfuscator) produisent du code illisible : variables renommées en _0x3f2a, chaînes encodées en Base64, flux de contrôle aplati. Une lecture directe est inefficace. Passer par l’AST permet de transformer le code de façon structurée plutôt que de l’analyser ligne par ligne.

Un Abstract Syntax Tree (arbre syntaxique abstrait) est la représentation structurée d’un programme. Lorsqu’un moteur JavaScript parse du code, il le traduit d’abord en un arbre de nœuds avant de l’exécuter. Chaque nœud correspond à un élément syntaxique : une déclaration de variable, un appel de fonction, un opérateur, une chaîne littérale.

// Code source
const x = 1 + 2;

Produit un AST de cette forme :

{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "x" },
"init": {
"type": "BinaryExpression",
"operator": "+",
"left": { "type": "NumericLiteral", "value": 1 },
"right": { "type": "NumericLiteral", "value": 2 }
}
}]
}

L’intérêt pour la désobfuscation : l’AST expose la structure logique indépendamment du renommage ou des encodages. On peut parcourir l’arbre, identifier les nœuds qui correspondent à des patterns connus (tableau de chaînes décodées, appels de rotation, wrappers inutiles), les transformer, puis régénérer du code source propre.

Avant d’écrire du code, AST Explorer permet de coller du JavaScript obfusqué et de visualiser l’arbre correspondant en temps réel. C’est le point de départ pour identifier quels types de nœuds cibler.

Deux bibliothèques couvrent la majorité des cas.

recast parse le code en AST, permet de le modifier, puis le régénère en préservant au maximum le formatage original des parties non modifiées. Il est agnostique au parseur et accepte des AST produits par acorn, babel, ou d’autres.

@babel/generator fonctionne avec les AST produits par @babel/parser. Il est plus adapté si le code obfusqué a été généré par un outil basé sur Babel, ou si les transformations utilisent l’API @babel/traverse.

Fenêtre de terminal
# Option recast
npm install recast
# Option Babel
npm install @babel/core @babel/generator @babel/traverse @babel/parser
const recast = require("recast");
const fs = require("fs");
const source = fs.readFileSync("obfuscated.js", "utf8");
const ast = recast.parse(source);
// Parcourir l'arbre et appliquer des transformations
recast.visit(ast, {
visitStringLiteral(path) {
const { node } = path;
// Décoder les chaînes encodées en hex ou unicode
if (node.value && /\\x[0-9a-f]{2}/i.test(node.extra?.raw || "")) {
// La valeur décodée est déjà dans node.value, rien à faire, recast la restituera proprement
}
this.traverse(path);
}
});
const output = recast.print(ast).code;
fs.writeFileSync("deobfuscated.js", output);

L’avantage de recast : il ne retouche que les nœuds modifiés. Les parties intactes de l’arbre conservent leur formatage d’origine, ce qui facilite la comparaison avec le fichier source.

const parser = require("@babel/parser");
const generate = require("@babel/generator").default;
const traverse = require("@babel/traverse").default;
const fs = require("fs");
const source = fs.readFileSync("obfuscated.js", "utf8");
const ast = parser.parse(source, {
sourceType: "module",
plugins: ["jsx"]
});
traverse(ast, {
// Remplacer les expressions binaires calculables à la compilation
BinaryExpression(path) {
const { node } = path;
if (
node.left.type === "NumericLiteral" &&
node.right.type === "NumericLiteral"
) {
const result = eval(`${node.left.value} ${node.operator} ${node.right.value}`);
path.replaceWith({ type: "NumericLiteral", value: result });
}
},
// Dérouler les appels inutiles à des wrappers identité
CallExpression(path) {
const { node } = path;
if (
node.callee.type === "FunctionExpression" &&
node.arguments.length === 0 &&
node.callee.body.body.length === 1 &&
node.callee.body.body[0].type === "ReturnStatement"
) {
path.replaceWith(node.callee.body.body[0].argument);
}
}
});
const { code } = generate(ast, { compact: false });
fs.writeFileSync("deobfuscated.js", code);

Exécuter avec :

Fenêtre de terminal
node index.js

Patterns d’obfuscation courants et comment les traiter

Section intitulée « Patterns d’obfuscation courants et comment les traiter »

L’obfuscateur extrait toutes les chaînes littérales dans un tableau global, puis remplace chaque usage par un appel à une fonction de lookup :

// Code obfusqué
var _0x1a2b = ["admin", "password", "/api/login"];
function _0x3c4d(index) { return _0x1a2b[index]; }
_0x3c4d(0); // → "admin"
_0x3c4d(2); // → "/api/login"

Approche AST : identifier le tableau de déclaration, évaluer statiquement les appels de lookup dont l’index est un littéral numérique, et remplacer les nœuds CallExpression par les StringLiteral correspondants.

traverse(ast, {
CallExpression(path) {
const { node } = path;
// Vérifier que c'est un appel au wrapper de lookup
if (
node.callee.type === "Identifier" &&
node.callee.name === "_0x3c4d" &&
node.arguments.length === 1 &&
node.arguments[0].type === "NumericLiteral"
) {
const index = node.arguments[0].value;
const strings = ["admin", "password", "/api/login"]; // extrait au préalable
path.replaceWith({ type: "StringLiteral", value: strings[index] });
}
}
});

Certains outils font tourner le tableau d’un offset pour rendre le lookup moins direct. L’offset est déterminé lors du chargement via une IIFE. Évaluer cette IIFE isolément pour récupérer l’état final du tableau avant d’appliquer le remplacement.

Le code est transformé en une machine à états avec un switch central :

var _state = "3|1|0|2";
var _steps = _state.split("|");
var _idx = 0;
while (true) {
switch (_steps[_idx++]) {
case "0": doC(); continue;
case "1": doB(); continue;
case "2": break;
case "3": doA(); continue;
}
break;
}

Approche AST : identifier la chaîne d’état, la parser, remettre les cases dans l’ordre défini, et remplacer le while/switch par la séquence linéaire correspondante.

Un fichier partiellement corrompu ou généré par un outil non standard peut provoquer des erreurs à l’étape de parse. Diagnostics courants :

  • Unexpected token : le fichier contient une syntaxe que le parseur ne reconnaît pas. Essayer un autre parseur (acorn à la place de @babel/parser, ou l’inverse) ou activer des plugins supplémentaires ("estOptionality", "v8intrinsic").
  • Maximum call stack size exceeded : l’arbre est très profond (obfuscation par nesting excessif). Implémenter un visiteur itératif plutôt que récursif.
  • Nœud inconnu : recast échoue si l’AST contient un type de nœud qu’il ne reconnaît pas. Vérifier que le parseur utilisé par recast correspond au format produit.
// Forcer recast à utiliser le parseur babel
const ast = recast.parse(source, {
parser: require("recast/parsers/babel")
});

Le dépôt SteakEnthusiast/Supplementary-AST-Based-Deobfuscation-Materials documente des cas concrets de désobfuscation AST avec le code source des scripts utilisés. Point de départ utile pour adapter des transformations à un obfuscateur spécifique.

L’AST transforme la désobfuscation d’un travail de lecture textuelle en un travail de transformation structurée. Identifier les patterns dans l’arbre, écrire un visiteur ciblé, régénérer le code. Chaque couche d’obfuscation correspond à un type de nœud spécifique : les traiter un par un plutôt que de chercher une solution générique.