{"id":"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","aliases":["CVE-2026-84308"],"modified":"2026-09-29T21:30:09.074541603Z","published":"2026-09-08T21:24:29Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-09-08T21:24:29Z","nvd_published_at":"2026-09-01T20:17:24Z","cwe_ids":["CWE-208","CWE-385"]},"references":[{"type":"WEB","url":"https://github.com/phpseclib/phpseclib/security/advisories/GHSA-q97c-8qh3-fpc6"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-84308"},{"type":"WEB","url":"https://github.com/phpseclib/phpseclib/commit/fb56bc5bb9009b54a6c26b31aeec8ed944f17373"},{"type":"PACKAGE","url":"https://github.com/phpseclib/phpseclib"},{"type":"WEB","url":"https://github.com/phpseclib/phpseclib/releases/tag/3.0.57"},{"type":"WEB","url":"https://github.com/phpseclib/phpseclib/releases/tag/4.0.1"}],"affected":[{"package":{"name":"phpseclib/phpseclib","ecosystem":"Packagist","purl":"pkg:composer/phpseclib/phpseclib"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.0.57"}]}],"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","2.0.19","2.0.2","2.0.20","2.0.21","2.0.22","2.0.23","2.0.24","2.0.25","2.0.26","2.0.27","2.0.28","2.0.29","2.0.3","2.0.30","2.0.31","2.0.32","2.0.33","2.0.34","2.0.35","2.0.36","2.0.37","2.0.38","2.0.39","2.0.4","2.0.40","2.0.41","2.0.42","2.0.43","2.0.44","2.0.45","2.0.46","2.0.47","2.0.48","2.0.49","2.0.5","2.0.50","2.0.51","2.0.52","2.0.53","2.0.54","2.0.55","2.0.6","2.0.7","2.0.8","2.0.9","3.0.0","3.0.1","3.0.10","3.0.11","3.0.12","3.0.13","3.0.14","3.0.15","3.0.16","3.0.17","3.0.18","3.0.19","3.0.2","3.0.20","3.0.21","3.0.22","3.0.23","3.0.3","3.0.33","3.0.34","3.0.35","3.0.36","3.0.37","3.0.38","3.0.39","3.0.4","3.0.40","3.0.41","3.0.42","3.0.43","3.0.44","3.0.45","3.0.46","3.0.47","3.0.48","3.0.49","3.0.5","3.0.50","3.0.51","3.0.52","3.0.53","3.0.54","3.0.55","3.0.56","3.0.6","3.0.7","3.0.8","3.0.9"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-q97c-8qh3-fpc6/GHSA-q97c-8qh3-fpc6.json"}},{"package":{"name":"phpseclib/phpseclib","ecosystem":"Packagist","purl":"pkg:composer/phpseclib/phpseclib"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.0.0"},{"fixed":"4.0.1"}]}],"versions":["4.0.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-q97c-8qh3-fpc6/GHSA-q97c-8qh3-fpc6.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N"}]}