Bug Hunting Journey

Writeups Race

Race

A webhook that could be replayed for eleven minutes

Severity High CVSS 7.1
Impact
Repeated crediting of an account balance
Bounty
$500 - $1k
Program
Private

TL;DR

Payment webhooks were signed correctly but the signed timestamp was never compared to the clock, and the idempotency check ran after the balance update. Replaying one captured callback in parallel credited the account once per request.

Signature verification on webhooks gets implemented carefully, because there is a library for it. The two things the library does not do for you are the ones that broke here.

What was checked, and what was not

The handler verified the HMAC over the raw body. It did not look at the t= component of the signature header, which carries the timestamp the signature covers. So a captured callback stayed valid forever.

Replay, twenty at a timebash
for i in $(seq 1 20); do  curl -s -X POST https://api.target.tld/webhooks/payments \    -H "X-Signature: t=1746███████,v1=████████████████" \    -H "Content-Type: application/json" \    --data-binary @captured.json &done; wait

The idempotency key in the payload was checked, but with a findOne before the balance update rather than a unique constraint inside the transaction. Twenty parallel requests all read “not seen yet”, and all twenty credited the balance.

200Balance after the burstresponse
HTTP/2 200 OKcontent-type: application/json{"balance": 20 × ████, "currency":"EUR"}

Takeaways

  • Verifying a signature proves authorship, not freshness. Compare the signed timestamp to your clock and reject anything outside a few minutes.
  • An idempotency key is only idempotent if the database enforces it. A findOne check is a read, and reads race.
  • Webhook handlers deserve the same concurrency testing as checkout flows. They move money with no user in front of them.

Disclosure timeline

  1. Report sent
  2. Triaged
  3. Fix deployed timestamp window added, idempotency moved into the transaction
  4. Cleared for publication