Padding Oracle in Django with Hmac Signatures
Padding Oracle in Django with Hmac Signatures — how this specific combination creates or exposes the vulnerability
A padding oracle attack can occur in Django when encrypted data is authenticated with HMAC signatures in a way that reveals whether padding is valid before the signature is verified. This typically arises when decryption and signature verification are performed in separate steps, or when error handling during unpadding leaks information to the attacker.
Consider a pattern where a cookie or API parameter contains an encrypted payload concatenated with an HMAC: encrypted || '.' || hmac. If the application first verifies the HMAC using a constant-time comparison and then decrypts, this order can be safe. However, if the application decrypts first and then verifies the HMAC, timing differences in the unpadding process can become observable: an invalid padding error raised during decryption can cause an early exception, allowing an attacker to infer that the padding was valid up to a certain point. Even when HMAC is used, if decryption errors (including padding errors) are surfaced with different messages or timing than signature failures, a padding oracle may still exist.
In Django, this can happen when using low-level cryptographic primitives such as cryptography or PyCrypto without careful error handling. For example, decrypting with AES in CBC mode and then calling .unpad() raises a ValueError on invalid padding. If this error is not caught and normalized into a generic failure response, an attacker can send modified ciphertexts and observe differences in response times or status codes to recover plaintext.
An insecure implementation might look like this:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
import hmac
import hashlib
import os
key = os.urandom(32)
iv = os.urandom(16)
signature_key = os.urandom(32)
def encrypt_then_sign(plaintext, key, signature_key, iv):
padder = padding.PKCS7(128).padder()
padded_data = padder.update(plaintext) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
encryptor = cipher.encryptor()
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
tag = hmac.new(signature_key, ciphertext, hashlib.sha256).digest()
return ciphertext + tag
def decrypt_and_verify(token, key, signature_key, iv):
ciphertext = token[:-32]
received_tag = token[-32:]
# No constant-time compare for HMAC; timing may leak
if not hmac.compare_digest(received_tag, hmac.new(signature_key, ciphertext, hashlib.sha256).digest()):
raise ValueError('Invalid signature')
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
# This unpad can raise ValueError on bad padding, potentially observable
unpadder = padding.PKCS7(128).unpadder()
plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()
return plaintext
In the above, if decryptor.update(...) + decryptor.finalize() produces invalid padding, the subsequent unpadder call raises an exception that may differ from a signature failure. An attacker can exploit these timing or error-message differences to mount a padding oracle, even though HMAC is used, because the padding is processed before signature verification and its errors are not uniformly handled.
Hmac Signatures-Specific Remediation in Django — concrete code fixes
To prevent padding oracle issues when using HMAC signatures in Django, ensure that decryption and verification are performed in a way that does not leak padding errors. Always verify the HMAC before decryption, and handle all exceptions uniformly so that error responses are consistent in timing and content.
Best practice is to verify the HMAC on the raw ciphertext first, then decrypt, and catch any padding errors during unpadding, converting them into generic authentication failures.
Here is a secure pattern using cryptography and hmac.compare_digest to avoid timing leaks:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
import hmac
import hashlib
import os
key = os.urandom(32)
signature_key = os.urandom(32)
def encrypt_then_sign(plaintext, key, signature_key, iv):
padder = padding.PKCS7(128).padder()
padded_data = padder.update(plaintext) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
encryptor = cipher.encryptor()
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
tag = hmac.new(signature_key, ciphertext, hashlib.sha256).digest()
return ciphertext + tag
def decrypt_and_verify(token, key, signature_key, iv):
ciphertext = token[:-32]
received_tag = token[-32:]
# Constant-time HMAC verification before decryption to avoid padding oracle
if not hmac.compare_digest(received_tag, hmac.new(signature_key, ciphertext, hashlib.sha256).digest()):
raise ValueError('Invalid signature')
try:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()
return plaintext
except ValueError:
# Normalize padding errors to a generic authentication failure
raise ValueError('Invalid token')
Additional recommendations:
- Use Django’s
django.middleware.csrfanddjango.contrib.authmechanisms where possible, as they handle signing and encryption with built-in protections. - If you rely on JWTs or custom tokens, prefer high-level libraries that implement authenticated encryption (e.g., Fernet) and avoid manual padding and HMAC concatenation.
- Ensure that all branches of token validation return the same HTTP status codes and similar response shapes to prevent information leakage through timing or error messages.