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:

  1. todo processo no cgroup de um pod com user namespace tem um uid dentro da faixa daquele pod;
  2. 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:

  1. Monta a tabela de faixas. O /proc/<pid>/uid_map diz 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.
  2. Confere cada processo. Lê o uid real em /proc/<pid>/status e 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
done

O 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únel

Os 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 entraO que o pod enxergaOnde 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 podsna tabela de conntrack do host
Sites, pela Cloudflareo IP do pod do cloudflaredno 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 destinona 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:

ChaveO que dispara
user-sem-sessaoum programa executado como o uid 1000 sem sessão de login
credenciaisescrita no kubeconfig, nas chaves SSH, na senha do backup ou na configuração do k3s
contasmudança em passwd, shadow, grupos ou sudoers
persistenciaserviço do systemd, cron ou o perfil seccomp do build alterados
modulosmódulo do kernel carregado ou removido
ptraceum processo lendo a memória de outro
auditoriaalteraçã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.