Ingeniería inversa de JS con LLM: cuatro tareas que la IA sí resuelve y tres que no debes delegar

Última actualización: 2026-07-30 10:18:20

Abres un SDK de firmas escupido por webpack. Todas las variables se llaman a o _0x3f2b, el flujo de control está aplanado y las cadenas se guardan en un array al que se accede por índices. El objetivo está claro: convertirlo en una implementación independiente que produzca exactamente los mismos bytes que el original.

Hoy casi todo el mundo empieza pegando el bundle completo en un chat con un modelo de lenguaje y preguntando «¿qué hace este código?». Ahí suele llegar el primer tropiezo: el modelo devuelve una explicación impecable, la implementas y el resultado no coincide con el objetivo ni en un solo byte.

No es que el modelo sea malo; el problema es haber repartido mal el trabajo. En desofuscación, un modelo destaca en el reconocimiento de patrones y la generación de hipótesis, pero flojea en la verificación factual. Puede detectar la estructura de una primitiva criptográfica enterrada bajo operaciones de bits, pero no puede garantizar que su diagnóstico sea correcto. Eso lo deciden tus pruebas.

Este es un flujo de trabajo de cuatro etapas construido alrededor de esa división, junto con tres tareas que nunca delego en el modelo.

Etapa 1: la separación mecánica no se delega

La primera reacción suele ser: «la ventana de contexto es enorme, volcamos todo y listo». No lo hagas. Hay dos motivos.

El primero es el desperdicio. La mayor parte de un bundle ofuscado son polyfills, adaptadores de runtime y módulos de negocio que no tienen relación con tu objetivo. Pagas por meterlos en contexto y, a cambio, recibes una atención más dispersa.

El segundo es aún más importante: cuanto mayor es el contexto, más lugares hay en los que puede aterrizar una alucinación. El modelo puede unir elementos de dos módulos sin relación y entregarte una conclusión coherente por dentro, pero inexistente en el código. Detectar ese error es mucho más difícil que descartar un disparate evidente.

Separar el código es trabajo determinista. Hazlo con un script:

  • Usa una herramienta AST (@babel/parser, acorn) para dividir el bundle en módulos y funciones, e indexarlos por ámbito;

  • Extrae todos los literales numéricos y constantes de cadena, agrupados por frecuencia y ancho de bits;

  • Construye el grafo de llamadas y marca los nodos con grado de entrada 0 (puntos de entrada) y grado de salida 0 (primitivas hoja);

  • Localiza las funciones con una densidad anormalmente alta de operaciones de bits: ^, >>>, << y & concentrados en una función suelen señalar el núcleo algorítmico.

Las primitivas hoja son las que realmente interesa enseñar al modelo. Normalmente tienen unas pocas decenas de líneas, no dependen del estado de capas superiores y presentan fronteras claras de entrada y salida. Una función hoja de 40 líneas junto con las constantes a las que hace referencia es una granularidad que el modelo maneja de forma fiable.

Al terminar esta fase deberías tener una «lista de primitivas candidatas»: cada entrada contiene el cuerpo de la función, las constantes que utiliza y los puntos desde los que se invoca. A partir de aquí, cada llamada al modelo trabaja con una sola entrada de esa lista.

Etapa 2: usa el modelo para reconocer la familia algorítmica

Aquí es donde el modelo resulta difícil de sustituir.

Los algoritmos criptográficos y de codificación tienen huellas muy marcadas: constantes concretas, combinaciones específicas de desplazamientos y estructuras de bucle reconocibles. Una persona las identifica por experiencia acumulada; un modelo ha visto implementaciones públicas de todos ellos, así que este patrón le resulta natural.

Algunos ejemplos públicos para entender qué forma tiene una «huella»:

  • 0x811c9dc5 y 0x01000193 juntos corresponden a la base offset y al primo del hash FNV-1a de 32 bits: los valores estándar publicados en la página de referencia de FNV, mantenida por el coautor Landon Curt Noll, donde aparecen en decimal como 2166136261 y 16777619;

  • 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574 son las palabras little-endian de la cadena ASCII "expand 32-byte k": las constantes del estado inicial de ChaCha20, recogidas en RFC 8439 §2.3;

  • una estructura como a += b; d ^= a; d = rotl(d, 16); c += d; b ^= c; b = rotl(b, 12);, con las cuatro rotaciones 16/12/8/7, es la firma de la quarter round de ChaCha20; Salsa20, su algoritmo hermano, usa 7/9/13/18, y esos cuatro números bastan para distinguirlos;

  • una tabla de 64 constantes que empieza por 0xd76aa478 es la tabla T de MD5 (RFC 1321 §3.4);

  • una tabla de 256 bytes que comienza por 0x63, 0x7c, 0x77, 0x7b es la S-box de AES (FIPS 197, Tabla 4);

  • 0xEDB88320 es el polinomio CRC-32 reflejado, el valor usado en la especificación gzip, RFC 1952.

La clave está por completo en cómo formulas la pregunta. «¿Qué hace este código?» te devolverá prosa. Lo que necesitas es un juicio estructurado y verificable, así que el prompt debe obligar a dar tres cosas: candidatas, pruebas y contraargumentos.

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.

La tercera parte es la que hace valioso este prompt. Las diferencias respecto a la implementación estándar son precisamente los puntos donde se modificó el código: un alfabeto personalizado, una constante sustituida o un número de rondas alterado. Y esas desviaciones son lo único que tendrás que resolver realmente al reescribirlo; el resto se copia de la implementación pública. Cómo conseguir que el modelo saque a la luz hasta la última diferencia es el tema central de este artículo sobre identificación por huellas algorítmicas.

La cuarta parte convierte directamente el juicio del modelo en las pruebas que ejecutarás después, y te ahorra otra ida y vuelta.

Por cierto, en esta fase aparecen a menudo codificaciones no estándar. La comprobación es mecánica: un alfabeto de longitud 65 —64 caracteres y un símbolo de relleno—, agrupación en 6 bits y longitud de salida ceil(n/3)*4 indican la familia Base64; si el alfabeto está reordenado, hay una tabla personalizada. Del mismo modo, un diccionario que empieza en 256 y crece, con una anchura del código que aumenta a medida que se llena, es LZW. Todo esto se puede verificar solo con las relaciones entre longitudes de entrada y salida, sin leer una línea de código.

Etapa 3: convierte la hipótesis en una prueba diferencial

El modelo te ha dado una hipótesis. Hasta que no escribes la prueba, no deja de ser una frase.

Aquí no hay atajos. Es además la única barrera de todo el proceso capaz de frenar las alucinaciones —el motivo de que sea la única está explicado en este artículo sobre pruebas diferenciales—. Trata la implementación original como una caja negra y compara tu reescritura caso por 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()

Algunas reglas prácticas:

Los cruces de límite son los casos más informativos. Los algoritmos por bloques revelan con más facilidad su lógica de padding en 64n y 64n±1. Si falla exactamente un caso de toda la batería, la longitud de esa entrada te indica directamente qué capa está mal.

Ejecuta dos veces la misma entrada. Si el resultado cambia, la implementación ha mezclado un número aleatorio o una marca de tiempo. Tendrás que encontrar el punto de inyección y permitir que se sobrescriba desde fuera; de lo contrario, ni siquiera podrás hacer una comparación diferencial. Este es el bloqueo más habitual al reescribir lógica de firmas: el algoritmo no está mal, simplemente aún no has separado la fuente de entropía.

Desmonta por capas; no compares el conjunto completo. Haz coincidir primero el hash más interno; después, cuando eso pase, la capa de codificación; finalmente, la capa de ensamblado. Si toda la salida difiere, no sabes dónde está el fallo. Al separarlo, la primera capa que falla es la defectuosa.

Guarda tus vectores fijos como fixture. Una tabla de entrada conocida → salida conocida es lo que te permite decidir rápidamente «¿lo escribí mal o ellos lo cambiaron?» tras una actualización aguas arriba. El valor de ese fixture solo crece con el tiempo.

En esta etapa, el papel del modelo es generar casos de prueba y explicar diferencias, no decidir qué está bien. Quien decide qué está bien es assert.

Etapa 4: intégralo y degrada por capa de transporte

Cuando todas las hipótesis pasan las pruebas, toca convertirlas en código sostenible. Hay un orden de prioridad, y cuanto más arriba esté una opción, más merece la pena pelear por ella:

  1. Una reescritura nativa en tu lenguaje objetivo. Totalmente independiente del runtime original y apoyada solo en la biblioteca estándar. Es la única forma sin procesos adicionales, sin dependencias extra y con una integración limpia en CI.

  2. Un motor JS local que ejecute un fragmento mínimo. A corto plazo, parte de la lógica puede resultar demasiado costosa de depurar, así que conservas una porción reducida del JS original y la ejecutas en un Node/V8 local. Ten presente que el contexto de un motor JS no es thread-safe: las llamadas desde varios hilos a un mismo contexto compilado deben protegerse con un bloqueo:

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. Un puente pasivo al navegador. Si hay estado que solo puede obtenerse desde el runtime de una página real, por ahora el navegador es tu única opción. Es una solución temporal: déjalo claramente indicado en las notas de interfaz y nunca permitas que se convierta en la implementación predeterminada.

Merece la pena incorporar este orden de degradación a las convenciones del proyecto. Cada nivel que bajas multiplica por un orden de magnitud la superficie de dependencias, los modos de fallo y el coste de despliegue: el nivel 1 es una función pura; el nivel 3 es un proceso externo que necesita a una persona para mantener viva una sesión. Defiende el nivel 1 por defecto y evitarás buena parte del coste a largo plazo que acumula silenciosamente el «que funcione como sea». Cómo delimitar estos tres niveles y evitar que el puente pasivo se descontrole está explicado en este artículo sobre la escalera de purificación.

Como referencia: un SDK de firmas ofuscado, procesado a través de las cuatro etapas, puede acabar en una implementación independiente de menos de 600 líneas que solo depende de crypto, incluido en el runtime. Esa compresión no significa que el modelo «entienda» el archivo original: una vez identificada correctamente la familia algorítmica, la inmensa mayoría del código se puede copiar directamente de la implementación pública.

Qué modelo usar en cada etapa

Las cuatro etapas exigen capacidades muy distintas. Usar un único modelo para todo supone desperdiciar dinero o precisión:

Etapa

Capacidad que realmente necesita

Mi elección

model id

Mapeo estructural tras la separación

Contexto largo; lectura del grafo de llamadas de un módulo completo de una pasada

Kimi K3

kimi-k3

Identificación de familia algorítmica y contraevidencia

Razonamiento sólido; detecta desviaciones y rebate sus propias hipótesis

Claude Opus 5

claude-opus-5

Renombrado masivo de símbolos y añadido de comentarios

Económico; soporta cientos de llamadas con alta concurrencia

Claude Sonnet 5

claude-sonnet-5

Atribución de diferencias al leer un diff tras fallar una prueba

Razonamiento intermedio; explica diferencias contra bytes concretos

GPT-5.6 Sol

gpt-5.6-sol

La etapa 2 es la que merece más atención. Identificar la familia algorítmica es el único paso en el que cambiar de modelo modifica visiblemente el resultado, porque evalúa exactamente dos cosas: «cuántas implementaciones públicas has visto» y «si eres capaz de discutir contra ti mismo». Con la misma función hoja, un modelo más débil te dará una respuesta errónea con seguridad; uno más fuerte descartará su propio candidato en la sección de contraevidencia.

No hace falta creerme: compruébalo por tu cuenta. El protocolo es este:

  1. Elige 3 funciones hoja de tu propio bundle ofuscado, al menos 1 cuya respuesta ya conozcas para usarla como control.

  2. Con el prompt de tres partes de la etapa 2, pasa la misma entrada por separado a claude-opus-5 y gpt-5.6-sol.

  3. Fíjate solo en dos aspectos: si acierta la familia candidata y si la sección de contraevidencia realmente rebate su hipótesis o solo repite el candidato con otras palabras.

  4. La calidad de la sección de contraevidencia es tu criterio de selección: determina directamente cuántas pruebas inútiles escribirás en la etapa 3.

Las etapas 3 y 4 apenas requieren elegir modelo: cualquiera que funcione sirve. La etapa 1 solo necesita un modelo de contexto largo para ahorrarte implementar tu propia recuperación por fragmentos.

El problema no es elegir modelo, sino el coste de cambiar entre ellos

Cuatro modelos repartidos entre tres proveedores significan tres SDK, tres esquemas de autenticación y tres formatos de error. Reescribir tu cliente para ahorrar un poco de dinero no compensa, y por eso la mayoría termina usando el mismo modelo para todo.

AIReiter elimina esa capa: una clave, una interfaz compatible con OpenAI, los cuatro modelos detrás y un cambio de modelo que se reduce a modificar el campo model del cuerpo de la petición.

# 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"

Si ya utilizas el SDK de OpenAI, apunta base_url a https://aireiter.com/api/v1 y no cambies nada más. Si utilizas el SDK de Anthropic, llama a POST /api/v1/messages con la misma clave.

En precios, los modelos Claude tienen un 30% de descuento sobre la tarifa de lista y los modelos GPT cuestan la mitad. En este flujo importa más de lo que parece: el renombrado masivo de símbolos de la etapa 3 alcanza fácilmente cientos de llamadas, y la pasada de contexto largo de la etapa 1 consume unos cientos de miles de tokens por cada entrada. Esas dos fases concentran el gasto, y el descuento se aplica justo a la parte más cara.

Tres cosas que no debes delegar en el modelo

Primera: no le encargues directamente la implementación final. Si pides al modelo «la reescritura completa», obtendrás código que parece terminado y funciona, pero con desviaciones sutiles. Y no podrás localizar la desviación porque nadie ha verificado ese código capa a capa. Úsalo para obtener una hipótesis de cada primitiva, verifica cada una por separado y ensámblalas tú mismo. Es más lento, pero sabes por qué está escrita cada línea.

Segunda: no dejes que decida si el resultado es correcto. «¿Puedes confirmar que esta implementación está bien?» es una pregunta sin salida: el modelo tiende a darte la razón. La única autoridad para decidir entre correcto e incorrecto es la prueba diferencial. ¿El modelo dice que está bien y la prueba falla? Gana la prueba. ¿El modelo dice que está mal y todas las pruebas pasan? También gana la prueba.

Tercera: no le delegues la evaluación de cumplimiento. Que puedas tocar el objetivo, publicar las conclusiones o utilizar los datos obtenidos depende de tu jurisdicción, de los términos del objetivo y de tu finalidad concreta. El modelo no tiene una base factual para decidir nada de eso; su respuesta solo imita el tono de los avisos legales que ha visto. Esa decisión es tuya o de tu asesoría jurídica real.

Para terminar

El lugar del modelo en este tipo de trabajo es muy concreto: es un reconocedor de patrones que ha visto implementaciones públicas de algoritmos y puede devolverte hipótesis candidatas en segundos. No es la fuente de la respuesta ni quien la verifica.

La estructura del proceso no depende de que intervenga la IA: separación mecánica, hipótesis, verificación y purificación. Esos cuatro pasos ya existían mucho antes de los modelos. Lo que el modelo comprime es la fase de «hipótesis», que pasa de días revisando referencias a minutos. Las otras tres etapas siguen costando exactamente lo mismo que siempre.

Cuando conviertes las llamadas al modelo de estas cuatro etapas en scripts, el flujo completo se vuelve rápido. En ese punto, la única fricción que queda es cambiar de modelo: un problema de infraestructura que se resuelve eligiendo tu modelo sobre una interfaz unificada.