Padding Oracle in Fastapi with Hmac Signatures
Padding Oracle in Fastapi with Hmac Signatures — how this specific combination creates or exposes the vulnerability
A padding oracle attack can occur in FastAPI when encrypted data is verified using an HMAC signature in a way that reveals whether padding is valid before signature verification completes. This specific combination becomes exploitable when an application decrypts a payload, checks or uses padding bytes, and then conditionally computes or accepts the HMAC based on padding correctness. If the server responds differently—such as returning a 400 error for bad padding and a 401 for invalid signatures—an attacker can send modified ciphertexts and observe response differences to gradually recover plaintext without knowing the key.
In FastAPI, this often happens when developers use streaming decryption or partial verification, or when error handling leaks timing or state information about padding. For example, using cryptography with AES-CBC and then verifying an HMAC after parsing JSON can expose a timing difference if invalid padding causes an early exception before HMAC comparison. An attacker can exploit this by iteratively crafting requests and measuring response times or observing distinct HTTP status codes, effectively treating the API as a padding oracle that guides decryption.
Consider a typical pattern where a FastAPI endpoint receives an encrypted + signed token, splits it into ciphertext and signature, decrypts, and then verifies the HMAC. If decryption or padding removal runs before HMAC verification and raises distinct exceptions for padding errors, the endpoint unintentionally prioritizes padding validation over integrity checks. This ordering and the resulting error paths create an oracle: the attacker learns about padding validity through observable API behavior, not through direct access to internal state.
To illustrate, a vulnerable FastAPI route might decrypt then verify like this:
from fastapi import FastAPI, HTTPException, Depends
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding, hashes, hmac
import os
app = FastAPI()
def decrypt_and_verify(ciphertext: bytes, key: bytes, iv: bytes, signature: bytes) -> bytes:
# Decrypt
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
# Remove padding — this can raise ValueError on bad padding
unpadder = padding.PKCS7(128).unpadder()
plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()
# Verify HMAC over the ciphertext
h = hmac.HMAC(key, hashes.SHA256())
h.update(ciphertext)
h.verify(signature)
return plaintext
@app.post("/data")
async def receive_data(token: str):
# Assume token is base64(ciphertext) + "." + base64(signature)
parts = token.split(".")
if len(parts) != 2:
raise HTTPException(status_code=400, detail="invalid token")
import base64
ciphertext = base64.urlsafe_b64decode(parts[0] + "==")
signature = base64.urlsafe_b64decode(parts[1] + "==")
key = b"32-byte-long-key-32-byte-long-key-1234" # example
iv = ciphertext[:16]
actual_ciphertext = ciphertext[16:]
try:
plaintext = decrypt_and_verify(actual_ciphertext, key, iv, signature)
return {"plaintext": plaintext.decode()}
except ValueError as e:
# Padding error leaking to client
raise HTTPException(status_code=400, detail="padding error")
except Exception:
raise HTTPException(status_code=401, detail="invalid signature")
In this example, a bad padding error is returned with status 400, while an invalid HMAC yields 401. This difference allows an attacker to perform a padding oracle attack by observing status codes. Even if the framework swallows exceptions, timing differences in decryption and padding removal can be measurable and exploitable in high-precision scenarios.
Hmac Signatures-Specific Remediation in Fastapi — concrete code fixes
To mitigate padding oracle risks when using HMAC signatures in FastAPI, ensure that decryption and integrity verification are performed in a constant-time manner and that errors are never exposed to the client. The key principle is to verify the HMAC before attempting to interpret or unpadding plaintext, and to use a single code path for all failures so that responses do not leak which step failed.
Use constant-time comparison for HMAC verification and avoid branching on padding correctness. In Python, you can use hmac.compare_digest for tag comparison and structure your code so that padding removal is not reached if verification fails. Always verify the signature over the ciphertext (not the plaintext) to preserve integrity guarantees, and handle all exceptions uniformly.
Here is a secure version of the earlier example:
from fastapi import FastAPI, HTTPException
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding, hashes, hmac
from cryptography.exceptions import InvalidSignature
import base64
import os
app = FastAPI()
def verify_hmac(key: bytes, msg: bytes, tag: bytes) -> bool:
h = hmac.HMAC(key, hashes.SHA256())
h.update(msg)
try:
h.verify(tag)
return True
except InvalidSignature:
return False
def decrypt_constant(padded: bytes, key: bytes, iv: bytes) -> bytes:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
return decryptor.update(padded) + decryptor.finalize()
@app.post("/data")
async def receive_data(token: str):
parts = token.split(".")
if len(parts) != 2:
raise HTTPException(status_code=400, detail="invalid token")
try:
ciphertext = base64.urlsafe_b64decode(parts[0] + "==")
signature = base64.urlsafe_b64decode(parts[1] + "==")
except Exception:
raise HTTPException(status_code=400, detail="invalid encoding")
key = b"32-byte-long-key-32-byte-long-key-1234" # same key for HMAC and AES key derivation in practice use a KDF
iv = ciphertext[:16]
actual_ciphertext = ciphertext[16:]
# Verify HMAC over ciphertext first (constant-time where possible)
if not verify_hmac(key, ciphertext, signature):
raise HTTPException(status_code=401, detail="invalid token")
# Only after integrity passes, proceed to decrypt and unpad
try:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
padded_plaintext = decryptor.update(actual_ciphertext) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()
return {"plaintext": plaintext.decode()}
except Exception:
# Do not distinguish between padding and other errors
raise HTTPException(status_code=400, detail="invalid token")
Key remediation steps:
- Verify HMAC over the ciphertext before any decryption or padding removal.
- Do not expose distinct error messages or status codes for padding versus signature failures.
- Use a single exception handler to return a generic error response.
- Ensure the cryptographic library’s unpad operation does not branch on secret data; in Python, catching exceptions and treating them uniformly is the practical approach.
- Consider using an authenticated encryption mode (e.g., AES-GCM) where appropriate, as it provides both confidentiality and integrity in one step and avoids manual padding.
These practices remove the oracle by eliminating observable differences tied to padding validity and ensure that HMAC verification is the gatekeeper for any further processing.