Modo ECB
O ECB cifra cada bloco de 16 bytes sozinho; blocos iguais saem iguais, e a forma da mensagem aparece no cifrado.
O AES é uma cifra de bloco: recebe exatamente 16 bytes e uma chave, e devolve 16 bytes que parecem aleatórios. Para uma mesma chave, ele funciona como uma tabela fixa: o mesmo bloco de entrada dá sempre o mesmo bloco de saída. Uma mensagem maior é cortada em blocos de 16 bytes (o último é completado com preenchimento, como o PKCS#7), e o modo de operação diz como esses blocos se encadeiam.
O modo mais simples, o ECB (Electronic Codebook, "livro de códigos"), não encadeia nada: cada bloco é cifrado sozinho, com a mesma chave. A consequência: dois blocos iguais na mensagem viram dois blocos iguais no cifrado. O conteúdo fica escondido; a repetição, não.
Um exemplo trabalhado, com uma planilha em que cada linha tem 16 bytes, em Python com a biblioteca pycryptodome:
>>> from Crypto.Cipher import AES
>>> chave = b'chave-de-exemplo' # 16 bytes: AES-128 (só para o exemplo)
>>> msg = b'vaca;preta;300kg' + b'boi;branco;410kg' + b'vaca;preta;300kg'
>>> c = AES.new(chave, AES.MODE_ECB).encrypt(msg)
>>> [c[i:i + 16].hex()[:8] for i in range(0, len(c), 16)]
['ad00bab7', '6dd9e52c', 'ad00bab7']
O primeiro e o terceiro blocos cifrados são idênticos (a lista mostra só os 8 primeiros algarismos hexadecimais de cada um, mas os 32 batem). Quem intercepta não sabe o que está escrito, mas sabe que a linha 1 e a linha 3 são iguais. Numa imagem é pior: áreas de cor lisa viram blocos repetidos, e o contorno do desenho passa inteiro para o cifrado. Num banco de dados, dois clientes com o mesmo valor ficam com o mesmo cifrado.
No modo CBC (Cipher Block Chaining, "encadeamento de blocos"), cada bloco da mensagem é misturado (XOR) com o bloco cifrado anterior antes de passar pelo AES, e o primeiro, com um IV (vetor de inicialização): 16 bytes sorteados a cada mensagem. A mesma planilha:
>>> import os
>>> iv = os.urandom(16)
>>> c = AES.new(chave, AES.MODE_CBC, iv).encrypt(msg)
>>> [c[i:i + 16].hex()[:8] for i in range(0, len(c), 16)]
['2adfa50a', 'f4205c77', 'ac81f1c4']
Os três saem diferentes, e o resultado muda a cada execução, porque o IV muda. O IV não é segredo: vai junto com o cifrado, em geral nos 16 primeiros bytes.
Para reconhecer ECB num arquivo cifrado, conte os blocos repetidos: blocos = [c[i:i + 16] for i in range(0, len(c), 16)] e compare len(blocos) com len(set(blocos)). Num cifrado bem feito, dois blocos de 16 bytes iguais não aparecem por acaso.
O uso correto. Não use ECB para mais de um bloco. Prefira um modo autenticado, como o GCM, que além de esconder a repetição detecta se alguém mexeu no cifrado. Ele usa um nonce (número usado uma vez) que precisa ser novo a cada mensagem e nunca se repetir com a mesma chave; a biblioteca sorteia um se você não passar:
import os
from Crypto.Cipher import AES
chave = os.urandom(16) # chave sorteada de verdade
cifra = AES.new(chave, AES.MODE_GCM) # nonce novo, sorteado
cifrado, etiqueta = cifra.encrypt_and_digest(msg)
# guarde cifra.nonce, o cifrado e a etiqueta; para abrir:
texto = AES.new(chave, AES.MODE_GCM, nonce=cifra.nonce).decrypt_and_verify(cifrado, etiqueta)
Se um único byte do cifrado ou da etiqueta for alterado, o decrypt_and_verify dá erro em vez de devolver lixo.