
Proof-of-concept for an unauthenticated Hyperswitch Worldpayxml webhook flaw (CWE-345/306) that forges SETTLED XML to mark payments succeeded without PSP capture.
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · hyperswitch-unsigned-webhook
Hyperswitch 2026.09.21.0 - Juspay
Worldpayxml::verify_webhook_source returns Ok(true) with a comment that verification is done via mTLS. There is no mTLS on this route. The function ignores the body and the headers. incoming.rs then trusts source_verified and takes HandleResponse with no PSync. LastEvent::Settled maps to PaymentIntentSuccess. The default IncomingWebhook algorithm is NoAlgorithm. BitPay and Shift4 inherit that. Worldpayxml does not even get that far. It short-circuits.
Unauthenticated dummy SETTLED XML marks the payment succeeded. No Worldpay capture. No client cert.
| ID | no CVE yet |
| CWE | CWE-345, CWE-306 |
| CVSS | High: 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N |
| Product | Hyperswitch |
| Affected | through 2026.09.21.0 Worldpayxml inbound webhooks |
| Auth | unauthenticated; need merchant_id and orderCode |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 only |
Need merchant_id (in every Hyperswitch dashboard URL) and orderCode equal to the attempt's connector_transaction_id. POST /webhooks/{merchant_id}/worldpayxml unsigned SETTLED. If source_verified were false, incoming would Trigger and PSync the connector. Worldpayxml never takes that path. Intent goes Charged. Lab: status=succeeded. Dummy XML. No PSP.
Guessing payment ids is not the bug. Forging SETTLED for an id you already saw is. Not a shell. Not a live Worldpay settlement.
Same product, different bug: payout confirm unbound client_secret.
I read Ok(true), then HandleResponse vs Trigger, then Settled -> PaymentIntentSuccess. The comment says mTLS. The lab POST has no client cert.
The first client that looks at this will POST the webhook against requires_confirmation. Incoming will not Charged a payment that has not left confirmation. Stamp the attempt, or wait until requires_capture / processing. Lab does the stamp. That stamp is a lab fixture. A live attempt already has the connector id. The webhook does not.
Wrong turns already recorded: signed HMAC on a connector that actually verifies (this path does not); treating webhook HTTP 200 as the oracle (poll GET /payments/{id} until status=succeeded); orderCode that is not connector_transaction_id; a reverse shell. Theatre.
Then: merchant, API key, worldpayxml MCA, payment with confirm=false, stamp connector_transaction_id, unsigned SETTLED, retrieve.
cd lab
./run.sh
Target only http://127.0.0.1:18082. run.sh clones tag 2026.09.21.0 into lab/hyperswitch-src for config and migrations.
IOC status_before=requires_confirmation
IOC webhook status=200 unsigned lastEvent=SETTLED
IOC poll status=succeeded
SUCCESS Hyperswitch unsigned Worldpayxml webhook Charged
Verify the Worldpay notify (signature / mTLS that the route actually terminates). Do not HandleResponse on a connector that returned Ok(true) from a no-op. Unsigned SETTLED must not move the intent to succeeded.
worldpayxml.rs · transformers.rs · incoming.rs · webhooks.rs · crypto.rsGNU Affero GPL v3.0. See LICENSE. Loopback lab only. No warranty.