Pruebas diferenciales: la única barrera contra las alucinaciones del modelo en ingeniería inversa

Última actualización: 2026-07-30 10:58:49

El modelo termina de analizar tu función hoja y te entrega una conclusión impecable: "Esto es ChaCha20; solo han cambiado el número de rondas por una variable derivada de la clave". Suena plausible. Tanto que casi apetece ponerse directamente a reescribirlo.

Pero no. Hasta que esa afirmación se convierta en un assert que se ejecute, no es más que una afirmación. Por muchas implementaciones públicas que el modelo haya visto, no puede verificar si ha acertado en este caso concreto. Eso no es una cuestión de lenguaje, sino de hechos: hay que ejecutar ambos lados con las mismas entradas y comparar las salidas byte a byte.

Para eso existe una herramienta: la prueba diferencial. Es la fase 3 del flujo de cuatro etapas y el destino final de cualquier hipótesis surgida de la búsqueda de desviaciones. El modelo plantea sospechas; la prueba diferencial dicta sentencia.

Pedir al modelo que confirme si algo es correcto no sirve de nada

Hay una pregunta que quien empieza suele hacerle al modelo: "¿Puedes confirmar que esta implementación es correcta?"

Es una pregunta sin salida, por tres motivos.

Primero, el modelo está predispuesto a darte la razón. "¿Es correcta esta implementación?" ya contiene una expectativa: claramente quieres que lo sea. El modelo suele captar ese tono y responder que sí.

Segundo, no tiene con qué juzgarlo. La única base para afirmar que algo es "correcto" es comparar su resultado con el de la caja negra usando el mismo conjunto de entradas, y eso no forma parte de su contexto. Sin una referencia real, solo puede estimar si el código "parece razonable". Y lograr que el código parezca razonable es, precisamente, una de las virtudes del código ofuscado.

Tercero, el destinatario de la pregunta es el equivocado. La corrección no es una opinión negociable: es el hecho de que una ecuación se cumpla o no. No se lo preguntes al modelo; entrégaselo a assert. Si el modelo dice que está bien pero la prueba falla, gana la prueba. Si el modelo dice que está mal pero todas las pruebas pasan, vuelve a ganar la prueba. En cuanto delegas el veredicto en el modelo, estás construyendo sobre una alucinación.

Qué entradas límite conviene probar

La estructura de una prueba diferencial es sencilla: usas el original como caja negra y comparas su salida con la de tu reescritura para cada entrada del mismo lote. El valor no está en el bucle, sino en las entradas que eliges.

Lanzarle diez mil cadenas aleatorias normales y obtener todo en verde no demuestra gran cosa. Las entradas corrientes recorren el camino principal; los puntos de desviación suelen esconderse en los bordes. Lo que de verdad aporta información son los límites:

# 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 interroga una rama concreta. El byte alto \xff deja al descubierto el tratamiento del bit de signo: la diferencia entre >>> y >> en JS, o la presencia de un & 0xff en Python, aparece en este único caso. El trío B-1 / B / B+1 es el más agresivo: la lógica de relleno de bloques solo se revela aquí. La cobertura completa de bytes sirve para alfabetos personalizados; basta un carácter de más o de menos en la tabla de mapeo para que este caso falle sin excepción.

La propia prueba diferencial cabe en menos de diez líneas:

# 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()}"

Hay un matiz importante: cuando tu hipótesis sobre el punto de desviación afecta a un algoritmo estándar, ni siquiera siempre necesitas la caja negra original. Los estándares públicos suelen incluir vectores de prueba oficiales. Por ejemplo, RFC 8439 §2.1.1 ofrece un par fijo de entrada y salida para la quarter round de ChaCha20: si introduces a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567, obtienes a=0xea2a92f4, …. Haz que tu reescritura supere primero el vector estándar y compárala después con la caja negra objetivo. Así separas limpiamente dos problemas: "he implementado mal ChaCha" frente a "el objetivo ha modificado ChaCha".

Fíjate en que el mensaje de assert incluye len(case). Es la línea de diagnóstico más útil de toda la suite. Si de los siete casos solo falla B+1, el problema estará casi con toda seguridad en el relleno o en el acarreo. Si falla únicamente la cobertura completa de bytes, el error está en el mapa del alfabeto. La longitud del caso fallido te señala directamente la capa incorrecta, sin conjeturas.

Ejecuta dos veces la misma entrada

El obstáculo más habitual al reescribir lógica de firmas no es identificar mal el algoritmo, sino no haber aislado una fuente de entropía.

Detectarlo requiere una sola línea: ejecutar dos veces la misma entrada.

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

Si los dos resultados son distintos, la implementación está mezclando un time.time(), un nonce, un contador autoincremental u otro elemento que cambia en cada llamada. En ese punto ni siquiera puedes hacer una comparación diferencial: la caja negra produce una respuesta distinta cada vez, así que no hay una salida estable contra la que comparar.

La solución no es eliminar la fuente de entropía —si lo haces, la firma deja de ser correcta—, sino extraerla del código y convertirla en un parámetro inyectable, fijado a un valor constante durante la prueba 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
        ...

Haz lo mismo con la caja negra objetivo: localiza dónde inyecta la marca de tiempo o el valor aleatorio y busca una forma de fijarlo, ya sea interceptando Date.now en la página o pasando una semilla fija a Node. Con la entropía bloqueada en ambos lados, la salida vuelve a ser determinista y la comparación adquiere sentido. Cuando la reescritura pase de extremo a extremo, vuelve a sustituir clock y rng por las implementaciones reales.

También aquí el modelo tiene poco que aportar: dónde se oculta la entropía y cómo se inyecta es comportamiento de ejecución. Lo haces aflorar ejecutando dos veces, no leyendo el código.

Aísla el fallo por capas, no compares solo el resultado final

Supón que la salida global de tu reescritura no coincide con la de la caja negra. No te obsesiones con esa cadena final de bytes: es el resultado de varias capas anidadas y aún no sabes cuál ha fallado.

Un firmador suele estar organizado por capas: un hash o cifrado de bloques en el núcleo, una capa de codificación alrededor —alguna familia de Base64, hexadecimal o una tabla personalizada— y, por encima, una capa de ensamblaje que concatena un prefijo, inserta campos o añade una cabecera de longitud. Aislar capa a capa implica comparar desde dentro hacia fuera y avanzar solo cuando la capa actual pase:

# 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

Esto presupone que tu reescritura puede volcar el estado intermedio de cada capa y que también puedes extraer los valores intermedios equivalentes de la caja negra, normalmente instrumentándola en tiempo de ejecución. El esfuerzo adicional merece la pena: la primera capa que diverge es la defectuosa, y el foco se estrecha de inmediato.

Además, encaja directamente con las cuatro categorías de puntos de desviación: una divergencia en la capa de digest suele indicar una constante alterada o un cambio en el número de rondas; en la capa de encode, normalmente un alfabeto reordenado; en la capa de assemble, suele haber material incrustado en la salida. La capa que diverge te dice en qué categoría del artículo sobre fingerprinting debes buscar.

Por qué los fixtures ganan valor con el tiempo

Cuando una prueba diferencial se pone en verde, la sensación es excelente, pero es una validación puntual. Mañana upstream puede publicar una versión nueva y la implementación que validaste hoy podría dejar de servir por completo.

El artefacto que realmente gana valor con el tiempo es el fixture: una tabla de entradas conocidas y sus salidas conocidas, guardada en el repositorio.

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

Su valor se hace evidente en cuanto cambia upstream. Un día el CI se pone en rojo y falla contra el fixture una implementación que no tocaste ayer. Esa información no tiene precio: descarta "he cometido un error" y apunta de forma inequívoca a "upstream ha cambiado". Sin un fixture, puedes perder media jornada depurando código perfectamente correcto porque no sabes si el problema es tuyo o si alguien ha movido la portería.

Las mejores entradas para el fixture son exactamente los casos límite anteriores: ya son el conjunto con mayor cobertura que tienes.

En esta etapa, el modelo solo hace dos cosas

La división de responsabilidades en una prueba diferencial es muy simple: el modelo hace dos cosas y no tiene voto en el veredicto.

Primero, generar casos. Preparar en bloque entradas límite para muchos primitivos, o construir un lote de entradas que difieren en un solo bit para investigar una constante sospechosa, es enumeración. No requiere razonamiento, y una capa barata con alta concurrencia es la opción más rentable.

Segundo, explicar un diff. Cuando falla un caso, le muestras los dos volcados por capas y le pides que explique, atendiendo a bytes concretos, dónde aparece la primera divergencia y a cuál de las cuatro categorías de desviación pertenece con más probabilidad. Este paso requiere razonamiento intermedio y capacidad para "explicar a partir de bytes": es donde el modelo de este artículo sí aporta trabajo real.

El veredicto sigue siendo de assert, sin excepciones. La explicación del modelo es una pista, no una conclusión. Que las pistas apunten en una dirección equivocada es normal, y assert las detecta.

Estas dos tareas exigen capacidades distintas; usar una sola capa para todo implica desperdiciar dinero o precisión:

Subpaso de la prueba diferencial

Capacidad necesaria

Elección

model id

Generar en bloque casos límite y de control para muchos primitivos

Bajo coste y alta concurrencia; enumerar no requiere razonamiento

Claude Sonnet 5

claude-sonnet-5

Leer un único diff fallido y explicarlo a partir de bytes

Razonamiento intermedio; la atribución debe aterrizar en una capa concreta

GPT-5.6 Sol

gpt-5.6-sol

Investigar causas raíz profundas cuando la atribución no converge, como alteraciones de constantes

Razonamiento sólido; infiere a través de estados intermedios de múltiples rondas

Claude Opus 5

claude-opus-5

Procesar muchos volcados por capas o un lote completo de fixtures para encontrar la divergencia

Contexto largo

Kimi K3

kimi-k3

La segunda capa es la herramienta de batalla. Una sola ronda de pruebas te dirá si la atribución de diferencias justifica elegir un modelo específico. El protocolo es corto:

  1. Elige un caso que falle de verdad de tu suite diferencial, junto con sus dos volcados por capas: el de la caja negra y el de tu reescritura.

  2. Entrega el mismo diff por separado a gpt-5.6-sol y claude-opus-5, y pídeles solo dos cosas: en qué capa surge la primera divergencia y a cuál de las cuatro categorías de desviación pertenece con mayor probabilidad.

  3. Fíjate únicamente en si la atribución señala un byte concreto y una capa concreta o si devuelve una vaguedad como "podría ser un problema de padding".

  4. La precisión de la atribución es tu criterio de selección: determina directamente cuántas rondas de cambios necesitarás para que ese caso pase.

Una sola ronda te mostrará la diferencia con más claridad que cualquier clasificación de benchmarks.

El problema real es el coste de cambiar de proveedor

Estas cuatro capas proceden de tres proveedores: tres SDK, tres esquemas de autenticación y tres formatos de error. Integrar tu cliente tres veces solo para alternar entre capas según el subpaso no merece la pena. Por eso la mayoría acaba usando una sola capa para todo, obtiene atribuciones vagas de diferencias y quema varias rondas de cambios sin entender el motivo.

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

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

¿Ya usas el SDK de OpenAI? Apunta base_url a https://aireiter.com/api/v1 y no cambies nada más. ¿Usas el SDK de Anthropic? Llama a POST /api/v1/messages con la misma clave.

En precio, los modelos Claude tienen un 30% de descuento sobre el precio de lista y los modelos GPT cuestan la mitad. En este flujo, el descuento afecta justo al paso más intenso: la atribución de diferencias es la parte de las pruebas diferenciales que se invoca con mayor frecuencia. Hay una ronda por cada caso fallido, y cada cambio en upstream implica reconstruir los fixtures y atribuir un lote completamente nuevo de casos fallidos. La herramienta principal, gpt-5.6-sol, es un modelo GPT a mitad de precio, así que el tramo más denso se reduce directamente a la mitad. La escalada ocasional a claude-opus-5 para investigar una causa raíz profunda supone pocas llamadas, pero también se beneficia del 30% de descuento de Claude.

Conclusión

La estructura de una reescritura de ingeniería inversa solo tiene dos pilares: el modelo formula la hipótesis; assert dicta el veredicto.

El modelo genera sospechas y ha visto todas las implementaciones públicas. Puede decirte en segundos "quizá se haya modificado aquí", pero nunca sabe si esta vez tiene razón. La prueba diferencial es la máquina que convierte el "quizá" en un "sí/no": las entradas límite fuerzan las ramas, ejecutar dos veces la misma entrada expone la fuente de entropía, el aislamiento por capas identifica la capa defectuosa y el fixture distingue entre "lo he implementado mal" y "ellos lo han cambiado".

Al final, todo juicio que te entreguen el flujo de cuatro etapas y el artículo sobre búsqueda de desviaciones tiene que pasar por esta barrera. Lo que diga el modelo no cuenta; solo cuenta lo que diga assert.