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
-cantes deseté cortada pelo sudo: no padrão,setestá colado ao nome do binário e nada se pode inserir antes; addactioneactionsem a palavrabaniptambém não passam — mas tudo o que é necessário cabe num único comandoset … 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> actionsmostra-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 gruposystemd-journal: então ojournalctlfunciona sem sudo nenhum;grep * /var/log/fail2ban.log— o primeiro argumento dogrepé o padrão, mas o asterisco permite fornecer também um segundo caminho, e o grep corre como root. Ler/etc/shadowpor essa regra é um só comando. A substituição é a mesma: o grupoadmpara a leitura dos registos;ufw --force *e osufw 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.