Preuve de concept pour une faille de webhook Hyperswitch Worldpayxml non authentifiée (CWE-345/306) qui falsifie du XML SETTLED pour marquer des paiements comme réussis sans capture PSP.
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · hyperswitch-unsigned-webhook
Write-up : https://abraxaslabs.tech/research/hyperswitch-unsigned-webhook
Hyperswitch 2026.09.21.0 - Juspay
Worldpayxml::verify_webhook_source renvoie Ok(true) avec un commentaire indiquant que la vérification est effectuée via mTLS. Il n'y a pas de mTLS sur cette route. La fonction ignore le corps et les en-têtes. incoming.rs fait ensuite confiance à source_verified et prend HandleResponse sans PSync. LastEvent::Settled correspond à PaymentIntentSuccess. L'algorithme par défaut de IncomingWebhook est NoAlgorithm. BitPay et Shift4 en héritent. Worldpayxml n'atteint même pas ce stade. Il court-circuite.
Un XML SETTLED factice non authentifié marque le paiement comme réussi. Aucune capture Worldpay. Aucun certificat client.
| ID | pas encore de CVE |
| CWE | CWE-345, CWE-306 |
| CVSS | Élevé : 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N |
| Produit | Hyperswitch |
| Affecté | jusqu'à 2026.09.21.0 webhooks entrants Worldpayxml |
| Auth | non authentifié ; nécessite merchant_id et orderCode |
| Licence | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 uniquement |
Nécessite merchant_id (présent dans chaque URL du tableau de bord Hyperswitch) et orderCode égal au connector_transaction_id de la tentative. POST /webhooks/{merchant_id}/worldpayxml avec un SETTLED non signé. Si source_verified était faux, incoming déclencherait Trigger et ferait un PSync du connecteur. Worldpayxml ne prend jamais ce chemin. L'intent passe à Charged. Lab : status=succeeded. XML factice. Aucun PSP.
Deviner les identifiants de paiement n'est pas le bug. Forger un SETTLED pour un identifiant déjà observé l'est. Pas un shell. Pas un règlement Worldpay réel.
Même produit, bug différent : payout confirm unbound client_secret.
J'ai lu Ok(true), puis HandleResponse vs Trigger, puis Settled -> PaymentIntentSuccess. Le commentaire mentionne mTLS. Le POST du lab n'a pas de certificat client.
Le premier client qui examine cela fera un POST du webhook contre requires_confirmation. Incoming ne passera pas à Charged un paiement qui n'a pas quitté la confirmation. Estampillez la tentative, ou attendez requires_capture / processing. Le lab effectue l'estampillage. Cet estampillage est un artefact de lab. Une tentative réelle possède déjà l'identifiant du connecteur. Le webhook, non.
Fausses pistes déjà consignées : HMAC signé sur un connecteur qui vérifie réellement (ce chemin ne le fait pas) ; traiter le HTTP 200 du webhook comme l'oracle (interroger GET /payments/{id} jusqu'à status=succeeded) ; orderCode qui n'est pas connector_transaction_id ; un reverse shell. Du théâtre.
Ensuite : marchand, clé API, MCA worldpayxml, paiement avec confirm=false, estampillage de connector_transaction_id, SETTLED non signé, récupération.
cd lab
./run.sh
Ciblez uniquement http://127.0.0.1:18082. run.sh clone le tag 2026.09.21.0 dans lab/hyperswitch-src pour la configuration et les migrations.
IOC status_before=requires_confirmation
IOC webhook status=200 unsigned lastEvent=SETTLED
IOC poll status=succeeded
SUCCESS Hyperswitch unsigned Worldpayxml webhook Charged
Vérifiez la notification Worldpay (signature / mTLS que la route termine réellement). Ne pas appeler HandleResponse sur un connecteur qui a renvoyé Ok(true) depuis une opération sans effet. Un SETTLED non signé ne doit pas faire passer l'intention à l'état succeeded.
worldpayxml.rs · transformers.rs · incoming.rs · webhooks.rs · crypto.rsGNU Affero GPL v3.0. Voir LICENSE. Laboratoire en boucle locale uniquement. Aucune garantie.