Single-endpoint race conditions
Race condition de un solo endpoint: dos solicitudes de cambio de email en paralelo compiten por el único registro pendiente y el token de una dirección termina en el correo de la otra, permitiendo apropiarse de la invitación de admin.
Target Overview & Vulnerability Summary
El objetivo es actualizar el email propio hacia carlos@ginandjuice.shop, que posee una invitación de admin sin reclamar. La funcionalidad de cambio de email guarda un único email pendiente por usuario y envía el enlace de confirmación de forma asíncrona. Disparando dos cambios en paralelo sobre el mismo endpoint, el token generado para una dirección se envía en el correo de la otra: la ventana entre "iniciar el envío" y "leer el email pendiente" permite el cruce de tokens.
| Campo | Valor |
|---|---|
| Severidad | Alta (escalada a admin por apropiación de confirmación / broken access control) |
| Endpoint vulnerable | POST /my-account/change-email |
| Root cause | Estado mutable compartido (un único email pendiente por usuario) leído/escrito sin atomicidad en un flujo asíncrono |
Technical Breakdown & Exploitation Flow
1. Observación del flujo legítimo
Con el exploit server como cliente de correo, un cambio de email normal produce:
Please click the link in your email to confirm the change of e-mail to:
anything@exploit0a6f00e8036ad3f1809e0c4a0138008aexploit-server.net
Y entrega un enlace de confirmación:
https://0a9d000f0344d3ed80df0de400a3002e.web-security-academy.net/confirm-email?user=wiener&token=7gidkYCg2PFLZvH8
Al enviar un segundo cambio de email y probar el enlace anterior, la respuesta es "This link is invalid.": el sistema almacena un único email pendiente a la vez — el nuevo cambio invalida el token anterior.
2. Solicitudes en paralelo
Enviando dos requests POST /my-account/change-email en paralelo (single-packet attack, grupo de Repeater), se reciben dos enlaces con el mismo timestamp y el mismo token:
https://0a9d000f0344d3ed80df0de400a3002e.web-security-academy.net/confirm-email?user=wiener&token=uk55BhkkCexOIIU6
https://0a9d000f0344d3ed80df0de400a3002e.web-security-academy.net/confirm-email?user=wiener&token=uk55BhkkCexOIIU6
El token es único por "generación", pero al correr ambas requests en la misma ventana, el token calculado en el envío A se reutiliza para el envío B porque la lectura del email pendiente no es coherente con el estado que cada request escribió.
3. Apropiación de la confirmación
Para el objetivo final, las dos direcciones en disputa son la propia (wiener@exploit-...) y carlos@ginandjuice.shop:
- Enviar en paralelo
POST /my-account/change-emailpara ambas direcciones. - Una de las requests escribe
pending_email = carlos@ginandjuice.shopmientras la otra ya leyó el email previo y envió el token a la dirección dewiener. - El enlace recibido en el correo de
wienercontiene el token del email pendientecarlos@ginandjuice.shop(o viceversa). - Visitar
/confirm-email?user=wiener&token=<TOKEN>confirma el cambio haciacarlos@ginandjuice.shop. - Con el email de la organización confirmado, reclamar la invitación de admin.
Root Cause Analysis
El patrón vulnerable:
# POST /my-account/change-email
if valid_session():
db.pending_email[user] = request.email # ❌ Sobrescribe un único registro global
token = generate_token(user)
async_send_email(user) # ❌ Tarea asíncrona
# Worker que envía el correo
def async_send_email(user):
email = db.pending_email[user] # ❌ Lee estado mutable compartido
send_confirmation(email, token)
El fallo es la dependencia de un registro mutable compartido reutilizado entre requests concurrentes sin aislamiento transaccional: la tarea asíncrona lee pending_email en un momento distinto al que la request lo escribió. El token se genera para un valor de email, pero el worker resuelve el email de destino desde el estado global, ya sobrescrito por la request concurrente.
Secure Remediation
Registro independiente e inmutable por petición, con el token ligado al email de destino en el propio registro:
# POST /my-account/change-email
if valid_session():
token = generate_token(user)
db.store_pending_change({
user: user,
email: request.email,
token: token
}) # Registro propio, no sobrescribible
send_confirmation(request.email, token) # Usa el valor inmutable de la request
- Guardar cada cambio pendiente como una fila con su propio token, en lugar de un campo por usuario; la confirmación valida que el token pertenezca al email que lo generó.
- Invalidar tokens anteriores de forma atómica dentro de la misma transacción que crea el nuevo.
SELECT ... FOR UPDATEsobre el registro del usuario si se mantiene un único pendiente, para serializar los cambios.