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

Ingénierie

Ingénierie

Améliorer la détection des XSS PostMessage grâce à l'instrumentation par objets Proxy

L'article présente une nouvelle méthode de détection des vulnérabilités de cross-site scripting (XSS) PostMessage à l'aide d'objets Proxy JavaScript, qui améliore les techniques traditionnelles de fuzzing dynamique.

Introduction

Les vulnérabilités de cross-site scripting (XSS) restent une menace persistante. Elles exploitent la nature dynamique des applications web pour exécuter des scripts malveillants. Parmi la multitude de canaux par lesquels les attaques XSS peuvent passer, l'API HTML5 postMessage se distingue par son usage très répandu pour les communications entre origines différentes.

Cependant, cette utilité ouvre aussi une boîte de Pandore de vulnérabilités, en particulier lorsque les messages ne sont pas rigoureusement validés. Efficaces jusqu'à un certain point, les méthodes de détection traditionnelles peinent souvent à identifier et à atténuer précisément ces vulnérabilités, surtout dans des environnements web complexes et générés dynamiquement.

Cet article présente une approche inédite pour améliorer la détection des vulnérabilités XSS PostMessage. Elle s'appuie sur les capacités des objets Proxy JavaScript pour l'instrumentation, combinées à une phase de profilage qui précède le fuzzing dynamique.

En intégrant l'instrumentation par objets Proxy au profilage préliminaire des applications web, nous préparons le terrain pour un processus de fuzzing plus ciblé et plus efficace. Cette approche simplifie la détection de vecteurs XSS subtils.

Les sections suivantes détaillent cette approche et son implémentation. À travers cette exploration, nous voulons doter les développeurs, les professionnels de la sécurité et les chercheurs d'une boîte à outils robuste pour protéger les applications web contre la menace toujours présente des XSS.

Contexte

L'API postMessage, partie intégrante de HTML5, a révolutionné les communications web en permettant l'échange de messages entre origines différentes. Conçue pour faciliter l'interaction entre des documents d'origines différentes, elle joue un rôle essentiel dans le web moderne, des widgets tiers aux applications monopages complexes. Cependant, sa flexibilité et sa puissance en font une cible d'exploitation, principalement via des attaques de cross-site scripting (XSS).

Les attaques XSS consistent à injecter des scripts malveillants dans des pages web consultées par d'autres utilisateurs, en abusant de la confiance qu'un utilisateur accorde à un site donné. Les vulnérabilités XSS traditionnelles proviennent d'entrées utilisateur mal assainies et directement incluses dans le contenu de la page. L'API postMessage introduit un nouveau vecteur pour ces attaques en envoyant des messages entre origines différentes, qui peuvent contenir du contenu malveillant exécutable dans le contexte de la page destinataire.

Vulnérabilités courantes et pièges

Validation de l'origine

L'une des principales vulnérabilités associées à postMessage est l'absence de validation de l'origine. Lorsqu'un message est reçu, ne pas vérifier son origine, ou implémenter cette vérification de manière incorrecte, peut conduire à accepter des messages provenant de sources malveillantes. L'exemple suivant montre une implémentation vulnérable :

window.addEventListener('message', (event) => {

  // Dangerous: No check for `event.origin`

  eval(event.data);

});

Dans cet extrait, l'écouteur d'événements de message exécute sans discernement le code contenu dans tout message reçu, sans vérifier son origine. Cette approche ouvre la porte à l'exécution de code JavaScript arbitraire, ce qui en fait une cible de choix pour les attaques XSS.

Exemple de code avec validation de l'origine.

window.addEventListener('message', (event) => {
  // Securely checking the origin of the message
  if (event.origin === 'https://trusted-origin.com') {
    // Assuming the content is safe, further validation can also be implemented here
    eval(event.data);
  } else {
    console.error('Untrusted origin:', event.origin);
  }
});

Cross-site scripting

Le XSS PostMessage se produit lorsqu'une application traite de manière incorrecte les données reçues via l'API postMessage, ce qui conduit à l'exécution de code non fiable et potentiellement malveillant. Cette vulnérabilité provient souvent de deux oublis courants :

  • Absence de validation de l'origine : ne pas valider l'origine du message entrant peut permettre à des attaquants d'envoyer des messages malveillants depuis des sources non fiables.
  • Assainissement insuffisant du contenu du message : traiter le contenu des messages reçus comme une entrée fiable, sans assainissement adéquat, peut conduire à l'exécution de code JavaScript arbitraire.

Prenons une application web qui écoute des messages pour mettre à jour dynamiquement son contenu à partir des données reçues via postMessage :

window.addEventListener('message', (event) => {
  // Assume the content of the message is a URL to be navigated to
  if (event.data.url) {
    window.location.href = event.data.url; // Potential for exploitation
  }
});

Dans ce scénario, un attaquant pourrait fabriquer un message contenant une URL JavaScript (javascript:), ce qui mènerait à l'exécution de code arbitraire :

parent.postMessage({url: "javascript:alert('XSS')"}, "*");

Les objets Proxy en JavaScript

L'objet Proxy JavaScript est une fonctionnalité puissante introduite dans ECMAScript 2015 (ES6) qui permet de créer un proxy pour un autre objet. Il permet d'intercepter et de redéfinir les opérations fondamentales sur cet objet, comme la recherche de propriétés, l'affectation, l'énumération et l'appel de fonctions. Cette capacité est particulièrement utile dans de nombreux scénarios avancés, comme la virtualisation d'objets, la journalisation, le profilage et la validation de données.

Principes de l'instrumentation par objets Proxy

Un objet Proxy est créé avec deux paramètres : l'objet cible qu'il encapsule et un objet gestionnaire (handler) qui définit des pièges (traps) pour diverses opérations. C'est dans le gestionnaire que la logique d'interception est définie. Voici un exemple de base :

let target = {};
let handler = {
  get: function(obj, prop) {
    console.log(`Accessing property ${prop}`);
    return prop in obj ? obj[prop] : 37; // Default value
  }
};

let proxy = new Proxy(target, handler);
console.log(proxy.a); // Output: Accessing property a
                      // 37 (since 'a' is not a property of target)

Dans cet exemple, le piège get journalise les accès aux propriétés et renvoie une valeur par défaut si la propriété n'existe pas sur l'objet cible.

Limites des objets Proxy

Bien que les Proxy JavaScript offrent de puissantes capacités pour l'instrumentation de sécurité et d'autres opérations avancées, ils comportent aussi certaines limites. Comprendre ces limites est essentiel pour que les développeurs utilisent efficacement les Proxy dans leurs applications.

  1. Impossibilité d'intercepter les actions sur les types natifs : les Proxy JavaScript ne peuvent pas intercepter directement les actions effectuées sur des types natifs comme les chaînes, les nombres ou les booléens. Ces types n'étant pas des objets, les opérations qui les concernent ne peuvent pas être interceptées par un Proxy. Par exemple, vous ne pouvez pas utiliser un Proxy pour intercepter directement des comparaisons ou des transformations de chaînes. Cette limite peut nuire à la capacité de surveiller et de valider les opérations portant sur des valeurs primitives.
let proxy = new Proxy('example string', handler); // This will throw an error
  1. Impossibilité de surveiller certaines opérations sur les objets natifs : les Proxy ne peuvent pas intercepter certaines opérations sur les objets natifs, comme la modification directe de la longueur d'un tableau ou la définition de propriétés sur des fonctions. Si vous pouvez intercepter les appels de méthodes et les accès aux propriétés, les modifications de propriétés internes qui ne déclenchent pas d'accès via un setter ou un getter échappent à un Proxy.
let numbers = new Proxy([1, 2, 3], handler);

numbers.length = 2; // This operation cannot be intercepted directly
  1. Absence de transparence pour certaines fonctions natives : certaines fonctions et méthodes natives de JavaScript peuvent se comporter différemment lorsque leurs arguments sont des objets Proxy. Par exemple, Array.isArray(proxyObject) renverra false même si le proxy encapsule un tableau. Ce comportement non transparent peut produire des résultats inattendus dans le code qui repose sur la vérification de types ou sur des comportements natifs.

Détection des XSS PostMessage par instrumentation d'objets Proxy

Profilage des messages

Intégrer une phase de profilage avant le fuzzing améliore l'efficacité et l'efficience de l'identification des vulnérabilités dans une application JavaScript, en particulier pour les gestionnaires postMessage et les schémas d'accès au stockage.

L'étape de profilage vise à recueillir des informations détaillées sur le comportement de l'application, en capturant les gestionnaires postMessage et les messages, ainsi que les interactions avec le stockage web (comme localStorage et sessionStorage). Ces informations servent deux objectifs principaux :

  • Reciblage pour le fuzzing : en collectant des informations détaillées sur les interactions de l'application et les flux de données, il est possible d'adapter les entrées de fuzzing pour exercer plus efficacement davantage de chemins de code, ce qui permet de découvrir des vulnérabilités qui resteraient sinon cachées.
  • Amélioration des performances : le profilage identifie les entrées pertinentes et effectivement utilisées par l'application, ce qui permet à la phase de fuzzing suivante de se concentrer sur ces zones. Cette approche ciblée évite de perdre du temps sur des entrées sans intérêt et maximise ainsi l'efficacité du fuzzing.

Pour capturer les gestionnaires postMessage, la méthode addEventListener est surchargée afin de journaliser tous les écouteurs d'événements enregistrés. C'est particulièrement utile pour identifier les gestionnaires de messages qui pourraient constituer des cibles potentielles pour le fuzzing.

// Object to store event listeners
const registeredEventListeners = [];


// Save the original addEventListener method
const originalAddEventListener = EventTarget.prototype.addEventListener;


// Override the addEventListener method
EventTarget.prototype.addEventListener = function(type, listener, options) {
   // Store the event details
   registeredEventListeners.push({ element: this, type, listener, options });


   // Call the original addEventListener method
   originalAddEventListener.call(this, type, listener, options);
};

Cet extrait de code stocke chaque écouteur d'événements enregistré dans un tableau, ce qui facilite l'examen et l'analyse ultérieurs des écouteurs, en particulier ceux associés aux événements de message.

Pour surveiller et journaliser l'activité postMessage, un gestionnaire personnalisé est ajouté : il journalise les messages postés, en utilisant la console par souci d'efficacité et de simplicité.

function handleMessage(event) {
   console.error("magic_post_message", JSON.stringify({data: event.data, origin: event.origin}));
}

window.addEventListener('message', handleMessage, false);

Ce gestionnaire capture les messages entrants et journalise leur contenu et leur origine. Il fournit ainsi des informations précieuses sur l'utilisation de postMessage dans l'application et met en évidence des zones à examiner plus avant pendant le fuzzing.

Le profilage s'étend au suivi des interactions de l'application avec les objets, y compris les accès à des propriétés imbriquées, en utilisant un Proxy pour journaliser les accès :

/**
* This function creates a proxy around the provided obj that tracks access to its properties. If a property of the object
* (or a nested object) is accessed, the accessHandler function is called with the object, the property name, and the path
* to the property.
*/
function createAccessTrackingProxy(obj, accessHandler) {
   // A recursive function to create a proxy for an object and its nested objects
   const createProxy = (target, path) => {
       return new Proxy(target, {
           get(target, property, receiver) {
               // Trigger the access handler function
               accessHandler(obj, property, path);


               // Check if the property accessed is an object and not null for recursion
               if (target[property] !== null && typeof target[property] === 'object') {
                   return createProxy(target[property], path.concat(property));
               }


               // Return the actual property value
               const value = Reflect.get(target, property, receiver);
               if (isObject(value)) {
                   return createProxy(value, path.concat(property));
               } else {
                   if (coinFlip()) {
                       return createProxy({}, path.concat(property));
                   } else {
                       return value;
                   }
               }


           }
       });
   };


   return createProxy(obj, []);
}


/**
* Logs path of object accesses
*/
function accessHandler(obj, property, path) {
   try {
       console.log('magic_post_message', JSON.stringify({
           data: obj,
           origin: null,
           path: [...path, property],
       }));
   } catch (e) {
       // Pass.
   }
}

Cette approche consiste à créer un Proxy autour d'un objet (ou d'objets imbriqués) et à journaliser chaque accès à ses propriétés. La fonction accessHandler est invoquée à chaque accès à une propriété et journalise le chemin de la propriété consultée. Ce suivi détaillé aide à comprendre comment l'application interagit avec ses données et alimente ensuite le processus de fuzzing.

Le recours à un tirage à pile ou face est ici un moyen simple d'introduire de la variabilité dans la phase de profilage : en décidant dynamiquement de renvoyer la valeur réelle ou un proxy vers un nouvel objet, il peut révéler des chemins et des comportements cachés.

Fuzzing dynamique de PostMessage

L'étape de fuzzing consiste à enrichir le corpus de messages collectés pendant la phase de profilage avec les chemins d'accès découverts. Cet enrichissement est essentiel pour découvrir des vulnérabilités plus subtiles, car il garantit que le fuzzing exerce un éventail plus large de chemins de code dans l'application JavaScript. Après cet enrichissement, l'ensemble de messages est minimisé pour supprimer les entrées redondantes ou sans intérêt, ce qui optimise l'efficacité du fuzzing.

Ostorlab utilise un générateur de points d'insertion qui comprend les schémas d'encodage imbriqués. Cette capacité est essentielle pour tester les applications qui manipulent des objets complexes, comme les messages SAML, qui peuvent comporter des structures imbriquées avec des encodages base64, XML, HTTP query et JSON.

Le code ci-dessous montre comment le fuzzer parcourt les événements postMessage collectés, construit dynamiquement des messages fuzzés à partir des chemins d'accès collectés et génère des insertions pour sonder l'application :

if request.profile is not None:
    post_message_events = request.profile.post_messages or []
    for post_message_event in post_message_events:
        post_message = post_message_event["data"]

        # Traverse the message structure based on collected paths
        post_message_pointer = post_message
        for elm in post_message_event.get("path", []):
            if elm not in post_message_pointer:
                post_message_pointer[elm] = {}
            post_message_pointer = post_message_pointer[elm]

        # Generate and yield fuzzed messages
        for generated_insertions in self.generate(post_message):
            insertions = [WhatInsertionPoints.POST_MESSAGE, post_message]
            insertions.extend(generated_insertions)
            yield insertions

        # Repeat the process for JSON-encoded messages
        for generated_insertions in self.generate(json.dumps(post_message)):
            insertions = [WhatInsertionPoints.POST_MESSAGE, json.dumps(post_message)]
            insertions.extend(generated_insertions)
            yield insertions

La détection des vulnérabilités de cross-site scripting (XSS) a lieu lorsqu'un callback de méthode JavaScript, déclenché par un message fuzzé, exécute du code malveillant. Ostorlab améliore le processus de détection en utilisant console.trace pour collecter des traces de pile chaque fois qu'une vulnérabilité XSS est déclenchée. Cette approche permet d'identifier précisément le chemin d'exécution qui mène à la vulnérabilité, ce qui facilite une analyse plus poussée et une remédiation plus efficace.

Conclusion

La combinaison du profilage, de l'instrumentation par objets Proxy et du fuzzing dynamique traditionnel s'est révélée efficace pour détecter des vulnérabilités XSS PostMessage jusque-là inconnues.

Cependant, des difficultés comme la comparaison de chaînes montrent qu'il faut aller plus loin. Notre prochaine étape consiste à intégrer l'analyse statique pour détecter les comparaisons de champs, afin d'obtenir une couverture encore meilleure.