Prueba de concepto para una vulnerabilidad de webhook Hyperswitch Worldpayxml no autenticada (CWE-345/306) que falsifica XML SETTLED para marcar pagos como exitosos sin captura del PSP.
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · hyperswitch-unsigned-webhook
Análisis: https://abraxaslabs.tech/research/hyperswitch-unsigned-webhook
Hyperswitch 2026.09.21.0 - Juspay
Worldpayxml::verify_webhook_source devuelve Ok(true) con un comentario que indica que la verificación se realiza mediante mTLS. No hay mTLS en esta ruta. La función ignora el cuerpo y las cabeceras. incoming.rs entonces confía en source_verified y toma HandleResponse sin PSync. LastEvent::Settled se mapea a PaymentIntentSuccess. El algoritmo predeterminado de IncomingWebhook es NoAlgorithm. BitPay y Shift4 heredan eso. Worldpayxml ni siquiera llega tan lejos. Hace un cortocircuito.
Un XML SETTLED ficticio no autenticado marca el pago como exitoso. Sin captura de Worldpay. Sin certificado de cliente.
| ID | aún sin CVE |
| CWE | CWE-345, CWE-306 |
| CVSS | Alto: 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N |
| Producto | Hyperswitch |
| Afectado | hasta 2026.09.21.0 webhooks entrantes de Worldpayxml |
| Autenticación | no autenticado; se necesita merchant_id y orderCode |
| Licencia | GNU Affero GPL v3.0 |
| Laboratorio | solo 127.0.0.1 |
Se necesita merchant_id (presente en cada URL del panel de Hyperswitch) y orderCode igual al connector_transaction_id del intento. POST a /webhooks/{merchant_id}/worldpayxml con un SETTLED sin firmar. Si source_verified fuera falso, incoming haría Trigger y PSync al conector. Worldpayxml nunca toma ese camino. El intent pasa a Charged. Laboratorio: status=succeeded. XML ficticio. Sin PSP.
Adivinar los ids de pago no es el fallo. Falsificar SETTLED para un id que ya se ha visto sí lo es. No es una shell. No es una liquidación real de Worldpay.
Mismo producto, fallo distinto: payout confirm unbound client_secret.
Leí Ok(true), luego HandleResponse frente a Trigger, luego Settled -> PaymentIntentSuccess. El comentario dice mTLS. El POST del laboratorio no lleva certificado de cliente.
El primer cliente que examine esto hará POST del webhook contra requires_confirmation. Incoming no marcará como Charged un pago que no ha salido de la confirmación. Hay que estampar el intento, o esperar hasta requires_capture / processing. El laboratorio hace el estampado. Ese estampado es un fixture del laboratorio. Un intento real ya tiene el id del conector. El webhook no.
Pistas falsas ya registradas: HMAC firmado en un conector que realmente verifica (esta ruta no lo hace); tratar el HTTP 200 del webhook como el oráculo (consultar GET /payments/{id} hasta que status=succeeded); orderCode que no sea connector_transaction_id; una reverse shell. Teatro.
Entonces: merchant, API key, MCA de worldpayxml, pago con confirm=false, estampar connector_transaction_id, SETTLED sin firmar, recuperar.
cd lab
./run.sh
Apunta solo a http://127.0.0.1:18082. run.sh clona la etiqueta 2026.09.21.0 en lab/hyperswitch-src para la configuración y las migraciones.
IOC status_before=requires_confirmation
IOC webhook status=200 unsigned lastEvent=SETTLED
IOC poll status=succeeded
SUCCESS Hyperswitch unsigned Worldpayxml webhook Charged
Verificar la notificación de Worldpay (firma / mTLS que la ruta realmente termina). No llamar a HandleResponse en un conector que devolvió Ok(true) desde una operación sin efecto. Un SETTLED sin firmar no debe mover la intención a succeeded.
worldpayxml.rs · transformers.rs · incoming.rs · webhooks.rs · crypto.rsGNU Affero GPL v3.0. Véase LICENSE. Solo para laboratorio en loopback. Sin garantía.