Encrypting Voter Data With a Broken Cipher
The platform in scope was an election management system used by municipal governments to record voter registrations, manage ballot configurations, and produce certified export files for downstream tabulation. The security assessment covered authentication controls, data access authorization, audit logging, and the cryptographic protections applied to sensitive data in transit and at rest.
Most of the cryptographic findings in election software assessments involve certificate validation, TLS configuration, or key management. The finding documented here was different: the encryption was implemented correctly in the narrow technical sense — the ciphertext was produced by AES, a well-regarded block cipher — but the mode of operation was fundamentally broken for the purpose it was serving.
The voter record export function used AES in CBC mode without a message authentication code. This is a textbook example of a broken cryptographic design. It provides no integrity protection and, under conditions that existed in this deployment, allowed partial plaintext recovery without the key.
What the Export Feature Did
Administrators with elevated privileges could export a snapshot of the voter registration database as an encrypted binary file. The file was then imported by a second system that decrypted it and reconciled the records against its own database.
The encryption was intended to serve two purposes: keep the voter data confidential in transit and ensure that the importing system could trust the data came from the authoritative source without modification.
AES-CBC without a MAC achieves only the first goal. It fails completely at the second.
Understanding CBC Mode
Block ciphers like AES operate on fixed-size blocks of plaintext — 128 bits for AES. To encrypt a message longer than one block, a mode of operation chains the blocks together.
In CBC mode, each plaintext block is XORed with the previous ciphertext block before being encrypted. For the first block, a random initialization vector (IV) takes the place of the previous ciphertext block. The resulting ciphertext blocks are concatenated with the IV to produce the full ciphertext.
IV → XOR(P₁, IV) → Encrypt → C₁
C₁ → XOR(P₂, C₁) → Encrypt → C₂
C₂ → XOR(P₃, C₂) → Encrypt → C₃
Decryption runs in reverse:
C₁ → Decrypt → XOR(result, IV) → P₁
C₂ → Decrypt → XOR(result, C₁) → P₂
C₃ → Decrypt → XOR(result, C₂) → P₃
The critical property to observe: the decrypted value of ciphertext block N is XORed with ciphertext block N-1 to produce plaintext block N. The plaintext of each block depends on the content of the preceding ciphertext block. If the preceding ciphertext block changes, the resulting plaintext changes in a predictable way.
This property is useful for understanding two distinct attack classes: bit-flipping and padding oracle attacks.
The Bit-Flipping Attack
Suppose an attacker intercepts an export file and knows (or can guess) that one of the plaintext blocks contains the field role=voter. They want to change it to role=admin without knowing the encryption key.
The XOR relationship between ciphertext block N-1 and plaintext block N means that flipping a bit in ciphertext block N-1 produces a corresponding flip in the same position of plaintext block N. Specifically:
Modified_Cᵢ₋₁[byte j] = Original_Cᵢ₋₁[byte j] XOR target_byte XOR known_current_byte
By applying this XOR operation to the correct bytes in the preceding ciphertext block, an attacker can change specific characters in the target plaintext block to any desired value. They do not need the key. They only need to know the position of the target bytes and their current values.
There is a cost: the block that was modified to produce the XOR becomes garbage during decryption (since its own decryption now uses the unmodified key and the modified ciphertext). But if the garbage block corresponds to a field the receiving application does not validate — or if the attacker can control which block is corrupted — the modification is effective.
In the export format used by this platform, field boundaries were aligned to 16-byte block boundaries. The voter status field — which controlled whether a voter record was marked as active, suspended, or provisional — occupied the first 12 bytes of a specific block. The preceding block contained only padding from the previous field. Corrupting the preceding block produced no meaningful data corruption in the import process, because the receiving system silently discarded blocks that failed to parse as known fields.
This was not exploitation in the traditional sense. It was a demonstration that the encryption provided no data integrity guarantee, and that an adversary with access to the export files in transit could silently alter voter status fields without detection.
Finding the Padding Oracle
PKCS#7 padding appends between 1 and 16 bytes to the final block of plaintext before encryption, setting each appended byte to the number of appended bytes. One byte of padding is \x01. Two bytes of padding are \x02\x02. Sixteen bytes of padding (for a full padding block) are \x10 repeated sixteen times.
During decryption, the final block's padding is verified. If the padding is invalid — if the final byte indicates N bytes of padding but the preceding bytes do not all equal N — the decryption function returns an error.
A padding oracle exists when an application's response to decryption reveals whether padding was valid. This can be an explicit error message ("invalid padding"), a different HTTP status code, a timing difference, or any behavior that distinguishes valid-padding from invalid-padding results.
The export file validation endpoint on this platform accepted an uploaded file, attempted to decrypt it, and returned one of two responses: an import preview showing the first ten records (if decryption succeeded), or an error page stating "File format error: unable to read export." No additional detail was provided — but the two responses were distinguishable, and that distinction was sufficient.
How the Oracle Works
To recover one byte of plaintext from a ciphertext, an attacker manipulates the preceding ciphertext block and repeatedly submits the modified ciphertext to the oracle. The goal is to find a byte value x for a specific position in the preceding ciphertext block such that, after decryption and XOR, the final plaintext block has valid padding.
For the last byte of the last plaintext block:
- Modify the last byte of the second-to-last ciphertext block to successive values from 0x00 to 0xFF.
- For each value, submit the modified ciphertext to the oracle.
- When the oracle returns a success response, the modified byte caused the decrypted last byte of the final block to equal
\x01(valid single-byte PKCS#7 padding). - From this, compute the original plaintext byte:
P_last = 0x01 XOR x XOR original_C[-2][-1].
To recover the second-to-last byte, set the last byte to produce \x02 padding in the decrypted output (using the already-recovered last byte), then iterate over values for the second-to-last byte until the oracle succeeds again.
This process recovers one plaintext byte per oracle round. Each round requires at most 256 queries. For a 16-byte block, full recovery requires at most 4,096 queries. For a file with many blocks, recovery scales linearly with the number of blocks.
The voter registration export files generated by the platform contained approximately 180 blocks of actual data plus padding. A full plaintext recovery required at most 737,280 queries. Against an endpoint with no rate limiting and a response time of approximately 40 milliseconds per query, this was a runtime of approximately eight hours for a single export file.
The Scope of the Finding
A full padding oracle attack recovering every voter record in the export is a theoretical worst case. The practical finding was narrower but still significant:
The encryption provides no integrity guarantee. Any modification to the ciphertext, including deliberate bit-flipping, produces modified plaintext with no detection. The import system has no way to distinguish a legitimate export from a modified one.
The decryption endpoint is a padding oracle. An attacker with network access to the validation endpoint and a copy of an intercepted export file can recover plaintext without the encryption key, given sufficient time and no rate limiting.
The system provides no authentication of the data source. A valid export file produced by the platform and a modified version of that file produce identical import results if the modifications are within the constraints described. The receiving system has no cryptographic basis for trusting the data's origin.
Reviewing the Implementation
The export implementation was straightforward to review once the behavior was observed:
python
# Simplified excerpt — identifying details changed
from Crypto.Cipher import AES
import os
def encrypt_export(plaintext: bytes, key: bytes) -> bytes:
iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CBC, iv)
padded = pad(plaintext, AES.block_size)
ciphertext = cipher.encrypt(padded)
return iv + ciphertext
def decrypt_export(data: bytes, key: bytes) -> bytes:
iv = data[:16]
ciphertext = data[16:]
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
return plaintextNo MAC. No authentication tag. No signature. The function encrypts and decrypts, and nothing else. The key is symmetric and shared between the export system and the import system through a configuration file.
The fix is one of three equivalent constructions:
AES-GCM. Replace the CBC mode with GCM. The GCM mode produces an authentication tag alongside the ciphertext. Decryption verifies the tag before returning any plaintext — if the ciphertext was modified in any way, decryption raises an exception.
python
from Crypto.Cipher import AES
def encrypt_export(plaintext: bytes, key: bytes) -> bytes:
cipher = AES.new(key, AES.MODE_GCM)
ciphertext, tag = cipher.encrypt_and_digest(plaintext)
return cipher.nonce + tag + ciphertext
def decrypt_export(data: bytes, key: bytes) -> bytes:
nonce = data[:16]
tag = data[16:32]
ciphertext = data[32:]
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
return cipher.decrypt_and_verify(ciphertext, tag)If decrypt_and_verify returns successfully, the ciphertext was not modified after encryption. If it raises ValueError, the ciphertext was tampered with.
Encrypt-then-MAC with HMAC-SHA256. Keep CBC encryption but add an HMAC over the IV and ciphertext, computed after encryption, verified before decryption:
python
import hmac, hashlib
def encrypt_export(plaintext: bytes, enc_key: bytes, mac_key: bytes) -> bytes:
iv = os.urandom(16)
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
return mac + iv + ciphertext
def decrypt_export(data: bytes, enc_key: bytes, mac_key: bytes) -> bytes:
mac = data[:32]
iv = data[32:48]
ciphertext = data[48:]
expected_mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
if not hmac.compare_digest(mac, expected_mac):
raise ValueError("Authentication failed")
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
return unpad(cipher.decrypt(ciphertext), AES.block_size)The hmac.compare_digest call uses a constant-time comparison to prevent timing attacks against the MAC verification step.
Why This Pattern Persists
CBC without a MAC appears in codebases for a predictable reason: developers implement encryption by following examples that demonstrate encryption and decryption as symmetric operations. The example encrypts, the example decrypts, the output matches — the feature works. The missing piece, authentication, produces no observable error in the happy path. There is no test case that submits modified ciphertext and checks that it is rejected, because the developer did not know to write one.
The pattern is self-reinforcing. Old documentation shows CBC examples. Older examples from cryptography tutorials focus on the encrypt-decrypt round trip. The word "authenticated" in "authenticated encryption" is easy to skip over if you already believe the encryption is sufficient.
The consequence in this platform was that voter data export files — intended to be both confidential and trustworthy — were only confidential. The integrity guarantee that the import system relied on was entirely absent.
Encryption answers the question: can an outsider read this? Authentication answers the question: can an outsider modify this without being caught? Systems that need both guarantees require both mechanisms.
For a deeper look at the cryptographic vulnerability class that makes unauthenticated CBC dangerous in practice, see the insecure deserialization knowledge article for context on how implementation errors in security-critical code create real-world exposure — and how to assess for them.