CVE-2026-70658
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
Pay is a payments engine for Ruby on Rails 6.0 and higher. Prior to 11.6.2, Pay::Webhooks::PaddleBillingController#valid_signature? in app/controllers/pay/webhooks/paddle_billing_controller.rb compares the computed 64-character SHA-256 HMAC with the attacker-controlled h1 token from the Paddle-Signature header using Ruby String#==. An unauthenticated remote attacker who can repeatedly submit requests to /pay/webhooks/paddle_billing and obtain sufficiently precise timing measurements can infer matching digest prefixes and recover a valid signature. A forged accepted webhook is enqueued through Pay::Webhooks::ProcessJob and can cause a host application to update billing state, provision paid features, record refunds, or trigger customer notifications. This issue is fixed in version 11.6.2.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- High
- Privileges needed
- None
- User interaction
- None
What an attacker can affect
- Confidentiality
- High
- Integrity
- High
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
7.4
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
Pay is a payments engine for Ruby on Rails 6.0 and higher. Prior to 11.6.2, Pay::Webhooks::PaddleBillingController#valid_signature? in app/controllers/pay/webhooks/paddle_billing_controller.rb compares the computed 64-character SHA-256 HMAC with the attacker-controlled h1 token from the Paddle-Signature header using Ruby String#==. An unauthenticated remote attacker who can repeatedly submit requests to /pay/webhooks/paddle_billing and obtain sufficiently precise timing measurements can infer matching digest prefixes and recover a valid signature. A forged accepted webhook is enqueued through Pay::Webhooks::ProcessJob and can cause a host application to update billing state, provision paid features, record refunds, or trigger customer notifications. This issue is fixed in version 11.6.2.
- Record
CVE-2026-70658- CVSS 3.1
- 7.4 High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N - Weakness
- CWE-208
- Updated
- 2026-11-02
EUVDEUVD-2026-77680
7.4
Pay is a payments engine for Ruby on Rails 6.0 and higher. Prior to 11.6.2, Pay::Webhooks::PaddleBillingController#valid_signature? in app/controllers/pay/webhooks/paddle_billing_controller.rb compares the computed 64-character SHA-256 HMAC with the attacker-controlled h1 token from the Paddle-Signature header using Ruby String#==. An unauthenticated remote attacker who can repeatedly submit requests to /pay/webhooks/paddle_billing and obtain sufficiently precise timing measurements can infer matching digest prefixes and recover a valid signature. A forged accepted webhook is enqueued through Pay::Webhooks::ProcessJob and can cause a host application to update billing state, provision paid features, record refunds, or trigger customer notifications. This issue is fixed in version 11.6.2.
- Record
EUVD-2026-77680- CVSS 3.1
- 7.4 High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-mjgf-xj26-9qf9
7.4
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
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:
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
- 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:
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)…
- Record
GHSA-mjgf-xj26-9qf9- CVSS 3.1
- 7.4 High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N - Weakness
- CWE-208
- Updated
- 2026-11-02
NVD
7.4
Pay is a payments engine for Ruby on Rails 6.0 and higher. Prior to 11.6.2, Pay::Webhooks::PaddleBillingController#valid_signature? in app/controllers/pay/webhooks/paddle_billing_controller.rb compares the computed 64-character SHA-256 HMAC with the attacker-controlled h1 token from the Paddle-Signature header using Ruby String#==. An unauthenticated remote attacker who can repeatedly submit requests to /pay/webhooks/paddle_billing and obtain sufficiently precise timing measurements can infer matching digest prefixes and recover a valid signature. A forged accepted webhook is enqueued through Pay::Webhooks::ProcessJob and can cause a host application to update billing state, provision paid features, record refunds, or trigger customer notifications. This issue is fixed in version 11.6.2.
- Record
CVE-2026-70658- CVSS 3.1
- 7.4 High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N - Weakness
- CWE-208
- Updated
- 2026-11-02
OSV
7.4
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
Pay is a payments engine for Ruby on Rails 6.0 and higher. Prior to 11.6.2, Pay::Webhooks::PaddleBillingController#valid_signature? in app/controllers/pay/webhooks/paddle_billing_controller.rb compares the computed 64-character SHA-256 HMAC with the attacker-controlled h1 token from the Paddle-Signature header using Ruby String#==. An unauthenticated remote attacker who can repeatedly submit requests to /pay/webhooks/paddle_billing and obtain sufficiently precise timing measurements can infer matching digest prefixes and recover a valid signature. A forged accepted webhook is enqueued through Pay::Webhooks::ProcessJob and can cause a host application to update billing state, provision paid features, record refunds, or trigger customer notifications. This issue is fixed in version 11.6.2.
- Record
CVE-2026-70658- CVSS 3.1
- 7.4 High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N - Weakness
- CWE-208
- Updated
- 2026-11-02
OSVGHSA-mjgf-xj26-9qf9
7.4
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
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:
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
- 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:
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)…
- Record
GHSA-mjgf-xj26-9qf9- CVSS 3.1
- 7.4 High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N - Weakness
- CWE-208
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
payRubyGems |
through 11.6.1 | None known |
payRubyGems |
through 11.6.1, plus listed versions 0.0.00.0.21.0.01.0.0.beta21.0.0.beta3 Show 45 moreShow less1.0.0.beta41.0.0.beta51.0.0.rc11.0.0.rc21.0.0.rc31.0.11.0.210.0.010.0.110.0.210.0.310.0.410.1.010.1.110.1.210.1.310.1.410.1.511.0.011.0.111.1.011.1.111.1.211.1.311.2.011.2.111.2.211.2.311.3.011.3.111.4.011.4.111.4.211.4.311.5.011.6.02.0.02.0.12.0.22.0.32.1.02.1.12.1.22.1.32.2.0 |
None known |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-70658 is a high vulnerability (CVSS 7.4) in Pay-rails Pay, published July 1, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.58%, the 47th percentile. No source names a fixed version.
References
github.com/pay-rails/pay/security/advisories/GHSA-mjgf-xj26-9qf9
github.com/pay-rails/pay/commit/ba6494109d88209fba2a4df2d9d6373fe81ed805
github.com/pay-rails/pay/releases/tag/v11.6.2
github.com/advisories/GHSA-mjgf-xj26-9qf9
github.com/CVEProject/cvelistV5/tree/main/cves/2026/70xxx/CVE-2026-70658.json
nvd.nist.gov/vuln/detail/CVE-2026-70658
github.com/pay-rails/pay
Left out here: 104 versions and the rest of 2 source descriptions. The JSON record has them.