Testes Diferenciais: a Única Barreira Contra Alucinações de Modelo na Engenharia Reversa

Última Atualização: 2026-07-30 10:58:49

O modelo termina de analisar sua função de baixo nível e entrega uma conclusão bem formulada: "Isto é ChaCha20, mas a contagem de rodadas foi trocada por uma variável derivada da chave." Parece plausível. Dá até vontade de partir direto para a reimplementação.

Não faça isso. Enquanto essa afirmação não virar um assert executável, ela continua sendo apenas uma frase. Não importa quantas implementações públicas o modelo tenha visto: ele não consegue confirmar se acertou neste caso específico. Essa é uma questão factual, não linguística, e só pode ser respondida colocando as saídas lado a lado e comparando byte a byte.

É para isso que serve o teste diferencial. Ele é o Estágio 3 do fluxo de quatro etapas e o destino de toda hipótese produzida na busca por desvios. O modelo levanta uma suspeita; o teste diferencial dá o veredito.

Pedir confirmação ao modelo não resolve nada

Uma das perguntas favoritas de quem está começando é: "Você pode confirmar que esta implementação está correta?"

Ela não leva a lugar nenhum, por três motivos.

Primeiro, o modelo é inclinado a concordar com você. A própria pergunta "esta implementação está correta?" já carrega uma expectativa: você quer que ela esteja certa. O modelo costuma captar esse sinal e responder que sim.

Segundo, ele não tem como julgar. A única base para declarar algo "correto" é comparar a saída da caixa-preta para o mesmo conjunto de entradas — e esses dados não estão no contexto dele. Sem uma referência real, o modelo só pode inferir se o código "parece razoável". E código ofuscado é especialmente bom em parecer razoável.

Terceiro, você está perguntando para a entidade errada. Correção não é uma opinião negociável; é o fato de uma equação se sustentar ou não. Não entregue essa pergunta ao modelo: entregue-a ao assert. Se o modelo disser que está certo e o teste falhar, o teste vence. Se o modelo disser que está errado e todos os testes passarem, o teste continua vencendo. Quando você terceiriza o veredito, passa a construir sobre uma alucinação.

Entradas de borda: onde estão os casos que importam

A estrutura de um teste diferencial é simples: você trata o original como uma caixa-preta e compara sua reimplementação com ele, caso a caso, usando o mesmo lote de entradas. O valor não está no loop, mas nas entradas que você escolhe.

Jogar dez mil strings aleatórias e comuns contra a função e obter tudo verde não prova quase nada. Entradas normais percorrem o caminho principal; os pontos de desvio geralmente ficam nos cantos. O que realmente traz informação são os limites:

# Boundary cases: derived from block size B
def boundary_cases(B: int) -> list[bytes]:
    return [
        b"",                 # empty: exposes initial state and padding logic
        b"\x00",             # single zero byte
        b"\xff",             # single high byte: checks sign-bit / unsigned handling
        b"A" * (B - 1),      # one below the block boundary
        b"A" * B,            # exactly one block
        b"A" * (B + 1),      # one above: checks carry and padding
        bytes(range(256)),   # full byte coverage: checks the alphabet map covers the whole domain
    ]

Cada caso força um ramo específico. O byte alto \xff expõe o tratamento do bit de sinal: a diferença entre >>> e >> em JS, ou a presença de um & 0xff em Python, aparece nesse único caso. A trinca B-1 / B / B+1 é a mais agressiva, porque é nela que a lógica de padding de blocos se revela. Já a cobertura completa de bytes serve para alfabetos customizados: basta um caractere a mais ou a menos na tabela de mapeamento para esse caso falhar.

A estrutura do teste diferencial em si cabe em menos de dez linhas:

# Original as black box, rewrite compared case by case
def diff_test(blackbox, rewritten, B: int) -> None:
    for case in boundary_cases(B):
        got, want = rewritten(case), blackbox(case)
        assert got == want, f"len={len(case)} hex={case.hex()}"

Há um detalhe importante: quando sua hipótese sobre o ponto de desvio envolve um algoritmo padronizado, nem sempre é necessário usar a caixa-preta original. Padrões públicos frequentemente incluem vetores de teste autoritativos. A RFC 8439 §2.1.1, por exemplo, fornece um par fixo de entrada e saída para a quarter round do ChaCha20: com a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567, o resultado é a=0xea2a92f4, …. Faça sua reimplementação passar primeiro pelo vetor padrão e só então compare-a à caixa-preta alvo. Assim, você separa claramente dois problemas: "eu implementei ChaCha errado" e "o alvo alterou ChaCha".

Repare que a mensagem do assert inclui len(case). Esta é a linha de diagnóstico mais útil de toda a suíte. Se, dos sete casos, só o B+1 falhar, o problema quase certamente está no padding ou no carry. Se apenas a cobertura completa de bytes falhar, o problema está no mapa de alfabeto. O tamanho da entrada que falhou aponta diretamente para a camada errada, sem adivinhação.

Execute a mesma entrada duas vezes

Ao reimplementar lógica de assinatura, o obstáculo mais comum não é identificar o algoritmo de forma errada. É uma fonte de entropia que ainda não foi isolada.

Você detecta esse problema com uma linha: execute a mesma entrada duas vezes.

# Same input twice: differing results mean a hidden RNG / timestamp
def assert_deterministic(fn, case: bytes) -> None:
    assert fn(case) == fn(case), "unfixed entropy source or timestamp present"

Se os resultados forem diferentes, a implementação misturou um time.time(), um nonce, um contador incremental ou qualquer outro elemento que muda a cada chamada. Nesse ponto, você nem consegue fazer uma comparação diferencial: a caixa-preta responde algo diferente toda vez. Comparar contra o quê?

A correção não é remover a fonte de entropia — se você a remover, a assinatura estará errada. O caminho é extraí-la do código e transformá-la em um parâmetro injetável, fixado em um valor conhecido durante o teste diferencial:

# Lift the entropy source into an injectable parameter, pin it with a stub for diffing
class Rewritten:
    def __init__(self, clock=time.time, rng=os.urandom):
        self._clock = clock          # formerly an inline time.time(), now injected
        self._rng = rng

    def __call__(self, data: bytes) -> bytes:
        ts = int(self._clock())      # for diffing, clock=lambda: 0
        nonce = self._rng(16)        # for diffing, rng=lambda n: b"\x00" * n
        ...

Faça o mesmo com a caixa-preta alvo: localize o ponto em que ela injeta timestamp ou aleatoriedade e encontre uma forma de fixá-lo, como interceptar Date.now na página ou passar uma seed fixa ao Node. Com a entropia controlada nos dois lados, a saída volta a ser determinística e o diff passa a ter significado. Quando a reimplementação passar de ponta a ponta, troque clock e rng de volta pelas implementações reais.

Também é um passo em que o modelo não pode ajudar: onde a entropia está escondida e como ela entra no fluxo são comportamentos de execução. Você os expõe rodando duas vezes, não lendo o código.

Isole as camadas em vez de comparar só o resultado final

Suponha que a saída total da sua reimplementação não corresponda à da caixa-preta. Não fique preso à sequência final de bytes: ela é o produto de várias camadas aninhadas, e você ainda não sabe qual delas está errada.

Um assinador normalmente é composto por camadas: um hash ou cifra de bloco no núcleo; uma camada de codificação por cima, como uma família Base64, hex ou uma tabela própria; e uma camada externa de montagem, que concatena prefixos, insere campos ou adiciona um cabeçalho de tamanho. Isolar essas camadas significa comparar de dentro para fora e avançar apenas quando a camada atual passar:

# Peel layer by layer: match the innermost first, then move outward
LAYERS = ["digest", "encode", "assemble"]   # inner → outer

def first_divergent_layer(bb_dump, rw_dump, case: bytes) -> str | None:
    for layer in LAYERS:
        if bb_dump(case)[layer] != rw_dump(case)[layer]:
            return layer                     # the first layer to diverge is the faulty one
    return None

Isso pressupõe que sua reimplementação consiga despejar o estado intermediário de cada camada e que você também consiga extrair os valores correspondentes da caixa-preta, normalmente com instrumentação em runtime. O trabalho adicional vale muito a pena: a primeira camada que divergir é a camada defeituosa, e seu foco se reduz imediatamente.

Isso se conecta diretamente às quatro categorias de ponto de desvio: divergência na camada de digest geralmente indica uma constante perturbada ou uma alteração na contagem de rodadas; na camada de encode, normalmente indica um alfabeto reordenado; na camada de assemble, em geral há algum material embutido na saída. A camada que diverge indica em qual categoria do artigo sobre fingerprinting você deve procurar.

Fixtures ficam mais valiosas com o tempo

Quando um teste diferencial finalmente fica verde, a sensação é ótima. Mas é uma validação pontual: amanhã o upstream pode lançar uma nova versão, e a implementação que você validou hoje pode estar completamente errada.

O artefato que realmente ganha valor com o tempo é a fixture: uma tabela de entrada conhecida → saída conhecida, versionada no repositório.

# Fixture: known input → known output, committed to the repo
# Re-run on every upstream change; green yesterday, red today = upstream changed, not you
VECTORS = load_json("vectors.json")   # [{"in": "<hex>", "out": "<hex>"}, ...]

for v in VECTORS:
    got = rewritten(bytes.fromhex(v["in"])).hex()
    assert got == v["out"], v["in"]

O valor aparece no instante em que o upstream muda. Um dia, o CI fica vermelho e é a implementação que você não alterou ontem que falha contra a fixture. Essa informação é valiosíssima: ela elimina a hipótese de "eu cometi um erro" e aponta diretamente para "o upstream mudou". Sem uma fixture, você pode perder meio dia depurando código perfeitamente correto, porque não consegue distinguir uma falha sua de uma mudança nas regras externas.

As melhores entradas para a fixture são justamente os casos de borda já apresentados: eles já formam o conjunto de maior cobertura que você tem.

Nesta etapa, o modelo só faz duas coisas

A divisão de trabalho em um teste diferencial é simples: o modelo faz duas coisas e não participa do veredito.

Primeiro: gerar casos. Montar em lote entradas de borda para várias primitivas, ou criar um conjunto de entradas que diferem por um único bit para investigar uma constante suspeita, é trabalho de enumeração. Não exige raciocínio, e uma camada barata com alta concorrência é a opção mais econômica.

Segundo: explicar um diff. Quando um caso falha, você apresenta os dumps das duas camadas e pede que ele explique, byte a byte, onde surge a primeira divergência e a qual das quatro categorias de desvio ela provavelmente pertence. Esta etapa exige raciocínio intermediário e capacidade de explicar com base nos bytes. É aqui que o modelo realmente trabalha.

O veredito continua sendo do assert, sem exceção. A explicação do modelo é uma pista, não uma conclusão. Pistas erradas são normais, e o assert está ali para capturá-las.

Essas duas tarefas exigem capacidades diferentes. Usar a mesma camada para tudo desperdiça dinheiro ou precisão:

Subetapa do teste diferencial

Capacidade necessária

Escolha

model id

Gerar em lote casos de borda/controle para várias primitivas

Baixo custo e alta concorrência; enumeração não exige raciocínio

Claude Sonnet 5

claude-sonnet-5

Ler um único diff com falha e explicar com base nos bytes

Raciocínio intermediário; atribuição em uma camada específica

GPT-5.6 Sol

gpt-5.6-sol

Investigar causas-raiz profundas quando a atribuição não converge, como perturbação de constantes

Raciocínio forte; inferência sobre estado intermediário de múltiplas rodadas

Claude Opus 5

claude-opus-5

Processar muitos dumps de camadas ou um lote inteiro de fixtures para encontrar a divergência

Contexto longo

Kimi K3

kimi-k3

A segunda camada é a principal ferramenta de trabalho. Para saber se vale a pena escolher um modelo especificamente para atribuição de diferenças, basta uma rodada de teste. O protocolo é curto:

  1. Escolha um caso que realmente falhou na sua suíte diferencial, com os dumps de camada dos dois lados: a caixa-preta e sua reimplementação.

  2. Envie o mesmo diff separadamente para gpt-5.6-sol e claude-opus-5, perguntando somente duas coisas: em qual camada está a primeira divergência e a qual das quatro categorias de desvio ela provavelmente pertence.

  3. Observe apenas se a atribuição chega a um byte específico e a uma camada específica ou se devolve algo vago como "pode ser um problema de padding".

  4. A precisão da atribuição é o critério de escolha, porque ela determina diretamente quantas rodadas de edição serão necessárias para fazer esse caso passar.

Uma única rodada revela a diferença de forma mais direta que qualquer ranking de benchmark.

O verdadeiro problema é o custo de alternar

Essas quatro camadas vêm de três fornecedores: três SDKs, três esquemas de autenticação e três formatos de erro. Configurar seu cliente três vezes apenas para alternar entre camadas nas subetapas não compensa. É por isso que tanta gente acaba usando uma camada para tudo, tenta atribuir diferenças com um modelo que só responde de forma vaga e desperdiça várias rodadas de edição sem saber por quê.

AIReiter simplifica essa camada: uma chave, uma interface compatível com OpenAI, as quatro opções disponíveis e uma troca que exige apenas alterar o campo model no corpo da requisição.

# Difference attribution: the mid-reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.6-sol",
    "messages": [{"role": "user", "content": "<diff of the two layer dumps + locate the first divergent layer and deviation category>"}]
  }'

# Attribution won't converge, escalate to dig the root cause: change one field
#   "model": "claude-opus-5"
# Bulk-generate boundary cases:
#   "model": "claude-sonnet-5"

Já usa o SDK da OpenAI? Aponte base_url para https://aireiter.com/api/v1 e não altere mais nada. Usa o SDK da Anthropic? Faça POST /api/v1/messages com a mesma chave.

Nos preços, os modelos Claude têm 30% de desconto sobre a tabela, enquanto os modelos GPT custam metade do preço. Neste fluxo, o desconto incide justamente na etapa mais intensa: a atribuição de diferenças é a parte mais chamada dos testes diferenciais. Há uma rodada por caso com falha, e toda mudança no upstream exige reconstruir as fixtures e atribuir um novo lote inteiro de falhas. O modelo principal, gpt-5.6-sol, é um modelo GPT pela metade do preço; assim, a parte mais densa do trabalho cai diretamente pela metade. A escalada ocasional para claude-opus-5, quando é preciso investigar uma causa-raiz profunda, envolve poucas chamadas, mas o desconto de 30% dos modelos Claude também se aplica ali.

Conclusão

A base de uma reimplementação em engenharia reversa tem apenas dois pilares: o modelo propõe a hipótese; o assert dá o veredito.

O modelo é um gerador de suspeitas que já viu implementações públicas de todo tipo. Em segundos, ele pode apontar que "talvez algo tenha sido alterado aqui", mas nunca sabe se acertou desta vez. O teste diferencial é a máquina que transforma "talvez" em "é/não é": entradas de borda expõem os ramos, executar a mesma entrada duas vezes revela a fonte de entropia, isolar as camadas localiza a falha e a fixture distingue "eu implementei errado" de "eles mudaram".

Toda conclusão produzida pelo fluxo de quatro etapas e pelo artigo sobre busca de desvios precisa passar por esse portão no fim. O que o modelo diz não conta; só conta o que o assert diz.