Hyperswitch payout confirm, unbound client_secret amount raise
Hyperswitch 2026.09.21.0 POST /payouts/{id}/confirm with api-key pk_ only requires client_secret present, not equal to payouts.client_secret. Confirm body is PayoutCreateRequest so amount is written before connector routing. Payments confirm still binds (IR_09). No CVE yet.
- Name
- Hyperswitch payout confirm, unbound client_secret amount raise
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Juspay
- Affected
- Hyperswitch through 2026.09.21.0 payouts confirm; unpublished. Needs publishable pk_ and payout_id. Payments confirm is the bound control.
- Published
- 28 Sept 2026
- Updated
- 28 Sept 2026
- Tags
- 0-day, hyperswitch, juspay, payouts, cwe-863, cwe-345, unauthenticated
present is not equal
I am @abraxas_null. The proof of concept is on GitHub: abraxas/hyperswitch-payout-confirm-secret (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh. Authorized lab only. It talks to loopback.
This is Hyperswitch 2026.09.21.0 (Juspay). Lab image hyperswitch-router:standalone v1.127.0. No CVE yet. CWE-863 / CWE-345. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N). POST /payouts/{payout_id}/confirm with api-key: pk_.... The publishable key is on checkout. You also need the payout id. Dummy client_secret. Not a shell. Not a connector payout.
Same product as the unsigned Worldpayxml webhook. Different bug.
payouts_confirm forces confirm=true and authenticates with check_sdk_auth_and_get_auth, which falls through to:
if api_key.starts_with("pk_") {
payload
.get_client_secret()
.check_value_present("client_secret")
.map_err(|_| errors::ApiErrorResponse::MissingRequiredField {
field_name: "client_secret".into(),
})?;
return Ok((
Box::new(HeaderAuth(PublishableKeyAuth { /* ... */ })),
api::AuthFlow::Client,
));
}Present. Not equal. Payments confirm still binds. authenticate_client_secret:
(Some(req_cs), Some(pi_cs)) => {
if req_cs != pi_cs {
Err(errors::ApiErrorResponse::ClientSecretInvalid)
} else {
// session expiry ...
}
}That is IR_09. Lab control: POST /payments/{id}/confirm with a dummy secret returns 400 IR_09. Same dummy on payouts confirm is accepted at the auth layer.
The confirm body is a full PayoutCreateRequest. update_payouts_and_payout_attempt writes amount before payouts_core routes a connector:
let amount = MinorUnit::from(req.amount.unwrap_or(payouts.amount.into()));
let updated_payouts = storage::PayoutsUpdate::Update {
amount,
// ...
};Create at 1000. Confirm with dummy secret and amount=99900. Connector routing on this image returns 400 IR_39 (no eligible payout connector). Retrieve still shows 99900. The write already happened.
I am not printing a confirm JSON you can paste at someone else's /payouts/{id}/confirm. The missing equality check is the useful part. Isolated lab. Dummy secret.
What an attacker can do
Need the publishable key (public, checkout) and the payout id (payout link, merchant dashboard, client create response). POST /payouts/{id}/confirm with a dummy secret and a new amount. Retrieve shows the raised amount even when connector routing returns 400 IR_39 (no eligible payout connector). The write already happened. Fulfillment on a shop that has a payout processor is the next step.
Guest without pk_ is not this bug. Secret sk_ confirm is merchant-side. Not a shell. Not a successful connector payout on this lab image.
Same product as the unsigned Worldpayxml webhook. Different bug.
The lab (run this at home)
Source of truth is lab/. Same router image as the webhook lab. Port 18083. Bind it to loopback. Do not share a compose project with the webhook lab.
# Loopback lab image pin for hyperswitch-payout-confirm-secret. Full stack: docker-compose.yml
FROM docker.io/debian:trixie-slimrun.sh clones tag 2026.09.21.0 into lab/hyperswitch-src. Compose is the webhook stack with the port changed:
hyperswitch-server:
image: docker.juspay.io/juspaydotin/hyperswitch-router:standalone
command: /local/bin/router -f /local/config/docker_compose.toml
ports:
- "127.0.0.1:18083:8080"Full docker-compose.yml on GitHub: postgres 16, redis 7, Diesel migrator, superposition-demo 0.113.0, router standalone.
The client creates a merchant, a secret key, a publishable pk_, a worldpayxml payout connector, and POST /payouts/create with confirm=false amount 1000 plus card payout_method_data. RequiresConfirmation. Then the payments dummy-secret control. Then payouts confirm.
git clone https://github.com/abraxas/hyperswitch-payout-confirm-secret
cd hyperswitch-payout-confirm-secret/lab
./run.shThe proof of concept is written for 127.0.0.1:18083. Do not publish the port off loopback.
What the tree actually writes
HTTP is POST /payouts/{payout_id}/confirm. Header api-key: pk_.... Function names are Rust methods. payload.confirm = Some(true) is forced in the route. The JSON is still a create request, so amount is not a no-op on confirm.
Auth for pk_ is "checkout SDK." The secret is supposed to bind the browser session to this payout. Payments does req_cs != pi_cs. Payouts does check_value_present. A non-empty dummy is enough.
IR_39 on this image is not failure of the bug. Routing has no payout connector that will take the job. The row already has 99900. Fulfillment on a shop that has a payout processor is the next step. The lab oracle is the retrieve.
Need the publishable key (public, checkout) and the payout id (payout link, merchant dashboard, client create response). Guest without pk_ is not this bug. Secret sk_ confirm is merchant-side.
The 400 that already wrote 99900
The first client that looks at this will send confirm without client_secret and get MissingRequiredField. Present. Send the real secret and you are the payer. Send a dummy on payments confirm and you get IR_09. That is the control. Send the dummy on payouts confirm.
A few other ways to lose without learning anything:
- Treating HTTP 400 IR_39 as "nothing happened." Retrieve the payout.
- Amount stays 1000 because confirm omitted
amount(unwrap_or keeps the old value). - Payout already terminal (
Success/Failed/Cancelled). Status gate. - A reverse shell. Theatre. The witness is retrieve amount=99900 after dummy secret, with payments confirm still IR_09.
What I actually did
Treat the on-disk product as the spec. I read check_value_present, then payments req_cs != pi_cs, then update_payouts_and_payout_attempt before payouts_core. Proof of concept: hyperswitch-payout-confirm-secret-Abraxas-Labs.py.
Create 1000, payments control IR_09, payouts dummy 99900, retrieve. I am not reprinting pk_ or payout ids.
Last lab run, trimmed:
IOC amount_before=1000 status_before=requires_confirmation
IOC payments.confirm dummy-secret status=400 IR_09 client_secret mismatch
IOC payouts.confirm dummy-secret status=400 IR_39 no eligible connector
IOC retrieve amount=99900 status=requires_confirmation
SUCCESS Hyperswitch payout confirm unbound client_secret amount raiseThe client that produced it is on GitHub.
Wrong turns already recorded: treating HTTP 400 IR_39 as "nothing happened" (retrieve the payout); amount stays 1000 because confirm omitted amount (unwrap_or keeps the old value); payout already terminal (Success / Failed / Cancelled); a reverse shell. Theatre. The witness is retrieve amount=99900 after dummy secret, with payments confirm still IR_09.
What this is not
It is not "anyone raises any payout without a key." You need pk_ and the payout id. It is not payments confirm. That path still compares the secret. It is not a successful connector payout on this lab image (IR_39). It is a stored amount change. It is not RCE.
The fix
Compare client_secret to payouts.client_secret the way payments does, and do not apply PayoutCreateRequest.amount on confirm until that check passes. Re-run the loopback client against a patched build: dummy secret must be IR_09 (or 401), and retrieve amount must stay 1000.
I am not going to print a payout confirm body you can paste at someone else's Hyperswitch. The presence-vs-equality check is the useful part. If you own the box, run the proof of concept against loopback.
Same product, different bug: unsigned Worldpayxml webhook.
References
- Proof of concept: abraxas/hyperswitch-payout-confirm-secret · hyperswitch-payout-confirm-secret-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh - Same product: Hyperswitch unsigned Worldpayxml webhook, unauthenticated payment bypass
- @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-863 · CWE-345
- 2026.09.21.0:
payouts.rsroutes ·authentication.rs·payouts/helpers.rs·core/payouts.rs·payments/helpers.rs - tag 2026.09.21.0 · juspay/hyperswitch
- VDP
- Product: Hyperswitch · docs · payouts