system: OPERATIONAL
← back to all hacks
INFRASTRUCTURE CRITICAL NEW

Pickle over gRPC: unauthenticated RCE in a robot policy server

Hugging Face's LeRobot ran its robot-to-policy inference channel on pickle over unauthenticated gRPC — any host that reached the port got remote code execution. The June 2026 fix drops pickle entirely.

2026-07-15 // 6 min affects: lerobot, async-inference, robot-policy-servers

What is this?

On June 29, 2026, SecureLayer7’s research team published a write-up of an unauthenticated remote-code-execution flaw in LeRobot, Hugging Face’s open-source robotics framework. LeRobot’s async inference pipeline splits a robot policy into a client–server pair: a client running on the physical robot streams observations to a PolicyServer on a separate GPU host, which streams actions back. The transport is gRPC — and the payloads were carried as raw Python pickle bytes.

The flaw is the predictable consequence of that choice. This write-up is based on the public advisory and the upstream fix; no working exploit is reproduced here. The lesson is deployment-shaped rather than model-shaped: when a serving component deserialises untrusted network data with pickle, and the channel has no authentication, the network is the vulnerability.

How it works

Python’s pickle is not a data format — it is a small stack-machine bytecode with opcodes that invoke arbitrary callables during load. CPython says so plainly: “The pickle module is not secure. Only unpickle data you trust.” Any object can define what happens on unpickling, so a crafted blob can run a command the moment it is deserialised, before any downstream validation runs.

In the affected release, the async-inference handlers read attacker-reachable bytes straight off the wire and passed them to pickle.loads():

# vulnerable pattern (illustrative)
policy_specs = pickle.loads(request.data)   # nosec  <-- runs before validation

Two problems compounded. First, the deserialisation happened before the type check meant to validate the message, so the object was already constructed — and construction is where the dangerous work occurs. Second, the gRPC listener was bound with add_insecure_port(...): no TLS, no token, no mutual auth. The default topology assumes the robot and the policy server share a trusted network (a VPC or a Tailscale mesh), but the moment the port (TCP/50051 by default) is reachable by any untrusted actor, that assumption fails open. The bug is also bidirectional: the server pickled its action responses too, so a malicious or compromised policy server could turn the same primitive against every robot client that connected to it.

A recurring smell in the code was a # nosec comment on each pickle call — an annotation that silences the static-analysis warning (Bandit’s B301) without addressing the runtime risk it flags.

Why it matters

The affected code shipped in the framework used to train and serve real-robot policies, so the exposed population includes shared research GPU clusters, multi-machine training topologies, demo notebooks left running, and cloud-hosted deployments where the operator never realised gRPC was listening. A single reachable port yields code execution as the server process — reported as root inside the reference container — which on a GPU host usually means model artifacts, datasets, and cloud credentials sit one step away.

There is a pointed irony here: Hugging Face authored safetensors specifically to kill “the pickle problem” for model files, and its documentation says in bold not to load pickle files from untrusted sources. The same anti-pattern reappeared in a different corner of the stack — the inference transport — which is exactly where deserialisation bugs keep hiding across the AI serving ecosystem.

Defenses

Never pickle.loads() network-controlled data. The durable fix is a typed, non-executable wire format. The upstream patch replaced pickle with safetensors for tensors and JSON for metadata, wrapped in an envelope that carries a type tag the deserialiser validates against the expected type. That removes the opcode interpreter, enforces a schema, and rejects a message routed to the wrong handler.

Authenticate the transport. gRPC does not imply authentication — mTLS or token auth is opt-in. Bind serving endpoints with credentials, not add_insecure_port, and require mutual TLS between robot and policy server.

Treat the network as hostile. Default-deny inbound to inference ports, bind to loopback or a private interface, and never expose a policy/inference port to a network any untrusted party can reach. Segment GPU hosts away from general workloads.

Hunt the pattern statically. Grep network-facing Python services for pickle.loads on anything tracing back to request input — and treat a # nosec sitting next to it as a finding, not a resolution.

Least privilege and egress control. Scope the serving process’s cloud role to the minimum, and alert on a policy-server process spawning an unexpected child (a shell, an interpreter) or making outbound connections it should never make.

Status

ItemDetail
ReferenceCVE-2026-25874 (see cve.org record)
DisclosedAdvisory published June 29, 2026
AffectedLeRobot async-inference (PolicyServer / robot client) in v0.4.3 and earlier
Root causepickle.loads() on unauthenticated gRPC input, deserialised before type validation
ImpactPre-auth remote code execution on the policy server; bidirectional (malicious server can hit clients)
FixUpstream PR #3048 — pickle removed, replaced with safetensors + JSON typed schema; upgrade and enable transport auth/network isolation

Sources