RAG Táctico: De Writeups a Acción
El año pasado, durante un engagement contra una infraestructura interna, perdí cuarenta minutos buscando un detalle muy concreto: cómo había alguien escalado privilegios contra una versión específica de un servicio Java mal configurado que exponía JMX. Sabía que lo había leído. En un writeup, en un hilo de Twitter, en un Discord. No me acordaba dónde. Google me devolvía SEO, los favoritos del navegador eran un cementerio, y mi carpeta ~/notes tenía 1.300 markdowns sin indexar.
Cuarenta minutos. En un pentest cronometrado, eso es un Domain Admin que no consigues.
De ahí salió la idea de Beorn: un RAG que no busca documentos, sino que entrega contexto accionable a un agente que está operando. Hoy lleva 9115 vectores indexados, 28 colecciones temáticas, y responde en menos de 50 ms. Pero llegar ahí fue bastante menos limpio de lo que suena.
El RAG genérico no sirve para ofensiva
El primer prototipo era el de manual: langchain, embeddings de OpenAI, ChromaDB local, chunking recursivo cada 512 tokens. Funcionaba para preguntas tipo "¿qué es Kerberoasting?". Fallaba estrepitosamente para "tengo un SeImpersonatePrivilege en un Windows Server 2019 con IIS 10 corriendo en pool de aplicaciones, dame el camino".
El problema no era el modelo. Era que la mayoría de literatura de RAG asume que tu corpus es FAQ corporativa, documentación de producto, o políticas en PDF. Los writeups ofensivos son otra cosa: mezclan narrativa ("noté que el banner devolvía Apache 2.4.49"), payloads literales, salida de herramientas, y conclusiones implícitas que solo entiendes si ya sabes el TTP. Si troceas eso por longitud fija, partes payloads, separas un CVE de su explicación, y el ranking se vuelve aleatorio.
Hay un patrón documentado por el equipo de PentestAgent en su paper de NeurIPS 2024 (Shen et al., 2024)[1]: el conocimiento ofensivo es procedural, no declarativo. No buscas un hecho. Buscas una receta condicionada a tu contexto. Y eso cambia todo el diseño del retriever.
Lo que aprendimos indexando 9115 writeups
Chunking por unidad semántica, no por tokens
Pasamos de chunking recursivo a uno orientado a estructura. Cada writeup de HTB tiene fases más o menos reconocibles: reconocimiento, foothold inicial, escalada lateral, escalada de privilegios, persistencia. Las parseamos con un preprocesador que mira encabezados markdown, bloques de código, y separadores explícitos. Cada chunk lleva metadatos:
{
"box": "Sauna",
"os": "Windows",
"phase": "privesc",
"ttp": "asreproast",
"tools": ["impacket", "hashcat"],
"cve": [],
"difficulty": "easy",
"lang": "en"
}El chunk medio quedó en 380 tokens, pero con varianza alta: algunos tienen 90 (un comando con su salida), otros 700 (una explicación completa de Kerberos delegation). El equipo de NVIDIA publicó este año un análisis empírico (Wang et al., 2024)[2] mostrando que el tamaño óptimo depende del dominio y que forzar uniformidad degrada la precisión entre un 8% y un 15%. Coincidió con lo que veíamos.
Embeddings multilingües porque los writeups no están solo en inglés
Casi un tercio de nuestro corpus es español, francés o ruso. Probamos text-embedding-3-small de OpenAI, e5-multilingual-large y acabamos en bge-m3 (Chen et al., 2024)[3], que soporta retrieval denso, esparso y multi-vector simultáneamente, en 100+ idiomas, con contexto de 8192 tokens. Es lo que mejor nos funcionó para mezclar lenguajes en una misma colección sin perder recall.
Un detalle no trivial: las queries del agente suelen venir en inglés técnico ("CVE-2021-26855 SSRF chain") pero apuntan a explicaciones que están en español. Sin embeddings multilingües de calidad, ese cross-lingual recall se hunde. Con bge-m3 medimos un nDCG@10 de 0.81 en un set evaluado a mano de 200 queries; con el modelo de OpenAI nos quedábamos en 0.69.
Reranking, porque el top-k denso miente
Devolver los 10 primeros vectores por similitud coseno es lo que te enseñan en los tutoriales. Es también lo que rompe en producción. El primer pase trae candidatos plausibles pero el orden es ruidoso. Añadimos un reranker bge-reranker-v2-m3 sobre los 50 candidatos top y nos quedamos con los 5 mejores. Coste: +18 ms. Mejora de precision@5: del 0.64 al 0.87.
# Pipeline simplificado
candidates = vector_store.search(query_emb, k=50)
reranked = reranker.score(query, [c.text for c in candidates])
top_k = sorted(zip(candidates, reranked), key=lambda x: -x[1])[:5]Entregando contexto a un agente que está exploiting
Beorn no es un chatbot. Vive como un servicio detrás del Gandalf Gateway, y las consultas las hace el agente sin intervención humana. Esto cambia el contrato: la respuesta tiene que ser ingerible por otro LLM en milisegundos, no por un humano que lee.
Una query real del último engagement:
beorn query \
--filter "os=linux,phase=privesc" \
--context "kernel 5.4.0, sudo 1.8.31, capabilities cap_dac_read_search+ep" \
"binary with capabilities exploitation"Respuesta:
{
"latency_ms": 41,
"results": [
{
"chunk_id": "htb-academy-linux-privesc-cap-dac",
"score": 0.94,
"ttp": "T1548.001",
"summary": "cap_dac_read_search permite leer cualquier fichero...",
"command": "getcap -r / 2>/dev/null | grep dac_read",
"next_steps": ["read /etc/shadow", "extract hashes",
"john --wordlist"]
}
]
}El agente recibe esto, no un párrafo de prosa. La diferencia entre dar a Gandalf "aquí tienes un texto, intuye qué hacer" y "aquí tienes el TTP MITRE, el comando inicial, y los siguientes tres pasos" es la diferencia entre un agente que improvisa y uno que ejecuta.
Métricas reales
Llevamos cuatro meses con Beorn en producción interna. Los números que monitorizamos:
- Latencia p50: 41 ms. P99: 78 ms. Sin caché.
- Precision@5 sobre un set anotado de 312 queries reales: 0.87.
- Recall@20: 0.93.
- Cobertura del corpus: 9115 chunks, 28 colecciones.
- Idiomas: EN 64%, ES 22%, FR 8%, RU 4%, otros 2%.
Un caso concreto que vale más que la tabla: en un assessment reciente contra Active Directory, el agente identificó una ruta de ataque ESC8 (ADCS HTTP enrollment) en 6 minutos desde el primer foothold. Sin Beorn, en ejercicios comparables anteriores, la fase equivalente nos llevaba entre 40 y 90 minutos de búsqueda manual de los detalles de explotación. No es magia. Es no perder el tiempo recordando dónde estaba el detalle.
Lo que no haríamos otra vez
Primero: empezamos guardando todo en una sola colección gigante con metadatos como filtro. Mala idea. Cuando llegas a varios miles de vectores, los filtros post-retrieval se vuelven caros y el recall se degrada porque los embeddings de dominios distintos compiten en el mismo espacio. Las 28 colecciones actuales son por tipo de fuente y SO. El gateway las orquesta.
Segundo: subestimamos la deduplicación. Tres writeups distintos explican la misma técnica con palabras casi idénticas. Sin dedup semántica, el top-5 te devuelve cinco versiones del mismo párrafo. Añadimos un paso de MMR (Maximal Marginal Relevance) y la utilidad subjetiva del top-k subió notablemente, aunque cueste medirlo con métricas clásicas.
Tercero, y este nos dolió: el primer release no tenía evaluación continua. Subíamos embeddings nuevos, cambiábamos el reranker, y nos guiábamos por sensación. Hasta que un cambio de modelo nos bajó la precision@5 ocho puntos y nadie se dio cuenta durante una semana. Ahora tenemos un golden set de 300+ queries con respuestas validadas, y cualquier cambio se evalúa antes de promover. La lección está documentada también en el survey de Gao et al. (2024)[4]: sin un loop de evaluación, un sistema RAG envejece mal.
El RAG no es magia, ni es buscador. Es la memoria operativa que tu agente debería haber tenido siempre. Tratarla como tal cambia el diseño desde el chunk hasta el contrato de salida.
Si tu equipo está construyendo algo parecido, el consejo más útil que puedo dar es: empieza por el contrato. No por el modelo. ¿Qué necesita recibir el consumidor — humano o agente — para actuar? Eso determina el chunking, los metadatos, y el formato de respuesta. Todo lo demás es ingeniería.
Referencias
- Shen, X., et al. (2024). PentestAgent: Incorporating LLM Agents to Automated Penetration Testing. arXiv:2411.05185.
- Wang, Y., et al. (2024). Finding the Best Chunking Strategy for Accurate AI Responses. NVIDIA Technical Blog.
- Chen, J., et al. (2024). BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings. BAAI / Hugging Face.
- Gao, A., et al. (2024). Retrieval Augmented Generation for Robust Cyber Defense. PNNL-36792.