Skip to main content

Contêineres Docker privilegiados: você realmente precisa deles?

Escrito por

5 de novembro de 2020

0 minutos de leitura

Esta semana, acabei entrando em um buraco de coelho ao fazer alguns testes com Podman para entender por que executar determinado contêiner em uma configuração sem root exigia a flag --privileged. Com toda razão, meu colega Eric Smalling perguntou por que essa flag seria necessária.

No fim das contas, --privileged é uma forma resumida de conceder tudo o que existe. Você pode achar que isso não importa tanto ao executar sem root, mas, de certa forma, isso rompe os princípios do menor privilégio e da confiança zero. Mesmo ao executar sem root, é sempre recomendável entender exatamente do que seu contêiner precisa e conceder apenas as permissões mínimas necessárias.

Embora definir essa flag em processos executados sem root não conceda ao processo mais privilégios do que o usuário tem, fiquei intrigado para descobrir quais recursos esse processo realmente precisava e que levaram a essa recomendação de configuração. No melhor espírito explorador, vesti meu equipamento de espeleologia e parti rumo ao coração das trevas.

Vamos investigar

Primeiro, vamos entender o que significa executar contêineres sem root. Tudo isso se deve à magia dos namespaces de usuário no kernel do Linux, que permitem que usuários sem privilégios criem novos namespaces de usuário. Quando um usuário cria e entra em um novo namespace de usuário, ele se torna root no contexto desse namespace e obtém a maioria dos privilégios necessários para iniciar um contêiner funcional. Nos namespaces de usuário, o root obviamente não tem os mesmos privilégios que o root do sistema. Isso significa, porém, que podemos executar contêineres sem precisar elevar privilégios e, assim, reduzir significativamente a superfície de ataque potencial de vulnerabilidades.

Estou usando uma máquina virtual com uma instalação mínima do CentOS 8, executada no VirtualBox em meu MacBook. Primeiro, instalei o Podman usando o dnf:

dnf install podman

Em seguida, criei um diretório na pasta pessoal do meu usuário e gerei nele um certificado autoassinado:

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

Agora quero usar esse certificado para executar um registro Docker local para fins de teste, e é aqui que nossa jornada realmente começa. Toda a documentação que encontrei sobre como fazer isso sugeria usar a flag --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 na linha de comando acima, estamos executando a imagem do registro identificada como 2, criando uma montagem de volume que vincula o diretório de certificados do diretório atual a /certs no contêiner, passando algumas variáveis de ambiente para configurar o registro e, sem pensar duas vezes, adicionando a flag --privileged para instruir o Podman a executar esse contêiner em modo privilegiado.

Para usar o registro com o Podman, precisamos adicionar uma entrada a /etc/containers/registries.conf:

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

A execução em modo privilegiado funciona bem. Então, a primeira coisa que quis verificar foi o que aconteceria se executássemos o contêiner sem privilégios. Foi simples: o contêiner inicia, depois falha e reinicia, e os logs mostram que ele não consegue acessar o certificado.

[matt@localhost log]$ podman logs bd323f90c60b

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

Nesse momento, supus que o problema estivesse relacionado aos recursos do Linux, pois uma das principais funções da flag --privileged é permitir que o contêiner acesse todos os recursos oferecidos pelo kernel. Podemos verificar isso usando o Podman para executar esse contêiner em modo privilegiado:

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

Em seguida, consultei as páginas de manual do Linux para ver em detalhes o que cada recurso permite que os processos façam. Também podemos usar o Podman para verificar quais recursos nosso contêiner em execução sem privilégios tem:

[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

Comparando essa lista com a página de manual do Linux, os recursos efetivos disponíveis no modo sem privilégios deveriam bastar para permitir que o contêiner lesse arquivos. Na verdade, essa lista provavelmente inclui mais recursos do que este contêiner específico precisa, mas voltaremos a isso mais adiante.

O próximo passo é consultar o audit.log no host e, como era de se esperar:

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

Essa entrada nos mostra que o SELinux bloqueou a chamada de leitura do arquivo domain.crt. Em sistemas CentOS e RHEL, o SELinux vem configurado por padrão no modo Enforcing, e podemos confirmar isso com a ferramenta 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

No modo Enforcing, o SELinux atribui rótulos a arquivos e diretórios para controlar o acesso a eles, e esses rótulos também influenciam o comportamento dos mecanismos de contêiner. Eles iniciam processos com o rótulo container_t e atribuem ao contêiner o rótulo container_file_t. O SELinux garante que os processos interajam apenas com arquivos rotulados dessa forma, negando por padrão o acesso a arquivos fora do contêiner. Quando usamos a flag --privileged, os rótulos são desativados e o contêiner é executado com o rótulo do mecanismo de contêiner que o iniciou. Podemos ver isso ao examinar nossos contêineres com o Podman. Aqui está um contêiner privilegiado:

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

E aqui está um executado sem privilégios:

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

Ao examinar o diretório de certificados no sistema de arquivos do host, vemos que ele tem o rótulo 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

Na prática, isso significa que, no modo sem privilégios, esses arquivos não ficam acessíveis ao contêiner, mesmo quando são montados como bind na imagem.

Então, em vez de executar nosso contêiner com --privileged, temos algumas opções para resolver isso. Primeiro, podemos desativar totalmente os rótulos usando --security-opts label=disable na linha de comando do Podman. Do ponto de vista da segurança, essa opção não é nada ideal. Por isso, tanto o Podman quanto o Docker oferecem um mecanismo para reatribuir os rótulos das montagens: de forma privada usando a opção Z ou, se a montagem for compartilhada, usando a opção z.

Para corrigir nosso contêiner, basta executá-lo com:

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 fim, voltando aos recursos: nunca é uma má ideia executar contêineres com o mínimo absoluto de recursos habilitados. Nos meus testes, foi possível executar esse contêiner de registro com todos os recursos removidos da configuração.

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

Há várias maneiras de descobrir quais recursos seu contêiner precisa. Por exemplo, você pode verificar no log de auditoria quais recursos o SELinux bloqueia durante a execução e, em seguida, adicionar os necessários com o argumento --cap-add. O princípio do menor privilégio é sempre a melhor opção! A moral dessa história é não jogar fora o bebê junto com a água do banho.

Em resumo

Marcar contêineres com --privileged, mesmo em namespaces de usuário, não é uma boa prática e contraria os princípios do menor privilégio e da confiança zero. Descubra do que seu contêiner realmente precisa antes de executá-lo. Use os resultados de ferramentas como o SELinux para auditar os recursos e as permissões solicitados pela imagem do contêiner e conceda apenas o necessário ao executá-lo em produção. Muitas vezes, você verá que as necessidades são bem reduzidas.

Segurança de contêineres que prioriza os desenvolvedores

O Snyk encontra e corrige automaticamente vulnerabilidades em imagens de contêiner e workloads do Kubernetes.