Stratégies de fuzzing pour le DOM XSS - Partie 1
Les XSS restent de loin le type de vulnérabilité le plus courant ; cet article présente des stratégies pour automatiser la recherche de XSS.
Les XSS restent de loin la vulnérabilité la plus courante dans les applications web : elles sont faciles à introduire et plus faciles à trouver que d'autres classes de vulnérabilités. Les XSS se répartissent en 3 familles : réfléchies, stockées et fondées sur le DOM. Les premières sont les plus répandues et aussi les plus difficiles à détecter des trois.
Pour traquer les XSS fondées sur le DOM, il est possible d'adopter une approche statique, en analysant le JavaScript, en marquant (tainting) les sources et les puits (sinks), en propageant ce marquage de façon statique, etc. Cette approche est difficile pour le JavaScript en raison de la nature dynamique du langage, ce qui la rend sujette aux faux positifs, complexe et gourmande en ressources.
Les approches dynamiques semblent mieux adaptées à la tâche : elles nécessitent une instrumentation du JavaScript pour inspecter l'environnement d'exécution JavaScript. Les approches possibles sont, cette liste n'étant en rien exhaustive :
Réécrire le code JavaScript à la volée pour y injecter du code d'instrumentation ; cette approche est fragile, modérément complexe et gourmande en ressources
Instrumenter le moteur JavaScript du navigateur, ce qui est l'approche la plus économe en ressources, mais nécessite de manipuler les rouages internes d'un navigateur, ce qui est difficile (à cause de toute la magie du JIT que gèrent la plupart des moteurs JavaScript) et coûteux à maintenir sur le long terme
Utiliser l'API de débogage, en posant des points d'arrêt aux endroits appropriés, en avançant pas à pas dans le code, etc. Cette méthode s'est révélée lente, moins complète qu'on ne l'espérerait ; bon courage pour poser un point d'arrêt sur tous les appels à eval.
Utiliser l'API de couverture de code pour avoir un aperçu grossier de ce qui s'exécute, combinée au monkey patching et à l'injection d'objets Proxy ; cette approche est performante, plus simple à mettre en œuvre, mais souffre théoriquement de certaines limites
Pour cet article de blog, nous allons construire une PoC simple d'un fuzzer XSS guidé par la couverture de code. Le fuzzer utilisera des informations de couverture précises pour identifier les chemins de code nouvellement exécutés, et utilisera ces informations pour générer de nouveaux payloads de test. Le fuzzer instrumentera les méthodes puits (sink), à l'exception de certaines limites (voir le casse-tête d'eval).
Pour garder la PoC bien délimitée, nous nous concentrerons sur le XSS via postMessage, nous utiliserons le navigateur Chrome, l'API de débogage distant, nous écrirons la PoC en Python 3 et utiliserons la bibliothèque Pychrome pour interagir avec le navigateur.
Assez d'introduction, commençons.
Pour interagir avec l'API de débogage de Chrome, nous devrons l'activer avec la ligne de commande suivante :
chrome --verbose --window-size=1200x600 --disable-gpu --remote-debugging-port=9222 --user-data-dir=/tmp/foo --disable-web-security
Vous pouvez éventuellement activer le mode headless, ce qui permet d'économiser environ 20 % de ressources et qui est important à utiliser si vous construisez un fuzzer XSS complet.
L'option importante est remote-debugging-port, vous pouvez ignorer le reste ; disable-web-security sert à désactiver l'auditeur XSS (nous voulons d'abord trouver des XSS, nous verrons comment contourner la protection un autre jour).
Pour accéder aux API, il suffit de l'instancier ainsi :
import pychrome
debug_host = '127.0.0.1'
debug_port = 9222
url = f"http://{debug_host}:{debug_port}"
browser = pychrome.Browser(url=url)
Maintenant que le décor est planté, voyons ce que doit faire le fuzzer :
- Créer un nouvel onglet
- Activer la collecte précise de couverture de code dans la page
- Visiter notre page cible (cela peut sembler simple, mais ça ne l'est pas)
- Injecter le code d'instrumentation
- Injecter les méthodes de détection de XSS
- Injecter le payload
- Détecter les chemins exécutés dans le code
- En générer de nouveaux payloads
Retour à l'étape 6
La première étape est simple : c'est l'initialisation de notre classe d'injection, qui crée un nouvel onglet, le démarre puis active un ensemble d'API du débogueur Chrome pour collecter certains types d'événements :
def __init__(self, browser):
self.browser = browser
self.debugger = browser.new_tab()
self.debugger.start()
self.debugger.Page.enable()
self.debugger.Console.enable()
self.debugger.Runtime.enable()
Activer la couverture de code est simple ; utiliser son résultat l'est un peu moins. Ce que le morceau de code suivant essaie de faire, c'est transformer les informations de couverture en quelque chose d'exploitable, il nous indiquera le morceau de code exécuté :
class Coverage:
def __init__(self, debugger):
self.debugger = debugger
self.sources = {}
self.coverages = []
self.debugger.Profiler.enable()
self.debugger.Profiler.start()
self.debugger.Debugger.enable()
self.debugger.Debugger.setSkipAllPauses(skip=True)
self.debugger.set_listener('Debugger.scriptParsed', self._on_script_parsed)
self.debugger.Profiler.startPreciseCoverage(callCount=True, detailed=True)
def _on_script_parsed(self, scriptId, **kwargs):
source = self.debugger.Debugger.getScriptSource(scriptId=scriptId)
self.sources[scriptId] = source
Visiter une page est simple, mais cela ne couvre pas tous les cas. Si par exemple nous devons envoyer une méthode POST, avoir certains cookies définis ou ajouter des en-têtes spécifiques à la requête, ces cas nécessitent un code plus complexe, qui dans certains cas, exigerait d'intercepter les requêtes au niveau réseau.
Pour les besoins de la PoC, nous supposerons le cas simple :
self.debugger.Page.navigate(url=url)
Détecter que la page a fini de charger est un autre casse-tête en soi : l'API de débogage envoie des événements qui aident à détecter la fin du chargement, mais là encore, pour les besoins de la PoC, nous allons simplement attendre :
self.debugger.wait(2)
L'étape suivante consiste à injecter du code d'instrumentation qui DOIT s'exécuter avant que le reste de la page ne commence à se charger. C'est essentiel si nous devons par exemple appliquer un monkey patch aux méthodes puits (sink). Le débogueur Chrome dispose d'une API pour cela :
source = open('instrument.js', 'r').read()
self.debugger.Page.addScriptToEvaluateOnNewDocument(source=source)
Récapitulons : nous pouvons maintenant contrôler l'instance Chrome, démarrer un nouvel onglet, injecter du code d'instrumentation et visiter notre page cible. Pour éviter de surcharger cet article, nous aborderons les étapes restantes dans le prochain article de blog, à savoir :
- Comment détecter le XSS ?
- Comment instrumenter des objets à l'aide de l'API Javascript Proxy ?
- Comment injecter des payloads ?
- Comment exploiter les données de couverture pour détecter de nouvelles branches ?
- Comment générer de nouveaux payloads ?