2025, Dec 31 07:00
Decrypting AES-256-CBC Across PHP OpenSSL and Python: Handling Null-Byte Key Padding and Double Base64
Make PHP OpenSSL and Python AES-256-CBC decryption align: replicate null-byte key padding, decode double Base64, unpad to recover expected serialized value.
Decrypting data across languages sounds straightforward until small, implicit behaviors skew the result. A common trap is mixing PHP’s OpenSSL routines with Python’s AES implementations. Here’s a practical walk-through of a real AES-256-CBC case where a passphrase is itself encrypted, why the Python port failed with block-size errors, and how to make the two worlds line up.
The failing Python attempt
The input consists of a locally available key/IV pair used to decrypt an intermediate key, and that derived key then decrypts the final value. The following Python snippet shows the shape of the original approach and the symptoms you’d hit.
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
seed_key = base64.b64decode('Po0KPxyF')
seed_iv = base64.b64decode('s8W+/a4jkp9mhO3NkCL7Yg==')
payload_ct = base64.b64decode('hl5n6Nq5QYtgKIyLEVCupA==')
wrapped_key_ct = base64.b64decode('MGRHRFlaMzhCR0lxb2VHS1JHQXcrWkV2bkJpNWFZb3cybW9iQW5KYTlOU0xKK1FHc2pPUW1MUE9JRU5zTXN1Rg==')
payload_iv = base64.b64decode('J31SrExr7KKIOertYIPhpQ==')
# Decrypt the wrapped key with the local seed key/iv
k_cipher = AES.new(pad(seed_key, 16), AES.MODE_CBC, seed_iv)
unwrapped_key = k_cipher.decrypt(wrapped_key_ct)
# Decrypt the final value using the unwrapped key
v_cipher = AES.new(unpad(unwrapped_key, 16), AES.MODE_CBC, payload_iv)
plaintext_raw = v_cipher.decrypt(payload_ct)
This produces block-size and padding complaints. The root cause isn’t the mode or the IVs; it’s subtle differences in key handling and encoding between PHP’s OpenSSL APIs and Python’s library calls.
What’s really happening on the PHP side
The working PHP decryption reveals three critical behaviors. First, the key used with aes-256-cbc can be shorter than 32 bytes; OpenSSL silently right-pads it with null bytes. Second, the wrapped key input had been base64-encoded twice, which means it must be decoded twice before decryption. Third, the final plaintext is a serialized PHP string, so the visible output looks like a serialized form rather than a plain word.
<?php
$seedKey = base64_decode('Po0KPxyF');
$seedIv = base64_decode('s8W+/a4jkp9mhO3NkCL7Yg==');
$payloadCt = base64_decode('hl5n6Nq5QYtgKIyLEVCupA==');
$wrappedKeyCt = base64_decode('MGRHRFlaMzhCR0lxb2VHS1JHQXcrWkV2bkJpNWFZb3cybW9iQW5KYTlOU0xKK1FHc2pPUW1MUE9JRU5zTXN1Rg==');
$payloadIv = base64_decode('J31SrExr7KKIOertYIPhpQ==');
$derivedKey = openssl_decrypt(base64_decode($wrappedKeyCt), 'aes-256-cbc', $seedKey, OPENSSL_RAW_DATA, $seedIv);
$decoded = openssl_decrypt($payloadCt, 'aes-256-cbc', $derivedKey, OPENSSL_RAW_DATA, $payloadIv);
echo $decoded;
The output is not just Jimmy, but a serialized value:
s:5:"Jimmy";
Why the Python version broke
Two mismatches caused the decryption to fail in Python. The first is key sizing. AES-256 requires a 32-byte key. OpenSSL will take a shorter passphrase and right-pad it with zero bytes. The Python snippet tried PKCS#7 padding on the key with pad(seed_key, 16), which is not equivalent to OpenSSL’s null-byte extension to 32 bytes. The second is encoding. The wrapped key blob had been base64-encoded twice, but the Python code decoded it only once. Both details must be replicated exactly to match PHP’s behavior.
Corrected Python decryption
To mirror the OpenSSL path, the key must be right-padded with null bytes to 32 bytes, and the wrapped key must be decoded twice before the first decryption. Unpadding is applied to the intermediate and final plaintext blocks as in the working PHP flow.
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
seed_key = base64.b64decode('Po0KPxyF')
seed_iv = base64.b64decode('s8W+/a4jkp9mhO3NkCL7Yg==')
payload_ct = base64.b64decode('hl5n6Nq5QYtgKIyLEVCupA==')
wrapped_key_ct = base64.b64decode('MGRHRFlaMzhCR0lxb2VHS1JHQXcrWkV2bkJpNWFZb3cybW9iQW5KYTlOU0xKK1FHc2pPUW1MUE9JRU5zTXN1Rg==')
payload_iv = base64.b64decode('J31SrExr7KKIOertYIPhpQ==')
key_cipher = AES.new(seed_key.ljust(32, b'\0'), AES.MODE_CBC, seed_iv)
derived_key = key_cipher.decrypt(base64.b64decode(wrapped_key_ct))
value_cipher = AES.new(unpad(derived_key, 16), AES.MODE_CBC, payload_iv)
raw_value = value_cipher.decrypt(payload_ct)
final_value = unpad(raw_value, 16).decode()
print(final_value)
The printed result matches the PHP output:
s:5:"Jimmy";
Why this matters
Interoperability hinges on reproducing every small decision in the original stack. OpenSSL’s implicit null-byte extension for keys shorter than 32 bytes directly affects AES-256-CBC compatibility. Encoding layers matter just as much; a single extra base64 pass is enough to derail block alignment and padding. Finally, the plaintext format influences what you expect to read; here it is a serialized PHP string rather than a bare token.
Wrap-up
If you are porting PHP OpenSSL code to Python, verify how keys are derived or extended, match the exact key length semantics, and audit the number of base64 transitions on every blob. Decrypt in the same order as the original pipeline and only then apply unpadding and interpretation. Following these steps yields a faithful Python reproduction of the PHP decryption and restores the expected value.