Seguridad en Docker:
los fallos de contenedor que acaban en el host
Linux avanzado | unfantasmaenelsistema.com
Docker resolvió un problema real: empaquetar una aplicación con sus dependencias y ejecutarla igual en cualquier sitio. Por el camino se coló una idea peligrosa: que un contenedor es «como una máquina virtual pequeña», una caja hermética de la que nada puede salir. No lo es. Un contenedor es un proceso normal del host, ejecutándose bajo el mismo kernel que todo lo demás, con unas cuantas restricciones encima. Cuando esas restricciones están mal puestas —o se quitan a propósito «para que funcione»— la distancia entre «tengo acceso al contenedor» y «tengo root en el host» puede ser de un solo comando.
Este artículo recorre esa frontera: qué la sostiene (namespaces, cgroups, capabilities, seccomp, AppArmor), qué la rompe (banderas como –privileged, montar el socket de Docker, correr como root), y cómo se endurece un despliegue propio sin dejar de ser práctico. Sigue a los artículos de contenedores y eBPF ya publicados, ahora por el lado de la seguridad.
Qué aprenderás en este artículo
Por qué un contenedor no es una VM y qué separa realmente al contenedor del host · Las banderas y montajes que convierten un contenedor en acceso directo al host · Cómo funcionan las Linux capabilities, seccomp y AppArmor en Docker y cómo endurecerlos · Buenas prácticas de imagen: escaneo, usuario no root, bases mínimas, firma · Docker rootless y Podman como cambio de modelo · Escapes de contenedor conocidos a alto nivel y cómo se detectan (Falco, auditd)
Encuadre legal y alcance — léelo antes de seguir
Todo lo que sigue está pensado para endurecer despliegues propios o de tu organización, con autorización: servidores, laboratorios y CI/CD sobre los que tienes responsabilidad · Probar escapes de contenedor o abusar de configuraciones débiles en infraestructura ajena y sin permiso escrito es acceso ilícito y daños informáticos (en España, arts. 197 bis, 197 ter y 264 del Código Penal) · Los ejemplos usan imágenes y hosts de laboratorio; no hay exploits contra versiones concretas de runc ni payloads de evasión, de forma deliberada · El objetivo es que administradores, DevOps y equipos de plataforma reconozcan y cierren estos fallos en sus propios entornos
1. Un contenedor no es una máquina virtual
Una máquina virtual ejecuta su propio kernel sobre un hipervisor; la frontera entre huésped y anfitrión es de hardware virtualizado y está muy estudiada. Un contenedor no tiene kernel propio: comparte el del host. Lo que crea la ilusión de aislamiento son dos mecanismos del kernel de Linux.
- Namespaces: dan a cada contenedor su propia vista de un recurso del sistema. El namespace PID hace que el proceso principal del contenedor se vea como PID 1 y no vea el resto de procesos del host; el de red le da sus propias interfaces; los de mount, UTS, IPC y user hacen lo propio con el sistema de ficheros, el hostname, la memoria compartida y los identificadores de usuario
- Cgroups (control groups): limitan cuánto puede consumir el contenedor —CPU, memoria, E/S, número de PIDs— para que un contenedor no ahogue al resto
La consecuencia práctica: namespaces y cgroups son una frontera, no una jaula. Una vulnerabilidad en el kernel del host es una vulnerabilidad para todos los contenedores a la vez. Y cualquier configuración que «perfore» un namespace —compartir el de red, el de PID o el de usuario con el host— reduce ese aislamiento de forma inmediata.
La regla mental
Trata la superficie del contenedor como si fuera un proceso más del host con privilegios acotados · Todo lo que le des (capabilities, montajes, namespaces del host, acceso a dispositivos) es superficie que un atacante dentro del contenedor puede intentar usar contra el host
2. Root en el contenedor es root en el host
Por defecto, el usuario root dentro de un contenedor (UID 0) es el mismo UID 0 del host. Las capabilities recortadas y el perfil seccomp impiden que haga muchas cosas, pero el mapeo de identidad es directo: si ese proceso consigue escapar del contenedor —por un montaje, por una capability de más, por un fallo del runtime— aterriza en el host como root.
2.1 User namespace remapping
El user namespace rompe ese mapeo: hace que el UID 0 del contenedor sea, por ejemplo, el UID 100000 del host, un usuario sin ningún privilegio. Se activa en el demonio con userns-remap. Es una de las defensas más eficaces y sigue infrautilizada, en parte porque complica los volúmenes con permisos y algunas imágenes mal hechas asumen ser root real.
# /etc/docker/daemon.json -- remapear identidades de contenedor a un rango sin privilegios
{
"userns-remap": "default"
}
# El demonio crea el usuario/grupo 'dockremap' y usa /etc/subuid y /etc/subgid
# UID 0 dentro del contenedor -> p.ej. UID 165536 en el host (sin privilegios)2.2 Lo mínimo, siempre
- Declara un usuario no privilegiado en el Dockerfile (USER appuser) y haz que la aplicación corra con él; root solo para instalar dependencias en la fase de build
- Añade –user $(id -u):$(id -g) o `user:` en Compose cuando la imagen no lo fije
- Monta el sistema de ficheros raíz como solo lectura (–read-only) y da rutas de escritura explícitas con tmpfs o volúmenes concretos
- Añade –security-opt no-new-privileges para que ningún binario setuid dentro del contenedor pueda elevar privilegios
3. Las banderas que son game over
Hay un puñado de opciones de `docker run` que, por sí solas, anulan casi todo el aislamiento. Aparecen una y otra vez en tutoriales, en imágenes de «todo en uno» y en pipelines de CI que necesitaban que algo funcionara rápido. Reconocerlas es media batalla.
| Opción | Qué hace | Por qué es peligrosa |
|---|---|---|
| –privileged | Concede casi todas las capabilities, desactiva seccomp/AppArmor y da acceso a todos los dispositivos del host | Escape trivial: se pueden montar discos del host, cargar módulos del kernel, acceder a /dev/mem |
| -v /var/run/docker.sock:… | Expone el socket de la API de Docker dentro del contenedor | Quien controla el socket puede crear un contenedor nuevo con el host montado y root: equivale a root en el host |
| –pid=host | El contenedor comparte el namespace de PIDs del host | Ve y puede señalizar/inspeccionar todos los procesos del host; facilita inyección y lectura de memoria ajena |
| –net=host | El contenedor usa la pila de red del host directamente | Sin aislamiento de red; puede escuchar en cualquier puerto, sniffar tráfico local, saltarse reglas de firewall por interfaz |
| –cap-add=SYS_ADMIN | Añade una capability enorme y multiuso | Permite montar sistemas de ficheros, manipular namespaces y varias vías conocidas de escape |
| -v /:/host | Monta el sistema de ficheros del host dentro del contenedor | Escritura directa sobre binarios, cron, claves SSH o /etc/shadow del host |
El socket de Docker montado es el error más repetido
Muchas imágenes de CI, de monitorización o de «gestión de contenedores» piden montar /var/run/docker.sock «para ver los contenedores» · Ese montaje da control total del demonio: `docker -H unix:///var/run/docker.sock run -v /:/host …` desde dentro del contenedor abre un shell con el host entero montado · Alternativas: usar la API de Docker por TCP con TLS y permisos acotados, un proxy de socket que filtre endpoints (docker-socket-proxy), o directamente Podman sin demonio
3.1 Cómo auditar lo que ya está corriendo
# Contenedores en modo privilegiado ahora mismo
docker ps -q | xargs docker inspect --format \
'{{.Name}} privileged={{.HostConfig.Privileged}} pid={{.HostConfig.PidMode}} net={{.HostConfig.NetworkMode}}'
# Contenedores con el socket de Docker o la raiz del host montada
docker ps -q | xargs docker inspect \
--format '{{.Name}}{{range .Mounts}} {{.Source}} -> {{.Destination}}{{end}}' | grep -E 'docker.sock|-> /host| / '
# Capabilities anadidas por contenedor
docker ps -q | xargs docker inspect --format '{{.Name}} {{.HostConfig.CapAdd}}'4. Linux capabilities: quitar, no añadir
El modelo tradicional de Unix era binario: o eras root y podías todo, o no lo eras. Las capabilities trocean ese «todo» en unas 40 piezas independientes: CAP_NET_BIND_SERVICE para abrir puertos por debajo de 1024, CAP_NET_RAW para sockets crudos, CAP_SYS_TIME para cambiar el reloj, etc. Docker arranca cada contenedor con un subconjunto reducido y descarta el resto.
El problema aparece cuando alguien añade capabilities «por si acaso». CAP_SYS_ADMIN es la más abusada: es tan amplia que se la conoce como «el nuevo root». CAP_SYS_PTRACE, CAP_SYS_MODULE, CAP_DAC_READ_SEARCH y CAP_NET_ADMIN también amplían mucho la superficie.
| Práctica | Comando | Efecto |
|---|---|---|
| Quitar todas y añadir solo lo necesario | –cap-drop=ALL –cap-add=NET_BIND_SERVICE | Superficie mínima; el patrón recomendado |
| Ver qué capabilities tiene un contenedor | docker exec <c> capsh –print | Diagnóstico rápido de privilegios efectivos |
| Evitar | –cap-add=SYS_ADMIN / SYS_MODULE / SYS_PTRACE | Cada una abre vías de escape o de interferencia con el host |
| Bloquear escaladas por setuid | –security-opt no-new-privileges | Ni siquiera un binario setuid dentro puede subir privilegios |
Regla práctica de capabilities
Empieza siempre por –cap-drop=ALL y añade una a una las que la aplicación necesite de verdad, comprobando qué falla · La mayoría de aplicaciones web en contenedor no necesitan ninguna capability extra si escuchan en un puerto alto · Si crees que necesitas NET_ADMIN o SYS_ADMIN, casi siempre hay una forma de rediseñar el despliegue para no necesitarla
5. Seccomp y AppArmor: los perfiles por defecto y cómo endurecerlos
5.1 Seccomp
Seccomp (secure computing mode) filtra qué llamadas al sistema puede hacer un proceso. Docker aplica un perfil por defecto que bloquea alrededor de 40-50 syscalls peligrosas o inútiles dentro de un contenedor (keyctl, ptrace en ciertos contextos, add_key, syscalls de módulos del kernel, etc.). Ese perfil por defecto ya para muchas cosas; el error habitual es desactivarlo con –security-opt seccomp=unconfined para depurar y olvidarse de volver a activarlo.
- No ejecutes con `seccomp=unconfined` en producción; si una syscall concreta falta, escribe un perfil derivado del de por defecto que solo añada esa
- Herramientas como el generador de perfiles de contenedores o el análisis con strace en un entorno de pruebas ayudan a construir un perfil ajustado
- –privileged también desactiva seccomp: otra razón para no usarlo
5.2 AppArmor (o SELinux)
En distribuciones basadas en Debian/Ubuntu, Docker carga el perfil AppArmor docker-default, que restringe escrituras en rutas sensibles, montajes y acceso a `/proc` y `/sys`. En RHEL/Fedora el equivalente es SELinux con el tipo `container_t`. Para cargas sensibles conviene un perfil propio más estricto y cargarlo con –security-opt apparmor=mi-perfil.
# Comprobar que el perfil por defecto esta activo (no debe decir 'unconfined')
docker inspect --format '{{.AppArmorProfile}}' <contenedor>
# Cargar un perfil propio y usarlo
sudo apparmor_parser -r -W /etc/apparmor.d/containers/mi-perfil
docker run --security-opt apparmor=mi-perfil ...
# En sistemas con SELinux: etiquetar los volumenes correctamente
docker run -v /datos:/datos:Z ... # :Z etiqueta el contenido para este contenedor6. Las imágenes: la mitad del problema
Buena parte de los incidentes con contenedores no vienen de la configuración del runtime sino de lo que hay dentro de la imagen: librerías con CVE conocidas, binarios de más, secretos incrustados y procesos corriendo como root.
| Riesgo en la imagen | Mitigación |
|---|---|
| CVE en paquetes del sistema o dependencias | Escaneo en CI con Trivy, Grype o el escáner del registro; fallar el build por severidad alta/crítica |
| Superficie enorme (shell, gestor de paquetes, compiladores) | Bases mínimas: distroless, Alpine, o imágenes ‘scratch’ con un binario estático |
| Proceso corriendo como root | USER no root en el Dockerfile; comprobarlo en CI |
| Secretos en capas (ENV, ficheros, ARG) | Nunca en la imagen: usar secretos del orquestador o montajes en runtime; escanear con git-secrets/trufflehog |
| Imagen alterada entre build y despliegue | Firmar con cosign (Sigstore) y verificar la firma en el clúster (policy-controller, Kyverno) |
| Build multi-etapa que arrastra herramientas de compilación | Copiar solo el artefacto final a una imagen runtime limpia |
# Escaneo de vulnerabilidades de una imagen, fallando si hay criticas trivy image --severity HIGH,CRITICAL --exit-code 1 miapp:1.4.2 # Dockerfile: build como root, runtime como usuario sin privilegios FROM golang:1.22 AS build WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o /app ./cmd/server FROM gcr.io/distroless/static:nonroot COPY --from=build /app /app USER nonroot:nonroot ENTRYPOINT ["/app"]
Los secretos no van en la imagen ni en el ENV
Cualquiera con la imagen puede hacer `docker history` y leer los ARG/ENV, y `docker inspect` expone las variables de entorno en runtime · Usa los secretos de Docker Swarm/Kubernetes, un gestor externo (Vault, SOPS) o montajes tmpfs; nunca capas ni variables de entorno persistentes · Para el build, `RUN –mount=type=secret` mantiene el secreto fuera de la imagen final
7. Rootless Docker y Podman: cambiar el modelo
Las defensas anteriores endurecen un modelo que sigue teniendo un demonio corriendo como root. Docker rootless y Podman eliminan ese demonio privilegiado: todo el stack corre bajo tu usuario, apoyándose en user namespaces. Si un contenedor escapa, aterriza como tu usuario sin privilegios, no como root.
- Docker rootless: instalación con `dockerd-rootless-setuptool.sh`; el demonio corre en tu sesión de usuario. Limitaciones: puertos < 1024 requieren ajuste, algunas redes y almacenamiento cambian
- Podman: sin demonio (cada contenedor es un hijo directo de tu shell), compatible con la CLI de Docker (`alias docker=podman`), integración con systemd para servicios (`podman generate systemd` / Quadlet), y pods al estilo Kubernetes
- Buildah y Skopeo completan el ecosistema sin demonio para construir y mover imágenes
- Para CI, ejecutar los builds rootless (o con `buildkit` en modo sin privilegios) reduce mucho el impacto de una imagen maliciosa
Cuándo merece la pena migrar
Servidores multiusuario, runners de CI compartidos y estaciones de desarrollo: el salto a rootless o Podman quita de la mesa la clase entera de ‘escape a root en el host’ · Clústeres gestionados (Kubernetes): el foco se mueve a Pod Security Standards, runtimes aislados (gVisor, Kata) y admission control
8. Escapes de contenedor y cómo se detectan
Los escapes reales suelen combinar una configuración débil con un fallo de software. A alto nivel, las familias más citadas:
| Familia | Idea a alto nivel | Condición que lo habilita |
|---|---|---|
| Fugas del runtime (p. ej. CVE-2019-5736 en runc) | Sobrescritura del binario del runtime desde dentro del contenedor al hacer exec | Runtime sin parchear; imagen o proceso controlado por el atacante |
| Abuso de docker.sock | Crear un contenedor nuevo con el host montado y root | Socket del demonio accesible dentro del contenedor |
| –privileged / SYS_ADMIN | Montar dispositivos del host, abusar de cgroups (release_agent), cargar módulos | Contenedor privilegiado o con capabilities amplias |
| Montajes sensibles del host | Escribir en /etc, cron, claves SSH o binarios del host | Volúmenes que exponen rutas del host con escritura |
| Fallos del kernel compartido | Elevar privilegios explotando el kernel del host desde el contenedor | Kernel del host vulnerable; seccomp no bloquea la syscall usada |
8.1 Detección en tiempo de ejecución
La detección se apoya en observar el comportamiento del contenedor a nivel de syscalls y de kernel. Falco (CNCF) es la herramienta de referencia: un motor de reglas sobre eventos del kernel (vía eBPF o su módulo) que alerta de patrones anómalos. auditd cubre la parte de auditoría del sistema y eBPF —cubierto en su propio artículo— es la tecnología por debajo de casi todo el tooling moderno.
- Shell interactivo lanzado dentro de un contenedor en producción (a menudo señal de intrusión o de operación manual no autorizada)
- Escritura en rutas sensibles del contenedor o del host montado (`/etc`, `/proc/sysrq-trigger`, `/var/run/docker.sock`)
- Procesos que intentan `ptrace`, cargar módulos, o montar sistemas de ficheros
- Contenedor que abre conexiones salientes inesperadas o hacia la API de Kubernetes/Docker
- Cambios en binarios del propio runtime o ejecución de binarios que no venían en la imagen
# Reglas de ejemplo que trae Falco (conceptual, no configuracion completa) # - 'Terminal shell in container' -> exec de bash/sh con tty dentro de un contenedor # - 'Write below etc' -> escritura en /etc dentro del contenedor # - 'Launch Privileged Container' -> arranque de un contenedor --privileged # - 'Docker or K8s client executed' -> uso de docker/kubectl desde dentro de un contenedor # auditd: vigilar el socket del demonio y el binario de runc en el host auditctl -w /var/run/docker.sock -p rwxa -k docker-socket auditctl -w /usr/bin/runc -p rwxa -k runc-integrity
Checklist de endurecimiento Docker
userns-remap activo o, mejor, Docker rootless / Podman en hosts compartidos · Cada servicio con –cap-drop=ALL y solo las capabilities imprescindibles · –security-opt no-new-privileges y sistema de ficheros –read-only por defecto · Nunca –privileged ni montar /var/run/docker.sock en contenedores de aplicación · Seccomp y AppArmor/SELinux en su perfil por defecto como mínimo; perfil propio para cargas sensibles · Imágenes mínimas (distroless/Alpine), USER no root, escaneo con Trivy/Grype en CI, firma con cosign · Secretos fuera de la imagen y del ENV: gestor externo o montajes en runtime · Runtime y kernel del host parcheados; red de contenedores segmentada y egress filtrado · Falco (o EDR equivalente) vigilando shells en contenedor, escrituras sensibles y uso de docker/kubectl
Conclusión
La seguridad de Docker se entiende mejor si se abandona la metáfora de la caja fuerte. Un contenedor es un proceso del host con un traje de restricciones: namespaces que le limitan la vista, cgroups que le limitan el consumo, capabilities, seccomp y AppArmor que le limitan lo que puede pedirle al kernel. Ese traje protege razonablemente bien mientras nadie le haga agujeros; el problema es que los agujeros —–privileged, el socket montado, –pid=host, correr como root— son justo lo que muchos tutoriales enseñan a hacer para que algo funcione rápido.
El endurecimiento real es acumulativo y poco espectacular: quitar capabilities, no montar lo que no hace falta, construir imágenes mínimas sin secretos ni root, y —el cambio de mayor impacto— mover el modelo entero a rootless o Podman en cualquier host compartido. Encima de todo eso, detección en tiempo de ejecución con Falco para ver el intento antes de que el intento sea una brecha. Ninguna de estas medidas es cara; su ausencia sí.
Referencias
Docker docs — Security, Rootless mode, Seccomp y AppArmor profiles · CIS Docker Benchmark — lista de verificación de configuración segura del demonio y los contenedores · OWASP Docker Security Cheat Sheet — resumen práctico de controles · Falco (falco.org) y las reglas por defecto del proyecto — detección en runtime · Trivy (aquasecurity/trivy) y Grype (anchore/grype) — escaneo de imágenes · Sigstore / cosign — firma y verificación de imágenes · NIST SP 800-190 — Application Container Security Guide · Nuestros artículos sobre contenedores y sobre eBPF, para el contexto de base
unfantasmaenelsistema.com | Seguridad, sistemas y todo lo que no debería funcionar (pero funciona)

