Skip to main content

Contenedores Docker con privilegios: ¿realmente los necesitas?

Escrito por

5 de noviembre de 2020

0 minutos de lectura

Esta semana, me fui por un agujero de conejo mientras hacía pruebas con Podman para entender por qué ejecutar cierto contenedor en una configuración sin privilegios de root requería la marca --privileged. Con toda razón, mi colega Eric Smalling preguntó por qué debía requerir esa marca.

En definitiva, --privileged es una forma abreviada de otorgar Todos Los Permisos, y aunque quizá pienses que no importa mucho cuando se ejecuta sin privilegios de root, sí rompe en cierta medida los principios de mínimo privilegio y confianza cero. Incluso cuando se ejecuta sin privilegios de root, siempre es recomendable entender exactamente qué necesita tu contenedor y otorgarle solo los permisos mínimos.

Aunque establecer esta marca en procesos que se ejecutan sin privilegios de root no les otorga realmente más privilegios que los que tiene el usuario, me dio curiosidad saber qué capacidades necesitaba realmente el proceso para que se recomendara esa configuración. Con el espíritu de exploración que nos caracteriza, me puse el equipo de espeleología y me adentré en el corazón de las tinieblas.

Vamos a explorarlo

Primero, veamos qué significa ejecutar contenedores sin privilegios de root. Todo se debe a la magia de los espacios de nombres de usuario en el kernel de Linux, que permiten a usuarios sin privilegios crear nuevos espacios de nombres de usuario. Cuando un usuario crea y entra en un nuevo espacio de nombres de usuario, se convierte en root dentro del contexto de ese espacio y obtiene la mayoría de los privilegios necesarios para iniciar un contenedor funcional. En los espacios de nombres de usuario, root obviamente no tiene los mismos privilegios que el root del sistema, pero esto significa que podemos ejecutar contenedores sin elevar los privilegios y, por lo tanto, reducir significativamente la superficie de ataque potencial de cualquier vulnerabilidad.

El sistema que uso es una máquina virtual con una instalación mínima de CentOS 8, que se ejecuta en VirtualBox en mi MacBook. Primero instalé podman con dnf:

dnf install podman

Luego creé un directorio en el directorio de inicio de mi usuario y generé allí un certificado autofirmado:

mkdir certs
openssl req -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key -x509 -days 365 -out certs/domain.crt

Ahora quiero usar ese certificado para ejecutar un registro local de Docker con fines de prueba, y aquí es donde realmente comienza nuestro recorrido. Toda la documentación que encontré para hacerlo sugería usar la marca --privileged:

[matt@localhost ~]$ podman run -d --name registry -p 5000:5000 -v "$(pwd)"/certs:/certs  --restart=always -e REGISTRY_HTTP_ADDR=0.0.0.0:5000 -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key --privileged registry:2

Como podemos ver en la cadena de comandos anterior, ejecutamos la imagen del registro con la etiqueta 2, creamos un montaje de volumen que vincula el directorio de certificados de mi directorio actual con /certs dentro del contenedor, pasamos algunas variables de entorno para configurar el registro y agregamos alegremente la marca --privileged, que indica a podman que ejecute este contenedor en modo privilegiado.

Para usar el registro con podman, luego debemos agregar una entrada en /etc/containers/registries.conf:

[registries.insecure]
registries = ['localhost:5000']

El modo privilegiado funciona bien, así que lo primero que quise ver fue qué pasaba si lo ejecutábamos sin privilegios. Fue bastante sencillo: el contenedor se inicia, luego falla y vuelve a iniciarse, y sus registros muestran que no puede acceder al certificado.

[matt@localhost log]$ podman logs bd323f90c60b

time="2020-10-20T18:24:27.806128235Z" level=fatal msg="open /certs/domain.crt: permission denied"

En este punto, supuse que se debía a las capacidades de Linux, ya que una de las principales funciones de la marca --privileged es permitir que el contenedor acceda a todas las capacidades que proporciona el kernel. Podemos comprobarlo con podman al ejecutar este contenedor en modo privilegiado:

[matt@localhost ~]$ podman top -l capeff
EFFECTIVE CAPS
full

Desde aquí fui a las páginas del manual de Linux, donde pude revisar en detalle qué permiten hacer los procesos con cada capacidad. También podemos usar podman para comprobar qué capacidades tiene nuestro contenedor en ejecución sin privilegios:

[matt@localhost ~]$ podman top b7cea04eb70e
USER   PID   PPID   %CPU    ELAPSED        TTY   TIME   COMMAND
root   1     0      0.000   1.400417458s   ?     0s     registry serve /etc/docker/registry/config.yml

[matt@localhost ~]$ podman top -l capeff
EFFECTIVE CAPS
AUDIT_WRITE,CHOWN,DAC_OVERRIDE,FOWNER,FSETID,KILL,MKNOD,NET_BIND_SERVICE,NET_RAW,SETFCAP,SETGID,SETPCAP,SETUID,SYS_CHROOT

Al comparar esta lista con la página del manual de Linux, las capacidades efectivas en modo sin privilegios deberían ser suficientes para permitir que el contenedor lea archivos. De hecho, esta lista probablemente nos da más capacidades de las que necesita este contenedor en particular, pero volveremos a eso más adelante.

El siguiente paso es revisar el audit.log del host y, efectivamente:

type=AVC msg=audit(1603218166.554:4674): avc:  denied  { read } for  pid=223435 comm="registry" name="domain.crt" dev="dm-0" ino=6582402 scontext=system_u:system_r:container_t:s0:c127,c779 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0

Esta entrada nos indica que SELinux bloqueó la llamada de lectura del archivo domain.crt. En los sistemas CentOS y RHEL, SELinux está configurado en modo Enforcing de forma predeterminada, y podemos comprobarlo con la herramienta sestatus:

[matt@localhost ~]$ sudo sestatus
[sudo] password for matt: 
SELinux status:                 enabled
SELinuxfs mount:                /sys/fs/selinux
SELinux root directory:         /etc/selinux
Loaded policy name:             targeted
Current mode:                   enforcing
Mode from config file:          enforcing
Policy MLS status:              enabled
Policy deny_unknown status:     allowed
Memory protection checking:     actual (secure)
Max kernel policy version:      31

SELinux etiqueta archivos y directorios para administrar el acceso a ellos en modo Enforcing, y este etiquetado también se refleja en el comportamiento de los motores de contenedores. Estos inician procesos con la etiqueta container_t y el contenedor propiamente dicho se etiqueta como container_file_t. SELinux garantiza que los procesos solo puedan interactuar con archivos etiquetados de esta manera y, de forma predeterminada, deniega el acceso a los archivos que están fuera del contenedor. Cuando ejecutamos el contenedor con la marca --privileged, las etiquetas se desactivan y el contenedor se ejecuta con la etiqueta con la que se inició el motor de contenedores. Podemos verlo al revisar nuestros contenedores con podman. Este es un contenedor con privilegios:

[matt@localhost ~]$ podman top -l label
LABEL
unconfined_u:system_r:container_runtime_t:s0

Y este es uno que se ejecuta sin privilegios:

[matt@localhost ~]$ podman top -l label
LABEL
System_u:system_r:container_t:s0:c23,c603

Al revisar el directorio de certificados en el sistema de archivos del host, vemos que tiene la etiqueta user_home_t:

[matt@localhost ~]$ ls -lZ certs
total 8
-rw-rw-r--. 1 matt matt unconfined_u:object_r:user_home_t:s0 1944 Oct 20 17:54 domain.crt
-rw-------. 1 matt matt unconfined_u:object_r:user_home_t:s0 3272 Oct 20 17:53 domain.key

En la práctica, esto significa que, en modo sin privilegios, el contenedor no puede acceder a esos archivos, aunque estén montados mediante bind mount en la imagen.

Entonces, en lugar de ejecutar nuestro contenedor con --privileged, tenemos un par de opciones para solucionar esto. Primero, podemos desactivar por completo las etiquetas con --security-opts label=disable en la línea de comandos de podman. Obviamente, esto no es lo ideal desde el punto de vista de la seguridad, así que tanto podman como Docker cuentan con un mecanismo para volver a etiquetar los montajes: de forma privada con la opción Z o, si el montaje se comparte, con la opción z.

Entonces, para solucionar el problema de nuestro contenedor, basta con ejecutarlo así:

podman run -d --name registry -p 5000:5000 -v "$(pwd)"/certs:/certs:Z  --restart=always -e REGISTRY_HTTP_ADDR=0.0.0.0:5000 -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key registry:2

Por último, volviendo a las capacidades, siempre es buena idea ejecutar los contenedores con la cantidad mínima posible de capacidades habilitadas. En mi configuración de prueba, es posible ejecutar este contenedor de registro sin ninguna capacidad:

podman run -d --name registry -p 5000:5000 -v "$(pwd)"/certs:/certs:Z  --restart=always -e REGISTRY_HTTP_ADDR=0.0.0.0:5000 -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key --cap-drop=all registry:2

Hay varias formas de determinar qué capacidades necesita tu contenedor. Por ejemplo, puedes revisar qué capacidades bloquea SELinux durante la operación en el registro de auditoría y luego agregar las necesarias con el argumento --cap-add. ¡La regla del mínimo privilegio siempre es la mejor opción! La moraleja de esta historia es que no hay que tirar al bebé junto con el agua de la bañera.

En resumen

Marcar los contenedores como --privileged, incluso en espacios de nombres de usuario, no es una buena práctica y rompe los principios de mínimo privilegio y confianza cero. Averigua qué necesita realmente tu contenedor antes de ejecutarlo: usa herramientas como SELinux para auditar qué capacidades y permisos solicita la imagen del contenedor, y configura solo esos permisos cuando ejecutes el contenedor en producción. A menudo verás que los requisitos son bastante mínimos.

Seguridad de contenedores centrada en los desarrolladores

Snyk encuentra y corrige automáticamente vulnerabilidades en imágenes de contenedores y cargas de trabajo de Kubernetes.

Leer más

feature customer snowflake
Article

Seguridad desde el inicio: presentamos la integración de Snyk Studio con Snowflake Cortex Code

Snyk Studio se integra con Snowflake Cortex Code para analizar código generado por IA, dependencias y contenedores en busca de vulnerabilidades durante el desarrollo.

blog feature pypi spoof
Article

Toda la Snyk AI Security Platform, gratis para quienes mantienen proyectos de código abierto

Quienes mantienen proyectos de código abierto reciben reportes de vulnerabilidades reales sin parar y necesitan ayuda para priorizarlas, corregirlas y publicar soluciones más rápido. El Secure Developer Program de Snyk ofrece a los proyectos que cumplen los requisitos acceso gratis a Snyk AI Security Platform.

feature insights announcement
Blog

Compromiso de la cadena de suministro de node-gyp: un gusano de npm que se propaga solo y se oculta en binding.gyp

Un nuevo gusano de npm abusa de binding.gyp para activar node-gyp durante la instalación y permitir que paquetes maliciosos ejecuten código sin scripts del ciclo de vida. Roba credenciales, persiste en GitHub y se propaga entre mantenedores.