Blog / Rust

  • rust
  • cryptography
  • aes-cbc
  • hmac
  • encrypt-then-mac
  • security

Why Encrypting a File with AES-CBC Still Needs a MAC in Rust

Encrypt a file with AES-256-CBC, hand the key to nobody, and someone with write access to the ciphertext can still change what you decrypt. Not read it, change it. Your decryption call will return Ok and you will happily process the altered data.

CBC gives confidentiality and nothing else. Here is the smallest demonstration I could write, then the fix.

Tampering with a file you cannot read

Assume the file format is IV || ciphertext, which is the obvious layout. The plaintext is amount=100;to=alice. We flip one byte of the IV and decrypt.

use aes::Aes256;
use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};

fn main() {
    let key = [7u8; 32]; // demo only, never a fixed key
    let mut iv = [1u8; 16];

    let ct = cbc::Encryptor::<Aes256>::new(&key.into(), &iv.into())
        .encrypt_padded_vec_mut::<Pkcs7>(b"amount=100;to=alice");

    // The attacker never sees the key. They just edit byte 7 of the IV.
    iv[7] ^= b'1' ^ b'9';

    let pt = cbc::Decryptor::<Aes256>::new(&key.into(), &iv.into())
        .decrypt_padded_vec_mut::<Pkcs7>(&ct)
        .unwrap();
    println!("{}", String::from_utf8_lossy(&pt));
}

This prints amount=900;to=alice. No error, valid padding, and a different payment. (This uses the cbc 0.1 API; trait names have moved around in later releases, so check the version you have.)

Why one flipped byte lands exactly where they want

CBC decryption computes each plaintext block as the block cipher's output XORed with the previous ciphertext block. For the first block, the "previous block" is the IV.

So flipping a bit in the IV flips the same bit in the first plaintext block, and nothing else changes. Flip a bit in ciphertext block N instead and block N+1 changes the same way, while block N itself decrypts to garbage. The attacker needs to know or guess the plaintext, but file formats are full of predictable headers and fields.

Quick detour: why does the garbage matter less than you'd think?

Block N turning to noise sounds like it would be caught. Often it is not. If that block sits in a padded region, a comment, a field your parser ignores or a chunk of a compressed stream the parser tolerates, nobody notices. And the IV trick damages nothing at all.

The padding oracle is the nastier cousin

Tampering is the mild problem. The worse one is that decrypt_padded_vec_mut can fail with a padding error, and your program probably reacts differently to that than to a parse error later on.

If an attacker can submit modified ciphertexts and learn "padding bad" versus "padding fine", they can recover the plaintext byte by byte without the key. This is the padding oracle attack, described by Serge Vaudenay in 2002, and it has been rediscovered in web frameworks and file tools ever since.

It does not need a network service. A command-line tool that prints different errors, or takes measurably different time, is enough if someone can run it against chosen inputs.

The fix: encrypt-then-MAC

Compute an HMAC over everything the decryptor will consume, verify it before touching the ciphertext, and only then decrypt. That order matters: if the MAC fails, the padding code never runs, so there is no oracle.

The rules:

  • Use two independent keys, one for AES and one for HMAC. Derive both from a master key with HKDF rather than reusing one key for two jobs.
  • MAC the IV as well as the ciphertext, otherwise the IV trick above still works.
  • Compare tags in constant time. verify_slice does this for you; == on two byte slices does not.
  • Return one undifferentiated failure. Do not tell the caller whether the MAC or the padding was wrong.
use aes::Aes256;
use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

/// Output layout: IV (16) || ciphertext || HMAC-SHA256 tag (32).
fn seal(enc_key: &[u8; 32], mac_key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
    let mut iv = [0u8; 16];
    getrandom::fill(&mut iv).expect("OS RNG failed"); // getrandom 0.3

    let ct = cbc::Encryptor::<Aes256>::new(enc_key.into(), &iv.into())
        .encrypt_padded_vec_mut::<Pkcs7>(plaintext);

    let mut out = iv.to_vec();
    out.extend_from_slice(&ct);

    let mut mac = HmacSha256::new_from_slice(mac_key).expect("any key length is fine");
    mac.update(&out);
    out.extend_from_slice(&mac.finalize().into_bytes());
    out
}

/// Every failure is the same `None`, so callers cannot build an oracle.
fn open(enc_key: &[u8; 32], mac_key: &[u8; 32], blob: &[u8]) -> Option<Vec<u8>> {
    if blob.len() < 16 + 16 + 32 {
        return None;
    }
    let (body, tag) = blob.split_at(blob.len() - 32);

    let mut mac = HmacSha256::new_from_slice(mac_key).ok()?;
    mac.update(body);
    mac.verify_slice(tag).ok()?; // constant-time, runs before any decryption

    let (iv, ct) = body.split_at(16);
    cbc::Decryptor::<Aes256>::new(enc_key.into(), iv.into())
        .decrypt_padded_vec_mut::<Pkcs7>(ct)
        .ok()
}

Rerun the earlier tamper: change any byte of the blob, IV included, and open returns None before AES is involved.

Checking it works

  • Seal something, flip each byte of the output in turn, and assert open returns None every time. It is a ten-line test and it catches a forgotten IV in the MAC input immediately.
  • Open with the wrong mac_key and confirm None.
  • Seal the same plaintext twice and confirm the outputs differ, which shows the IV is fresh.

Or skip all of this

If you are not bound to an existing CBC format, use an AEAD such as AES-GCM or ChaCha20-Poly1305. They do the authentication inside one call and there is no ordering to get wrong. Encrypt-then-MAC with CBC is the right answer when you must stay compatible with something that already uses CBC, not when starting fresh.

One thing this does not cover: a valid sealed file can still be swapped for an older valid sealed file. If rollback matters, put a version or counter inside the authenticated data.