TL;DR: Dos agentes de Claude, cada uno en su propia VM de Kali, resolvieron un lab de PortSwigger en 3:50 y una máquina completa de VulnHub ("Mr. Robot: 1", 3 flags + root) en 9:07, colaborando mediante una pizarra compartida y deteniéndose solos cuando el objetivo ya estaba resuelto. Pero llegar ahí tomó arreglar media docena de bugs reales — y el hallazgo más interesante no fue sobre inteligencia artificial, fue sobre ingeniería de sistemas.
Código completo: github.com/digilabmx/agentic-pentest-orchestration
"La IA se descontroló" — no, en realidad no
En los últimos meses leímos titulares sobre agentes que "hackean" sistemas por su cuenta, modelos que extorsionan usuarios, IA que se sale de control. La realidad, al menos la que yo construí y rompí varias veces en mi propio laboratorio, es mucho menos cinematográfica y bastante más interesante: no es una historia sobre IA fuera de control, es una historia sobre herramientas, permisos y orquestación.
Un modelo de lenguaje no obtiene acceso a un sistema por sí solo. Necesita herramientas, credenciales y permisos que alguien le expuso de antemano, y actúa — siempre — dentro de una arquitectura que un humano diseñó. El problema real nunca fue qué tan "inteligente" es el modelo. Fue la gobernanza, la supervisión y el control del sistema a su alrededor. Este artículo es la bitácora honesta de construir ese sistema: lo que funcionó, lo que se rompió, y por qué.
⚠️ Aviso. Todo lo descrito acá corrió contra objetivos explícitamente autorizados: VMs propias y labs de PortSwigger Web Security Academy. Nada de esto es invitación a probar nada sin autorización — el uso indebido de estas técnicas es responsabilidad exclusiva de quien las ejecute.
La arquitectura: dos agentes, una pizarra
El sistema tiene tres capas, corriendo en dos máquinas distintas:
┌──────────────────────────────┐
│ WINDOWS HOST │
│ │
│ orquestador.py (GUI) │
│ ┌─────────┐ ┌─────────┐ │
│ │Agente 1 │ Agente 2 │
│ │ (CLI) │ (Browser) │
│ └────┬────┘ └────┬────┘ │
│ └──────┬──────┘ │
│ PIZARRA COMPARTIDA │
│ (objeto en memoria, con lock) │
└───────────────┬───────────────┘
│ MCP (stdio)
client.py × 2
│ HTTP + JSON
┌────────┴────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ KALI #1 │ │ KALI #2 │
│ server.py │ │ server.py │
│ (Flask) │ │ (Flask) │
│ nmap · curl │ │ nmap · curl │
│ Firefox · │ │ Firefox · │
│ xdotool │ │ xdotool │
└───────────┘ └───────────┘
El modelo nunca toca Kali directamente. Cada acción pasa por: tool MCP → HTTP → Flask → subprocess real. Esa cadena de capas es, a propósito, el único lugar donde se puede meter control, auditoría y — como van a ver abajo — un freno de emergencia.
Los dos agentes pueden correr las mismas fases (recon, enumeración, explotación); uno tiende a trabajar por terminal, el otro por navegador real con computer-use (clicks y capturas de pantalla, no una API headless). Lo que los conecta es la pizarra: un tablero compartido, en memoria, donde cada agente puede publicar un hallazgo y leer lo que publicó el otro.
Cajita de código: el mecanismo que de verdad importa
De los cientos de líneas de este proyecto, esta es la que más pesa. Es la clase Pizarra, completa en su parte esencial:
class Pizarra:
"""Tablero de hallazgos compartido entre los agentes de una misma
ejecucion conjunta. Thread-safe: cada agente corre en su propio hilo."""
def __init__(self):
self._lock = threading.Lock()
self._hallazgos = []
self._resuelto = None # {"agente", "texto"} o None
def agregar(self, agente, texto):
with self._lock:
self._hallazgos.append({"agente": agente, "texto": texto, ...})
if self._resuelto is None and texto.strip().upper().startswith("RESUELTO:"):
self._resuelto = {"agente": agente, "texto": texto}
No hay nada de "IA" en este fragmento. Es un if sobre un string. Pero ese if es la diferencia entre un agente que sigue trabajando 10 minutos de más sobre un reto ya resuelto, y uno que se detiene en el mismo segundo en que su compañero gana.
Por qué importa: una vez que
_resueltodeja de serNone, el orquestador consulta ese valor antes de autorizar cualquier tool call del otro agente (vía un hookcan_use_tooldel Agent SDK). No es una instrucción de texto que el modelo puede decidir seguir o ignorar — es código que corre antes de que el modelo pueda actuar. Lo comprobé en los logs: cuando el Agente 2 publicóRESUELTO:en el lab de PortSwigger a las 22:18:17, la siguiente tool call del Agente 1 fue rechazada en el mismo segundo, con el mensaje literal"The user doesn't want to proceed with this tool use... STOP". No fue coincidencia de timing ni el modelo "decidiendo" revisar la pizarra — fue el interrupt duro cortando la ejecución a mitad de un tool call en curso. Pasó otra vez, de forma idéntica, en la corrida de Mr. Robot.
Tres formas de orquestar (y por qué elegí la del medio... o ni eso)
- Caos — agentes sueltos, sin memoria entre llamadas, sin guía de orden. Comportamiento impredecible, imposible de reproducir o depurar. Es el punto de partida de casi cualquier demo agentic improvisada.
- Pipeline duro — una máquina de estados fuerza el orden (recon → enum → exploit → report). Reproducible y auditable, pero rígido: no se adapta cuando el objetivo real no calza con el guion.
- Pipeline blando — existe memoria, pizarra y una metodología sugerida, no obligatoria. El agente puede empezar donde quiera, volver atrás, saltar fases — pero siempre tiene la opción de consultar lo que ya se sabe.
Elegí pipeline blando. Y el primer hallazgo real fue que "blando" no significa que funcione solo.
Lo que salió mal (y es el contenido que más vale la pena leer)
Corrí este sistema una y otra vez contra el mismo tipo de objetivo, documentando cada corrida real. Acá el resumen honesto — no la versión bonita:
| Corrida | Lo que pasó | La causa raíz | El fix |
|---|---|---|---|
| 1 | record_finding existía como tool MCP real... y tuvo 0 usos | El prompt del orquestador nunca lo mencionaba — exponer una tool no basta si el modelo no sabe que existe | Agregar una mención explícita en el prompt de metodología |
| 2 | Timeouts en cascada, un agente bloqueado por el clasificador de seguridad de Anthropic | Carrera de timeouts cliente/servidor + un filtro externo de contenido, ninguno de los dos un bug "nuestro" | Timeouts dinámicos por comando; diagnóstico honesto en el log en vez de "ciclos agotados" genérico |
| 3 | 4 cuelgues de ~5 minutos en comandos locales, sin red de por medio | Un window_id de X11 reciclado — el servidor mataba la terminal equivocada | Verificar que la terminal siga siendo la actual antes de cerrarla |
| 4 | El equipo cerró "RESUELTO" en 7 minutos... con el lab sin resolver de verdad | Cualquier hallazgo con al menos un bloque disparaba el estado de éxito, sin verificar evidencia | Campo ESTADO: CONFIRMADO/PARCIAL/NO_RESUELTO obligatorio, con criterios explícitos |
| 5 | Resolución real, verificada, en 3:50 | El fix de la corrida 4 funcionando como se diseñó + el interrupt duro cortando al compañero a tiempo | — |
El patrón detrás de toda la tabla: los resultados dependieron mucho más del diseño del sistema que de qué tan "inteligente" era el modelo por debajo.
Y hay un matiz importante en la fila 1 que vale la pena decir en voz alta, porque contradice la narrativa fácil: el fix que hizo que record_finding se empezara a usar fue agregar texto al prompt, no solo exponer la tool. Los mecanismos duros (como el interrupt de la fila 5) ganan cuando el comportamiento es crítico y binario. Pero para que un agente simplemente sepa que algo existe, a veces sigue haciendo falta decírselo con palabras.
Los resultados, con tiempos reales
PortSwigger — "Web shell upload via extension blacklist bypass"
3:50 de principio a fin. Un agente identificó el lab por el <title> del HTML, subió un .htaccess (AddType application/x-httpd-php .l33t) seguido de un webshell de una línea con esa extensión no filtrada, y confirmó RCE con ?cmd=id. Verificado en tres capas independientes: la salida del comando, la flag leída del sistema de archivos, y el propio "Congratulations, you solved the lab!" de la plataforma.
Mr. Robot: 1 (VulnHub) — 3 flags, root incluido
9:07. robots.txt → credenciales de WordPress conocidas del lab → RCE vía editor de temas → reverse shell con nc+mkfifo → un hash MD5 identificado sin john ni hashcat (un one-liner de Python bastó) → privilege escalation por un binario SUID (nmap --interactive) → root. El momento más interesante no fue el éxito: fue que su robot falló tres veces seguidas por un problema de sincronización de shell — y en vez de insistir en un loop, el agente pivoteó solo hacia el vector SUID. Ese tipo de auto-recuperación no la diseñé yo; salió del propio modelo.
⚠️ La parte honesta que casi nadie cuenta en estas demos: ninguno de los dos tiempos es "velocidad sobrehumana de pentesting". Es, en buena parte, conocimiento recordado, no descubierto. "Mr. Robot: 1" es una de las máquinas de CTF más documentadas que existen — el modelo reconoció la metodología completa (robots.txt → WordPress → SUID nmap) antes de ejecutar un solo comando. Comparar 9:07 contra un humano resolviéndolo a ciegas (la comunidad estima 1–3 horas para un nivel intermedio) es injusto para el humano. Comparado contra alguien que también ya conoce el walkthrough — TryHackMe estima su propia copia del box en ~30 min, Root-Me en ~2h con al menos un intento real registrado en 20 min — la brecha sigue existiendo, pero es mucho menos dramática. Esa es la comparación intelectualmente honesta.
El veredicto
Sigue siendo ficción:
- Un agente que resuelva pentests completos, de forma confiable y universal, sin supervisión humana.
- Que determine por sí solo, sin ambigüedad, qué está dentro o fuera de alcance.
- Que opere sin ajustes, monitoreo ni mantenimiento de infraestructura.
Ya funciona hoy:
- Compartir hallazgos entre agentes y entre ejecuciones, con memoria estructurada y verificable.
- Detener trabajo redundante cuando el objetivo ya se alcanzó — con un mecanismo de código, no una sugerencia de prompt.
- Verificar el éxito contra evidencia real, no contra la confianza del modelo en su propio texto.
- Recuperarse de errores de infraestructura mediante ciclos reales de observación y reintento.
No es ciencia ficción. Tampoco es magia. Es ingeniería de sistemas aplicada a un componente nuevo — y como con cualquier sistema nuevo, la mayoría de los bugs no estaban en el modelo. Estaban en el cableado.
El código completo
Todo lo de este artículo — client.py, server.py, orquestador.py, y los 10 logs reales de estas corridas (incluidas las que fallaron) — está público:
** github.com/digilabmx/agentic-pentest-orchestration**
El repo incluye instrucciones paso a paso para correrlo en tu propio entorno (VirtualBox + Kali + Windows), y las notas técnicas completas con referencia a cada línea de log detrás de cada claim de este artículo.
Autor: Edu Quijano
