Escalonamento de privilégios no kernel: como o isolamento de contêineres do Kubernetes afeta esses ataques
Kamil Potrec
3 de dezembro de 2020
0 minutos de leituraDurante o dia, passo meu tempo analisando código Terraform, arquivos de configuração de objetos do Kubernetes e identificando problemas de segurança comuns. Quando o sol se põe, visto meu moletom com capuz, inicio máquinas virtuais Linux e depuradores para investigar as tecnologias que compõem o ecossistema cloud native.
Neste post, vamos explorar como o isolamento de contêineres do Kubernetes afeta os ataques de escalonamento de privilégios. Usaremos técnicas comuns de exploração do kernel para entender como as camadas de abstração dos contêineres podem dificultar o caminho até o tão desejado shell de root.
O que é escalonamento de privilégios?
Escalonamento de privilégios é o termo usado para descrever o processo de obtenção de permissões adicionais para acessar um recurso. O escalonamento de privilégios no kernel é o processo de obter essas permissões explorando uma vulnerabilidade em um dos vários pontos de entrada do kernel, também chamados de vetores de ataque. Um vetor de ataque é simplesmente um caminho que dá acesso ao código vulnerável.
Interagimos com o kernel de várias maneiras: lendo o sistema de arquivos, abrindo um arquivo de dispositivo, emitindo chamadas de sistema ou enviando um pacote pela interface de rede. Todas essas ações exigem que algum processo ocorra no espaço do kernel. Quando o kernel executa uma ação em nome de um processo do usuário, dizemos que ele opera em um contexto de processo. Cada processo é representado no kernel por uma estrutura struct task_struct. Essas estruturas são armazenadas em uma lista circular duplamente ligada e acessadas por variáveis PER_CPU na arquitetura x86-64 quando ocorre uma troca de contexto entre o espaço do usuário e o espaço do kernel.
Uma task_struct contém um membro struct creds, que armazena o identificador do usuário e os recursos associados ao processo. O kernel usa essas informações para determinar se o processo pode executar uma ação — por exemplo, se tem permissão para executar uma chamada de sistema específica. Em geral, o objetivo do escalonamento de privilégios no kernel é substituir ou atualizar a estrutura de credenciais para obter mais permissões.
Como funciona o escalonamento de privilégios?
A técnica mais comum para obter permissões elevadas no espaço do kernel é usar a combinação das funções do kernel [commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437)([prepare_kernel_cred(0)](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682)). Isso só é possível depois que uma exploração obtém o controle do ponteiro de instrução (RIP) e consegue contornar os controles de acesso à memória e de randomização. [prepare_kernel_cred](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682) pode gerar um objeto de credenciais com base em um já existente ou, de forma mais generosa, criar um padrão com todas as permissões de root. [commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437) simplesmente atualiza a task_struct do processo atual com o novo objeto de credenciais.
A exploração do kernel é um campo muito amplo. Por isso, neste post vamos analisar apenas uma versão bastante simplificada do escalonamento de privilégios no kernel. O kernel conta com diversos controles de segurança criados para dificultar a exploração. SMEP, SMAP, KASLR e KPTI são mecanismos implementados no hardware ou no kernel, que podem ser ativados ou desativados pela distribuição usada ou por um administrador do sistema. Não há uma maneira direta de controlar essas configurações pelo Kubernetes; por isso, elas não serão abordadas neste post.
Vamos usar uma vulnerabilidade antiga na implementação de af_packet, identificada como CVE-2017-7308. A vulnerabilidade pode ser explorada com o recurso CAP_NET_RAW, pois exige acesso a sockets brutos. Os detalhes da vulnerabilidade estão explicados em profundidade aqui, então não vamos nos aprofundar neles. Podemos obter todos os recursos necessários em um namespace de usuário sem privilégios. Na distribuição Ubuntu, o acesso a namespaces de usuário not é restrito por padrão.
Vamos ao que interessa
Primeiro, vamos analisar o processo de ponta a ponta em um ambiente sem contêineres.
Precisamos conectar o GNU Debugger (gdb) ao stub da máquina virtual. Depois de conectar o depurador, podemos definir um breakpoint em um ponto conveniente. Neste caso, usamos uma chamada de sistema mlock, que podemos acionar manualmente pela exploração sempre que quisermos observar o estado interno do processo em execução. O gdb só vai parar se o processo em execução se chamar “exploit”. Isso reduz o risco de o breakpoint ser acionado por outro processo do sistema. As tarefas de configuração estão convenientemente automatizadas em um arquivo de comandos .gdb. Executamos o GDB com a flag -x para fazer a configuração de forma consistente e reproduzível.
Os breakpoints serão acionados antes da criação do namespace de usuário sem privilégios, imediatamente antes da execução da vulnerabilidade e depois de obtermos credenciais de root. Para implementar esse comportamento, basta executar a chamada de sistema correta.
Agora, podemos executar a exploração:
Nosso primeiro breakpoint é acionado como esperado. Podemos examinar a estrutura cred executando a função auxiliar do gdb $lx_current. O UID efetivo do processo atual é 1000 e, como esperado, ele não tem recursos efetivos no namespace atual.
O segundo breakpoint é acionado após uma chamada a unshare, que cria um novo namespace de usuário para o processo. Observe que o UID não muda, mas os atributos cap_effective e user_ns mudam. Os recursos são armazenados como uma máscara de bits, mais fácil de ler no formato hexadecimal.
Nosso último breakpoint é acionado depois que a vulnerabilidade é explorada. Observe que o UID agora é 0 e o namespace de usuário é redefinido para init_user_ns, que representa o namespace de usuário init do host.
O shell retorna e agora temos permissões de root completas no host.
Exploração do kernel em um contêiner
Em seguida, vamos tentar executar a mesma exploração dentro de um pod. Criamos uma definição simples de objeto pod e a implantamos no cluster.
Vamos ver o que acontece na configuração padrão.
Root por padrão
A imagem usada na demonstração não especifica um usuário sem privilégios e, por padrão, o Kubernetes não impõe um UID. Portanto, parece que já tínhamos acesso de root sem precisar explorar o kernel. Vamos executar a mesma exploração novamente e interromper o kernel pouco antes de ele seguir o caminho vulnerável. Ao analisar os recursos efetivos do processo, fica claro que alguns estão faltando. O valor é 2818844155, que representa o conjunto padrão de recursos concedido pelo runtime do Docker.
Depois que a exploração termina, o conjunto efetivo volta a incluir todos os recursos.
Desta vez, vamos impor um ID de usuário sem privilégios ao contêiner, definindo os atributos de contexto de segurança runAs.
Desta vez, não temos permissões de root por padrão. No entanto, a exploração funciona da mesma forma, com uma diferença importante no resultado final. Parece que temos todas as permissões, mas não conseguimos ver tudo no sistema.
A jaula dos namespaces
Conseguimos obter todos os recursos e o UID de root, mas só contornamos a barreira de recursos do contêiner. Ainda não temos acesso ao sistema de arquivos do host, então não podemos ver todos os processos nem nos comunicar pelas interfaces de rede do host.
Neste ponto, podemos carregar os módulos de kernel que quisermos, mas isso gera muito ruído e acionará a maioria dos sistemas básicos de detecção de intrusão — pelo menos, é o que se espera. Para testar isso, vamos remover um módulo não utilizado. Observe que a imagem Docker precisa ter os pacotes de módulos instalados. No caso de imagens Debian, será necessário instalar o pacote kmod.
Em vez disso, podemos ampliar nossa exploração do kernel e definir o objeto [struct nsproxy](https://github.com/torvalds/linux/blob/master/include/linux/nsproxy.h#L31) no contexto atual para apontar para os namespaces que quisermos. Os namespaces são identificados por inodes, mas o kernel exporta o endereço de [init_nsproxy](https://github.com/torvalds/linux/blob/master/kernel/nsproxy.c#L32), que podemos usar para copiar os namespaces init do host para o nosso contêiner.
A chamada de sistema sys_setns pode atualizar os namespaces do contexto do processo. Há três namespaces principais para os quais queremos escalar privilégios: PID, rede e montagem. Primeiro, precisamos obter uma referência aos namespaces de root. Para isso, podemos mover o PID 1 do contêiner para os namespaces do host. Em seguida, podemos obter referências a qualquer namespace pelo sistema de arquivos /proc/ do PID 1. Por fim, movemos o processo atual para os namespaces necessários.
Depois de executar nossa exploração, podemos acessar todos os recursos interessantes do sistema.
Uso dos recursos
Os recursos padrão atribuídos aos contêineres do Kubernetes (com o runtime do Docker) incluem CAP_NET_RAW. Isso significa que conseguiríamos explorar a vulnerabilidade mesmo com os namespaces de usuário sem privilégios desativados? Adicionamos código para definir os recursos efetivos necessários para alcançar o código vulnerável.
Como você pode ver, a exploração falha. Mas por quê?
Isso tem a ver com recursos herdáveis e a forma como são implementados. Embora o runtime do contêiner tenha concedido esses recursos aos processos no contêiner, é preciso defini-los explicitamente como efetivos usando sys_capset. No momento, apenas processos com UID 0 podem definir recursos efetivos. Portanto, se você quiser executar como um usuário sem privilégios e ainda acessar alguns recursos, precisará incluir um binário suid no contêiner para definir os recursos efetivos. Como alternativa, basta definir os recursos necessários no executável e remover os recursos do contêiner. Os recursos de arquivo são limitados a sistemas de arquivos com atributos estendidos.
Seccomp ao resgate
Agora, vamos falar sobre a acessibilidade dos vetores de ataque. Nossa exploração funciona porque usuários sem privilégios podem obter o recurso CAP_NET_RAW em namespaces de usuário sem privilégios. Vimos o impacto disso na exploração na discussão acima sobre recursos. Há mais uma contramedida que podemos usar para impedir esse ataque — e sim, você pode ativá-la pelo Kubernetes.
O Seccomp é um mecanismo que pode reduzir a superfície de ataque do kernel filtrando chamadas de sistema. Infelizmente, por padrão, o Kubernetes não aplica um perfil seccomp ao contêiner. Isso significa que todas as chamadas de sistema são permitidas, sujeitas às verificações de permissões já mencionadas. Podemos mudar isso adicionando uma anotação à declaração do objeto (antes da versão 1.19) ou incluindo o atributo do perfil seccomp no contexto de segurança do pod.
Vamos ver como o perfil padrão fornecido pelo runtime do contêiner (neste caso, o Docker) afeta nossa exploração. Recebemos o erro “Operation not permitted” porque o perfil seccomp padrão não permite a chamada de sistema unshare.
O Seccomp é ótimo para limitar pontos de entrada desnecessários no kernel. Chamadas de sistema como unshare ou userfaultfd podem ser desativadas com segurança na maioria dos casos de uso e ajudam a impedir algumas técnicas de exploração. Mas algumas chamadas são difíceis de bloquear, como waitid. Você encontra essas e outras técnicas para explorar contêineres aqui.
Conclusões
Conseguimos impedir essa exploração com um perfil seccomp padrão. Como você pode ver, embora nosso sistema operacional esteja vulnerável, o caminho de exploração não pode ser acessado a partir do nosso contêiner (neste caso). Essa técnica pode dar a você o tempo necessário para planejar a tão necessária atualização do sistema operacional! Considere essas medidas como controles de defesa em profundidade e estratégias de mitigação.
Mantenha sempre seus sistemas atualizados! Snyk Infrastructure as Code ajuda você a identificar essas opções de mitigação logo no início do pipeline de CI/CD, muito antes de qualquer coisa ser implantada em produção. Usamos técnicas adversariais para identificar opções de segurança de alto impacto no Kubernetes e em provedores de serviços de nuvem. Use o Snyk gratuitamente: crie uma conta grátis.
Comece a participar de desafios de Capture the Flag
Aprenda a resolver desafios de Capture the Flag assistindo sob demanda ao nosso workshop virtual introdutório.


