Um painel de administração num servidor bate quase sempre na mesma parede: o processo web precisa de fazer algo para o qual não tem permissões. Bloquear um endereço, abrir uma porta, ler o registo do sistema. A resposta que salta à vista é conceder uma única linha estreita no sudoers, e nada mais:

www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *

A linha lê-se como «apenas bloquear». Na verdade significa «qualquer coisa, como root». Segue-se o motivo, como verificá-lo no seu próprio servidor em cinco minutos e por que se substituem regras assim.

O que o * faz realmente

O detalhe decisivo: o sudo não compara argumento a argumento, mas a linha de comando inteira, e o * no padrão atravessa sem dificuldade os espaços, isto é, as fronteiras entre argumentos. O literal banip colocado no meio da regra não restringe, por isso, nada: basta que a palavra banip apareça em algum ponto do comando, e antes como depois pode passar-se o que quer que seja.

A seguir procura-se um programa a que se possa entregar um comando para executar. No nosso caso estava na própria regra. No fail2ban a ação de bloqueio é definida como texto, e esse texto pode ser redefinido em funcionamento:

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<comando>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

A primeira chamada substitui o texto de ação de uma ação real, a segunda faz a jail disparar — e o fail2ban, que corre como root, executa o que foi introduzido. Fizemo-lo a 4 de julho no nosso próprio servidor: um ficheiro em /root foi criado como root, ou seja, o processo web obteve direitos completos sobre a máquina a partir de uma regra que parecia estreita.

Pelo caminho surgiram três pormenores que costumam faltar nas análises alheias:

  • a opção -c antes de set é cortada pelo sudo: no padrão, set está colado ao nome do binário e nada se pode inserir antes;
  • addaction e action sem a palavra banip também não passam — mas tudo o que é necessário cabe num único comando set … banip …, e o padrão fica assim cumprido;
  • o nome da ação tem de ser real, ou não há nada a substituir: fail2ban-client get <jail> actions mostra-o.

Quem mais consta da mesma lista

O fail2ban não tem aqui culpa nem é caso único. O perigoso é a combinação de NOPASSWD com um asterisco. O que encontrámos ao lado nos nossos servidores:

  • journalctl * — o registo abre-se através de um paginador, e a partir do paginador lança-se uma linha de comandos. A regra parece dizer respeito à leitura de registos; na prática é uma shell de root. O remédio não é um padrão mais estreito, mas o grupo systemd-journal: então o journalctl funciona sem sudo nenhum;
  • grep * /var/log/fail2ban.log — o primeiro argumento do grep é o padrão, mas o asterisco permite fornecer também um segundo caminho, e o grep corre como root. Ler /etc/shadow por essa regra é um só comando. A substituição é a mesma: o grupo adm para a leitura dos registos;
  • ufw --force * e os ufw allow/deny/delete * nus — o processo web pode desligar a firewall por completo. Especialmente desagradável: em ambos os casos o painel não usava essas regras de forma alguma — permissões mortas deixadas por versões iniciais do instalador;
  • lynis-scan.sh * — um invólucro que passa "$@" a um processo root equivale a uma regra sem restrição alguma.

Como ver o que existe no seu servidor

O que há para olhar não é o ficheiro, mas os direitos efetivos de um utilizador concreto — aquele com que o pool do PHP-FPM realmente corre (nem sempre www-data; numa das nossas máquinas o painel corria como admin):

ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin

Depois, a própria lista de ficheiros, e aqui há duas armadilhas que nos custaram tempo:

sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/

A primeira: as regras vivem em dois lugares ao mesmo tempo. No nosso caso, a linha perigosa estava tanto em /etc/sudoers.d/monitor como no /etc/sudoers principal — neste último em cerca de oito cópias, acumuladas por diferentes versões do instalador. Limpar um lugar e ficar descansado é a maneira habitual de deixar o buraco aberto.

A segunda: o sudo ignora ficheiros cujo nome contém um ponto. Um ficheiro www-data.bak3 em /etc/sudoers.d/ parece uma regra em vigor e lê-se como uma regra em vigor, mas não produz efeito. Isto funciona nos dois sentidos: «a regra está lá, os direitos não» e o falso descanso de uma cópia da configuração que «está ali ao lado».

Por que substituir

Estreitar o padrão não serve de nada: um asterisco em qualquer ponto da linha devolve o problema ao início. Só uma abordagem se aguenta: um invólucro de root que recebe argumentos posicionais fixos, valida-os ele mesmo e chama o programa sem a mínima possibilidade de acrescentar algo.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, e nada mais
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }

case "$action" in
  ban)   exec /usr/bin/fail2ban-client set "$jail" banip   "$ip" ;;
  unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac

O invólucro pertence ao root, permissões 755, e — isto não é opcional — não pode ser gravável a partir da web, ou toda a construção perde sentido. No sudoers fica apenas ele:

www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b

Os argumentos não se enumeram na regra: verifica-os o próprio script, e as posições dentro dele são fixas, pelo que não há onde enfiar um texto de ação. As regras de leitura apenas — estados, ss, ipset list — vão em linhas separadas, também sem asterisco quando é possível.

À parte, sobre substituir o sudo por grupos. A abordagem é correta: systemd-journal para o registo e adm para os logs saem mais baratos e mais seguros do que qualquer regra do sudo. Mas não se pode tirar o utilizador web dos grupos às cegas. Retirámos o www-data de um grupo num anfitrião com painel e obtivemos 403 em todos os sites de uma vez: ali o Apache corre como www-data, os ficheiros dos sites pertencem a outro utilizador e o acesso de leitura era dado precisamente por essa pertença ao grupo. A recuperação exigiu um reinício completo — não um recarregamento — do Apache e do PHP-FPM, porque os processos antigos conservam o conjunto anterior de grupos e produzem um «funciona, e depois 403».

Verificação após a alteração

Antes de substituir um ficheiro do sudoers é preciso verificar-lhe a sintaxe, ou pode ficar-se sem sudo nenhum:

sudo visudo -cf /etc/sudoers.d/monitor

Depois, confirmar que o vetor antigo está morto, e fazê-lo em nome desse mesmo utilizador:

sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10

A resposta deve ser a password is required, enquanto chamar o invólucro com lixo em vez de um endereço deve dar invalid ip. Aqui espreita uma armadilha própria: o sudo -v guarda a confirmação em cache durante quinze minutos e, depois disso, qualquer verificação com sudo -n passa «com êxito». Foi exatamente assim que uma vez confirmámos uma regra que no servidor não existia. Daí que sudo -k antes da verificação seja obrigatório.

Para que serve observar

As regras do sudo mudam raramente, mas mudam sem ruído: o instalador acrescenta-lhes linhas, o painel do alojamento completa as suas, um pacote removido deixa as dele atrás. Uma revisão pontual fecha o que existe hoje e não diz nada sobre o que trará a próxima atualização. A conclusão prática é simples: a lista de quem pode tornar-se root fica melhor à vista, ao lado do restante estado do servidor, do que na memória depois de um incidente. O aspeto disto tudo reunido está nas páginas de demonstração abaixo.