ReAct Prompting para Kill Chain Orchestration
La primera vez que intentamos que un único modelo orquestara una campaña completa de pentest, falló en el minuto cuarenta. No por falta de capacidad técnica. El modelo sabía perfectamente qué hacer con un Redis expuesto, qué hacer con un Jenkins sin autenticar y cómo encadenar SSRF con metadata de cloud. El problema fue otro, más aburrido: a las cuarenta páginas de contexto, el agente se había olvidado del alcance que le habíamos dado al principio. Empezó a escanear rangos que estaban fuera del scope, alucinó dos herramientas que no existen en nuestra toolbox (nmap-fast-aggressive y burp-pro-headless, ninguna real), y propuso explotar un CVE que el cliente ni siquiera tenía instalado.
Esa noche reescribimos buena parte del orquestador. Lo que salió de ahí es la base de lo que hoy es el motor de Gandalf CLI: una extensión bastante terca del framework ReAct adaptada a campañas largas, multifase, con varios modelos colaborando bajo el mismo techo. Este artículo es el intento de contar por qué llegamos a ese diseño, qué tomamos prestado, qué tuvimos que inventar nosotros, y dónde seguimos rompiéndonos la cara.
ReAct, sin marketing
ReAct lo publicaron Yao y compañía a finales de 2022 (Yao et al., 2022)[1]. La idea es engañosamente simple. En lugar de pedirle al modelo solo una respuesta, o solo una acción, le pides que intercale tres cosas: un Thought (pensamiento explícito en lenguaje natural sobre qué hay que hacer), una Action (la llamada a una herramienta externa, normalmente bien tipada) y una Observation (la salida que devuelve la herramienta, que entra de vuelta al contexto). Bucle. Otra vuelta. Y así hasta que el modelo decide que ya tiene suficiente.
Lo importante es que el razonamiento aparece como texto en el propio rollout. Eso resuelve dos cosas a la vez. Por un lado, el modelo gana la capacidad de Chain-of-Thought (Wei et al., 2022)[2], es decir, descomponer un problema antes de actuar. Por otro, no se queda atrapado en su propia cabeza: cada pocas iteraciones tiene que tocar el mundo de verdad y volver con observaciones reales. Eso reduce mucho la alucinación frente a CoT puro.
Para una pregunta de HotpotQA o una receta de WebShop está perfecto. Para correr un pentest, no.
Dónde se rompe ReAct cuando la campaña dura más de una hora
Tres fallos los hemos vivido prácticamente en todas las pruebas que hicimos con ReAct vainilla sobre objetivos reales.
El primero es la compactación. Una traza Thought/Action/Observation en una sesión de recon decente ocupa muchísimo más de lo que parece. Un solo nmap -sV -p- contra una /24 razonable son varios miles de tokens en la Observation. Multiplica por veinte hosts, mezcla con HTTP fingerprinting, y a las dos horas el contexto está saturado. Cuando el orquestador empieza a recortar mensajes antiguos para que quepa lo nuevo, lo primero que se pierde suele ser justo lo que más importa: las reglas de engagement, la hipótesis inicial, las restricciones del cliente.
El segundo es el drift. Sin un objetivo explícitamente reanclado en cada vuelta, el modelo se va por las ramas. Empieza con "encontrar un vector inicial" y termina enumerando subdominios de un proveedor que no era parte del scope. PentestGPT (Deng et al., 2023)[3] documentó este problema bastante bien y propuso dividirlo en módulos. Nosotros llegamos a una conclusión parecida por nuestra cuenta.
El tercero es la alucinación de herramientas. Cuando el modelo lleva muchas iteraciones, empieza a inventar acciones que no están en la toolbox. masscan-with-banners, burp-active-scan, nuclei-cloud-edition. Nombres plausibles que no existen. Si tu runner es estricto, falla en seco. Si es laxo, peor: te hace algo parecido pero no es lo que pediste.
Lo que añadimos: handoff por fase y artefactos tipados
La intuición clave para nosotros fue tratar el kill chain como una máquina de estados, no como un bucle único. Cada fase (Recon, Planning, Execution, Reporting) tiene su propio agente, su propio prompt y su propio modelo. Y entre fases no pasa contexto crudo: pasan artifacts.
Un artifact es una estructura tipada que un agente produce al cerrar su fase y otro consume al abrir la suya. No es la traza completa. No son los pensamientos intermedios. Es la conclusión destilada, validada por un segundo paso de revisión antes de avanzar.
Hoy la distribución que usamos en Gandalf CLI por defecto es la siguiente: Beorn para reconocimiento, donde queremos un modelo barato y resistente a observaciones ruidosas. Gandalf para planning, donde necesitamos el razonamiento más fino disponible y aceptamos pagar latencia. Gwaihir para execution, donde priorizamos throughput y respuesta rápida a feedback de herramientas. Cada uno tiene su sistema de prompts adaptado y, lo más importante, su propia toolbox restringida. Beorn no puede llamar exploits. Gwaihir no puede modificar el alcance.
Esto último es deliberado. Restringir la toolbox por fase es la forma más barata que encontramos de cortar la alucinación de herramientas. El modelo no inventa lo que no ve.
Un ejemplo trazado
Sirva como muestra. Un agente Beorn termina la fase de reconocimiento sobre un host con esto:
Thought: He completado el escaneo de servicios. El host expone
SSH (22), HTTP (80), HTTPS (443) y un Jenkins en 8080 con
header X-Jenkins: 2.346.1. Ese build tiene CVE-2024-23897
(arbitrary file read pre-auth). Esto es relevante.
Action: emit_artifact({
"phase": "recon",
"host": "10.42.0.17",
"services": [...],
"candidate_cves": ["CVE-2024-23897"],
"confidence": "high",
"evidence": "banner X-Jenkins: 2.346.1"
})
Observation: artifact_id=art_8c1a stored. handoff ready.
El artefacto pasa a Gandalf, que entra en planning. Gandalf no ve la traza completa de Beorn. Solo recibe el artefacto, las reglas de engagement, y el inventario actual. Su trabajo es decidir vector.
Thought: CVE-2024-23897 permite leer ficheros arbitrarios
sin autenticación vía el CLI de Jenkins. El objetivo del
cliente es validar acceso a credenciales. La ruta natural
es leer /var/jenkins_home/secrets/master.key y luego
credentials.xml. Confianza alta, ruido bajo. Acepto.
Action: emit_plan({
"vector": "cve-2024-23897-file-read",
"steps": [
{"id": 1, "tool": "jenkins-cli-cve", "target": "10.42.0.17:8080",
"args": {"file": "/var/jenkins_home/secrets/master.key"}},
{"id": 2, "tool": "jenkins-cli-cve", "target": "10.42.0.17:8080",
"args": {"file": "/var/jenkins_home/credentials.xml"}}
],
"abort_if": ["http_status != 200", "response_size < 16"]
})
Observation: plan_id=pln_2f9b validated. handoff to execution.
Gwaihir recoge el plan, ejecuta paso a paso, devuelve evidencia cruda y normalizada. Si un paso falla, no improvisa: devuelve control a Gandalf para replanificación. Esa decisión, no dejar improvisar al agente de ejecución, es de las que más nos ha bajado los falsos positivos.
Validación cruzada entre fases
Entre la salida de un agente y la entrada del siguiente metemos un paso de verificación que no es del agente original. Para artefactos críticos (CVEs candidatos, credenciales encontradas, decisiones de explotación), un segundo modelo, normalmente el mismo Gandalf en modo crítico, revisa si el artefacto está bien formado, si la evidencia justifica la conclusión, y si el siguiente paso no se sale del scope.
Es caro en tokens. Lo asumimos. La alternativa que probamos al principio, confiar en el self-consistency del propio agente, no funcionó. El modelo tiende a estar de acuerdo consigo mismo.
Comparativa con lo que ya había
No inventamos nada radicalmente nuevo. Lo que hicimos fue mezclar piezas de varios sitios.
Frente a Chain-of-Thought puro (Wei et al., 2022), ganamos la capacidad de actuar y verificar contra el mundo. CoT por sí solo es un buen prompt para problemas cerrados, no para campañas con feedback externo.
Frente a Tree of Thoughts (Yao et al., 2023)[4], simplificamos. ToT propone explorar varias ramas de razonamiento en paralelo con backtracking. Es elegante, pero el coste en tokens y latencia es prohibitivo cuando una rama implica lanzar un escáner real contra infraestructura. Lo que sí adoptamos es la idea de evaluar candidatos antes de comprometerse.
Frente a AutoGPT y descendientes, que persiguen una autonomía completa con un agente único, nosotros vamos en la dirección opuesta. La revisión del campo (Wang et al., 2023)[5] es clara: los agentes autónomos monolíticos se atascan en bucles, queman presupuesto y pierden el hilo. Multi-agente con handoff disciplinado nos da mucha más previsibilidad.
Frente a PentestGPT (Deng et al., 2023), compartimos la modularización por sub-tarea y la idea de aislar contexto. La diferencia principal es que nosotros tipamos los artefactos y forzamos la validación cruzada, no dejamos que el handoff entre módulos sea texto libre.
Trade-offs honestos
Hay tres tensiones permanentes que no resolvemos, solo gestionamos.
La primera es latencia contra precisión. Cada handoff añade segundos. Cada validación cruzada, más. Una campaña que un agente monolítico despacharía en quince minutos en nosotros tarda cuarenta. Hemos decidido que está bien. El cliente prefiere esperar y recibir un informe sin alucinaciones.
La segunda es presupuesto de tokens. Validar cruzado significa pagar dos veces por las decisiones críticas. Lo compensamos usando modelos baratos para fases de bajo riesgo y reservando los modelos caros para planning. Sin esa segmentación el coste por engagement sería inasumible.
La tercera es debugging. Cuando una campaña falla, hay que reconstruir qué agente decidió qué cosa con qué contexto. Loguear las trazas completas Thought/Action/Observation es no negociable. Sin eso no se puede hacer postmortem. Pero ocupa un disco indecente.
El fallo del mes pasado
Cerramos con un ejemplo reciente, todavía caliente. Una campaña contra un perímetro web. Beorn detectó un endpoint que respondía con un header Server: Werkzeug/3.0.1 Python/3.11.4. Su artefacto marcó "posible Flask en debug mode" con confianza media. Gandalf, en planning, decidió probar el path /console esperando un PIN-protected debugger.
Hasta ahí, perfecto. El problema vino en Gwaihir. La toolbox tenía un módulo werkzeug-debugger-pin-bruteforce que estaba marcado como deprecated en una rama interna pero seguía siendo seleccionable. Gwaihir lo eligió, el módulo lanzó peticiones a un ritmo que el WAF marcó como abuso, y nos cerraron el origen IP en mitad del engagement.
La causa raíz no fue del modelo. Fue de la toolbox. El registro de herramientas tenía un flag deprecated: true que el cargador no estaba respetando. Tres líneas de cambio en el loader y un test de regresión. Pero el fallo nos enseñó algo más profundo: la validación cruzada entre fases asume que la toolbox es correcta. Si la toolbox miente, todo el orquestador miente.
Desde entonces tenemos un check adicional al arrancar cada agente: lista las herramientas que va a poder llamar, las imprime en el log, y un humano firma el set la primera vez que ejecuta contra un cliente nuevo. No es elegante. Pero ningún agente, por bien orquestado que esté, sustituye un par de ojos cuando hay producción de por medio.
El bucle Thought/Action/Observation sigue siendo la mejor primitiva que conocemos para que un LLM hable con el mundo. Lo que cambia, en una campaña real, es lo que pasa entre bucles. Ahí es donde se gana o se pierde.
Referencias
- Yao, S. et al. (2022). ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629.
- Wei, J. et al. (2022). Chain-of-Thought Prompting Elicits Reasoning in Large Language Models. arXiv:2201.11903.
- Deng, G. et al. (2023). PentestGPT: An LLM-empowered Automatic Penetration Testing Tool. arXiv:2308.06782.
- Yao, S. et al. (2023). Tree of Thoughts: Deliberate Problem Solving with Large Language Models. arXiv:2305.10601.
- Wang, L. et al. (2023). A Survey on Large Language Model based Autonomous Agents. arXiv:2308.11432.