Introducción#
Docker es una plataforma que permite empaquetar una aplicación junto con sus dependencias en una unidad llamada imagen, que luego se ejecuta como un contenedor.
Un contenedor es un proceso aislado que se comporta como si tuviera su propio sistema operativo, pero que en realidad, a diferencia de una VM, comparte el kernel de la máquina host.
Linux ya ofrecía mecanismos de aislamiento como los de un container (chroot, namespaces, cgroups, etc.), pero Docker además aporta una mayor abstracción y una experiencia de usuario más sencilla. Cuenta con:
- Un formato estándar de imagen.
- Un daemon (
dockerd) que gestiona contenedores, imágenes, redes, volúmenes, etc. - Un lugar (
Docker Hub) en el que compartir imágenes. - Una herramienta de CLI (
docker) para interactuar con todo lo anterior.
Es importante entender que un contenedor no es una máquina virtual, es un proceso normal de Linux, con su correspondiente PID, y ejecutándose bajo el mismo kernel, pero con una “visión” restringida de dicho sistema, gracias a las abstracciones.
Esto por un lado de la da una muchísima mayor eficiencia y velocidad frente a una VM, pero los aislamientos son bastante menores que los de las VMs.
Arquitectura#
Docker sigue una arquitectura cliente-servidor:
| |
docker(CLI): Cliente que traduce comandos a llamadas a la API REST de dockerddockerd: El daemon central que gestiona imágenes, redes, volúmenes, etc. Se ejecuta como root por defecto. Delega la ejecución real de contenedores acontainerdcontainerd: Runtime de alto nivel que gestiona el ciclo de vida de los contenedores. Delega la creación real del container arunc.runc: Runtime de bajo nivel. Es el componente que invoca las syscalls de Linux para crear los namespaces, aplicar cgroups e iniciar el proceso.- Es un proceso que dura muy poco por lo complejo que es el binario. Por ello, se encarga de montar todos los namespaces y abstracciones, luego le pasa el control a un proceso intermedio (entre
containerdyrunc) llamadocontainerd-shim, y muere. Al haberle pasado el control al otro proceso, al morir no mueren los procesos hijos (que son los containers).
- Es un proceso que dura muy poco por lo complejo que es el binario. Por ello, se encarga de montar todos los namespaces y abstracciones, luego le pasa el control a un proceso intermedio (entre
Imágenes y contenedores#
Una imagen es una plantilla de solo lectura. Es un conjunto de capas apiladas, cada una representando un conjunto de cambios en el filesystem (ficheros añadidos, modificados o eliminados), y metadatos (comando por defecto, variables de entorno, puertos expuestos, usuario, etc.). Una imagen no se ejecuta, es algo estático.
Por otro lado, un container es una instancia en ejecución de una imagen (así como un proceso es una instancia en ejecución de un programa, más su estado). Cuando inicia un container nuevo (docker run), Docker añade una capa RW encima de las capas Read-Only de la imagen, e inicia un proceso con las abstracciones configuradas (namespaces, etc.).
Cada imagen se construye con un Dockerfile, un archivo de texto por líneas:
| |
Cada instrucción aquí (FROM,RUN,COPY…) crea una nueva capa. Docker cachea estas capas de tal forma que si se cambia una del final no hace falta hacer rebuild de todo, sino solo de lo modificado.
Las imágenes se identifican mediante un hash y se etiquetan de forma “legible”, p.ej app:lastest, docker.io/sagemath/sagemath:lastest o similares.
Sistema de capas (OverlayFS)#
Supongamos que descargamos una imagen que pesa 500MB. Si levantásemos 10 containers, el SO copiaría 10 veces esos 500MB, ocupando 5GB de disco duro para tener 10 copias idénticas.
Para solucionar esto, Docker usa OverlayFS, un sistema de archivos de superposición. Cuando se inician los containers, esto es lo que pasa en realidad:
lowerdir(Capas de la imagen): Docker guarda esos 500MB una sola vez en el disco (/var/lib/docker/overlay2/), con permisos de solo lectura- Al arrancar los 10 containers, Docker le dice al kernel de Linux que haga que los 10 containers apunten al mismo directorio de 500MB y les haga creer que es su disco duro.
upperdir(Capas del container): Para que los contenedores puedan escribir archivos sin interferir entre sí, Docker le da a cada uno sus propias capas de lectura/escritura.- Copy-on-Write (CoW): Si un container quiere modificar un archivo base de la imagen, no puede hacerlo modificando las capas read-only. Lo que hace el kernel es copiar ese archivo específico a una capa de lectura/escritura única del container. Así, el cambio solo afecta a un container, no al resto.
Aislamiento#
Namespaces (Visión)#
Los namespaces son una funcionalidad del kernel de Linux que permite limitar lo que un proceso puede ver en cuanto a recursos. Cuando runc() crea un container, invoca syscalls como clone() que, con una combinación de flags, permiten especificar qué namespaces se crean para el proceso hijo (que luego se pasará a containerd-shim). Algunos de ellos son:
PIDNamespace: Aísla el árbol de procesos.- Dentro del container, el primer proceso se ve a sí mismo como PID 1, aunque en el host tenga PID normal.
NETNamespace: Aísla la pila de red y le da una propia a cada container.- Docker crea interfaces virtuales, tablas de rutas, reglas de iptables, etc.
MNTNamespace: Aísla el árbol de mountpoints.- Esto permite que el container tenga su propia raíz (
/) sin ver los montajes del host, salvo que se compartan algunos explícitamente al configurarlo.
- Esto permite que el container tenga su propia raíz (
UTSNamespace: Aísla hostname y domain name.IPCNamespace: Aísla mecanismos de comunicaciones entre procesos (Memoria compartida, semáforos, colas de mensajes, etc.).
Cgroups (Recursos)#
Mientras que los namespaces controlan visibilidad, los grupos de control (CGroups) controlan y limitan el consumo de recursos: CPU, memoria, I/O de disco, ancho de banda, máximo de procesos, etc.
Hay dos versiones, v1 y v2. La estándar actualmente es la v2, que consiste en estructurar archivos de configuración de forma unificada bajo /sys/fs/cgroup/, de la siguiente manera:
| |
Para configurar estos límites al iniciar un container con Docker, se escribe algo p.ej así:
| |
Los CGroups se exponen al usuario en forma de los archivos mencionados arriba, y esto es relevante para la seguridad. Si un container tiene dicho directorio montado en modo Read-Write, puede llegar a modificar su propia configuración de CGroup, lo que le permitiría escapar al host.
Capabilities#
Las capabilities existen en Linux para poder dividir los privilegios de root en unidades más pequeñas otorgables de forma independiente. Docker por defecto no da al contenedor todas las capabilities de root, sino que le concede algunas limitadas (excluyendo obviamente las que permitirían salir del container, como montar discos, reconfigurar red, etc.).
Pueden añadirse o quitarse al iniciar un container de la siguiente forma:
| |
Seccomp, AppArmor/SELinux#
Otras limitacione extra.
- Seccomp es un filtro de syscalls. Permite limitar qué llamadas al sistema puede o no hacer el proceso. Docker por defecto bloquea bastantes syscalls peligrosas. Esto implica que hay veces en las que un proceso puede tener una capability peligrosa pero no el permiso para invocar la syscall que le permita explotarla.
- AppArmor/SELinux: Restringen qué ficheros puede leer/escribir/ejecutar el proceso del container de forma más compleja que los permisos de Linux comunes (rwx)
Privesc: Grupo docker#
En un sistema (host) Linux, ser un usuario que pertenece al grupo docker es equivalente a tener privilegios de root. El daemon de Docker se ejecuta como root, y cuando un usuario es del grupo docker tiene vía libre para comunicarse con el socket.
Esto significa que un atacante puede simplemente usar el comando de abajo para:
- Descargar una imagen ligera (Alpine Linux)
- Montar el filesystem del host dentro del container
- Inicia un shell como root con acceso a todo el filesystem
| |
Escapando de Docker#
Estos son algunos métodos para escapar de un contenedor Docker (y potencialmente escalar privilegios en el host), ordenados por preferencia en función de facilidad de ejecución, limpieza y requisitos.
- A) Socket de Docker: Requiere que el socket esté montado y tener privilegios R/W sobre él.
- B) Escape con nsenter: Requiere que el PID Namespace del host esté mapeado al container y tener la capability CAP_SYS_ADMIN
- C) Flag –privileged: Requiere que el container se haya iniciado con el flag –privileged, un error bastante exagerado.
A) docker.sock#
dockerd expone su API REST a través de un socket de Unix ubicado en /var/run/docker.sock. Cualquier proceso que pueda escribir en ese socket puede hablar con el daemon con los mismos privilegios que dockerd, que se ejecuta como root en el host.
Si, por error, se monta el socket dentro de un container, cualquier proceso dentro del mismo tiene control total sobre el daemon de Docker. Esto significa que puede pedirle al daemon que arranque un container nuevo con la raíz del host montada, y hacer chroot a esa ruta.
El ataque es trivial:
| |
Este comando:
- Pide al daemon del host (mapeado en el container) que cree un container nuevo con el disco raíz mapeado en /mnt.
- Hace
chrootal contenedor recién creado Al final, el resultado es un shell con privilegios administrativos sobre todo el sistema de archivos del host, equivalente a tener root en el host.
B) nsenter#
Cómo funciona docker exec?#
Cuando un usuario, desde el host, ejecuta docker run ..., se crean el proceso y las abstracciones, y luego se entra al contexto del proceso. Pero, si un container en sí es un proceso, cómo es que pueden ejecutarse otros procesos “dentro de containers”? P.ej:
| |
Al ejecutar esto en el host, el contenedor container_123 ya está creado y en ejecución, entonces, lo que pasa para poder ejecutar /bin/bash “dentro de él” es lo siguiente:
- El cliente de docker avisa a
dockerdde que un usuario con privilegios de root quiere iniciar un proceso/bin/bashcomo root dentro del container - El kernel del host crea el proceso
bashnuevo. Al principio este proceso está en el host. - El daemon de docker (
dockerd) usa la syscallsetns()para coger el procesobashnuevo y “meterlo” en las abstracciones (namespaces) del container ya existente.
Y esto es lo que pasa al iniciar un proceso dentro de un container ya en ejecución. Ahora vamos con la parte de nsenter, que está relacionada con setns().
Escape con nsenter#
nsenter es una utilidad legítima de Linux. Es una interfaz del CLI para usar la syscall setns(). Su función es decirle al kernel que meta el proceso actual dentro de los aislamientos (namespaces) de otro proceso.
Para que este ataque funcione, ha de cumplirse que:
- OBLIGATORIO: El administrador ha lanzado el contenedor con el flag
--pid=host, es decir, compartiendo el PID Namespace entre host y contenedor.- El proceso del container puede por tanto ver que
init/systemdtiene el PID 1 (Ve el árbol de procesos del host)
- El proceso del container puede por tanto ver que
- OBLIGATORIO: Ha de cumplirse una de estas dos (para poder cambiar de Namespace).
- Se ha lanzado el container con la capability
--privileged(que daCAP_SYS_ADMIN) - Se ha lanzado el container con la capability
CAP_SYS_ADMIN
- Se ha lanzado el container con la capability
Cuando se cumplen ambas condiciones, el ataque consiste en hacer algo así:
| |
C) Flag –privileged#
El flag --privileged, que puede usarse al iniciar un container (docker run) o al ejecutar un nuevo proceso en uno ya existente (docker exec) hace lo siguiente:
- Otorga al container TODAS las Capabilities (por tanto permite montar discos)
- Desactiva el filtro Seccomp
- Desactiva restricciones de AppArmor/SELinux
- Da acceso a todos los dispositivos (
/dev/*) del host
Esto significa que si un proceso se inicia en un container con --privileged, puede (si es root en el container):
- Buscar el disco que tenga la partición
/del host (porque puede ver todos los dispositivos) (P.ej,/dev/sda1) - Montarlo en un directorio del container (porque tiene Capabilities) (P.ej,
/mnt/) - Hacer chroot al mismo Y ya con eso el atacante tendría privilegios de admin sobre todo el sistema host.
| |
Aunque tener el flag --privileged es un camino directo a root, suele preferirse la vía de nsenter porque es más limpia y no monta y desmonta discos ni crea archivos (cosas que suelen dejar rastro). Además, el cambio de entorno con nsenter es completamente nativo: Cambian variables de entorno, interfaces de red, etc., mientras que con el método de --privileged y chroot sigues con las del container.



