Quebrei minha própria criptografia sete vezes — e isso foi o ponto
Tudo começou com uma pergunta ingênua: "dá pra fazer minha própria cifra usando Fibonacci e números primos?" Terminou como uma suíte de 50 ataques criptográficos rodando, byte a byte idêntica, em dez linguagens diferentes — PHP, Node, TypeScript, Python, Bash, Java, Go, Rust, Dart e Julia.
Pelo caminho, quebrei a própria cifra sete vezes. De propósito. Esse artigo é sobre esse processo — porque o valor real não estava em "ter uma cripto própria" (ninguém deveria usar isso em produção, e o README do projeto avisa isso em letras garrafais), estava em cada vulnerabilidade encontrada ensinar algo que nenhum curso teórico ensina tão bem quanto quebrar algo com as próprias mãos.
O ponto de partida: XOR com header fixo
A primeira versão era simples: XOR do texto contra uma chave, com um marcador fixo (NKC) no início do token pra identificar o formato. Parecia razoável. Levou menos de dez minutos pra cair.
O problema: o marcador fixo, combinado com uma chave estática reaproveitada em todo XOR, criava uma âncora de texto-claro-conhecido. Bastavam alguns tokens pra reconstruir a chave inteira por eliminação — o mesmo princípio usado há mais de cem anos pra quebrar a cifra de Vigenère, só que dessa vez o "texto conhecido" era um byte de formatação que eu mesmo tinha colocado lá.
Lição 1: qualquer estrutura previsível dentro de dados cifrados com chave reutilizada é uma porta aberta. Não importa quão "escondida" pareça.
A cifra caseira: Fibonacci, primos e dígitos de Pi
Depois da primeira queda, a reconstrução ficou interessante. A ideia central: gerar o keystream através de uma recorrência parecida com Fibonacci, operando em blocos de 32 bytes, com uma etapa de difusão entre posições.
Aqui apareceram os elementos que dão nome ao projeto:
- Fibonacci → números primos: as distâncias usadas na etapa de mistura entre posições do bloco vêm de mapear números de Fibonacci para o N-ésimo primo correspondente. Como o bloco tem 32 posições (uma potência de 2), qualquer distância ímpar é automaticamente coprima com 32 — o que garante, por pura teoria dos números, que a mistura nunca colapsa em subgrupos menores. Escolher primos (que são ímpares, exceto o 2) foi uma forma elegante de garantir essa propriedade sem precisar calcular o MDC toda vez.
- Dígitos de Pi: usados como fonte pública e determinística de variação nas rotações de bits internas — o mesmo princípio de "nothing up my sleeve numbers" que o Blowfish usa há décadas. Não é segredo; é só uma forma de evitar padrões repetitivos sem alegar aleatoriedade que não existe.
Isso já era mais sofisticado que XOR puro. E ainda assim, quebrou de novo. Várias vezes.
Sete quedas, sete lições
1. O keystream vazava o prefixo do texto
Uma versão inicial gerava o keystream numa cadeia que dependia do conteúdo já processado. Resultado: cifrar "A", "AA" e "AAAA" produzia os primeiros bytes do keystream idênticos entre as três mensagens. Isso é a assinatura clássica de um cifrador de fluxo mal implementado — o tipo de coisa que quebrou protocolos reais no passado quando o IV não estava sendo misturado de verdade no gerador.
2. Metades do bloco colapsavam numa cópia idêntica
Essa foi a mais elegante de explicar matematicamente. A etapa de difusão combinava a posição p com a posição p + distância usando XOR — uma operação comutativa. Sempre que a distância era exatamente metade do tamanho do bloco (16, num bloco de 32), as posições p e p+16 calculavam a mesma coisa, porque XOR(x, y) == XOR(y, x). Rodei o teste com 10.000 IVs aleatórios: todos os 10.000 produziam blocos com a primeira metade idêntica à segunda.
A correção veio de duas frentes complementares: quebrar a simetria do combinador (rotacionar só um dos lados antes do XOR) e trocar a régua de distâncias fixas por distâncias primas ímpares — a mesma ideia de Fibonacci→primo mencionada acima, que evita esse tipo de divisor problemático por construção.
3. Avalanche fraca no MAC
O código de integridade (um checksum caseiro de 32 bytes, inspirado em hashes estilo FNV) processava os bytes em ordem, sempre do primeiro ao último. O último byte processado só passava por uma rodada de mistura antes de virar saída — resultado: mudar 1 bit na última posição só alterava ~25% dos bits do MAC, longe dos ~50% esperados de um bom código de autenticação. Adicionar uma etapa de "finalização" (mais rodadas de mistura depois que todos os bytes já entraram) resolveu.
4. Ataque integral — um eco do Square Attack contra o Rijndael
Esse foi o mais sofisticado de todos, e mereceu revisão de literatura de verdade. A técnica: variar 1 byte de entrada por todos os 256 valores possíveis e somar (XOR) as 256 saídas resultantes. Para uma função com grau algébrico máximo, essa soma deveria dar zero só por coincidência (1 em 256 vezes). Encontramos 65 posições de saída zeradas em 8.192 testes, quando o esperado eram ~32 — um desvio estatístico de mais de 12 desvios-padrão.
A causa: rotação de bits e XOR são operações lineares (grau algébrico 1); só soma e multiplicação (via carry) aumentam o grau, e aumentam devagar. Com poucas rodadas de mistura, várias posições de saída não tinham tido "tempo" de acumular grau algébrico suficiente. A correção foi dobrar o número de rodadas de difusão — um ajuste simples, mas que só fez sentido depois de entender a matemática por trás do sintoma.
5. Separação frágil entre chave e IV
Descobrimos que keystream(chave, iv) era sempre idêntico a keystream(chave XOR D, iv XOR D16), para qualquer máscara D de 32 bytes com período 16. O IV só deslocava a chave por XOR uma única vez, no início — sem injetar entropia própria a cada rodada. Provamos isso construindo a máscara na mão e confirmando que os keystreams batiam byte a byte. A correção: fazer a chave participar de cada rodada da recorrência, não só do estado inicial.
6. Token smuggling via base64 não-canônico
Essa fraqueza não estava na cifra — estava na decodificação. base64_decode() sem modo estrito ignora silenciosamente espaços, quebras de linha e caracteres inválidos. Pior: mesmo em modo estrito, o último grupo de um base64 pode ter bits "não utilizados" marcados como 1 em vez de 0 e ainda decodificar para o mesmo valor. Isso abre espaço para um WAF, cache ou log enxergar uma string diferente da que o sistema de decriptação realmente processa — uma classe real de vulnerabilidade em sistemas web. A correção envolveu validar o alfabeto por regex, decodificar em modo estrito, e — o passo que realmente fecha a brecha — reencodar o resultado e comparar com a entrada original.
7. O bug que só existia em uma linguagem
Ao portar tudo para Bash (sim, Bash — mais sobre isso já já), reintroduzi acidentalmente um problema que já tínhamos resolvido no núcleo: guardar bytes binários decodificados numa variável de string comum. Bash não representa o byte 0x00 dentro de uma string — trunca silenciosamente ali. Como IV e texto cifrado têm ~1/256 de chance de conter um byte zero em qualquer posição, esse bug seria intermitente e digno de pesadelo em produção. A correção: manter os dados sempre como array de números decimais até o último instante possível, convertendo para binário só na saída final via stream direto.
Formalizando: de "achei um bug" para uma suíte de 50 ataques
Depois da quinta ou sexta rodada de "acho que achei algo" seguida de verificação manual, ficou claro que precisávamos de uma suíte reutilizável em vez de scripts descartáveis. Nasceu daí uma arquitetura simples: uma interface AtaqueInterface com dois métodos (nome() e executar(alvo)), um objeto ResultadoAtaque padronizado, e um adaptador CriptografiaAlvo que expõe os hooks necessários (gerarKeystreamBruto, checksumBruto, decompor/recompor do token) sem exigir que a cifra real vire pública só por causa dos testes.
A suíte cresceu para 50 ataques, cobrindo categorias que vão de sanidade básica a criptoanálise formal:
- Correção: ida-e-volta, dados binários, determinismo (vetores de referência)
- Integridade: adulteração de bits, colisão de MAC, length extension, força bruta de MAC
- Estrutural: fold em subdivisões do bloco, cobertura de dependência entrada×saída, correlação entre posições
- Estatístico clássico: qui-quadrado, monobit/runs/blocos (bateria NIST-style), cumulative sums, entropia aproximada
- Criptoanálise formal: complexidade linear (Berlekamp-Massey), SAC (Strict Avalanche Criterion), BIC (Bit Independence Criterion), aproximação linear de Walsh — a mesma técnica usada historicamente para atacar o DES
- Vazamento entre mensagens: reuso forçado de IV, correlação entre ciphertexts do mesmo texto, chaves relacionadas
Cada ataque, ao encontrar um problema real, virou uma correção concreta no código — e uma vez corrigido, o teste correspondente passou a rodar para sempre, pegando qualquer regressão futura antes que ela chegasse a qualquer lugar importante.
A parte inesperada: dez linguagens, um vetor de referência
Em algum momento a pergunta virou "dá pra portar isso pra outras linguagens?" — e a resposta, byte a byte, foi sim. A cifra e a suíte inteira hoje rodam de forma independente em PHP, Node.js, TypeScript, Python, Bash, Java, Go, Rust, Dart e Julia.
O critério de validação era simples e implacável: gerar o keystream para uma chave e IV fixos ("K" repetido 32 vezes, IV zerado) e comparar o hexadecimal resultante entre todas as implementações. As dez batem, byte a byte:
219a73a5bdb588b63187fa656d1492e0728c73ef526f6c525cf37cfb0f125249
O porte para Bash mereceu destaque à parte. A primeira versão, escrita de forma direta (cada operação aritmética como uma chamada de função em subshell), levava mais de 5 segundos para cifrar 200 bytes. Depois de embutir a aritmética diretamente nos laços — eliminando a criação de processos filhos a cada rotação de bits —, o mesmo teste caiu para ~0.23 segundos: cerca de 22 vezes mais rápido, sem mudar um único byte da saída (confirmado pelo vetor de referência).
O que fica
Nenhuma das descobertas aqui é original — cada uma delas ecoa uma técnica de criptoanálise real, documentada há décadas: ataques de texto-claro-conhecido, colapso por simetria algébrica, ataques integrais (Square Attack), separação inadequada de material de chave, ambiguidade de codificação. A diferença de encontrá-las numa cifra própria, com as próprias mãos, é que cada uma delas deixou de ser um parágrafo de livro-texto e virou uma intuição — o tipo de coisa que você reconhece de longe da próxima vez.
O projeto termina onde deveria terminar qualquer cifra caseira: com um aviso bem visível. Passar em 50 ataques automatizados prova ausência dos bugs que sabíamos procurar — não prova segurança. AES e ChaCha20 têm décadas de escrutínio público, competições formais e ataques publicados por milhares de criptoanalistas profissionais. Esse projeto tem algumas semanas e duas pessoas. Para qualquer coisa que precise proteger dados de verdade, a resposta continua sendo a mesma de sempre: use uma biblioteca consagrada.
Mas para aprender por que essas bibliotecas são desenhadas do jeito que são? Não achei nada melhor do que quebrar a própria criptografia — sete vezes — até ela parar de quebrar.
O código completo (cifra + suíte de ataques nas dez linguagens) está disponível em github.com/code-cfernandes/fbc-criptografia.