Blog  /  Criptografia  /  Série AES
Série AES · Parte 1

O modo ECB: quando a cifra revela a estrutura

O AES pode ser perfeito e, mesmo assim, o texto cifrado entregar o formato dos seus dados. Uma análise rigorosa do modo ECB: o que ele é, o que exatamente vaza, por que falha na definição formal de segurança e como um atacante transforma esse vazamento em ataques reais.

FikreSekhel Research10 de novembro de 2026Leitura: ~21 minCódigo: Python 3.10+ · cryptography

O ECB (Electronic Codebook) é o modo de operação mais simples que existe: cada bloco da mensagem é cifrado sozinho, sempre do mesmo jeito. Essa simplicidade tem um preço exato e mensurável. O ECB vaza a relação de igualdade entre os blocos, e isso basta para quebrar a definição formal de segurança e para viabilizar ataques práticos, por mais forte que seja o AES.

Esta é a primeira parte de uma série sobre o AES e seus modos de operação. Começamos pelo ECB porque ele isola uma pergunta fundamental: uma cifra de bloco segura é suficiente para cifrar dados? A resposta é não, e entender por que é o primeiro passo para entender todos os outros modos. Neste artigo, o AES é tratado como uma caixa-preta; o funcionamento interno dele (rodadas, S-box, expansão de chave) terá uma parte própria da série.

Todos os códigos foram executados e as saídas mostradas são reais. Para reproduzir, basta pip install cryptography e colocar os arquivos na mesma pasta.

1.Cifras de bloco: o objeto matemático

Uma cifra de bloco transforma blocos de tamanho fixo em blocos do mesmo tamanho, controlada por uma chave. Formalmente:

Definição 1 · Cifra de bloco

Uma cifra de bloco com chaves de kk bits e blocos de nn bits é uma função

E:{0,1}k×{0,1}n⟶{0,1}n,(K,x)⟼EK(x)E : \{0,1\}^k \times \{0,1\}^n \longrightarrow \{0,1\}^n, \qquad (K, x) \longmapsto E_K(x)

tal que, para toda chave KK, a função EKE_K é uma permutação (bijeção) de {0,1}n\{0,1\}^n, e tanto EKE_K quanto sua inversa EK−1E_K^{-1} são eficientemente computáveis a partir de KK.

O AES é uma cifra de bloco com n=128n = 128 e k∈{128,192,256}k \in \{128, 192, 256\}, padronizada no FIPS 197. Ao longo do artigo, N=16N = 16 denota o tamanho do bloco em bytes.

Duas consequências imediatas da Definição 1 serão usadas o tempo todo. Por ser função, EKE_K é determinística; por ser bijeção, é injetiva. Juntas, elas dão a equivalência que está no centro de tudo o que segue:

x=y  ⟺  EK(x)=EK(y)x = y \iff E_K(x) = E_K(y)
(1)

O que significa uma cifra de bloco ser "segura"? A noção padrão é a de permutação pseudoaleatória (PRP): sem conhecer KK, ninguém consegue distinguir EKE_K de uma permutação escolhida uniformemente ao acaso entre todas as (2n)!(2^n)! permutações de {0,1}n\{0,1\}^n.

Definição 2 · Segurança PRP

Seja Perm(n)\mathrm{Perm}(n) o conjunto das permutações de {0,1}n\{0,1\}^n. A vantagem de um distinguidor 𝒟𝒟, com acesso a um oráculo, é

AdvEprp(𝒟)=∣ Pr⁡K ⁣[𝒟EK(⋅)=1]−Pr⁡π←Perm(n) ⁣[𝒟π(⋅)=1]∣\mathrm{Adv}^{\mathrm{prp}}_E(𝒟) = \left|\, \Pr_{K}\!\left[𝒟^{E_K(\cdot)} = 1\right] - \Pr_{\pi \leftarrow \mathrm{Perm}(n)}\!\left[𝒟^{\pi(\cdot)} = 1\right] \right|

Dizemos que EE é uma PRP segura se essa vantagem é desprezível para todo 𝒟𝒟 eficiente.

Acredita-se que o AES seja uma PRP segura: depois de mais de duas décadas de criptoanálise pública, não se conhece ataque prático contra ele. Mas observe o detalhe: uma PRP processa exatamente um bloco de 16 bytes. Mensagens reais têm tamanhos arbitrários. É aí que entram os modos de operação.

2.Esquemas de cifragem e modos de operação

Um modo de operação é uma receita para construir, a partir de uma cifra de bloco, um esquema que cifra mensagens de qualquer tamanho.

Definição 3 · Esquema de cifragem simétrica

Um esquema de cifragem simétrica é uma tripla de algoritmos Π=(Gen,Enc,Dec)\Pi = (\mathrm{Gen}, \mathrm{Enc}, \mathrm{Dec}):

  • Gen\mathrm{Gen} sorteia uma chave KK;
  • EncK(M)\mathrm{Enc}_K(M) produz um texto cifrado CC a partir da mensagem M∈{0,1}∗M \in \{0,1\}^*, possivelmente usando aleatoriedade;
  • DecK(C)\mathrm{Dec}_K(C) devolve uma mensagem ou um símbolo de erro ⊥\bot.

Exige-se corretude: DecK(EncK(M))=M\mathrm{Dec}_K(\mathrm{Enc}_K(M)) = M para toda chave KK e toda mensagem MM.

Um ponto que será decisivo: a definição permite que Enc\mathrm{Enc} seja probabilístico, isto é, que a mesma mensagem produza textos cifrados diferentes a cada cifragem. O ECB não usa essa liberdade. Veremos que isso não é um detalhe de implementação: é a raiz do problema.

3.A definição do ECB

3.1 Padding: completando o último bloco

Para cifrar bloco a bloco, o tamanho da mensagem precisa ser múltiplo de NN. O padrão mais usado é o PKCS#7 (RFC 5652).

Definição 4 · Padding PKCS#7

Para MM com ∣M∣|M| bytes, seja k=N−(∣M∣ mod N)k = N - (|M| \bmod N), de modo que k∈{1,…,N}k \in \{1, \dots, N\}. Então

pad(M)=M ∥ k ∥ k ∥ ⋯ ∥ k⏟k bytes\mathrm{pad}(M) = M \,\|\, \underbrace{k \,\|\, k \,\|\, \cdots \,\|\, k}_{k \text{ bytes}}

A remoção unpad\mathrm{unpad} lê o último byte kk, verifica que 1≤k≤N1 \le k \le N e que os kk últimos bytes valem todos kk, e os remove; caso contrário, devolve ⊥\bot.

O fato de k≥1k \ge 1 é essencial: sempre se acrescenta pelo menos um byte, então o último byte do texto completado sempre informa quanto remover. Por isso unpad(pad(M))=M\mathrm{unpad}(\mathrm{pad}(M)) = M para todo MM, sem ambiguidade. A consequência é que, quando ∣M∣|M| já é múltiplo de NN, acrescenta-se um bloco inteiro de padding (k=Nk = N). Isso vai aparecer no primeiro experimento.

3.2 Cifragem e decifragem

Definição 5 · Modo ECB

Seja pad(M)=P0 ∥ P1 ∥ ⋯ ∥ Pm−1\mathrm{pad}(M) = P_0 \,\|\, P_1 \,\|\, \cdots \,\|\, P_{m-1}, com Pi∈{0,1}nP_i \in \{0,1\}^{n}. Então

ECB.EncK(M)=EK(P0) ∥ EK(P1) ∥ ⋯ ∥ EK(Pm−1)\mathrm{ECB}.\mathrm{Enc}_K(M) = E_K(P_0) \,\|\, E_K(P_1) \,\|\, \cdots \,\|\, E_K(P_{m-1})

ou seja, Ci=EK(Pi)C_i = E_K(P_i) para cada ii. A decifragem aplica a inversa bloco a bloco e remove o padding:

ECB.DecK(C)=unpad(EK−1(C0) ∥ ⋯ ∥ EK−1(Cm−1))\mathrm{ECB}.\mathrm{Dec}_K(C) = \mathrm{unpad}\big(E_K^{-1}(C_0) \,\|\, \cdots \,\|\, E_K^{-1}(C_{m-1})\big)

A corretude é imediata: como EK−1∘EKE_K^{-1} \circ E_K é a identidade, a decifragem recupera pad(M)\mathrm{pad}(M), e unpad\mathrm{unpad} recupera MM.

P₀ = "BLOCO-A…" EK C₀ P₁ = "BLOCO-B…" EK C₁ P₂ = "BLOCO-A…" EK C₂ = C₀ Nenhuma ligação entre as colunas: nem IV, nem encadeamento, nem contador.
Figura 1 — No ECB, cada bloco passa sozinho pela mesma permutação EKE_K. Blocos iguais na entrada (azul) saem iguais.

A implementação abaixo segue a definição ao pé da letra: a função E é a permutação AES aplicada a um único bloco, e o ECB é construído em cima dela. O módulo será reutilizado em todos os experimentos.

aes_ecb.pypython
# aes_ecb.py — ECB implementado a partir da permutação E_K de um único bloco
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

N = 16  # tamanho do bloco do AES, em bytes (128 bits)

def E(K: bytes, x: bytes) -> bytes:
    """E_K(x): a permutação AES aplicada a exatamente um bloco."""
    assert len(x) == N
    enc = Cipher(algorithms.AES(K), modes.ECB()).encryptor()
    return enc.update(x) + enc.finalize()

def D(K: bytes, y: bytes) -> bytes:
    """E_K^{-1}(y): a permutação inversa."""
    assert len(y) == N
    dec = Cipher(algorithms.AES(K), modes.ECB()).decryptor()
    return dec.update(y) + dec.finalize()

def blocos(m: bytes) -> list[bytes]:
    return [m[i:i + N] for i in range(0, len(m), N)]

def pad(m: bytes) -> bytes:
    """PKCS#7: k bytes de valor k, com k = N - (|m| mod N) em {1, ..., N}."""
    k = N - (len(m) % N)
    return m + bytes([k]) * k

def unpad(m: bytes) -> bytes:
    k = m[-1]
    if not 1 <= k <= N or m[-k:] != bytes([k]) * k:
        raise ValueError("padding inválido")
    return m[:-k]

def ecb_encrypt(K: bytes, M: bytes) -> bytes:
    return b"".join(E(K, P) for P in blocos(pad(M)))

def ecb_decrypt(K: bytes, C: bytes) -> bytes:
    assert len(C) % N == 0
    return unpad(b"".join(D(K, Ci) for Ci in blocos(C)))

4.Primeiro experimento: o padrão aparece

Ciframos uma mensagem de três blocos com estrutura A B AA\,B\,A e verificamos, bloco a bloco, se Ci=EK(Pi)C_i = E_K(P_i):

01_padrao.pypython
import os
from aes_ecb import E, blocos, ecb_encrypt, ecb_decrypt

K = os.urandom(16)
M = b"BLOCO-A........." b"BLOCO-B........." b"BLOCO-A........."   # estrutura A B A

C = ecb_encrypt(K, M)
for i, Ci in enumerate(blocos(C)):
    print(f"C{i} = {Ci.hex()}")

# C_i = E_K(P_i): cada bloco cifrado depende só do seu próprio bloco claro
print("C0 == E_K(P0) calculado isoladamente:", blocos(C)[0] == E(K, M[:16]))
print("C0 == C2:", blocos(C)[0] == blocos(C)[2])
print("Decifra corretamente:", ecb_decrypt(K, C) == M)
$ python 01_padrao.pysaída
C0 = 02758068ae6197136d2b2871ee8c019a
C1 = fbeb6c0d635e83432fa63ffa31f83900
C2 = 02758068ae6197136d2b2871ee8c019a
C3 = 4094ffcf75c03352d6792912b0e9ec7a
C0 == E_K(P0) calculado isoladamente: True
C0 == C2: True
Decifra corretamente: True

Três observações, todas previstas pela teoria:

Como a chave é sorteada a cada execução, os valores hexadecimais mudam se você rodar de novo. A igualdade C0=C2C_0 = C_2, não.

5.O que exatamente vaza

"O ECB vaza padrões" é a frase que se costuma repetir. Podemos ser muito mais precisos e dizer exatamente o que vaza, nem mais, nem menos.

Proposição 1 · Igualdade de blocos

Seja C=ECB.EncK(M)C = \mathrm{ECB}.\mathrm{Enc}_K(M). Para todo par de índices i,ji, j:

Ci=Cj  ⟺  Pi=PjC_i = C_j \iff P_i = P_j

O mesmo vale entre mensagens diferentes cifradas com a mesma chave: se CC e C′C' cifram MM e M′M', então Ci=Cj′  ⟺  Pi=Pj′C_i = C'_j \iff P_i = P'_j.

Prova.

Por definição, Ci=EK(Pi)C_i = E_K(P_i) e Cj=EK(Pj)C_j = E_K(P_j) (ou Cj′=EK(Pj′)C'_j = E_K(P'_j)), com a mesma permutação EKE_K, pois a chave é a mesma e nada mais entra na cifra. Basta aplicar a equivalência (1). ∎

A proposição diz que quem observa os textos cifrados conhece a partição dos índices em classes de blocos iguais. Vamos dar nome a isso.

Definição 6 · Padrão de igualdades

Para pad(M)=P0 ∥ ⋯ ∥ Pm−1\mathrm{pad}(M) = P_0 \,\|\, \cdots \,\|\, P_{m-1}, o padrão de igualdades de MM é a relação de equivalência ∼M\sim_M sobre {0,…,m−1}\{0, \dots, m-1\} dada por i∼Mj  ⟺  Pi=Pji \sim_M j \iff P_i = P_j. A função de vazamento do ECB é

L(M)=(m, ∼M)ℒ(M) = \big(m,\ \sim_M\big)

Na mensagem do experimento, L(M)ℒ(M) é o número de blocos e as classes {0,2}\{0, 2\}, {1}\{1\}, {3}\{3\}: a estrutura A B A XA\,B\,A\,X. O atacante não sabe o que são AA e BB, mas sabe que o primeiro e o terceiro blocos são iguais.

A pergunta natural é se o ECB vaza mais do que isso. A resposta, sob a hipótese de que a cifra de bloco é ideal, é não:

Proposição 2 · O ECB vaza exatamente Lℒ

Substitua EKE_K por uma permutação π\pi uniforme em Perm(n)\mathrm{Perm}(n). Se L(M)=L(M′)ℒ(M) = ℒ(M'), então os textos cifrados de MM e M′M' têm exatamente a mesma distribuição.

Prova.

Sejam v1,…,vrv_1, \dots, v_r os valores distintos entre os blocos de pad(M)\mathrm{pad}(M), na ordem da primeira ocorrência, e c(i)∈{1,…,r}c(i) \in \{1, \dots, r\} a classe do bloco ii. Então Ci=π(vc(i))C_i = \pi(v_{c(i)}).

Como π\pi é uniforme em Perm(n)\mathrm{Perm}(n) e os vℓv_\ell são distintos, a tupla (π(v1),…,π(vr))(\pi(v_1), \dots, \pi(v_r)) é uniforme sobre as rr-uplas de blocos distintos. Esse fato não depende dos valores vℓv_\ell, só de serem distintos. Logo, a distribuição de CC é determinada apenas por mm e pela função cc, isto é, por L(M)ℒ(M). Se L(M)=L(M′)ℒ(M) = ℒ(M'), as duas distribuições coincidem. ∎

Com o AES real no lugar de π\pi, a mesma conclusão vale a menos da vantagem PRP (Definição 2): um atacante que distinguisse as duas distribuições serviria de distinguidor para o AES.

A conclusão importante

A Proposição 2 mostra que o defeito não está no AES. Mesmo com uma cifra de bloco perfeita, o ECB vaza L(M)ℒ(M), e nenhuma cifra de bloco, por mais forte que seja, conserta isso. O defeito está no modo de operação, que é determinístico e processa os blocos de forma independente. As seções 6 e 7 mostram que esse vazamento, que parece pequeno, é suficiente para quebrar a segurança.

Uma intuição útil: fixada a chave, EKE_K é uma tabela gigante "bloco claro → bloco cifrado", com 21282^{128} linhas. Daí o nome Electronic Codebook, livro de códigos eletrônico. O ECB é, literalmente, uma cifra de substituição cujo alfabeto tem 21282^{128} símbolos. O atacante não consegue calcular a tabela, mas cada par (claro, cifrado) que observa é uma linha que passa a conhecer, válida para sempre e em qualquer posição.

6.Segurança semântica: por que o ECB não é IND-CPA

A noção padrão de confidencialidade para cifragem simétrica é a indistinguibilidade sob ataque de texto claro escolhido (IND-CPA). A ideia: mesmo podendo cifrar o que quiser, o adversário não consegue dizer qual de duas mensagens, escolhidas por ele, foi cifrada.

Definição 7 · Jogo IND-CPA
  1. O desafiante sorteia K←GenK \leftarrow \mathrm{Gen} e um bit b←{0,1}b \leftarrow \{0, 1\}.
  2. O adversário 𝒜𝒜, com acesso ao oráculo EncK(⋅)\mathrm{Enc}_K(\cdot), escolhe M(0),M(1)M^{(0)}, M^{(1)} com ∣M(0)∣=∣M(1)∣|M^{(0)}| = |M^{(1)}|.
  3. Recebe o desafio C∗=EncK(M(b))C^* = \mathrm{Enc}_K(M^{(b)}), continua podendo consultar o oráculo e devolve um palpite b′b'.

A vantagem de 𝒜𝒜 é

AdvΠind-cpa(𝒜)=∣Pr⁡[b′=1∣b=1]−Pr⁡[b′=1∣b=0]∣=∣2Pr⁡[b′=b]−1∣\mathrm{Adv}^{\text{ind-cpa}}_{\Pi}(𝒜) = \big|\Pr[b' = 1 \mid b = 1] - \Pr[b' = 1 \mid b = 0]\big| = \big|2\Pr[b' = b] - 1\big|

Π\Pi é IND-CPA se essa vantagem é desprezível para todo 𝒜𝒜 eficiente.

Contra o ECB, o adversário vence sem esforço, e nem precisa usar o oráculo:

Teorema 1 · O ECB não é IND-CPA

Existe um adversário 𝒜𝒜 que faz zero consultas ao oráculo, roda em tempo constante e tem AdvECBind-cpa(𝒜)=1\mathrm{Adv}^{\text{ind-cpa}}_{\mathrm{ECB}}(𝒜) = 1.

Prova.

Fixe dois blocos A≠BA \neq B e escolha M(0)=A ∥ AM^{(0)} = A \,\|\, A e M(1)=A ∥ BM^{(1)} = A \,\|\, B. Ao receber C∗C^*, responda b′=0b' = 0 se C0∗=C1∗C^*_0 = C^*_1 e b′=1b' = 1 caso contrário.

Se b=0b = 0, os dois primeiros blocos completados são A,AA, A e, pela Proposição 1, C0∗=C1∗C^*_0 = C^*_1: o palpite é 0. Se b=1b = 1, são A,BA, B com A≠BA \neq B e, pela Proposição 1, C0∗≠C1∗C^*_0 \neq C^*_1: o palpite é 1. Logo Pr⁡[b′=1∣b=1]=1\Pr[b' = 1 \mid b = 1] = 1 e Pr⁡[b′=1∣b=0]=0\Pr[b' = 1 \mid b = 0] = 0, e a vantagem é 1. ∎

Simulando o jogo 10 mil vezes, com uma chave nova a cada rodada:

02_ind_cpa.pypython
import os, secrets
from aes_ecb import ecb_encrypt, blocos

def jogo_ind_cpa(adversario, rodadas=10_000):
    acertos = 0
    for _ in range(rodadas):
        K = os.urandom(16)                     # chave nova a cada rodada
        M0, M1 = adversario.escolher()
        b = secrets.randbits(1)                # bit secreto do desafiante
        C = ecb_encrypt(K, (M0, M1)[b])
        acertos += adversario.adivinhar(C) == b
    return acertos / rodadas

class Adversario:
    A, B = b"A" * 16, b"B" * 16
    def escolher(self):
        return self.A + self.A, self.A + self.B     # mesmo tamanho: 32 bytes
    def adivinhar(self, C):
        C0, C1 = blocos(C)[:2]
        return 0 if C0 == C1 else 1

p = jogo_ind_cpa(Adversario())
print(f"Taxa de acerto: {p:.4f}  ->  vantagem = |2p - 1| = {abs(2*p - 1):.4f}")
$ python 02_ind_cpa.pysaída
Taxa de acerto: 1.0000  ->  vantagem = |2p - 1| = 1.0000

O Teorema 1 é caso particular de um fato mais geral: nenhum esquema de cifragem determinístico (e sem estado) é IND-CPA. Mesmo com mensagens de um único bloco, o adversário pode consultar o oráculo com M(0)M^{(0)} e comparar a resposta com o desafio. Para ser IND-CPA, um esquema precisa ser probabilístico (ou manter estado, como um contador), de modo que cifrar a mesma mensagem duas vezes produza resultados diferentes. É exatamente o que os outros modos fazem com o IV ou o nonce.

7.Do vazamento ao ataque

Um teorema de vantagem 1 é convincente para um criptógrafo, mas a pergunta de quem defende um sistema é outra: o que um atacante consegue fazer na prática? Os quatro ataques a seguir partem de modelos de ameaça realistas e exploram duas propriedades da Definição 5: o determinismo (Proposição 1) e a independência entre blocos.

AtaqueO que o atacante precisaO que obtémCusto
7.1 Análise de padrõesSó observar textos cifradosEstrutura e repetições dos dadosNenhum
7.2 DicionárioDomínio pequeno + cifrar valores escolhidosO valor de cada registro∣V∣|V| cifragens
7.3 Recorte e colagemObter textos cifrados de entradas escolhidasMensagens forjadas aceitas como válidasPoucas consultas
7.4 Decifragem byte a bytePrefixar dados a um segredo cifradoO segredo inteiro≤257\le 257 consultas por byte

7.1 Análise de padrões

É o ataque passivo: o atacante apenas observa. Pela Proposição 1, ele aprende L(M)ℒ(M), e em dados reais isso costuma revelar muito. O exemplo clássico é uma imagem bitmap cifrada em ECB: regiões de cor uniforme viram blocos repetidos, e o contorno da figura continua visível no texto cifrado. O mesmo acontece com registros de formato fixo, cabeçalhos de protocolos e campos que se repetem.

Como a chave é a mesma entre mensagens, o vazamento também é temporal: se o mesmo bloco cifrado aparece em duas mensagens enviadas em dias diferentes, o atacante sabe que o conteúdo se repetiu.

7.2 Ataque de dicionário em domínios pequenos

Considere uma coluna de banco de dados cifrada com ECB para "proteger" um PIN de 4 dígitos, um tipo sanguíneo ou uma faixa salarial. Seja VV o conjunto de valores possíveis. Se o atacante consegue obter a cifragem de valores escolhidos (por exemplo, cadastrando a própria conta várias vezes), ele monta a tabela completa

T={ EncK(v)↦v  :  v∈V }T = \big\{\, \mathrm{Enc}_K(v) \mapsto v \;:\; v \in V \,\big\}

com ∣V∣|V| consultas e, pela injetividade, inverte qualquer registro com uma busca. Para PINs, ∣V∣=104|V| = 10^4:

05_dicionario.pypython
import os
from aes_ecb import ecb_encrypt

K = os.urandom(16)

# Banco de dados: PIN de 4 dígitos cifrado de forma determinística
usuarios = {"ana": "4821", "bruno": "0007", "carla": "9314"}
banco = {nome: ecb_encrypt(K, pin.encode()) for nome, pin in usuarios.items()}

# O atacante consegue cadastrar valores próprios (oráculo de cifragem)
# e lê a coluna cifrada. Ele monta a tabela inteira: 10^4 consultas.
tabela = {ecb_encrypt(K, f"{n:04d}".encode()): f"{n:04d}" for n in range(10_000)}

for nome, cifrado in banco.items():
    print(f"{nome:6s} -> PIN {tabela[cifrado]}")
$ python 05_dicionario.pysaída
ana    -> PIN 4821
bruno  -> PIN 0007
carla  -> PIN 9314

Sem oráculo nenhum, a análise de frequências ainda funciona, exatamente como contra uma cifra de substituição clássica: se 40% dos registros têm o mesmo bloco cifrado, esse bloco provavelmente corresponde ao valor mais comum.

7.3 Recorte e colagem: forjando um cookie de administrador

Agora o ataque explora a independência entre blocos. Um servidor guarda o perfil do usuário num cookie cifrado com ECB:

email=<e-mail>&uid=10&role=user

O atacante só controla o e-mail. Como cada bloco cifrado decifra para o mesmo bloco claro em qualquer posição e em qualquer mensagem, ele pode recombinar blocos de cookies legítimos. O plano é alinhar as fronteiras dos blocos a seu favor:

CookieBloco 0Bloco 1Bloco 2Bloco 3
c1email=AAAAAAAAAAadmin + 11 × 0x0b&uid=10&role=user + padding
c2email=fran@exemp.lo&uid=10&role=user + padding—
forjadobloco 0 de c2bloco 1 de c2bloco 1 de c1—

O bloco 1 de c1 é a cifragem de admin já com um padding PKCS#7 válido, então ele pode encerrar a mensagem forjada. O filtro que remove & e = do e-mail não ajuda: o atacante não precisa injetar nenhum desses caracteres.

03_recorta_cola.pypython
import os
from aes_ecb import ecb_encrypt, ecb_decrypt, blocos, pad

K = os.urandom(16)  # segredo do servidor

# ---------- servidor ----------
def emitir_cookie(email: str) -> bytes:
    email = email.replace("&", "").replace("=", "")          # "sanitização"
    perfil = f"email={email}&uid=10&role=user"
    return ecb_encrypt(K, perfil.encode())

def ler_cookie(cookie: bytes) -> dict:
    texto = ecb_decrypt(K, cookie).decode("latin-1")
    return dict(par.split("=", 1) for par in texto.split("&"))

# ---------- atacante (só usa emitir_cookie) ----------
# 1) "admin" + padding PKCS#7 ocupando sozinho o bloco 1
#    "email=" tem 6 bytes; 10 bytes de enchimento fecham o bloco 0
e1 = "A" * 10 + pad(b"admin").decode("latin-1")
c1 = emitir_cookie(e1)

# 2) "role=" terminando exatamente no fim do bloco 1
#    6 ("email=") + 13 (e-mail) + 13 ("&uid=10&role=") = 32 bytes
e2 = "fran@exemp.lo"
c2 = emitir_cookie(e2)

forjado = c2[:32] + c1[16:32]
print("Blocos de c2:", [b.hex()[:8] for b in blocos(c2)])
print("Blocos de c1:", [b.hex()[:8] for b in blocos(c1)])
print("Forjado     :", [b.hex()[:8] for b in blocos(forjado)])
print("Servidor lê :", ler_cookie(forjado))
$ python 03_recorta_cola.pysaída
Blocos de c2: ['179fa309', '4df675d5', '0b793315']
Blocos de c1: ['686a878c', '899e245a', '2da77af2', '7af41891']
Forjado     : ['179fa309', '4df675d5', '899e245a']
Servidor lê : {'email': 'fran@exemp.lo', 'uid': '10', 'role': 'admin'}

Repare que o servidor decifrou e aceitou a mensagem forjada sem perceber nada. Cifrar garante (no máximo) confidencialidade; não garante integridade. A falta de integridade não é exclusiva do ECB, mas a independência entre blocos torna a falsificação trivial.

7.4 Decifragem byte a byte: de exponencial a linear

Este é o ataque mais impressionante. Suponha que o servidor cifre a entrada do usuário seguida de um segredo SS: um token, um identificador de sessão, dados de outro usuário. O atacante tem acesso ao oráculo

𝒪(x)=ECB.EncK(x ∥ S)𝒪(x) = \mathrm{ECB}.\mathrm{Enc}_K\big(x \,\|\, S\big)

Isso acontece, por exemplo, quando um sistema monta um token cifrado concatenando um campo controlado pelo usuário com dados internos. Adivinhar SS por força bruta custaria 256∣S∣256^{|S|} tentativas. O ataque byte a byte recupera SS com custo linear em ∣S∣|S|.

A ideia. Escolha um enchimento x=Akx = \texttt{A}^{k} com k=N−1k = N - 1. O primeiro bloco cifrado é EK(A15 ∥ s0)E_K(\texttt{A}^{15} \,\|\, s_0), em que s0s_0 é o primeiro byte do segredo. O atacante conhece 15 dos 16 bytes desse bloco. Basta testar os 256 valores possíveis de cc, consultando 𝒪(A15 ∥ c)𝒪(\texttt{A}^{15} \,\|\, c), e comparar o primeiro bloco: pela Proposição 1, há coincidência se e somente se c=s0c = s_0. Depois, com k=14k = 14, o primeiro bloco passa a ser EK(A14 ∥ s0 ∥ s1)E_K(\texttt{A}^{14} \,\|\, s_0 \,\|\, s_1), e o atacante já conhece s0s_0. E assim por diante.

O caso geral. Para recuperar o byte sis_i, use k=N−1−(i mod N)k = N - 1 - (i \bmod N). Então ∣x∣+i=k+i=(N−1)+N⌊i/N⌋|x| + i = k + i = (N-1) + N\lfloor i/N \rfloor, e o byte sis_i cai na última posição do bloco de índice ℓ=⌊i/N⌋\ell = \lfloor i/N \rfloor. Os 15 bytes anteriores a ele são os 15 últimos de x ∥ s0⋯si−1x \,\|\, s_0 \cdots s_{i-1}, todos conhecidos. Chamando esses 15 bytes de ww:

𝒪(x)ℓ=EK(w ∥ si)e𝒪(w ∥ c)0=EK(w ∥ c)𝒪(x)_{\ell} = E_K(w \,\|\, s_i) \qquad\text{e}\qquad 𝒪(w \,\|\, c)_0 = E_K(w \,\|\, c)

e, pela equivalência (1), 𝒪(x)ℓ=𝒪(w ∥ c)0  ⟺  c=si𝒪(x)_{\ell} = 𝒪(w \,\|\, c)_0 \iff c = s_i.

Descobrindo o tamanho de SS. Com PKCS#7, ∣𝒪(x)∣=N⌈(∣x∣+∣S∣+1)/N⌉|𝒪(x)| = N\big\lceil (|x| + |S| + 1)/N \big\rceil. Aumentando ∣x∣|x| a partir de zero, o tamanho do texto cifrado salta pela primeira vez quando ∣x∣+∣S∣|x| + |S| atinge um múltiplo de NN, isto é, quando ∣x∣+∣S∣=∣𝒪(ε)∣|x| + |S| = |𝒪(\varepsilon)|. Logo ∣S∣=∣𝒪(ε)∣−j|S| = |𝒪(\varepsilon)| - j, em que jj é o menor tamanho de xx que causa o salto.

04_byte_a_byte.pypython
import os
from aes_ecb import ecb_encrypt, N

K = os.urandom(16)
# segredo desconhecido do atacante
S = b"token_api=7f3a9c2e1b5d;usuario=admin;validade=2027-01-31"

consultas = 0
def oraculo(x: bytes) -> bytes:
    """O servidor devolve ECB_K(x || S)."""
    global consultas
    consultas += 1
    return ecb_encrypt(K, x + S)

# Passo 1: descobrir |S| pelo salto no tamanho do texto cifrado
t0 = len(oraculo(b""))
j = 1
while len(oraculo(b"A" * j)) == t0:
    j += 1
tamanho_S = t0 - j
print("Tamanho do segredo descoberto:", tamanho_S)

# Passo 2: confirmar ECB (dois blocos iguais de entrada -> dois blocos iguais de saída)
c = oraculo(b"A" * 2 * N)
print("ECB confirmado:", c[:N] == c[N:2 * N])

# Passo 3: recuperar S byte a byte
conhecido = b""
for i in range(tamanho_S):
    k = N - 1 - (i % N)                       # põe o byte i no fim de um bloco
    prefixo = b"A" * k
    idx = i // N                              # índice do bloco que contém o byte i
    alvo = oraculo(prefixo)[idx * N:(idx + 1) * N]
    janela = (prefixo + conhecido)[-(N - 1):] # os 15 bytes que antecedem o byte i
    for c in range(256):
        if oraculo(janela + bytes([c]))[:N] == alvo:
            conhecido += bytes([c])
            break

print("Segredo recuperado:", conhecido.decode())
print("Correto:", conhecido == S, "| consultas ao oráculo:", consultas)
$ python 04_byte_a_byte.pysaída
Tamanho do segredo descoberto: 56
ECB confirmado: True
Segredo recuperado: token_api=7f3a9c2e1b5d;usuario=admin;validade=2027-01-31
Correto: True | consultas ao oráculo: 4902

O custo. Para cada byte, o ataque faz uma consulta para obter o alvo e no máximo 256 para testar os candidatos. Somando a fase de descoberta do tamanho (no máximo N+1N + 1 consultas) e a confirmação do ECB (uma consulta):

#consultas  ≤  (N+2)+257 ∣S∣\#\text{consultas} \;\le\; (N + 2) + 257\,|S|

Para ∣S∣=56|S| = 56, isso dá no máximo 14.410 consultas, contra 25656≈10135256^{56} \approx 10^{135} da força bruta. O código testa os candidatos em ordem crescente e para no primeiro acerto, então gasta si+2s_i + 2 consultas no byte ii. Como o segredo é texto ASCII (valor médio dos bytes ≈ 85), o total observado foi 4.902. Este é o ataque dos desafios 12 e 14 do Cryptopals, e a mesma ideia de controlar onde caem as fronteiras dos blocos reaparece em ataques a outros modos, como o BEAST contra o TLS.

8.Como identificar ECB

8.1 Em textos cifrados capturados

Pela Proposição 1, blocos repetidos no texto cifrado são um forte indício de ECB: em um modo probabilístico seguro, a chance de dois blocos de 128 bits coincidirem por acaso é desprezível (da ordem de m2/2129m^2 / 2^{129} para mm blocos, pelo paradoxo do aniversário). O teste é simples: contar repetições.

06_detectar.pypython
import os
from collections import Counter
from aes_ecb import ecb_encrypt
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

def blocos_repetidos(C: bytes, n: int = 16) -> int:
    contagem = Counter(C[i:i + n] for i in range(0, len(C), n))
    return sum(v - 1 for v in contagem.values() if v > 1)

def ctr_encrypt(K, M):
    enc = Cipher(algorithms.AES(K), modes.CTR(os.urandom(16))).encryptor()
    return enc.update(M) + enc.finalize()

K = os.urandom(16)
alinhado     = b"status=ativo;plano=basico;uf=SP;" * 8   # registro de 32 bytes
desalinhado  = b"status=ativo;plano=basico;" * 8          # registro de 26 bytes

print("Alinhado    | ECB:", blocos_repetidos(ecb_encrypt(K, alinhado)),
      "| CTR:", blocos_repetidos(ctr_encrypt(K, alinhado)))
print("Desalinhado | ECB:", blocos_repetidos(ecb_encrypt(K, desalinhado)),
      "| CTR:", blocos_repetidos(ctr_encrypt(K, desalinhado)))
$ python 06_detectar.pysaída
Alinhado    | ECB: 14 | CTR: 0
Desalinhado | ECB: 0 | CTR: 0

O caso desalinhado é um alerta importante: a ausência de repetições não prova que o modo não é ECB. Registros de 26 bytes nunca se alinham às fronteiras de 16 bytes dentro dessa mensagem (o padrão só se repetiria a cada mmc(26,16)=208\mathrm{mmc}(26, 16) = 208 bytes), então nenhum bloco se repete, embora o vazamento continue lá. É por isso que, nos ataques da seção 7, o atacante controla o alinhamento com enchimentos.

8.2 Numa revisão de código

Em auditoria, o ECB costuma aparecer de forma explícita ou, pior, como valor padrão de uma API:

Linguagem / bibliotecaComo o ECB aparece
Java (JCA)Cipher.getInstance("AES") sem modo explícito usa AES/ECB/PKCS5Padding no provedor padrão; também "AES/ECB/..."
Python cryptographymodes.ECB()
Python PyCryptodomeAES.new(chave, AES.MODE_ECB)
Node.jscrypto.createCipheriv("aes-128-ecb", ...)
OpenSSL (C)EVP_aes_128_ecb(), EVP_aes_256_ecb()
.NETCipherMode.ECB

Uma busca inicial no repositório já encontra a maioria dos casos:

busca rápidashell
grep -rniE 'ecb|getInstance\("AES"\)' --include=*.{java,kt,py,js,ts,go,c,cpp,cs} .

Ferramentas de análise estática como Semgrep e CodeQL têm regras prontas para esses padrões. Ao encontrar o problema, o achado no relatório pode ser descrito assim: "dados cifrados com AES-ECB, sem autenticação; vazamento de igualdade entre blocos e possibilidade de recombinação de blocos permitem [consequência concreta]", com a severidade definida pela consequência: escalonamento de privilégio, recuperação de segredos, inferência sobre dados pessoais.

9.Como corrigir

A seção 6 mostrou que o problema de fundo é o determinismo, e a seção 7.3 mostrou que falta integridade. A correção precisa resolver os dois. Trocar ECB por outro modo sem autenticação resolve só o primeiro, e cada modo tem suas próprias armadilhas, que serão assunto das próximas partes da série.

A recomendação padrão é cifragem autenticada (AEAD), como AES-GCM ou ChaCha20-Poly1305: um nonce único por mensagem torna a cifragem não determinística, e a tag de autenticação faz qualquer alteração ser rejeitada.

07_correcao.pypython
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag

chave = AESGCM.generate_key(bit_length=128)
aead = AESGCM(chave)

def emitir_cookie(perfil: bytes) -> bytes:
    nonce = os.urandom(12)                        # nunca repetir com a mesma chave
    return nonce + aead.encrypt(nonce, perfil, b"cookie-v1")

def ler_cookie(cookie: bytes) -> bytes:
    nonce, ct = cookie[:12], cookie[12:]
    return aead.decrypt(nonce, ct, b"cookie-v1")  # InvalidTag se foi alterado

m = b"email=fran@exemp.lo&uid=10&role=user"
c1, c2 = emitir_cookie(m), emitir_cookie(m)
print("Mesma mensagem, cifrados iguais?", c1 == c2)

# Tentativa de recorte e colagem: troca um trecho do cookie por outro
forjado = c1[:28] + c2[28:]
try:
    ler_cookie(forjado)
except InvalidTag:
    print("Cookie alterado rejeitado: InvalidTag")
$ python 07_correcao.pysaída
Mesma mensagem, cifrados iguais? False
Cookie alterado rejeitado: InvalidTag

Três cuidados que costumam aparecer em revisões:

Existe algum uso legítimo para o ECB? Apenas como componente interno de outras construções, aplicado a um único bloco de conteúdo imprevisível. Para proteger dados, a resposta prática é: não use.

10.Resumo

  • O ECB cifra cada bloco de forma independente: Ci=EK(Pi)C_i = E_K(P_i), sem IV, encadeamento ou contador.
  • Como EKE_K é determinística e injetiva, Ci=Cj  ⟺  Pi=PjC_i = C_j \iff P_i = P_j, dentro de uma mensagem e entre mensagens (Proposição 1).
  • Com uma cifra de bloco ideal, o ECB vaza exatamente o padrão de igualdades L(M)ℒ(M) (Proposição 2). O defeito é do modo, não do AES.
  • Esse vazamento basta para vencer o jogo IND-CPA com vantagem 1, sem nenhuma consulta (Teorema 1). Nenhum esquema determinístico é IND-CPA.
  • Na prática, o vazamento vira análise de padrões, ataques de dicionário, falsificação por recorte e colagem e recuperação de segredos com custo linear.
  • A correção é cifragem autenticada com nonce único, como AES-GCM ou ChaCha20-Poly1305.

Nas próximas partes da série, vamos abrir o AES por dentro e analisar os outros modos de operação com o mesmo nível de rigor: o que cada um garante, sob quais hipóteses e como cada um falha quando essas hipóteses são violadas.


Referências

  1. NIST. FIPS 197: Advanced Encryption Standard (AES).
  2. NIST. SP 800-38A: Recommendation for Block Cipher Modes of Operation.
  3. Housley, R. RFC 5652: Cryptographic Message Syntax (seção 6.3, padding).
  4. Katz, J.; Lindell, Y. Introduction to Modern Cryptography, 3ª ed. CRC Press (capítulo sobre cifragem simétrica e modos de operação).
  5. Boneh, D.; Shoup, V. A Graduate Course in Applied Cryptography.
  6. Bellare, M.; Desai, A.; Jokipii, E.; Rogaway, P. "A Concrete Security Treatment of Symmetric Encryption". FOCS 1997.
  7. Cryptopals, Set 2 (desafios 12, 13 e 14).
  8. Documentação da biblioteca cryptography para Python.