Dissecando o erro 521 da Cloudflare: autópsia de netfilter, DNAT e configuration drift no Apache#
Quando colocamos uma aplicação atrás de um proxy reverso ou CDN como a Cloudflare, esperamos que o tráfego chegue ao servidor de origem sem mistério. Mas às vezes um teste simples de apontamento vira uma investigação detalhada entre regras de kernel e o servidor web.
Recentemente passei por uma situação curiosa em dois servidores Linux rodando CloudLinux, cPanel, Imunify360 e proteção SPFBL. Na teoria, os dois nós eram gêmeos de configuração: server1 e server2.
Ao apontar um domínio novo na Cloudflare, antes mesmo de criar a conta de hospedagem no cPanel, testei a resposta contra o IP de cada máquina. O resultado foi totalmente diferente:
server2(Node B): retornou HTTP 301, que é o redirecionamento padrão de fallback quando o vhost não existe.server1(Node A): retornou Erro 521 (Web Server is Down).
O que parecia um bloqueio bobo de firewall revelou uma sequência de comportamentos encadeados: precedência de chains no Netfilter, interceptação de camada 7 via rotinas Lua em proxy interno e desvio de configuração (configuration drift) na leitura de VirtualHosts do Apache.
A ilusão da camada de transporte e o módulo xt_recent#
A primeira suspeita diante de um erro 521, quando a Cloudflare não completa o handshake TCP com a origem, é o firewall local descartando pacotes por excesso de conexões vindas dos mesmos blocos de IP de proxy.
No server1, comecei listando as chains do iptables para checar a presença de proteções ativas:
iptables -nvL | grep -E 'Chain|SPFBL|imunify360'
A chain SPFBL continha regras atreladas ao módulo xt_recent do kernel:
-A SPFBL -p tcp -m recent --rcheck --seconds 600 --name SPFBL32 --mask 255.255.255.255 --rsource -j DROP
O módulo xt_recent gerencia tabelas dinâmicas de IPs na memória do kernel, acessíveis pelo pseudofs em /proc/net/xt_recent/. O parâmetro --rcheck avalia se o IP de origem já consta na lista e se a última tentativa ocorreu na janela de 600 segundos. Em caso afirmativo, o kernel executa a ação de DROP, descartando o pacote em silêncio, sem devolver pacote de reset (RST). A Cloudflare esgota o tempo de espera e exibe a tela de erro 521 para o visitante.
Antes de seguir, descartei dois cenários comuns:
- Saturação de SYN backlog: verifiquei o
dmesg -wpara checar se havia estouro de filas de escuta (net.ipv4.tcp_max_syn_backlogounet.core.somaxconn) e acionamento forçado de SYN cookies. As filas estavam limpas. - Queda por fragmentação de pacotes: com o comando
iptables -nvL | grep "f", validei que não existiam regras descartando fragmentos de MTU.
Para testar se o xt_recent era o vilão, criei uma regra de RETURN no início da chain SPFBL. No Netfilter, o RETURN encerra a avaliação da chain personalizada e devolve o pacote para a chain chamadora (INPUT), contornando o descarte:
# Backup das regras antes de alterar o netfilter
iptables-save > /root/iptables_backup_$(date +%F_%H%M).rules
CF_RANGES=(
"173.245.48.0/20" "103.21.244.0/22" "103.22.200.0/22" "103.31.4.0/22"
"141.101.64.0/18" "108.162.192.0/18" "190.93.240.0/20" "188.114.96.0/20"
"197.234.240.0/22" "198.41.128.0/17" "162.158.0.0/15" "104.16.0.0/13"
"104.24.0.0/14" "172.64.0.0/13" "131.0.72.0/22"
)
for range in "${CF_RANGES[@]}"; do
iptables -I SPFBL 1 -s $range -j RETURN
done
# Limpeza dos buffers do modulo recent
echo "/" > /proc/net/xt_recent/SPFBL32
echo "/" > /proc/net/xt_recent/SPFBL28
Mesmo com as regras aplicadas e conferidas em iptables -nvL SPFBL, o erro 521 continuou. O detalhe revelador estava nos contadores: o número de pacotes (pkts) da regra permanecia em zero. O tráfego HTTPS na porta 443 nem sequer alcançava a chain SPFBL.
Precedência no netfilter: quando o NAT roda antes do filtro#
Ao inspecionar a chain INPUT com iptables -S INPUT, a estrutura da tabela de filtros ficou clara:
-P INPUT ACCEPT
-A INPUT -j INPUT_imunify360
-A INPUT -p tcp -m tcp --dport 25 --tcp-flags FIN,SYN,RST,ACK SYN -m state --state NEW -j SPFBL
A chain SPFBL estava restrita à porta 25 (SMTP). Para a porta 443, o tráfego seguia direto para INPUT_imunify360.
Criei uma chain dedicada para colocar os blocos da Cloudflare logo na primeira posição do INPUT:
iptables -N CLOUDFLARE_WHITE
for range in "${CF_RANGES[@]}"; do
iptables -A CLOUDFLARE_WHITE -s $range -j ACCEPT
done
iptables -I INPUT 1 -j CLOUDFLARE_WHITE
Os contadores da CLOUDFLARE_WHITE subiram imediatamente. O kernel aceitava os pacotes na camada de filtro. Mas a Cloudflare continuava recebendo o erro 521.
Fiz então um teste com o netcat a partir de um host externo:
nc -zv 177.X.X.X 443
# Retorno: Connection to 177.X.X.X 443 port [tcp/https] succeeded!
O netcat testa apenas a camada 4 (transporte). Se o socket TCP completou o three-way handshake com sucesso, a conexão estava aberta. O problema precisava estar na camada de aplicação ou no aperto de mãos TLS.
Se o pacote passava pela tabela filter e abria o socket, onde estava ocorrendo o desvio? A resposta estava no hook PREROUTING da tabela NAT:
iptables -t nat -nvL | grep -E "52223|52224"
A saída mostrou o redirecionamento:
257 15004 DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 match-set i360.ipv4.splashscreen.sync src tcp dpt:443 to::52223
O Imunify360 utiliza o WebShield como proxy intermediário. Antes que o pacote atinja a tabela filter na chain INPUT, ele passa pelo hook PREROUTING da tabela nat. Ali, regras de DNAT jogam o tráfego da porta 443 para a porta local 52223, onde roda uma instância interna do Nginx/OpenResty.
Rastreabilidade na camada 7: WebShield, Lua e hooks de SNI#
Como o WebShield opera como proxy reverso para inspeção de segurança e telas de desafio, ele precisa negociar o certificado TLS antes de repassar a requisição ao Apache.
Abri o log de erros do serviço:
tail -f /var/log/imunify360-webshield/error.log
As mensagens registraram a raiz do problema:
2026/04/14 18:36:58 [warn] 1790576#1790576: *796 [lua] sslutils.lua:123: get_cert_by_host(): Could not get SNI for request (IP: 177.X.X.X), context: ssl_certificate_by_lua*, client: 66.X.X.X, server: 0.0.0.0:52231
2026/04/14 18:29:34 [warn] 1790576#1790576: *774 [lua] sslutils.lua:148: get_cert_by_host(): Certificate not found (host: 177.X.X.X), context: ssl_certificate_by_lua*
O Nginx do WebShield usa blocos ssl_certificate_by_lua_block para resolver dinamicamente os certificados em tempo de conexão. A função sslutils.lua -> get_cert_by_host() consulta a base de dados local para encontrar o par de chaves correspondente ao Server Name Indication (SNI) enviado pelo cliente.
Como o domínio em teste estava apontado no DNS da Cloudflare, mas ainda não tinha sido criado no cPanel do servidor local, o WebShield não encontrou certificado válido para o host. Em vez de entregar uma resposta de fallback ou deixar a conexão fluir, ele encerrava a conexão abruptamente com um FIN/RST antes de emitir o ServerHello do TLS. A Cloudflare, configurada em modo SSL Strict, interpretava o reset prematuro como indisponibilidade da origem e devolvia o erro 521.
Para confirmar o diagnóstico, parei temporariamente o serviço do WebShield:
systemctl stop imunify360-webshield
Em ambientes onde o serviço deve permanecer ativo gerenciado pelo agente, a desativação pode ser feita via configuração:
imunify360-agent config update '{"WEBSHIELD": {"enable": false}}'
Para manter o WebShield operacional sem interferir no tráfego dos nós de borda, incluí as faixas de IP da Cloudflare na lista de permissões do Imunify360:
for range in "${CF_RANGES[@]}"; do
imunify360-agent whitelist ip add "$range" --comment "Cloudflare Bypass"
done
O comando grava as redes na base SQLite interna (/var/lib/imunify360/imunify360.db), fazendo o WebShield ignorar a inspeção profunda nessas conexões.
Ao repetir a consulta com o curl:
curl -sklI https://server1.domain.com | grep HTTP
HTTP/2 200
A origem voltou a responder. Mas ainda restava uma ponta solta: por que o server2 entregava HTTP 301 enquanto o server1 devolvia HTTP 200?
Desvio de configuração no Apache e vhost hijacking#
Nenhum dos dois servidores tinha conta criada para o domínio testado. Ainda assim, um respondia com redirecionamento e o outro com página de conteúdo.
Para entender a precedência de VirtualHosts no Apache, usei a flag -S:
httpd -S | grep -A 1 "port 443 namevhost" | head -n 2
O Apache processa o cabeçalho Host e o SNI em etapas:
- Identifica o IP local e a porta da conexão (
177.X.X.X:443). - Procura um bloco
VirtualHostcujoServerNameouServerAliascoincida exatamente com a requisição. - Se nenhum bloco corresponder, o Apache adota o comportamento de fallback: atende a conexão com o primeiro VirtualHost carregado na memória para aquela porta.
No server2, a ordem alfabética dos arquivos de configuração fazia o Apache carregar primeiro o vhost padrão do cPanel (ou o próprio hostname da máquina). Esse bloco continha uma regra de reescrita via mod_rewrite que direcionava requisições desconhecidas para a porta 2083 com código 301.
No server1, ocorreu um caso clássico de captura indevida de VirtualHost (vhost hijacking). Ao rodar o curl com a flag de localidade:
curl -sklI https://server1.domain.com | grep -i Location
A requisição bateu no arquivo de um cliente real da máquina (01-cliente.conf), que estava na primeira posição alfabética dentro de /etc/apache2/conf.d/vhosts/. O Apache atendeu a requisição órfã com o site e o certificado desse cliente.
Comprovei a divergência de certificado conectando via OpenSSL:
openssl s_client -connect 177.X.X.X:443 -servername server1.domain.com
O certificado retornado no handshake pertencia a outro domínio, comprovando o desvio de configuração entre os nós.
A solução definitiva foi padronizar a estrutura criando um arquivo de prioridade máxima (000_default_catchall.conf) em /etc/apache2/conf.d/vhosts/, com um VirtualHost neutro que responde por qualquer requisição não mapeada e aplica o redirecionamento adequado.
Da borda ao socket: lições de uma depuração em camadas#
Quando um erro 521 aparece, a tentação mais comum é culpar a Cloudflare ou presumir que o Apache parou de rodar. Como vimos nesta análise, o socket TCP pode fechar perfeitamente no nível de rede e ainda assim morrer milissegundos depois dentro de um proxy interno que ninguém lembrava que existia.
Três detalhes me economizaram tempo nessa investigação:
- Checar a tabela
natantes de achar que a tabelafilterconta a história inteira do tráfego no iptables. - Inspecionar os logs de componentes intermediários (como o WebShield) em vez de olhar apenas o
access_logdo servidor web final. - Conferir a ordem alfabética dos VirtualHosts no Apache (
httpd -S) para evitar que domínios não provisionados entreguem o conteúdo de outras contas hospedadas na mesma máquina.
Se você mantém servidores com camadas extras de segurança ou atende clientes em ambientes compartilhados, vale validar essa precedência na sua stack antes que um apontamento novo passe a responder com o site vizinho.
Este artigo foi útil?
Deixe uma reação rápida para apoiar o conteúdo:
Este post está licenciado sob CC BY-NC.



Comentários
Participe da discussão abaixo.
0 comentários