Estrategias de fuzzing para DOM XSS - Parte 1
Las XSS siguen siendo, con diferencia, el tipo de vulnerabilidad más común; este artículo presenta estrategias para automatizar la búsqueda de XSS.
Las XSS siguen siendo, con diferencia, la vulnerabilidad más común en las aplicaciones web; son fáciles de introducir y más fáciles de encontrar en comparación con otras clases de vulnerabilidades. Las XSS se dividen en 3 familias: reflejadas, almacenadas y basadas en el DOM. Las primeras son las más frecuentes y también las más difíciles de detectar de las tres.
Para buscar XSS basadas en el DOM, es posible seguir un enfoque estático: analizar JavaScript, marcar fuentes y sumideros (taint), propagar el taint de forma estática, etc. Este enfoque es difícil para JavaScript por la naturaleza dinámica del lenguaje, lo que lo hace propenso a falsos positivos, complejo e intensivo en recursos.
Los enfoques dinámicos pueden parecer más adecuados para la tarea; requieren instrumentar el JavaScript para inspeccionar el runtime de JavaScript. Los posibles enfoques, sin que la lista sea en absoluto exhaustiva, son:
Reescribir el código JavaScript sobre la marcha para inyectar código de instrumentación; este enfoque es frágil, moderadamente complejo e intensivo en recursos
Instrumentar el motor JavaScript del navegador, que es el enfoque más económico en recursos, pero que requiere manipular los componentes internos del navegador, algo difícil (por toda la magia del JIT que gestionan la mayoría de los motores JavaScript) y costoso de mantener a largo plazo
Usar la API del depurador, estableciendo breakpoints donde corresponda, recorriendo el código paso a paso, etc. Esto ha demostrado ser lento, no tan completo en funcionalidades como cabría esperar; buena suerte estableciendo un breakpoint en todas las llamadas a eval.
Usar la API de cobertura para echar un vistazo aproximado a lo que se está ejecutando, combinada con monkey patching e inyección de objetos Proxy; este enfoque es eficiente, es más fácil de implementar, pero en teoría sufre algunas limitaciones
Para esta entrada del blog, construiremos una sencilla prueba de concepto de un fuzzer de XSS guiado por cobertura. El fuzzer usará información precisa de cobertura para identificar rutas de código recién ejecutadas y usará esa información para generar nuevos payloads de prueba. El fuzzer instrumentará métodos sumidero (sink), salvo algunas limitaciones (véase el dolor de cabeza de eval).
Para mantener acotada la prueba de concepto, nos centraremos en el XSS de postMessage, usaremos el navegador Chrome, la API del depurador remoto, escribiremos la prueba de concepto en Python 3 y usaremos la biblioteca Pychrome para interactuar con el navegador.
Basta de introducciones, empecemos.
Para interactuar con la API del depurador de Chrome, necesitaremos habilitarla con la siguiente línea de comandos:
chrome --verbose --window-size=1200x600 --disable-gpu --remote-debugging-port=9222 --user-data-dir=/tmp/foo --disable-web-security
Opcionalmente, puede que quiera habilitar el modo headless, lo que ahorrará alrededor de un 20% de recursos, algo importante si va a construir un fuzzer de XSS a gran escala.
El indicador importante es remote-debugging-port; el resto puede ignorarlo. disable-web-security sirve para deshabilitar el auditor de XSS (primero queremos encontrar las XSS; ya veremos cómo eludirlo otro día).
Para acceder a las API, lo único que tenemos que hacer es instanciarla de esta forma:
import pychrome
debug_host = '127.0.0.1'
debug_port = 9222
url = f"http://{debug_host}:{debug_port}"
browser = pychrome.Browser(url=url)
Ahora que tenemos el escenario listo, averigüemos qué debería hacer el fuzzer:
- Crear una nueva pestaña
- Habilitar la recopilación precisa de cobertura en la página
- Visitar nuestra página objetivo (puede parecer fácil, pero resulta que no lo es)
- Inyectar código de instrumentación
- Inyectar métodos de detección de XSS
- Inyectar payload
- Detectar las rutas ejecutadas en el código
- Generar nuevos payloads a partir de ello
Volver al paso 6
El primer paso es directo; es el init de nuestra clase inyectora, que crea una nueva pestaña, la inicia y luego habilita un conjunto de API en el depurador de Chrome para recopilar ciertos tipos de eventos:
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()
Habilitar la cobertura de código es fácil; usar su salida es algo más complejo. Lo que intenta hacer el siguiente fragmento de código es convertir la información de cobertura en algo explotable; nos indicará qué fragmento de código se ha ejecutado:
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
Visitar una página es fácil, pero no cubre todos los casos. Si, por ejemplo, tuviéramos que enviar un método POST, tener ciertas cookies configuradas o añadir cabeceras específicas a la solicitud, estos casos requieren un código más complejo que, en algunos casos, exigiría interceptar la solicitud a nivel de red.
Para esta prueba de concepto, supondremos el caso sencillo:
self.debugger.Page.navigate(url=url)
Detectar que la página ha terminado de cargarse es otro problema en sí mismo; la API del depurador envía eventos que ayudan a detectar la finalización de la carga, pero, de nuevo, para esta prueba de concepto simplemente esperaremos:
self.debugger.wait(2)
El siguiente paso es inyectar código de instrumentación que DEBE ejecutarse antes de que el resto de la página haya empezado a cargarse. Esto es fundamental si necesitamos aplicar monkey patching a métodos sumidero, por ejemplo. El depurador de Chrome dispone de una API para ello:
source = open('instrument.js', 'r').read()
self.debugger.Page.addScriptToEvaluateOnNewDocument(source=source)
Recapitulemos: ahora podemos controlar la instancia de Chrome, podemos iniciar una nueva pestaña, podemos inyectar código de instrumentación y podemos visitar nuestra página objetivo. Para no alargar demasiado el artículo, cubriremos los pasos restantes en la siguiente entrada del blog, a saber:
- ¿Cómo detectar el XSS?
- ¿Cómo instrumentar objetos usando la API Proxy de JavaScript?
- ¿Cómo inyectar payloads?
- ¿Cómo explotar los datos de cobertura para detectar nuevas ramas?
- ¿Cómo generar nuevos payloads?