Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐘
🐘 Packagist
Not in CISA KEV
MEDIUM severity

GHSA-q97c-8qh3-fpc6

MEDIUMFix: phpseclib/phpseclib@fb56bc5

GHSA-q97c-8qh3-fpc6 is a medium-severity (CVSS 6.3) CWE-208 vulnerability in phpseclib. O3 Security confirms whether GHSA-q97c-8qh3-fpc6 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

phpseclib — non-constant-time X25519 scalar multiplication permits full private-key recovery

Also known asCVE-2026-84308
Published
Sep 8, 2026
Updated
Sep 8, 2026
Affected
2 pkgs
Patched
2 / 2
Exploits
None indexed
Exploitation data as of Sep 8, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • A successful exploit gives an attacker total control of the affected component, not partial access.

Exploitation and automatability from CISA’s SSVC triage for GHSA-q97c-8qh3-fpc6.

Real-World Exposure

2 pkgs affected
🐘phpseclib🐘phpseclib

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.

Description

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.

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

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

2. Affected component

Confirmed on phpseclib 3.0.56 (338 files under phpseclib/,sha256(sorted(relpath NUL file_sha256 LF)) = cc7250b611f520e809131aab0931503457c44d8cbfb10d535251c6fec5f62a2b). The code appears unchanged across the 3.0 series wherever Curve25519 is supported, please confirm the affected range.

file:linerole
Math/PrimeField/Integer.php:189add() — conditional subtract($modulo) when the sum ≥ p
Math/PrimeField/Integer.php:207subtract() — conditional add($modulo) when the result is negative
Crypt/EC/BaseCurves/Montgomery.php:229–234ladder branch on the secret bit, selecting argument order of doubleAndAddPoint
Crypt/EC/Formats/Keys/MontgomeryPrivate.php:66multiplyPoint(getBasePoint(), dA)no engine check of any kind
Crypt/EC/Formats/Keys/PKCS8.php:194–200the same derivation, correctly gated on ext-sodium — the pattern MontgomeryPrivate is missing

3. Technical description

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

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.

Two facts bound the problem and are worth stating precisely, because they determine whether a fix is needed at all:

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

Results, 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):

observerpathobservations neededexact 251-bit recovery
timingkey load, u = 93220/20 keys, 95% CI [83.9%, 100%]
timingECDH, u = p−13218/20 keys, 95% CI [69.9%, 96.8%]
timingeither818–23% of trials
libgmp call countseither120/20 keys; tolerates 20–30% of per-step counts being wrong

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

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

5. Impact

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

6. Restrictions

Required for exploitation:

  1. 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.
  2. 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.
  3. The pure-PHP path must actually run. Measured across four extension configurations:
    • EC::loadFormat('MontgomeryPrivate', $raw32) runs the ladder in every configuration — but the format declares IS_INVISIBLE (MontgomeryPrivate.php:40), so PublicKeyLoader::load skips it and nothing inside phpseclib calls it. An application must name the format explicitly.
    • PKCS8 / PublicKeyLoader::load / EC::createKey run the ladder only when ext-sodium is absentPKCS8.php:194 gates on sodium_crypto_box_publickey_from_secretkey. OpenSSL does not help here.
    • 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.
  4. 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).

7. Suggested remediation

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Contact

George Stergiopoulos Assistant Professor of cybersecurity Athens University of Economics and Business, Greece E: [email protected] | s: https://www.aueb.gr/en/faculty_page/stergiopoulos-georgios

Affected Packages

2 total 2 fixed
EcosystemPackageVulnerable rangeFix
🐘Packagistphpsecliball versions3.0.57
🐘Packagistphpseclib4.0.0&&< 4.0.14.0.1

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for phpseclib. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update phpseclib to 3.0.57 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-q97c-8qh3-fpc6 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-q97c-8qh3-fpc6 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-q97c-8qh3-fpc6. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

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*. An 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 call
O3 Security · Impact-Aware SCA

Is GHSA-q97c-8qh3-fpc6 in your dependencies?

O3 detects GHSA-q97c-8qh3-fpc6 across Packagist dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.

GHSA-q97c-8qh3-fpc6: phpseclib (Medium 6.3) | O3 Security