{"schema":"threatline.record/2","record":{"id":"CVE-2026-84308","aliases":["GHSA-q97c-8qh3-fpc6"],"summary":"phpseclib — non-constant-time X25519 scalar multiplication permits full private-key recovery","details":"The pure-PHP X25519 scalar multiplication in phpseclib is not constant-time. Field addition and subtraction each perform a **data-dependent conditional modular reduction**, so the cost of each Montgomery-ladder step is a linear function of that step's reduction count which is a quantity determined by the secret scalar's *prefix*.\n\nAn observer with per-ladder-step resolution recovers the 251-bit clamped private scalar. This is a per-step leak, not an aggregate one: an instrumented code proof-of-concept recovers 20/20 test keys from 32 observed operations, and an observer that counts libgmp calls instead of timing them recovers a key from a **single** operation.\n\nThis is **not** a low-order-input issue. Recovery works with the RFC 7748 base point `u = 9`, with no attacker-chosen input at all. Rejecting low-order public values does not close it.\n\n## 2. Affected component\n\nConfirmed on **phpseclib 3.0.56** (338 files under `phpseclib/`,`sha256(sorted(relpath NUL file_sha256 LF)) = cc7250b611f520e809131aab0931503457c44d8cbfb10d535251c6fec5f62a2b`).\nThe code appears unchanged across the 3.0 series wherever Curve25519 is supported, please confirm the affected range.\n\n| file:line | role |\n|---|---|\n| `Math/PrimeField/Integer.php:189` | `add()` — conditional `subtract($modulo)` when the sum ≥ p |\n| `Math/PrimeField/Integer.php:207` | `subtract()` — conditional `add($modulo)` when the result is negative |\n| `Crypt/EC/BaseCurves/Montgomery.php:229–234` | ladder branch on the secret bit, selecting argument order of `doubleAndAddPoint` |\n| `Crypt/EC/Formats/Keys/MontgomeryPrivate.php:66` | `multiplyPoint(getBasePoint(), dA)` — **no engine check of any kind** |\n| `Crypt/EC/Formats/Keys/PKCS8.php:194–200` | the same derivation, correctly gated on ext-sodium — the pattern `MontgomeryPrivate` is missing |\n\n## 3. Technical description\n\nOperation *counts* in the ladder are already constant — 10 field multiplications, 4 additions and 4 subtractions per step, 2560 multiplications per 256-step ladder. Operand *values* are not. Each `PrimeField\\Integer::add()` / `subtract()` takes a data-dependent branch costing ~0.85–1.0 µs on the GMP engine, against a ~32 µs step period, so per-step cost is `α + β·c` where `c` is that step's conditional-reduction count. Measured across 20 keys: R² = 0.91–0.98, β = 838–920 ns.\n\n`c` depends on the whole scalar prefix, not on the current bit, so per-step thresholding is useless — it saturates at ~93% per bit for `u = p−1` and at **chance** for `u = 9`, and recovers 0/20 keys either way, because the bit string is a prefix-XOR in which one flipped step inverts the entire tail. Conditioning on the prefix removes the ambiguity: a beam search replays both branches from each candidate ladder state, reads off the exact `c` for each, and scores against the observation. The victim's public key adjudicates the small residual search.\n\nTwo facts bound the problem and are worth stating precisely, because they determine whether a fix is needed at all:\n\n- **Aggregate observation is provably useless.** The adjacent-bit transition count `T(k)` has exact entropy `H(T) = 4.0357` bits over clamped scalars, so a noiseless transition-count oracle still leaves ~2^247 candidates. The summed reduction count `Σc` is richer (~6.6–6.9 bits) and still leaves ~2^244. Any measurement that collapses the call to one number is safe. Per-step measurement is not.\n- **The libgmp call counts are exactly determined.** Per ladder step, `__gmpz_add = 4 + csub`, `__gmpz_sub = 4 + cadd`, `__gmpz_mul = __gmpz_mod = 10`. Verified by differencing gdb breakpoint counts against phpseclib's own `doubleAndAddPoint` — 27/27 steps exact, extended independently to 64/64 and 38/38 by our two reviewers. An observer that only *counts* these calls needs no timing, no calibration and no repetition.\n\nResults, 20 keys × 3 sampling seeds, 800 traces per path collected from **800 distinct PHP processes** (so the observations are cross-process, as real requests would be):\n\n| observer | path | observations needed | exact 251-bit recovery |\n|---|---|---|---|\n| timing | key load, `u = 9` | 32 | **20/20 keys**, 95% CI [83.9%, 100%] |\n| timing | ECDH, `u = p−1` | 32 | **18/20 keys**, 95% CI [69.9%, 96.8%] |\n| timing | either | 8 | 18–23% of trials |\n| libgmp call counts | either | **1** | 20/20 keys; tolerates 20–30% of per-step counts being wrong |\n\nThe model underlying the decoder is validated against the pinned implementation: 254/254 (key, peer) outputs match the real `DH::computeSecret`, and all four RFC 7748 §6.1 vectors match both phpseclib and the published constants.\n\nNegative controls are clean — wrong public key, shuffled trace, wrong peer value, foreign key: 0/20 in every case. Nothing derived from the private key reaches the decoder; its inputs are the observation vector, the peer value, the victim's public key, and the public clamping constants.\n\n## 5. Impact\n\nIn the instrumented, local model, recovery of the clamped scalar gives a permanent compromise of the X25519 private key. Clamping is applied on every call, so the recovered value is what every past and future operation with that key uses.\n\n## 6. Restrictions\n\n**Required for exploitation:**\n\n1. **A reused / long-lived X25519 private key.** Ephemeral X25519 — the normal TLS and SSH case — defeats this outright. phpseclib's own SSH path generates a fresh scalar per exchange and is not affected.\n2. **Knowledge of the victim's public key.** It adjudicates the decoder's residual search; without it no candidate can be selected. This is normally public, but it is a precondition, not a convenience.\n3. **The pure-PHP path must actually run.** Measured across four extension configurations:\n   - `EC::loadFormat('MontgomeryPrivate', $raw32)` runs the ladder in **every**\n     configuration — but the format declares `IS_INVISIBLE` (`MontgomeryPrivate.php:40`),\n     so `PublicKeyLoader::load` skips it and nothing inside phpseclib calls it. An\n     application must name the format explicitly.\n   - `PKCS8` / `PublicKeyLoader::load` / `EC::createKey` run the ladder **only when ext-sodium is absent** — `PKCS8.php:194` gates on `sodium_crypto_box_publickey_from_secretkey`. OpenSSL does not help here.\n   - `DH::computeSecret` runs the ladder only under `EC::forceEngine('PHP')`, or when *both* `openssl_pkey_derive` (`DH.php:325`) and `sodium_crypto_scalarmult` (`EC/PrivateKey.php:75`) are unavailable. ext-sodium is bundled and enabled by default in PHP 7.2+, so the reachable configurations are a minority — though `disable_functions` hardening and `--disable-sodium` builds do occur, particularly in shared hosting.\n4. **An observer with per-ladder-step resolution**, i.e. one that can distinguish ~0.9 µs within a ~32 µs step, or count libgmp entry-point calls. In practice that means local co-residency (e.g. a Flush+Reload spy on the shared `libgmp.so` mapping — `__gmpz_add` / `__gmpz_sub` are the correct targets; `__gmpn_*` are not, being size-dispatched internals).\n\n**Not demonstrated — stated so it is not found rather than disclosed:**\n\n- **We did not build a co-resident spy.** Per-step observations in the PoC come from a small `hrtime()` hook inside `Montgomery::multiplyPoint` (one file differs from the pinned tree; `Math/PrimeField/Integer.php`, which carries the leak, is byte-identical). This models an observer with intra-call resolution; it is not itself an attacker capability. We note that the requisite primitives are present on ordinary hardware — on our test host `clflush` and `rdtscp` work, with  187–312 cycle cached/flushed separation — but building and validating a spy is separate work we have not done.\n- **Perfect step segmentation is assumed.** A real observer must recover step boundaries and separate the ladder from the surrounding modular inversion.\n- **A single host and configuration.** All timing figures are from one 2-vCPU shared VM (PHP 8.3.6, GMP 6.3.0). A noisier host needs more observations; recovery holds within roughly 25–30% additional noise beyond this host's residual.\n- **We have not identified an affected deployed caller.**\n\nAccordingly we report this as a **hardening issue and demonstrated side channel**, not as a completed remote exploit.\n\n## 8. Suggested remediation\n\n1. **Constant-time, fixed-width field arithmetic, or delegate to a vetted native provider.** This is the actual fix. Removing the ladder's bit branch is *not* sufficient while `Integer.php:189` and `:207` remain operand-dependent.\n2. **Gate `MontgomeryPrivate.php:66` the way `PKCS8.php:194–200` already is.** That is a one-block change and it closes the only entry point that is un-gated in every configuration. Keep the `$curve instanceof Curve25519` guard — `MontgomeryPrivate` also accepts Curve448 keys.\n3. **Consider an OpenSSL arm alongside the sodium arm in `PKCS8::loadECDH`**, or fail closed, so stacks without ext-sodium do not fall through to the ladder.\n4. Separately, and unrelated to this channel: the pure-PHP path returns an all-zero 32-byte shared secret for low-order peer inputs. Rejecting the full canonicalised low-order set and adding a constant-time all-zero check is correct hygiene for contributory behaviour — but it does **not** mitigate the timing channel, since recovery works with `u = 9`.\n\n## Contact\nGeorge Stergiopoulos\nAssistant Professor of cybersecurity\nAthens University of Economics and Business, Greece\nE: geostergiop@aueb.gr | s: https://www.aueb.gr/en/faculty_page/stergiopoulos-georgios","categories":["Libraries & packages"],"product":"phpseclib/phpseclib","vendor":"phpseclib","ecosystem":"composer","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-q97c-8qh3-fpc6","source_modified_at":"2026-09-29T21:15:47Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"composer","name":"phpseclib/phpseclib"},"range":"< 3.0.57"},{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-q97c-8qh3-fpc6","source_modified_at":"2026-09-29T21:15:47Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"composer","name":"phpseclib/phpseclib"},"range":">= 4.0.0, < 4.0.1"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-84308","source_modified_at":"2026-09-03T17:41:50.214Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"phpseclib","vendor":"phpseclib","versions":[{"version":"< 3.0.57","status":"affected"},{"version":">= 4.0.0, < 4.0.1","status":"affected"}],"defaultStatus":null},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-q97c-8qh3-fpc6","source_modified_at":"2026-09-29T21:30:09.074541603Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"Packagist","name":"phpseclib/phpseclib","purl":"pkg:composer/phpseclib/phpseclib"},"ranges":[{"events":[{"introduced":"0"},{"fixed":"3.0.57"}],"type":"ECOSYSTEM"}],"versions":["0.3.0","0.3.1","0.3.10","0.3.5","0.3.6","0.3.7","0.3.8","0.3.9","1.0.0","1.0.1","1.0.10","1.0.11","1.0.12","1.0.13","1.0.14","1.0.15","1.0.16","1.0.17","1.0.18","1.0.19","1.0.2","1.0.20","1.0.21","1.0.22","1.0.23","1.0.24","1.0.25","1.0.26","1.0.27","1.0.28","1.0.29","1.0.3","1.0.30","1.0.4","1.0.5","1.0.6","1.0.7","1.0.8","1.0.9","2.0.0","2.0.1","2.0.10","2.0.11","2.0.12","2.0.13","2.0.14","2.0.15","2.0.16","2.0.17","2.0.18"]},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-q97c-8qh3-fpc6","source_modified_at":"2026-09-29T21:30:09.074541603Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"Packagist","name":"phpseclib/phpseclib","purl":"pkg:composer/phpseclib/phpseclib"},"ranges":[{"events":[{"introduced":"4.0.0"},{"fixed":"4.0.1"}],"type":"ECOSYSTEM"}],"versions":["4.0.0"]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-q97c-8qh3-fpc6","source_modified_at":"2026-09-29T21:15:47Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"composer","name":"phpseclib/phpseclib"},"version":"3.0.57"},{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-q97c-8qh3-fpc6","source_modified_at":"2026-09-29T21:15:47Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"composer","name":"phpseclib/phpseclib"},"version":"4.0.1"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-q97c-8qh3-fpc6","source_modified_at":"2026-09-29T21:30:09.074541603Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"Packagist","name":"phpseclib/phpseclib","purl":"pkg:composer/phpseclib/phpseclib"},"version":"3.0.57"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-q97c-8qh3-fpc6","source_modified_at":"2026-09-29T21:30:09.074541603Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"Packagist","name":"phpseclib/phpseclib","purl":"pkg:composer/phpseclib/phpseclib"},"version":"4.0.1"}],"cvss":6.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":0.00327,"percentile":0.24564,"score_date":"2026-10-23"},"published_at":"2026-09-01T19:28:09.378Z","modified_at":"2026-09-29T21:30:09.074541603Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-q97c-8qh3-fpc6","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-09-29T21:15:47Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-84308","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-09-03T17:41:50.214Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-q97c-8qh3-fpc6","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-09-29T21:30:09.074541603Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-84308","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/phpseclib/phpseclib/security/advisories/GHSA-q97c-8qh3-fpc6","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-84308","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://github.com/phpseclib/phpseclib/commit/fb56bc5bb9009b54a6c26b31aeec8ed944f17373","tags":["x_refsource_MISC"]},{"source":"CVE List","url":"https://github.com/phpseclib/phpseclib/releases/tag/3.0.57","tags":["x_refsource_MISC"]},{"source":"CVE List","url":"https://github.com/phpseclib/phpseclib/releases/tag/4.0.1","tags":["x_refsource_MISC"]},{"url":"https://github.com/advisories/GHSA-q97c-8qh3-fpc6","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"no","technical_impact":"total","assessed_at":"2026-09-03T17:41:43.770409Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-09-29T21:30:09.074541603Z","content_hash":"fb657b2a1cdb94871b092fb3efbe4bd3add58ae89da61f6fa102f6ec4700d37a"}}
