Introducción

Cuando se audita un clúster de Kubernetes por primera vez, ya sea como parte de un ejercicio de pentesting interno, una revisión de configuración o simplemente para entender qué está corriendo en producción, la mayoría de los problemas reales no aparecen en escáneres automáticos sofisticados, sino en la lectura paciente del estado del clúster. Permisos demasiado amplios, contenedores privilegiados que nadie recuerda haber desplegado, secretos en variables de entorno, certificados TLS a punto de expirar, y volúmenes hostPath montando rutas sensibles del nodo son hallazgos recurrentes que cualquier persona puede detectar con kubectl y un poco de método.

Este artículo recorre, paso a paso, cómo enumerar un clúster de Kubernetes de forma totalmente manual para identificar malas configuraciones y posibles vulnerabilidades. La idea no es sustituir a una herramienta de auditoría, sino ofrecer una guía concreta de los puntos en los que conviene fijarse y los comandos que devuelven la información relevante. El único requisito es disponer de un kubeconfig válido con permisos de lectura razonables sobre los recursos del clúster.

Reconocimiento inicial del clúster

Antes de meterse en el detalle de cada espacio de nombres, conviene dibujar el mapa general. El primer dato útil es la versión del servidor y del cliente, porque marca la vigencia de las APIs y las funcionalidades de seguridad disponibles.

kubectl version
kubectl cluster-info
kubectl api-resources
kubectl api-versions

Una versión por debajo de la 1.28 ya está fuera del ciclo oficial de soporte, y todo lo que sea anterior debería levantar una alerta inmediata. Las versiones próximas al fin de vida útil tienen parches de seguridad limitados, así que es habitual encontrar clústeres vulnerables a CVEs ya conocidos simplemente porque nadie se ha encargado de la actualización.

Después conviene listar las APIs deprecadas que siguen activas. Cualquier referencia a extensions/v1beta1, policy/v1beta1 o rbac.authorization.k8s.io/v1beta1 indica recursos antiguos que probablemente sigan funcionando por compatibilidad pero que en cualquier actualización futura pueden romperse.

kubectl get --raw /metrics | grep apiserver_requested_deprecated_apis

Para hacerse una idea del tamaño del clúster:

kubectl get nodes -o wide
kubectl get ns
kubectl get all --all-namespaces

Enumeración de nodos y plano de control

Los nodos cuentan mucho sobre la salud del clúster. Las condiciones DiskPressure, MemoryPressure, PIDPressure o NetworkUnavailable indican que algo está mal incluso antes de mirar las cargas. También conviene comprobar si los nodos del plano de control tienen el taint correspondiente, porque sin él pueden acabar ejecutando cargas de usuario y exponiendo el plano de control a contenedores comprometidos.

kubectl get nodes -o json | jq '.items[] | {name: .metadata.name, taints: .spec.taints, conditions: .status.conditions}'
kubectl describe nodes | grep -A2 Taints

La inconsistencia entre versiones de kubelet también es un indicador habitual de mantenimiento descuidado. Una diferencia de más de dos versiones menores entre kubelet y API server puede provocar comportamientos no soportados.

kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.nodeInfo.kubeletVersion}{"\t"}{.status.nodeInfo.osImage}{"\t"}{.status.nodeInfo.kernelVersion}{"\n"}{end}'

Para detectar saturación, comparar pods en ejecución con la capacidad máxima del nodo:

kubectl get nodes -o json | jq '.items[] | {name: .metadata.name, capacity: .status.capacity.pods, allocatable: .status.allocatable.pods}'
kubectl get pods --all-namespaces --field-selector spec.nodeName=<nodo> -o name | wc -l

API server y configuración del plano de control

El API server es el corazón del clúster y su configuración determina buena parte de la postura de seguridad. Si se tiene acceso a los pods de kube-system, se pueden inspeccionar los flags con los que arranca:

kubectl -n kube-system get pods -l component=kube-apiserver -o yaml | grep -E '\\-\\-(insecure-port|anonymous-auth|authorization-mode|enable-admission-plugins|audit-log|encryption-provider)'

Las banderas críticas a las que prestar atención son varias. --anonymous-auth=true permite peticiones sin autenticación; combinado con bindings débiles puede dar acceso a listar pods o leer secretos. --insecure-port distinto de 0 expone un puerto sin TLS ni autenticación. --authorization-mode=AlwaysAllow desactiva totalmente la autorización. La ausencia de NodeRestriction en --enable-admission-plugins permite que un kubelet comprometido modifique recursos de otros nodos. La ausencia de --audit-log-path significa que no hay trazabilidad de quién hizo qué. La ausencia de --encryption-provider-config implica que los secretos están en etcd en texto claro.

Probar el acceso anónimo es trivial:

kubectl auth can-i list pods --all-namespaces --as=system:anonymous
kubectl auth can-i get secrets --all-namespaces --as=system:anonymous

Cualquier respuesta afirmativa es un hallazgo crítico.

Para etcd, los pods correspondientes se pueden inspeccionar de la misma manera buscando --client-cert-auth=true y la ausencia de --auto-tls. Etcd accesible sin autenticación de cliente equivale a tener todo el clúster comprometido.

Exposición de servicios y red

Un error muy frecuente es exponer accidentalmente componentes internos a internet a través de un Service de tipo LoadBalancer o NodePort. Conviene revisar todos los servicios en busca de puertos sensibles como el 6443 del API server o el 2379 de etcd.

kubectl get svc --all-namespaces -o wide
kubectl get svc --all-namespaces -o json | jq '.items[] | select(.spec.type=="LoadBalancer" or .spec.type=="NodePort") | {ns: .metadata.namespace, name: .metadata.name, type: .spec.type, ports: .spec.ports}'

Los Ingress también merecen una revisión cuidadosa. Hay que comprobar si fuerzan HTTPS, si tienen la cabecera HSTS, y si las anotaciones del controlador no permiten versiones antiguas de TLS o cifradores débiles.

kubectl get ingress --all-namespaces -o yaml | grep -E '(tls|ssl-redirect|ssl-protocols|ssl-ciphers|hsts)'

RBAC

La autorización basada en roles es donde se concentran la mayoría de los hallazgos críticos. Lo primero es buscar quién tiene cluster-admin y comprobar si hay sujetos que no sean cuentas del sistema.

kubectl get clusterrolebindings -o json | jq '.items[] | select(.roleRef.name=="cluster-admin") | {name: .metadata.name, subjects: .subjects}'

Cualquier ServiceAccount, usuario o grupo que no empiece por system: y que tenga cluster-admin debería justificarse. Especial atención al grupo system:masters, que da acceso total al clúster sin pasar por autorización: ningún binding nuevo debería referenciarlo.

A continuación hay que buscar reglas con comodines, que son la forma más habitual de privilegios excesivos:

kubectl get clusterroles -o json | jq '.items[] | select(.rules[]?.verbs[]? == "*" or .rules[]?.resources[]? == "*") | .metadata.name'
kubectl get roles --all-namespaces -o json | jq '.items[] | select(.rules[]?.verbs[]? == "*") | {ns: .metadata.namespace, name: .metadata.name}'

Los verbos escalate, bind e impersonate son particularmente peligrosos porque permiten escalar privilegios sin necesidad de tener cluster-admin directamente. Cualquier rol que los incluya merece una revisión.

kubectl get clusterroles -o json | jq '.items[] | select(.rules[]?.verbs[]? | IN("escalate","bind","impersonate")) | .metadata.name'

Otro patrón frecuente es el acceso amplio a secrets. Roles con get, list o watch sobre secretos sin restringir por resourceNames permiten exfiltrar credenciales en bloque.

kubectl get clusterroles,roles --all-namespaces -o json | jq '.items[] | select(.rules[]? | select(.resources[]? == "secrets" and (.verbs[]? | IN("get","list","watch","*"))))'

Para cada pod, comprobar qué ServiceAccount usa y si esa cuenta monta el token automáticamente. Pods que usan la cuenta default con token montado son un patrón que conviene corregir, ya que rompen el principio de mínimo privilegio.

kubectl get pods --all-namespaces -o json | jq '.items[] | {ns: .metadata.namespace, name: .metadata.name, sa: .spec.serviceAccountName, automount: .spec.automountServiceAccountToken}'

Pods y contextos de seguridad

Los pods son donde se materializan la mayoría de las malas configuraciones de ejecución. La lista de banderas a vigilar es bastante concreta: privileged: true, hostNetwork: true, hostPID: true, hostIPC: true, runAsUser: 0 o ausencia de runAsNonRoot, allowPrivilegeEscalation: true, sistema de archivos raíz escribible, y capacidades añadidas como SYS_ADMIN, NET_ADMIN, SYS_PTRACE o NET_RAW.

kubectl get pods --all-namespaces -o json | jq '.items[] | select(.spec.containers[]?.securityContext.privileged==true) | {ns: .metadata.namespace, name: .metadata.name}'

kubectl get pods --all-namespaces -o json | jq '.items[] | select(.spec.hostNetwork==true or .spec.hostPID==true or .spec.hostIPC==true) | {ns: .metadata.namespace, name: .metadata.name, hostNetwork: .spec.hostNetwork, hostPID: .spec.hostPID, hostIPC: .spec.hostIPC}'

kubectl get pods --all-namespaces -o json | jq '.items[] | select(.spec.containers[]?.securityContext.capabilities.add[]? | IN("SYS_ADMIN","NET_ADMIN","SYS_PTRACE","NET_RAW","DAC_OVERRIDE")) | {ns: .metadata.namespace, name: .metadata.name}'

Un contenedor privilegiado equivale prácticamente a tener acceso root al nodo. Combinado con hostPID y un nsenter adecuado, la barrera entre contenedor y nodo desaparece.

También es buena práctica revisar pods sin límites de recursos definidos, porque pueden agotar la memoria o la CPU del nodo y afectar al resto de cargas:

kubectl get pods --all-namespaces -o json | jq '.items[] | select(.spec.containers[]? | .resources.limits == null) | {ns: .metadata.namespace, name: .metadata.name}'

Los pods en CrashLoopBackOff o con ImagePullBackOff también merecen atención: a veces son problemas operativos, pero a veces revelan referencias a imágenes que ya no existen o credenciales de registro filtradas.

kubectl get pods --all-namespaces --field-selector=status.phase!=Running,status.phase!=Succeeded

Secretos y ConfigMaps

Los secretos en Kubernetes no están cifrados por defecto, solo codificados en Base64. Eso significa que cualquiera con permisos de lectura sobre el espacio de nombres puede recuperarlos en claro. Un primer paso es ver qué tipos de secretos hay y dónde:

kubectl get secrets --all-namespaces
kubectl get secrets --all-namespaces -o json | jq '.items[] | {ns: .metadata.namespace, name: .metadata.name, type: .type, keys: (.data // {} | keys)}'

Los secretos de tipo kubernetes.io/service-account-token creados manualmente son legacy desde la versión 1.24 y deberían reemplazarse por el flujo de TokenRequest. Los secretos TLS incompletos (sin tls.crt o tls.key) son inservibles. Los secretos en el espacio de nombres default indican falta de organización y suelen acabar olvidados.

El punto verdaderamente delicado es comprobar cómo se exponen los secretos a los contenedores. Inyectarlos como variables de entorno los hace visibles en kubectl describe pod y en cualquier volcado de proceso, así que la práctica recomendada es montarlos como archivos.

kubectl get pods --all-namespaces -o json | jq '.items[] | {ns: .metadata.namespace, name: .metadata.name, envFrom: [.spec.containers[]?.envFrom[]?.secretRef.name], envSecrets: [.spec.containers[]?.env[]? | select(.valueFrom.secretKeyRef) | .name]}'

Para los ConfigMaps, lo principal es que no contengan credenciales. No es raro encontrar contraseñas, tokens o cadenas de conexión a base de datos en un ConfigMap por descuido.

kubectl get cm --all-namespaces -o json | jq '.items[] | {ns: .metadata.namespace, name: .metadata.name, data: .data}' | grep -iE 'password|secret|token|api[_-]?key|private[_-]?key|aws_access|bearer'

El mismo análisis aplica a las variables de entorno definidas directamente en los pods, sin pasar por secretos:

kubectl get pods --all-namespaces -o json | jq '.items[] | .spec.containers[]?.env[]? | select(.value != null) | select(.name | test("(?i)password|secret|token|api[_-]?key|access[_-]?key|credentials"))'

Almacenamiento y volúmenes

Los volúmenes hostPath son uno de los vectores más directos para escapar de un contenedor al nodo. Cualquier pod con un hostPath montando /, /etc, /var/run/docker.sock, /var/lib/kubelet, /proc o /root puede leer y modificar el sistema de archivos del nodo.

kubectl get pods --all-namespaces -o json | jq '.items[] | select(.spec.volumes[]?.hostPath) | {ns: .metadata.namespace, name: .metadata.name, hostPaths: [.spec.volumes[]? | select(.hostPath) | .hostPath.path]}'

El acceso al socket de Docker (/var/run/docker.sock) merece una mención aparte, porque permite lanzar contenedores arbitrarios en el nodo y, por extensión, comprometer el clúster entero.

Para los PersistentVolumes a nivel de clúster:

kubectl get pv -o json | jq '.items[] | {name: .metadata.name, hostPath: .spec.hostPath, accessModes: .spec.accessModes, reclaimPolicy: .spec.persistentVolumeReclaimPolicy}'

La política de reciclaje Recycle está deprecada y borra datos sin garantías. Los modos de acceso ReadWriteMany permiten que varios pods escriban sobre el mismo volumen, lo que puede ser intencional o un error.

Pod Security Admission

Desde la versión 1.25, Kubernetes incorpora Pod Security Admission, que reemplaza a los antiguos PodSecurityPolicy. La revisión consiste en comprobar qué espacio de nombres tienen las etiquetas correspondientes y con qué nivel:

kubectl get ns -o json | jq '.items[] | {name: .metadata.name, labels: (.metadata.labels // {} | with_entries(select(.key | startswith("pod-security.kubernetes.io"))))}'

Un espacio de nombres sin ninguna etiqueta pod-security.kubernetes.io/enforce permite cualquier pod, incluyendo los privilegiados. La configuración recomendada para cargas de aplicación es enforce=restricted, mientras que baseline ofrece un compromiso aceptable. Encontrar enforce=privileged en un espacio de nombres de producción es un hallazgo serio.

Cadena de suministro de imágenes

Las imágenes de contenedor son código que se ejecuta con privilegios variables, así que conviene saber de dónde vienen y si están fijadas a una versión concreta. El uso del tag latest o la ausencia de tag impide saber qué se está ejecutando exactamente y abre la puerta a actualizaciones no controladas.

kubectl get pods --all-namespaces -o json | jq '.items[] | .spec.containers[]? | {image: .image, pullPolicy: .imagePullPolicy}' | grep -E ':latest|^[^:]*$'

Idealmente, las imágenes deberían referenciarse por su digest (@sha256:...), no solo por tag. Esto garantiza inmutabilidad real.

kubectl get pods --all-namespaces -o jsonpath='{range .items[*].spec.containers[*]}{.image}{"\n"}{end}' | grep -v '@sha256:'

Las imágenes provenientes de registros privados deben tener imagePullSecrets configurados, ya sea en el pod o en la ServiceAccount asociada.

Logging y auditoría

Detectar incidentes requiere logs. Sin un agente de recolección como Fluent Bit, Filebeat, Promtail, Vector o Alloy desplegado como DaemonSet, los logs viven y mueren en cada nodo.

kubectl get daemonset --all-namespaces -o json | jq '.items[] | {ns: .metadata.namespace, name: .metadata.name, image: .spec.template.spec.containers[].image}' | grep -iE 'fluent|filebeat|promtail|vector|logstash|alloy'

A nivel de API server, hay que comprobar que el audit log está habilitado y con una política de retención razonable. Esto se ve revisando los flags del pod kube-apiserver como ya se explicó antes.

Aislamiento entre espacios de nombres

Por último, conviene revisar si hay cargas de usuario en el espacio de nombres default, lo cual suele ser señal de despliegues poco organizados, y si los espacios de nombres de producción y desarrollo conviven en el mismo clúster sin políticas de red que los aíslen. Las NetworkPolicies son la herramienta para esto:

kubectl get networkpolicies --all-namespaces
kubectl get pods -n default

Un clúster sin ninguna NetworkPolicy permite tráfico libre entre todos los pods, lo que facilita movimientos laterales tras un compromiso inicial.

También conviene comprobar si los espacios de nombres tienen etiquetas de gobernanza mínimas (environment, team, owner) que permitan saber quién es responsable de cada carga, y si existen RoleBindings que referencian sujetos de otros espacios de nombres, lo cual puede generar relaciones de confianza inesperadas entre entornos que se suponían aislados.

kubectl get rolebindings --all-namespaces -o json | jq '.items[] | select(.subjects[]?.namespace != null and .subjects[]?.namespace != .metadata.namespace) | {ns: .metadata.namespace, name: .metadata.name, subjects: .subjects}'

Por último, revisar las ResourceQuota y LimitRange por espacio de nombres ayuda a detectar entornos sin protección frente a consumo abusivo de recursos, lo cual puede derivar en denegaciones de servicio entre cargas vecinas.

kubectl get resourcequota --all-namespaces
kubectl get limitrange --all-namespaces

Un espacio de nombres sin cuotas y sin rangos de límites confía en el buen comportamiento de cualquier carga que aterrice en él, lo cual raramente coincide con la realidad. Combinado con la ausencia de NetworkPolicies, ese tipo de espacio de nombres se convierte en el punto de entrada más fácil para un atacante que ya tenga un pie dentro de cualquier otra parte del clúster, porque no hay nada técnico que impida el escalado en el uso de recursos ni en el alcance de red.

Conclusión

La enumeración manual de un clúster con kubectl no requiere herramientas especiales, solo método y atención a los detalles correctos. Los hallazgos que más impacto suelen tener son los mismos en casi cualquier auditoría: bindings de cluster-admin a sujetos que no deberían tenerlo, contenedores privilegiados o con hostPath sensibles, secretos expuestos en variables de entorno, certificados TLS olvidados, API servers con autenticación anónima activa, y RBAC plagado de comodines.

Recorrer el clúster en este orden (versión, nodos, plano de control, exposición de servicios, RBAC, pods, secretos, almacenamiento, PSA, imágenes y logging) cubre la mayoría de los puntos críticos y permite generar una imagen bastante completa de la postura de seguridad. Lo importante es entender que cada uno de estos comandos no es un fin en sí mismo: lo que aporta valor es la lectura crítica del resultado, comparándolo con lo que la organización dice tener desplegado y con las prácticas recomendadas.

Una vez familiarizado con estos patrones, la enumeración deja de ser una tarea de varias horas y se convierte en una rutina manejable. Y en ese momento, cualquier sorpresa que aparezca tendrá muchas más probabilidades de ser un hallazgo real que un falso positivo enterrado en ruido.