Engenharia reversa de JS com LLM: quatro tarefas para a IA e três que você não pode delegar

Última Atualização: 2026-07-30 10:18:56

Você abre um SDK de assinaturas que saiu do webpack. As variáveis se chamam a ou _0x3f2b, o fluxo de controle foi achatado e as strings foram parar em um array acessado por índice. O objetivo é direto: extrair dali uma implementação independente que gere exatamente os mesmos bytes do original.

Hoje, o impulso inicial de quase todo mundo é jogar o bundle inteiro em um modelo de linguagem e perguntar: “o que este código faz?”. É aí que muita gente erra logo de saída. O modelo devolve uma explicação aparentemente impecável, você implementa a ideia e o resultado não bate com o alvo nem por um byte.

O problema não é falta de capacidade do modelo, mas uma divisão de trabalho mal feita. Em desofuscação, LLMs são excelentes em reconhecer padrões e formular hipóteses, mas fracos em validar fatos. Eles podem encontrar a estrutura de uma primitiva criptográfica escondida em uma massa de operações de bits, mas não têm como provar que acertaram. Essa parte precisa ficar com os seus testes.

A seguir está um fluxo de quatro etapas baseado nessa separação — e três responsabilidades que eu nunca delego ao modelo.

Etapa 1: separe o código com scripts, não com IA

O reflexo mais comum é pensar: “a janela de contexto é enorme, então basta enviar tudo”. Não basta. Há dois bons motivos para evitar isso.

O primeiro é desperdício. Grande parte de um bundle ofuscado é formada por polyfills, shims de runtime e módulos de negócio que não têm relação alguma com o trecho que você procura. Você paga para colocá-los no contexto e recebe uma análise com atenção diluída.

O segundo motivo é ainda mais importante: quanto maior o contexto, mais pontos existem para uma alucinação se encaixar. O modelo pode unir características de dois módulos sem relação entre si e chegar a uma conclusão coerente por dentro, mas inexistente no código. Esse tipo de erro é muito mais difícil de detectar do que um absurdo evidente.

Separar o código é trabalho determinístico. Faça isso em um script:

  • Use uma ferramenta de AST, como @babel/parser ou acorn, para dividir o bundle em módulos e funções, indexando-os por escopo;

  • Extraia todos os literais numéricos e constantes de string, agrupados por frequência e largura de bits;

  • Monte o grafo de chamadas e marque os nós com in-degree 0, que são os pontos de entrada, e out-degree 0, que são as primitivas de folha;

  • Localize funções com densidade incomum de operações bit a bit — ^, >>>, << e & concentrados em uma função normalmente indicam o núcleo algorítmico.

As primitivas de folha são o que realmente interessa mostrar ao modelo. Em geral, têm poucas dezenas de linhas, não dependem do estado das camadas superiores e oferecem fronteiras claras de entrada e saída. Uma função de folha com 40 linhas, acompanhada das constantes que referencia, é uma granularidade que o modelo lida de forma confiável.

Ao fim desta etapa, você deve ter uma “lista de primitivas candidatas”: cada item contém o corpo da função, as constantes referenciadas e seus pontos de chamada. A partir daí, cada consulta ao modelo trabalha com um único item dessa lista — um por vez.

Etapa 2: use o modelo para reconhecer a família do algoritmo

Esta é a etapa em que o modelo é insubstituível.

Algoritmos criptográficos e de codificação têm impressões digitais fortes: constantes específicas, combinações características de deslocamentos de bits, determinadas estruturas de loop. Uma pessoa aprende a reconhecê-los com experiência acumulada; um modelo já viu implementações públicas de todos eles, então esse reconhecimento lhe é natural.

Alguns exemplos públicos ajudam a entender como é uma “impressão digital”:

  • 0x811c9dc5 e 0x01000193, quando aparecem juntos, são a base de deslocamento e o primo do hash FNV-1a de 32 bits — valores padrão publicados na página de referência do FNV, mantida pelo coautor Landon Curt Noll e apresentados ali em decimal: 2166136261 e 16777619;

  • 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574 são as palavras little-endian da string ASCII "expand 32-byte k" — as constantes do estado inicial do ChaCha20, listadas na RFC 8439 §2.3;

  • uma estrutura como a += b; d ^= a; d = rotl(d, 16); c += d; b ^= c; b = rotl(b, 12);, com as quatro rotações 16/12/8/7, é a assinatura da quarter round do ChaCha20; o algoritmo aparentado Salsa20 usa 7/9/13/18, e esses quatro números já bastam para diferenciá-los;

  • uma tabela de 64 constantes que começa com 0xd76aa478 é a tabela T do MD5 (RFC 1321 §3.4);

  • uma tabela de 256 bytes iniciada por 0x63, 0x7c, 0x77, 0x7b é a S-box do AES (FIPS 197, Tabela 4);

  • 0xEDB88320 é o polinômio refletido do CRC-32, o valor usado na especificação gzip, RFC 1952.

O segredo está inteiramente em como você pergunta. “O que este código faz?” rende texto explicativo. O que você precisa é de um parecer estruturado e verificável. Por isso, o prompt deve obrigar uma resposta em três partes: candidatos, evidências e contraevidências.

Below is a leaf function extracted from an obfuscated bundle, together with
every numeric constant it references.

<function>
{{function body}}
</function>

<constants>
{{constant list, with locations}}
</constants>

Answer in the following structure. Do not write prose.

1. Candidate algorithm families (at most 3, ranked by likelihood)
   For each: the name, and which class it belongs to
   (hash / stream cipher / block cipher / encoding / compression / checksum)

2. Supporting evidence
   Every piece of evidence must point to a specific constant value or a
   specific line above. "Structurally similar" and other unverifiable
   phrasing is not allowed.

3. Counter-evidence and deviation points
   If this were the standard implementation of that algorithm, what should
   appear here but doesn't? What appears that a standard implementation
   would never contain? Are these deviations a "variant," or "I got it wrong"?

4. The minimal test to decide this hypothesis
   Give 3 concrete inputs and, if the hypothesis holds, the shape of the
   output each should produce. Include boundary cases.

A parte 3 é o que faz esse prompt valer a pena. Os desvios em relação à implementação padrão são justamente os pontos em que o código foi alterado: um alfabeto personalizado, uma constante trocada, uma quantidade de rodadas modificada. E esses desvios são as únicas partes que realmente vão exigir trabalho na reescrita; todo o resto pode vir diretamente da implementação pública. Como fazer o modelo expor cada um desses desvios é o tema central de nosso artigo sobre identificação por impressão digital de algoritmos.

A parte 4 converte o julgamento do modelo diretamente nos testes que você executará em seguida, poupando uma rodada adicional de interação.

Esta etapa também costuma encontrar codificações fora do padrão. O teste é mecânico: alfabeto com 65 caracteres, sendo 64 caracteres mais um símbolo de padding, grupos de 6 bits e comprimento de saída ceil(n/3)*4 indicam a família Base64; se o alfabeto estiver reordenado, trata-se de uma tabela personalizada. Da mesma forma, um dicionário que começa em 256 e cresce, enquanto a largura dos códigos aumenta à medida que o dicionário enche, indica LZW. Tudo isso pode ser validado apenas pelas relações entre os tamanhos de entrada e saída, sem ler uma linha de código.

Etapa 3: transforme a hipótese em um teste diferencial

O modelo forneceu uma hipótese. Antes de você escrever o teste, ela continua sendo apenas uma frase.

Não há atalho aqui. Este é o único portão de todo o fluxo capaz de barrar alucinações — e o artigo sobre testes diferenciais explica por que ele é o único. Trate a implementação original como uma caixa-preta e compare sua reescrita contra ela, caso a caso:

# Differential-test skeleton: original as black box, rewrite compared case by case
CASES = [
    b"",                      # empty input: exposes initial state and padding logic
    b"\x00",                  # single zero byte
    b"\xff",                  # single high byte: checks sign-bit handling
    b"a" * 63,                # one below the block boundary
    b"a" * 64,                # exactly one block
    b"a" * 65,                # one above: checks padding and carry
    bytes(range(256)),        # full byte coverage: checks alphabet mapping
]

for case in CASES:
    assert rewritten(case) == blackbox(case), case.hex()

Algumas regras práticas ajudam:

As transições de fronteira carregam mais informação. Algoritmos em bloco revelam sua lógica de padding com maior facilidade em 64n e 64n±1. Se apenas um caso falhar em toda a execução, o tamanho daquela entrada aponta diretamente para a camada problemática.

Execute a mesma entrada duas vezes. Se os resultados forem diferentes, a implementação mistura um número aleatório ou timestamp ao processamento. Nesse caso, você precisa localizar o ponto de injeção e permitir que ele seja sobrescrito externamente; sem isso, não há como fazer uma comparação diferencial. Esse é o obstáculo mais comum ao reimplementar lógica de assinatura: o algoritmo não está errado, mas a fonte de entropia ainda não foi isolada.

Descasque as camadas; não compare tudo de uma vez. Primeiro faça o hash interno coincidir. Depois valide a camada de codificação e, em seguida, a camada de montagem. Quando a saída inteira diverge, não dá para saber onde está a falha; separando as camadas, a primeira que falhar é a defeituosa.

Versione seus vetores fixos como fixture. Uma tabela de entrada conhecida → saída conhecida é o recurso que permite decidir rapidamente “eu implementei errado ou eles mudaram algo?” depois de uma atualização upstream. O valor desse fixture só aumenta com o tempo.

Nesta etapa, o papel do modelo é gerar casos de teste e explicar diferenças — não decidir quem está certo. Quem decide é o assert.

Etapa 4: entregue com degradação por camada de transporte

Depois que todas as hipóteses passarem, transforme o resultado em código capaz de rodar de forma sustentável. Existe uma ordem de prioridade, e quanto mais cedo estiver a camada, mais vale a pena lutar por ela:

  1. Reescrita nativa na linguagem de destino. Totalmente desacoplada do runtime original e dependente apenas da biblioteca padrão. É a única opção sem processo extra, sem dependência adicional e com integração limpa ao CI.

  2. Motor JS local executando um fragmento mínimo. Algumas lógicas são caras demais para purificar no curto prazo. Nesse caso, mantenha uma pequena fatia do JS original e execute-a em um Node/V8 local. Vale lembrar que o contexto de um motor JS não é thread-safe; chamadas ao mesmo contexto compilado a partir de múltiplas threads precisam de lock:

class Signer:
    def __init__(self, source: Path) -> None:
        self._context = execjs.get("Node").compile(source.read_text())
        self._lock = threading.Lock()  # V8 context is not thread-safe

    def call(self, fn: str, *args):
        with self._lock:
            return self._context.call(fn, *args)
  1. Ponte passiva com o navegador. Para estados que só podem ser obtidos em um runtime de página real, o navegador é a única saída por enquanto. Essa é uma solução temporária; deixe isso explícito nas notas de interface e nunca permita que ela vire a implementação padrão.

Vale registrar essa ordem de degradação nas convenções do projeto. A cada camada abaixo, a superfície de dependências, os modos de falha e o custo de implantação aumentam em uma ordem de grandeza — a camada 1 é uma função pura; a camada 3 é um processo externo que exige uma pessoa para manter uma sessão viva. Lute pela camada 1 como padrão e você evitará boa parte do custo de longo prazo que o “faz funcionar de qualquer jeito” acumula silenciosamente. Como definir essas três camadas e impedir que a ponte passiva saia de controle está detalhado em nosso artigo sobre a escada de purificação.

Como referência, um SDK de assinaturas ofuscado, processado pelas quatro etapas, pode terminar como uma implementação independente com menos de 600 linhas, dependente apenas do crypto nativo do runtime. Essa redução não significa que o modelo “entendeu” o arquivo original: uma vez identificada corretamente a família do algoritmo, a maior parte do código pode ser copiada diretamente da implementação pública.

O modelo certo para cada etapa

As quatro etapas exigem capacidades bem diferentes. Usar um único modelo para tudo desperdiça dinheiro ou precisão:

Etapa

Capacidade realmente necessária

Minha escolha

model id

Mapeamento estrutural após a separação

Contexto longo, capaz de ler o grafo de chamadas de um módulo inteiro em uma passada

Kimi K3

kimi-k3

Identificação da família do algoritmo e contraevidências

Raciocínio forte; encontra desvios e questiona a própria conclusão

Claude Opus 5

claude-opus-5

Renomeação em massa de símbolos e inclusão de comentários

Baixo custo, capaz de fazer centenas de chamadas com alta concorrência

Claude Sonnet 5

claude-sonnet-5

Atribuição de diferenças, ao analisar um diff depois de um teste falhar

Raciocínio intermediário; explica divergências com base em bytes específicos

GPT-5.6 Sol

gpt-5.6-sol

A etapa 2 merece atenção especial. A identificação da família do algoritmo é a única fase em que trocar de modelo muda visivelmente o resultado, porque ela testa exatamente duas coisas: “quantas implementações públicas você já viu?” e “você consegue contestar a si mesmo?”. Diante da mesma função de folha, um modelo mais fraco pode oferecer uma resposta errada com confiança; um modelo mais forte pode riscar seu próprio candidato na seção de contraevidências.

Não precisa acreditar na diferença sem testar. O protocolo é este:

  1. Escolha 3 funções de folha do seu bundle ofuscado, incluindo pelo menos 1 cuja resposta você já conheça, para servir de controle.

  2. Usando o prompt em três partes da Etapa 2, envie a mesma entrada separadamente para claude-opus-5 e gpt-5.6-sol.

  3. Observe apenas duas coisas: o modelo acertou a família candidata? E a seção de contraevidências realmente questiona a própria hipótese ou apenas repete o candidato com outras palavras?

  4. A qualidade da seção de contraevidências é o seu critério de escolha, pois ela determina diretamente quantos testes inúteis você escreverá na Etapa 3.

As etapas 3 e 4 quase não exigem escolha de modelo — qualquer um que execute a tarefa serve. A etapa 1 só precisa de um modelo de contexto longo para evitar que você implemente sua própria recuperação segmentada.

O problema não é escolher o modelo, e sim trocar entre eles

Quatro modelos de três fornecedores significam três SDKs, três esquemas de autenticação e três formatos de erro. Reescrever seu cliente para economizar um pouco não compensa — e é exatamente por isso que a maioria das pessoas acaba usando um único modelo para tudo.

AIReiter elimina essa camada: uma chave, uma interface compatível com OpenAI, os quatro modelos por trás dela e a troca se resume a alterar o campo model no corpo da requisição.

# Algorithm-family ID: the reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<Stage 2 prompt + leaf function + constants>"}]
  }'

# Bulk symbol renaming: change the model field, leave everything else
#   "model": "claude-sonnet-5"

Se você já usa o SDK da OpenAI, aponte base_url para https://aireiter.com/api/v1 e não mude mais nada. Se usa o SDK da Anthropic, envie uma requisição para POST /api/v1/messages com a mesma chave.

Em preço, os modelos Claude têm 30% de desconto sobre o valor de tabela, e os modelos GPT custam metade do preço. Isso pesa mais do que parece neste fluxo: a renomeação em massa de símbolos na Etapa 3 facilmente chega a centenas de chamadas, e a passada de contexto longo na Etapa 1 consome algumas centenas de milhares de tokens por entrada — são essas duas etapas que concentram o custo, e o desconto incide justamente sobre a parte mais cara.

Três tarefas que não devem ir para o modelo

Primeira: não deixe o modelo gerar diretamente a implementação final. Peça uma “reescrita completa” e você receberá um código que parece pronto, executa e contém desvios sutis. O problema é que você não conseguirá localizar o desvio, pois esse código não foi validado por você camada a camada. O uso correto é pedir uma hipótese para cada primitiva, verificá-las uma por uma e fazer a montagem por conta própria. É mais lento, mas você sabe por que cada linha existe.

Segunda: não deixe o modelo decidir se o resultado está correto. “Você pode confirmar que esta implementação está certa?” é uma pergunta sem saída: o modelo tende a concordar com você. A única autoridade para decidir o que está certo ou errado é o teste diferencial. O modelo diz que está certo, mas o teste falha? O teste vence. O modelo diz que está errado, mas todos os testes passam? O teste também vence.

Terceira: não deixe o modelo tomar decisões de conformidade por você. Se você pode analisar o alvo, publicar a conclusão ou usar os dados obtidos depende da sua jurisdição, dos termos do alvo e do seu propósito específico. O modelo não tem base factual para decidir nada disso; a resposta dele apenas imita o tom dos avisos legais que já viu. Essa decisão é sua ou do seu advogado de verdade.

Conclusão

O lugar do modelo nesse tipo de trabalho é específico: ele é um reconhecedor de padrões que já viu implementações públicas de algoritmos e pode entregar hipóteses candidatas em segundos. Ele não é a fonte definitiva das respostas nem o verificador.

A estrutura do fluxo independe do uso de IA: separação mecânica, hipótese, verificação e purificação. Essas quatro etapas já formavam o processo muito antes dos modelos. O que o modelo comprime é a fase de hipótese, reduzindo dias de pesquisa em referências para minutos. As outras três continuam custando exatamente o que sempre custaram.

Quando as chamadas ao modelo nessas quatro etapas estiverem consolidadas em scripts, o fluxo inteiro passa a rodar rápido. A única fricção restante será trocar de modelo — um problema de infraestrutura, resolvido ao escolher seu modelo por meio de uma interface unificada.