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
Hyperswitch 2026.09.21.0 - Juspay
Worldpayxml::verify_webhook_source devuelve Ok(true) con un comentario de 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 | sin CVE todavía |
| 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 |
| Auth | no autenticado; se necesita merchant_id y orderCode |
| Licencia | GNU Affero GPL v3.0 |
| Laboratorio | solo 127.0.0.1 |
Se necesita merchant_id (en cada URL del panel de Hyperswitch) y orderCode igual al connector_transaction_id del intento. POST /webhooks/{merchant_id}/worldpayxml con SETTLED sin firmar. Si source_verified fuera falso, incoming haría Trigger y PSync al conector. Worldpayxml nunca toma esa ruta. 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 viste sí lo es. No es un shell. No es una liquidación real de Worldpay.
Mismo producto, fallo diferente: payout confirm unbound client_secret.
Leí Ok(true), luego HandleResponse vs Trigger, luego Settled -> PaymentIntentSuccess. El comentario dice mTLS. El POST del laboratorio no tiene certificado de cliente.
El primer cliente que mire esto hará POST del webhook contra requires_confirmation. Incoming no hará Charged de un pago que no ha salido de confirmación. Marca el intento, o espera hasta requires_capture / processing. El laboratorio hace la marca. Esa marca es un accesorio del laboratorio. Un intento en vivo ya tiene el id del conector. El webhook no.
Caminos equivocados ya registrados: 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 status=succeeded); orderCode que no es connector_transaction_id; una reverse shell. Teatro.
Luego: merchant, API key, MCA de worldpayxml, pago con confirm=false, marcar connector_transaction_id, SETTLED sin firmar, retrieve.
cd lab
./run.sh
Objetivo solo http://127.0.0.1:18082. run.sh clona el tag 2026.09.21.0 en lab/hyperswitch-src para configuración y 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 hacer HandleResponse en un conector que devolvió Ok(true) desde un no-op. Un SETTLED sin firmar no debe mover el intent a succeeded.
worldpayxml.rs · transformers.rs · incoming.rs · webhooks.rs · crypto.rsGNU Affero GPL v3.0. Ver LICENSE. Solo laboratorio en loopback. Sin garantía.