Les Cross-Site Scripting (XSS) restent l'une des vulnérabilités web les plus communes et les plus souvent découvertes en audit, pentest ou Bug Bounty. Certaines peuvent sembler être des faux-positifs, où la réflexion est bien présente dans le DOM, mais hélas l'injection ne se déclenche pas en raison d'erreurs préalables dans le code source... Avant de déclarer forfait en tant qu’auditeur ou hunter, n’est-il pas possible de corriger/réparer le code légitime pour tout de même réussir l’injection ?
1. Introduction et contextualisation
1.1 Erreur de dev… Correction d’attaquant !
Combien de fois un pentester / bug-hunter a subit l’ascenseur émotionnel d’une réflexion DOM-based (sans possibilité de sortir du <script></script> courant) qui au final ne se déclenche pas, car le code JavaScript légitime qui préfixe l’injection sur la page comporte des erreurs ?
Un exemple vaut mieux qu’un long discours :
Dans cet exemple, une réflexion est décelée au niveau d’INJECTION. Un attaquant cherchera à démontrer la présence d’une XSS via l’usage d’une charge utile (payload) telle que ";alert(document.domain);//.
Cependant, cette injection XSS ne se déclenchera jamais en l’état, car le bloc dans lequel elle est réfléchie tombe en erreur avant son exécution (figure 1) :
Et même si cette fonction myUndefinedFunction était définie, les deux instructions qui suivent produiraient des erreurs similaires :
Sans l’usage d’un try / catch, dès lors qu’une exception de type ReferenceError est levée, le code JavaScript de la portée courante s’arrête.
Une telle exception peut être issue d’une négligence du développeur légitime, d’une « faute de frappe », ou encore du fait que la page en question n’est pas censée être invoquée directement, mais est chargée via AJAX dans un contexte déjà établi d’une autre page où toutes les variables / fonctions / classes sont d’ores et déjà référencées et initialisées.
Quoi qu’il en soit, tout n’est pas perdu ! En tant qu’auditeur endossant le rôle d’un attaquant, il est possible d’ajuster le payload de l’injection pour réparer / corriger / fixer le code source erroné et tout de même produire une injection fonctionnelle !
Le concept inhérent au langage JavaScript et à son interprétation dans les navigateurs modernes qui est employé à ces fins se nomme le JavaScript Hoisting [MDN].
1.2 Hoisting Power !
« Hoisting », anglicisme que l’on peut traduire par « remontée » ou « hissage » en JavaScript, correspond au déplacement implicite de la déclaration de toutes les variables, fonctions ou classes en amont de leur portée, avant même l’exécution du code en question.
Il est bien question du déplacement implicite des déclarations, et non pas de leurs initialisations. Ainsi, tous les identifiants (noms de variables, de classes, de fonctions) sont sémantiquement présents avant les lignes de code de la portée courante pour garantir l’absence de confusion entre identifiants de même nom.
Ainsi, en exécutant le code suivant :
Le moteur JavaScript va implicitement remanier la sémantique du code en :
// initialisation, donc exception "ReferenceError"
// la portée
// précédente avec la valeur "1")
Pour produire le résultat suivant :
Il est donc possible, grâce au JavaScript Hoisting, de prévenir le déclenchement d’exceptions en déclarant des variables, fonctions ou classes alors que leur utilisation est faite a priori dans le flux d’exécution du code.
Ce qu’il faut bien comprendre c’est que quand le moteur JavaScript cherche à exécuter un quelconque script, il va en réalité réaliser a minima 2 passes sur celui-ci (pour simplifier) :
- une première passe où le code est parsé, afin d’analyser les erreurs de syntaxe, et pour réaliser le hoisting de certaines formes de déclarations où celles-ci sont remontées en amont de la pile d’exécution. Si cette étape est concluante, le moteur poursuit vers l’exécution ;
- l’exécution du code remanié et syntaxiquement valide, où les déclarations ont été hissées en amont.
D’après la documentation MDN [MDN], il y a 4 types de hoisting en JavaScript :
- pouvoir utiliser la valeur d’une variable dans sa portée avant la ligne sur laquelle elle est déclarée (« levage de valeur ») ;
- pouvoir référencer une variable dans sa portée avant la ligne sur laquelle elle est déclarée, sans déclencher de ReferenceError, mais la valeur est toujours indéfinie (« levage de déclaration ») ;
- la déclaration de la variable provoque des changements de comportement dans sa portée avant la ligne dans laquelle elle est déclarée ;
- les effets secondaires d'une déclaration sont produits avant d'évaluer le reste du code qui la contient.
Les déclarations de fonctions sont hissées avec un comportement de type 1 ; la déclaration var est levée avec un comportement de type 2 ; les déclarations let, const et class (également appelées collectivement déclarations lexicales) sont hissées avec un comportement de type 3 ; les déclarations import sont hissées avec un comportement de type 1 et de type 4.
2. XSS Hoisting pour les attaquants
2.1 Variable hoisting
Le cas classique de hoisting d’une variable s’illustre lorsqu’une injection-réflexion a lieu dans une portée où une variable est utilisée au préalable, sans avoir été déclarée. Exemple :
Dans cet exemple, l’exécution du code au sein de cette portée lèvera une exception Uncaught ReferenceError: myUndefVar is not defined dès la tentative d’utilisation de cette variable myUndefVar qui s’avère non-déclarée et non-initialisée ; empêchant un payload tel que ";alert(1);// de fonctionner.
Ainsi, pour profiter du hoisting de variable et rendre cette injection fonctionnelle, il suffit de déclarer une variable nommée myUndefVar pour que le déclenchement de la XSS s’effectue (figure 2) :
L’usage du mot clé var est nécessaire, car celui-ci, en plus d’assurer une déclaration via hoisting, réalise aussi une initialisation avec undefined qui ne déclenchera pas d’erreur. Let ou const engendreront une erreur de type ReferenceError: can’t access lexical declaration ‘myUndefVar’ before initialization, car ces mots clés remontent bien les déclarations, mais les valeurs restent non-initialisées.
2.2 Fonction hoisting
Le hoisting de fonction est similaire : la déclaration d’une fonction après que l’usage de celle-ci soit fait permet d’éviter la levée d’exception.
La fonction myUndefFunction est appelée sans être définie, ce qui lève une exception ReferenceError une nouvelle fois.
Ce code peut être « corrigé » via une injection profitant du hoisting tel que :
Le prototype de la fonction peut être simplifié pour écourter le payload final, sans préciser avec exactitude les paramètres d’entrée / sortie de la fonction.
2.3 Classe hoisting
Concernant les classes, les déclarations de celles-ci sont hoisted, mais elles demeurent non-initialisées tant que non-évaluées [JLA]. Exemple :
Autrement dit, si l’on suit la même logique que pour les variables ou les fonctions comme vues précédemment, le code avec l’injection suivante n’est hélas pas fonctionnel :
Cependant, il est possible de profiter de l’usage de l’opérateur new pour indiquer non pas une classe, mais une fonction à instancier. En effet, en JavaScript, chaque fonction est en réalité un objet Function. Ce qui permet une injection réussie :
2.4 Fonction/objet/attribut : cas de JQuery
Cas concret d’une XSS-hoisting dans un contexte JQuery rencontré lors d’un excellent programme de Bug Bounty. L’usage massivement adopté de la bibliothèque JQuery depuis de nombreuses années engendre des applications web dynamiques, où cette bibliothèque est chargée une fois pour toutes via la première page structurante, et où le contenu de celle-ci est mis à jour via des appels AJAX asynchrones.
Ces appels AJAX GET/POST se traduisent par la récupération d’une réponse pouvant être au format HTML/CSS/JavaScript afin de mettre à jour le DOM de la page. Pas besoin de recharger la bibliothèque JQuery dans le code de ces réponses, car celle-ci est déjà incluse dans le contexte de la page appelante.
Lors d’une utilisation légitime d’une telle application (figure 3), les étapes sont les suivantes :
- un utilisateur réalise une requête vers le frontend, la page principale de l’application ;
- la page index.php charge la bibliothèque JQuery dans son contexte d’exécution ;
- cette même page principale effectue un appel AJAX vers /ajax/auth.php accompagné d’un paramètre GET (cette page n’est pas vouée à être appelée directement, mais uniquement de manière asynchrone) ;
- la page destination de la requête AJAX génère un rendu, incluant la réflexion du paramètre GET (le point d’injection). Ce rendu utilise une syntaxe JQuery ;
- le rendu de l’appel AJAX est récupéré par la page appelante, qui intègre les résultats dans une <div>. Le code JQuery de la page AJAX est ainsi bien exécuté, car la bibliothèque est déjà chargée dans le contexte de la page appelante (étape 2) ;
- un attaquant ciblant cette application cherchera à appeler directement la page /ajax/auth.php en incluant un payload dans le paramètre GET. Il devra exploiter le hoisting, car cette page ne charge pas la bibliothèque JQuery n’étant pas vouée à être appelée directement.
On observe en conséquence des réponses AJAX de la forme :
La syntaxe d’appel $(document).ready() est propre à JQuery, et permet d’exécuter le code qu’elle renferme uniquement quand la page a fini de se charger / quand le DOM est prêt.
Seulement, dans le cas présent, l’injection-réflexion est localisée explicitement au sein de ce bloc JQuery ; et la bibliothèque JQuery elle-même n’est pas chargée dans cette source. Une exception est donc levée : Uncaught ReferenceError: $ is not defined.
Cette syntaxe se décortique ainsi :
- $(document) : fonction nommée $, qui prend l’objet document de la page en paramètre ;
- .ready() : la fonction $() retourne un objet avec l’attribut .ready, qui est également une fonction.
Pour profiter du hoisting dans un tel cas, il est nécessaire de déclarer tous ces éléments avant de fournir le code à injecter lui-même de la XSS, tout en gardant une syntaxe globale valide.
Ainsi :
- on s’échappe du bloc $(document).ready(function(){ via }); ;
- on déclare nos éléments à ce niveau pour le hoisting, ainsi que notre payload XSS final ;
- puis on corrige la syntaxe globale en réouvrant ({.
Pour le hoisting en lui-même :
- on déclare une fonction $() ;
- celle-ci doit retourner un objet ;
- cet objet doit avoir un attribut ready ;
- cet attribut ready est une fonction (dont le retour importe peu).
Ce qui donne :
2.5 Objets, propriétés et attributs imbriqués en profondeur…
2.5.1 Injection post-accesseurs : KO ?
Les accesseurs de propriétés via l’usage du caractère . ou des [] sur des objets ne sont hélas pas hissés (not-hoisted).
Ainsi, il n’est pas aisé de rendre une injection prolifique via du hoisting pour des cas tels que :
Contrairement au cas précédent avec JQuery, undef01 est un objet (et non pas une fonction JQuery). Ainsi, en cherchant à profiter du hoisting, il est possible de définir undef01, mais pas undef02 étant une propriété…
Un tel cas ne semble pas exploitable pour produire une XSS fonctionnelle...
2.5.2 Injection dans les paramètres d’un accesseur : OK !
Un cas de hoisting est cependant possible lorsque l’injection est localisée en tant que paramètre de la fonction elle-même, tel que [JGA][JCA] :
Dans le cas présent, il est possible de déclarer undef01 et de réaliser l’injection dans les paramètres de l’appel de undef02 qui reste pourtant undefined :
La précédente injection via un accesseur de propriété indéfinie fonctionne, car :
- le moteur JavaScript remonte (hoist) la déclaration de undef01 ;
- exécution de « get property undef02 » de « undef01 » (ce qui retourne undefined) ;
- évaluation des paramètres de la fonction avant même de savoir si l’objet est bien une fonction, donc exécution de alert(1) ;
- exécution de undef01.undef02() qui tombe en erreur, car undef02 est undefined et n’est pas une fonction ; mais cela importe peu, car l’injection s’est déclenchée à l’étape d’avant.
Résultats similaires pour l’utilisation des [] pour accéder aux propriétés :
2.5.3 Multi-imbrications (nested objects)...
L’imbrication multiple d’objets et d’accesseurs peut accentuer la difficulté de hoisting en vue de faire une injection fonctionnelle. Un exemple tel que le suivant peut paraître inexploitable :
La spécificité ici, qui rend l’exploitation possible, est le typage de la balise <script type="module">, où le bloc est défini comme un module JavaScript [MOD] ; ce qui laisse la possibilité d’employer le mot clé import [JOA][JCA].
Ainsi, il est possible de définir un module.js hébergé sur le site de l’attaquant-auditeur, « réparant » l’entièreté des déclarations du code. Exemple de module.js :
Puis une injection avec import tel que :
Le mot clé import, utilisable dans les blocs JavaScript déclarés en tant que module, est à bien différencier de l’expression dynamique import(). La documentation MDN indique clairement que les instructions import sont hoisted avec les règles 1 et 4 : ces instructions sont remontées au début de la pile d’exécution et, surtout, exécutées avant toutes les autres.
Le module.js pourrait donc être simplifié en intégrant uniquement le payload XSS final :
Avec une injection telle que :
Ou encore en version one-liner sans même l’utilisation d’un script hébergé sur un serveur tiers :
2.6 Déclaration + initialisation : hijack de fonction !
Dans les cas où toutes les tentatives de hoisting s’avèrent infructueuses, trop complexes ou inefficientes, la technique du hoisting-hijacking de fonction peut débloquer la situation [BRU].
En reprenant l’exemple précédent sans que celui-ci soit typé en module (donc impossibilité d’utiliser import), avec une multi-imbrication d’accesseurs de propriétés tous indéfinis, le hoisting ne paraît pas fructueux.
Cependant, si une quelconque fonction JavaScript est appelée en amont de la ReferenceError, fonction personnalisée ou native au langage, alors il est possible de profiter du hoisting pour la redéfinir et en altérer le comportement.
Dans cet exemple, la portée courante réalise un appel à atob(), fonction native de JavaScript pour décoder une chaîne en Base64, juste avant la levée de l’exception ReferenceError précédant elle-même le point d’injection.
Dans un tel cas, le hoisting reste l’allié de l’attaquant-auditeur en redéfinissant la fonction atob() elle-même, avec le code de son choix :
À l’exécution, l’appel de atob() se traduira par une injection arbitraire réussie.
Il est également possible, via ce hijack de fonction en amont, de réparer l’ensemble du code en profitant du hoisting pour déclarer undef01 en tant que variable globale, puis en définissant chacun des accesseurs de propriétés dans le corps de la fonction atob() redéfinie tel que :
3. La prévention pour les développeurs
La première prévention à ce type d’injection, qui n’en reste pas moins qu’une XSS, reste bien évidemment de protéger la réflexion de l’entrée utilisateur. « Never trust user input » demeure l’adage par excellence. Ainsi : échapper, encoder, normaliser, contrôler, nettoyer...
À cela, de bonnes pratiques additionnelles de développement, notamment en JavaScript, sont conseillées. En particulier avec les nouveautés introduites dans le langage ces dernières années telles que l’usage du mode strict et des mots clés let ou const [DIG].
3.1 Bref rappel historique
Le « JavaScript » a été conçu initialement en une dizaine de jours en 1995 par Brendan Eich chez Netscape. À l’origine appelé « LiveScript » il a été renommé en « JavaScript » pour des raisons marketing.
Ce langage permettant de dynamiser les pages web rencontre un grand succès avec le navigateur Netscape 2.0 en 1996. Microsoft s’empresse de produire sa propre implémentation, nommée « JScript » avec son navigateur IE3.0 en août 1996.
Netscape propose en fin d’année 1996 à l’organisme ECMA de standardiser le langage. C’est donc en 1997 que le standard « ECMAScript » voit le jour.
« ECMAScript » est une norme, ce à quoi « JavaScript » ou « JScript » en sont des implémentations. L’ECMA a standardisé le langage et non pas les interactions / implémentations faites en fonction des navigateurs, dont ce rôle revient au W3C.
Le cycle de vie et de mises à jour des navigateurs a grandement évolué et s’est accéléré ces dernières années, influant sur celui du standard ECMA qui sort une nouvelle version chaque année depuis 2015 [ANI].
De nouvelles fonctionnalités, notions et mots clés du langage sont apparus conjointement avec ces évolutions ECMA, qui permettent de durcir et d’optimiser le code pour freiner les éventuelles injections qui profiteraient du hoisting.
3.2 ES5 (2009) strict-mode
La version ECMAScript 2009 (ES5) du standard introduit la notion de strict-mode [STM]. En activant ce mode dans les scripts JS, le développeur opte pour une variante restreinte du langage qui ne tolère pas l’utilisation de variables avant qu’elles ne soient déclarées ; réduisant en conséquence les abus liés au hoisting.
En déclarant un code en mode strict, cela permet :
- d’éliminer certaines erreurs JavaScript silencieuses en levant des erreurs explicites au sein de l’interpréteur ;
- de corriger des erreurs qui complexifient la tâche d’optimisation du moteur JS ;
- d’interdire certaines syntaxes pouvant être conflictuelles avec de futures versions du langage.
L’activation du strict-mode s’effectue en ajoutant l’instruction suivante en amont d’un fichier JS ou d’une fonction :
3.3 ES6 (2015) let / const
ECMAScript 2015 (ES6) introduit quelques changements d’importance dans le langage concernant la déclaration et l’initialisation des variables avec de nouveaux mots clés.
3.3.1 let
Le mot clé let [LET] permet la déclaration de variable (tout comme var). Cependant, avec let, la portée de déclaration est dans le périmètre du bloc courant et non pas dans le périmètre de la fonction elle-même.
De plus, avec l’usage du mot clé let, cela force à toujours déclarer une variable avant son utilisation. Il n’est pas possible de profiter du hoisting avec let : l’interpréteur lèvera une Reference error.
let force donc à toujours déclarer puis assigner une valeur à une variable (initialisation) avant de pouvoir l’utiliser.
3.3.2 const
Idem, le mot clé const [CON] a également été introduit avec ES6 pour permettre les variables immuables, dont la valeur ne peut être modifiée une fois assignée.
L’interpréteur lève automatiquement une erreur si une constante est utilisée avant sa déclaration et son initialisation.
Avec une approche de réduction de la surface d’attaque et de limiter les portées des variables au strict nécessaire et suffisant, il est donc recommandé pour les développeurs d’employer les mots clés let et const au détriment de var.
Conclusion
Le JavaScript hoisting est une fonctionnalité du langage qui peut être abusée pour « réparer » un code générant des erreurs afin de disposer d’une injection prolifique selon la vision d’un attaquant.
En guise de synthèse, point de vue auditeur :
- si une injection est présente après une variable ou une fonction indéfinie, il est possible de la déclarer et de contourner la « undeclared error » pour déclencher la XSS grâce au JavaScript hoisting ;
- ne pas employer let ou const pour ce type d’injection, privilégier var ;
- toutes les déclarations hoisted via var sont automatiquement initialisées avec undefined ;
- si l’injection a lieu après un new constructor, ne pas déclarer une class, mais une Function ;
- si l’injection a lieu après un élément qui utilise les accesseurs de propriétés, l’exploitation n’est pas possible sauf dans un bloc JavaScript de type module avec l’usage du mot clé import ;
- dans tous les cas, la réécriture d’une fonction personnalisée ou native du langage qui est appelée avant permet de contourner toutes les restrictions.
Pour les développeurs, JavaScript protège des dérives d’injection liées au hoisting via l’usage des mots clés let ou const, qu’il est recommandé d’employer conjointement avec le strict-mode pour un développement plus qualitatif et en prévention d’attaques de type XSS abusant des mécanismes du hoisting.
Pour terminer, je tenais finalement à remercier mon cher @chackal pour les cas toujours plus tricky d’injections qu’on trouve et creuse ensemble, @jo / @kwilyine pour leurs relectures attentives et @zancrows pour sa patience en éprouvant et subissant mes challenges JS toujours plus alambiqués et capillotractés.
Références
[MDN] Mozilla, Hoisting, https://developer.mozilla.org/en-US/docs/Glossary/Hoisting
[DIG] DigitalOcean, Understanding Hoisting in JavaScript,
https://www.digitalocean.com/community/tutorials/understanding-hoisting-in-javascript
[JCA] Johan Carlsson, Having some fun with JavaScript hoisting,
https://joaxcar.com/blog/2023/12/13/having-some-fun-with-javascript-hoisting/
[JLA] Jorge Lajara, Javascript Hoisting in XSS Scenarios,
https://jlajara.gitlab.io/Javascript_Hoisting_in_XSS_Scenarios
[BRU] BruteLogic, XSS With Hoisting,
https://brutelogic.com.br/blog/xss-with-hoisting/
[MOD] Mozilla, JavaScript modules,
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
[JGA] Justin Gardner @Rhynorater, x.y(1,INJECT); challenge,
https://twitter.com/Rhynorater/status/1722636015070744713
[JOA] Johan Carlsson @joaxcar, x.y.z("test-INJECT"); challenge,
https://twitter.com/joaxcar/status/1730558718754840613
[ANI] Alexandre Niveau, Nouveautés ECMAScript 5, 6, 7, 8,
https://ensweb.users.info.unicaen.fr/pres/es7/
[STM] Mozilla, Strict mode,
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode
[LET] Mozilla, let, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let
[CON] Mozilla, const, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const