Blog

La falacia de la confianza: cómo nuestra configuración Docker multiinquilino filtró datos (y cómo lo solucionamos)

Aprende cómo la ingenua configuración Docker de un equipo provocó una fuga de datos entre inquilinos y la estrategia de aislamiento en capas que lo evitó.

Resumen

Los contenedores Docker no están aislados por defecto: comparten el kernel del host y, sin una configuración deliberada, los inquilinos pueden interferir entre sí. Este artículo recorre un escenario real donde un proveedor de hosting multiinquilino descubrió que los contenedores de los clientes podían acceder a las bases de datos de otros debido a la red compartida y los valores de seguridad débiles. Mostramos los cambios paso a paso que solucionaron la brecha: redes por inquilino definidas por el usuario, usuarios no root, capacidades eliminadas, sistemas de archivos de solo lectura y perfiles seccomp. Una suposición común es que los contenedores proporcionan inherentemente un fuerte aislamiento; desafiamos eso explicando por qué las máquinas virtuales aún ofrecen un límite más duro y cuándo considerar un enfoque híbrido. La conclusión refuerza que el aislamiento es un ejercicio en capas, no una sola casilla de verificación.

El incidente: cuando los contenedores hablan demasiado

Has configurado Docker en un solo host para ejecutar varios sitios web de clientes. Cada cliente tiene su propio contenedor, un entorno aislado y ordenado, ¿verdad? Eso es lo que pensamos. Hasta que una auditoría de seguridad rutinaria reveló que el contenedor del Cliente A estaba leyendo el socket MySQL del contenedor del Cliente B en el mismo host. Compartían la red bridge predeterminada. Peor aún, los contenedores se ejecutaban como root, por lo que un atacante que comprometiera uno podría manipular el socket Docker del host o el sistema de archivos de otro contenedor. La brecha no fue un exploit sofisticado; fue una mala configuración básica. Se filtraron datos. La confianza se disolvió.

El escenario de fallo no es poco común. Muchos equipos asumen que los namespaces y cgroups de Docker automáticamente aíslan a los inquilinos, pero subestiman cuántas vías de escape permanecen abiertas por defecto. Las redes bridge predeterminadas no ofrecen aislamiento de red entre contenedores. Ejecutar como root le da al contenedor más poder del necesario. Y sin límites de recursos explícitos, un vecino ruidoso puede privar a otros de CPU o memoria.

Paso 1: dejar de compartir una sola red

Nuestra primera solución fue dar a cada inquilino su propia red Docker definida por el usuario. Esto evita que los contenedores se comuniquen entre sí a menos que los conectes explícitamente. Creamos un script que, para cada inquilino, inicia una red dedicada y adjunta su contenedor de aplicación a ella. El contenedor de base de datos vive en la misma red del inquilino, pero también agregamos una red interna solo para comunicación intra-inquilino. No más intromisiones entre inquilinos.

También aislamos las bases de datos ejecutándolas en contenedores separados en la misma red del inquilino, usando volúmenes de datos separados. Esto aseguró que incluso si un atacante irrumpiera en el contenedor de la aplicación, no pudiera esnifar el tráfico de base de datos de otro inquilino.

Para una inmersión más profunda en las estrategias de aislamiento de red, consulta la Lista de verificación práctica de seguridad de aislamiento Docker para hosting multiinquilino.

Paso 2: eliminar privilegios innecesarios

Por defecto, los contenedores Docker se ejecutan con un conjunto limitado de capacidades de Linux, pero aún tienen más de las que la mayoría de las aplicaciones necesitan. Nuestros contenedores se ejecutaban como root, lo que permitía a los procesos internos realizar acciones como montar sistemas de archivos o cambiar parámetros del kernel. Cambiamos a ejecutar la aplicación como un usuario no root dentro del contenedor (usando la directiva USER en el Dockerfile) y eliminamos todas las capacidades excepto las absolutamente necesarias. Para una aplicación web típica, eso podría ser solo NET_BIND_SERVICE (para enlazar a puertos menores de 1024) y CHOWN (para escribir en directorios). También agregamos --security-opt no-new-privileges para prevenir la escalada de privilegios.

Este solo paso eliminó muchos vectores comunes de escape de contenedores. Un atacante que comprometa el servidor web no puede instalar paquetes, modificar binarios del sistema ni acceder al socket Docker del host porque el proceso carece de las capacidades CAP_SYS_ADMIN o CAP_DAC_OVERRIDE.

Paso 3: bloquear el sistema de archivos

Los sistemas de archivos modificables son una superficie de ataque común. Hicimos que el sistema de archivos raíz fuera de solo lectura (--read-only) para todos los contenedores, y luego montamos sistemas de archivos temporales (tmpfs) para los directorios que necesitan acceso de escritura, como /tmp y el directorio de caché de la aplicación. Esto evita que un atacante modifique el código de la aplicación o persista binarios maliciosos.

Además, usamos la opción --mount de Docker para montar por enlace directorios sensibles como el socket Docker solo cuando es absolutamente necesario, y nunca en contenedores de producción. El principio: si el contenedor no necesita escribir en una ruta, hazla de solo lectura.

Paso 4: aplicar perfiles Seccomp y AppArmor

Los perfiles seccomp predeterminados ya bloquean muchas llamadas al sistema peligrosas, pero los personalizamos aún más para permitir solo las llamadas al sistema que nuestra aplicación realmente necesita. Esto es un compromiso porque requiere perfilar la aplicación. Un enfoque más simple es usar el perfil seccomp predeterminado de Docker y luego agregar --security-opt seccomp=path/to/profile.json si necesitas reglas más estrictas. De manera similar, los perfiles de AppArmor pueden confinar los procesos del contenedor a rutas y capacidades de archivos específicos. Habilitamos AppArmor y usamos un perfil personalizado que restringía el acceso solo a los directorios de datos de la aplicación.

Para una guía completa sobre estos pasos de endurecimiento, consulta Guía de aislamiento paso a paso para endurecer contenedores Docker en hosting multiinquilino.

La visión contraria: a veces necesitas VMs

No importa cuán endurecidos estén, los contenedores comparten el kernel del host. Una vulnerabilidad del kernel puede romper todo el aislamiento de una vez. Es por eso que muchas plataformas conscientes de la seguridad ejecutan contenedores dentro de VMs ligeras: cada inquilino obtiene su propio kernel. Esto agrega sobrecarga pero proporciona un límite a nivel de hardware que los contenedores solos no pueden. Si tus inquilinos manejan datos de tarjetas de crédito o registros médicos, un enfoque híbrido (contenedores dentro de VMs) puede ser la decisión correcta. No asumas que el aislamiento de contenedores es suficiente para tu modelo de amenaza; evalúa la sensibilidad de los datos y los requisitos regulatorios.

Para una comparación más profunda de los niveles de aislamiento, lee Diseñando una arquitectura Docker multiinquilino: eligiendo el nivel de aislamiento adecuado.

Conclusión: el aislamiento es una pila, no un interruptor

La solución no fue un solo cambio, sino capas: aislamiento de red, privilegios limitados, sistemas de archivos de solo lectura y filtrado de llamadas al sistema. Aun así, aceptamos que el aislamiento perfecto es imposible con contenedores de kernel compartido. Para nuestros inquilinos de mayor seguridad, los trasladamos a hosts dedicados. La lección: no confíes en ningún valor predeterminado. Audita tu configuración Docker como si ya hubiera ocurrido una brecha. El momento de asegurar es antes de la fuga, no después.

Sources (5)