Blog /
Detecção de intrusão em containers: invariantes de uid e auditoria do kernel
No primeiro post eu tornei o cluster mais difícil de atacar, mas nada ali avisa se um ataque aconteceu. Se um processo escapasse de um container, eu não ficaria sabendo.
Este segundo post da série trata de detectar essa fuga, com duas ferramentas: uma checagem periódica que aproveita uma regra deixada pelos user namespaces, e a auditoria do kernel, que registra eventos no instante em que acontecem. O terceiro post trata do tráfego que chega da internet.
Em Resumo
- Antes: nada no Pi avisava se um processo saísse de um container.
- Depois: um script de 30 linhas, sem root, confere de tempos em tempos se todo uid alto pertence ao seu pod; o auditd registra na hora sete tipos de evento que quase nunca acontecem por acaso.
- O que um alerta diz: de qual pod o processo veio e, portanto, por onde o ataque entrou. Quem atacou, quase nunca.
- Custo: nenhum pod a mais; só o auditd, com poucos MB, no host.
- Falta: tirar os alertas do Pi no momento do evento, para que um invasor com root não consiga apagá-los.
A regra que os user namespaces deixaram
Com hostUsers: false, cada pod recebe um user namespace: uma faixa própria de 65.536 uids altos no host, para onde os uids de dentro do container são traduzidos. O root de um pod vira, no Pi, algo como o uid 3.558.147.048.
Além disso, o Kubernetes coloca os processos de cada pod num cgroup, um grupo do kernel usado para limitar CPU e memória. O caminho desse grupo contém o UID do pod (um identificador sem relação com o uid de usuário), e qualquer processo pode lê-lo em /proc/<pid>/cgroup. Olhando só o /proc, dá para saber de qualquer processo do Pi com qual uid ele roda e a qual pod pertence. Daí saem duas regras:
- todo processo no cgroup de um pod com user namespace tem um uid dentro da faixa daquele pod;
- todo uid acima da faixa dos usuários do host (65.535) pertence a algum pod com user namespace.
Quebrar a regra 2 significa um uid de pod rodando fora do pod, ou seja, algo que saiu do container. Quebrar a regra 1 significa, por exemplo, um root de verdade dentro de um pod que deveria ter só uids traduzidos. Qualquer uma das duas quebras é um forte indício de fuga.
Minha hipótese era que, vigiando essas regras, eu descobriria qual componente foi comprometido e talvez o IP de quem atacou. Escrevi um script para testar isso no Pi.
A checagem
O script tem cerca de 30 linhas, roda sem root e percorre o /proc duas vezes:
- Monta a tabela de faixas. O
/proc/<pid>/uid_mapdiz para qual uid do host o uid 0 do processo foi traduzido e quantos uids a faixa tem. Início 0 significa que o processo não está num user namespace. Para os demais, guarda a faixa junto com o pod. - Confere cada processo. Lê o uid real em
/proc/<pid>/statuse aplica a regra 1 aos processos de pods com faixa conhecida e a regra 2 aos demais.
HOST_MAX=65535
declare -A RANGE # pod -> "início fim"
for p in /proc/[0-9]*; do
{ read -r _ lo n < "$p/uid_map"; } 2>/dev/null || continue
[ "$lo" != 0 ] || continue
pod=$(grep -oE 'pod[0-9a-f_-]{36}' "$p/cgroup" 2>/dev/null | head -1)
[ -n "$pod" ] && RANGE[$pod]="$lo $((lo + n - 1))"
done
for p in /proc/[0-9]*; do
uid=$(awk '/^Uid:/{print $2}' "$p/status" 2>/dev/null); [ -n "$uid" ] || continue
pod=$(grep -oE 'pod[0-9a-f_-]{36}' "$p/cgroup" 2>/dev/null | head -1)
if [ -n "$pod" ] && [ -n "${RANGE[$pod]}" ]; then
read -r lo hi <<< "${RANGE[$pod]}"
{ [ "$uid" -lt "$lo" ] || [ "$uid" -gt "$hi" ]; } &&
echo "ALERTA pid=${p#/proc/} uid=$uid fora da faixa do $pod"
elif [ "$uid" -gt "$HOST_MAX" ] && [ "$uid" != 4294967295 ]; then
echo "ALERTA pid=${p#/proc/} uid=$uid fora de qualquer pod com user namespace"
fi
doneO 4294967295 é ignorado porque representa “uid nenhum”. O uid vem do /proc/<pid>/status, e não do ps, porque o ps o imprime como número de 32 bits com sinal: acima de 2³¹ − 1 ele sai negativo (o uid 4.046.848.000 aparece como -248119296), e um filtro como awk '$1 > 1000000000' perderia a maioria dos uids de pod.
O resultado
Rodei no Pi em 16 de outubro, com Gitea, Mempalace, Postgres, os túneis e os runners em user namespace. O script ligou os 22 processos de uid alto aos seus 7 pods, sem nenhum alerta:
ok pid=2605883 (gitea) uid=3558147048 pod do Gitea
ok pid=2342646 (python3) uid=839648232 pod do Mempalace
ok pid=2306821 (postgres) uid=4017094726 pod do Postgres
ok pid=2307465 (cloudflared) uid=2633760764 pod do túnelOs jobs de CI aparecem do mesmo jeito: na véspera, o go de um pod de teste rodava com o uid 432209920, dentro da faixa do seu pod.
Um script que nunca alerta pode estar quebrado. Para testar o alerta, baixei o limite para HOST_MAX=999, e os meus próprios processos (uid 1000, fora de qualquer pod) apareceram como ALERTA … fora de qualquer pod com user namespace.
O que um alerta diz
O cgroup do processo diz de qual pod ele veio, e aqui cada pod tem uma única forma de ser alcançado de fora. O Gitea aponta para o SSH ou a interface web; um túnel, para a internet; um pod de teste, para uma dependência envenenada. A tabela de conexões do processo (/proc/<pid>/net/tcp, legível sem root) mostra com quem ele está falando naquele momento.
A primeira metade da hipótese se confirmou: a checagem diz qual componente foi comprometido.
E o IP de quem atacou?
Aqui a resposta é mais limitada. O endereço que o pod enxerga depende do caminho de entrada:
| Por onde entra | O que o pod enxerga | Onde está o IP de verdade |
|---|---|---|
| SSH do Gitea (NodePort, pela rede de casa ou pela VPN privada) | 10.42.0.1, o endereço do próprio nó na rede dos pods | na tabela de conntrack do host |
| Sites, pela Cloudflare | o IP do pod do cloudflared | no cabeçalho CF-Connecting-IP da requisição |
| Conexão que o invasor abre para fora (reverse shell, servidor de comando) | o IP e a porta de destino | na tabela de conexões do processo, legível sem root |
No SSH, o nó repassa a conexão para o pod trocando o endereço de origem pelo dele; testei a partir do desktop, e o pod registrou 10.42.0.1. O endereço original fica na tabela de conntrack do kernel, que só o root lê. Nos sites, o IP real chega no cabeçalho da Cloudflare, e registrá-lo é assunto do próximo post.
Mesmo quando se consegue o IP, ele raramente é o do atacante: quem ataca costuma passar por VPN, Tor ou proxy residencial, e o destino de um reverse shell costuma ser um servidor alugado ou uma máquina já invadida. O IP serve para bloquear, denunciar ao provedor e cruzar com outros logs, mas não identifica ninguém. A exceção é um IP da minha VPN privada: ele indica que um dispositivo da minha própria rede foi comprometido.
O que essa checagem não pega
- Uma fuga pelo kernel que vira root de verdade. Um exploit que troca as credenciais do processo pelas do root do host deixa um uid 0, não um uid estranho. A regra 1 só o pega enquanto ele estiver no cgroup do pod, e um root de verdade pode mudar de cgroup ou matar o script.
- Processos rápidos. Uma checagem periódica perde qualquer processo que nasça e morra entre duas leituras.
- Pods sem user namespace. Os sites e os apps ainda rodam com uids do host (101, no caso do nginx). Para eles a regra teria de ser outra, como “o uid 101 só existe dentro do cgroup de um pod”.
O segundo item se resolve registrando cada evento no momento em que acontece.
Auditoria por evento com o auditd
O kernel do Linux tem um sistema de auditoria embutido. As regras dizem o que vigiar, como “escrita neste arquivo” ou “execução de programa por este usuário”, e o kernel gera um registro no instante do evento, mesmo para um processo que dura milissegundos. O auditd é o serviço que recebe esses registros e os grava em disco.
O kernel do Pi já vinha com a auditoria ligada (CONFIG_AUDIT); bastou instalar o auditd e escrever as regras. É o único componente deste post que ocupa memória: poucos MB, no host, fora do cluster. Descartei o Falco e o Tetragon porque, além da RAM, os dois dependem de BTF, informação sobre os tipos internos do kernel que o kernel do Pi não expõe.
As regras cobrem só eventos que quase nunca acontecem por acaso, para que cada alerta mereça atenção. Cada uma tem uma chave, usada para filtrar o log:
| Chave | O que dispara |
|---|---|
user-sem-sessao | um programa executado como o uid 1000 sem sessão de login |
credenciais | escrita no kubeconfig, nas chaves SSH, na senha do backup ou na configuração do k3s |
contas | mudança em passwd, shadow, grupos ou sudoers |
persistencia | serviço do systemd, cron ou o perfil seccomp do build alterados |
modulos | módulo do kernel carregado ou removido |
ptrace | um processo lendo a memória de outro |
auditoria | alteração nas regras da própria auditoria |
A regra user-sem-sessao
Quando alguém faz login por SSH, o sistema marca a sessão com o audit uid (ou loginuid), o uid de quem entrou. A marca passa para todos os processos da sessão e não pode ser trocada. O cron e o systemd do usuário fazem a mesma marcação, e conferi no Pi que todo processo legítimo do uid 1000 vem de um desses três. Um processo de container descende do containerd, iniciado pelo systemd no boot, sem login, e por isso não tem marca. Logo, um programa executado como uid 1000 sem audit uid é o rastro de algo que saiu de um container com o meu uid. A única exceção no Pi é o metrics-server do k3s, que roda como uid 1000 sem user namespace, mas nunca executa outro programa e por isso não dispara a regra.
O script de instalação termina com um autoteste de cada regra principal. Para esta, ele usa systemd-run --uid=1000, que inicia um processo sem sessão, como faria um container; o log registrou auid=unset uid=user, como esperado.
O que ainda falta
O log da auditoria continua no Pi, e um invasor com root pode apagá-lo junto com as regras antes que eu olhe. O alerta só tem valor se sair do Pi no momento do evento. O plano é um serviço de notificações próprio, o notification-chat, que vai receber esses eventos, ler o log de acesso dos sites e mandar tudo para o meu celular. O desenho dele fecha o próximo post.
Para o SSH do Gitea, o próximo passo é registrar o IP real de quem conecta: com externalTrafficPolicy: Local no Service, que entrega a conexão sem trocar a origem, ou com um conntrack -E gravando cada conexão nova.