O dia em que o MySQL sumiu em produção: forense em baixo nível e hardening no Laravel
Voltar para blog

O dia em que o MySQL sumiu em produção: forense em baixo nível e hardening no Laravel

Atualizado em 20/09/2026 · Publicado em 19/09/2026 · 5 min · Banco de Dados

Poucas situações no dia a dia geram tanta apreensão quanto notar que tabelas e bancos de dados inteiros do MySQL simplesmente desapareceram do servidor de produção.

A stack envolvia Laravel, um painel de gerenciamento aaPanel sobre Linux e múltiplos subdomínios em operação. Diante de um sumiço repentino, o primeiro impulso pode ser cogitar um ransomware ou invasão externa. Contudo, em incidentes críticos, o caminho seguro é a análise forense estruturada: checagem de sockets, inspeção de logs binários (binlogs), snifagem de pacotes e contenção definitiva.

Abaixo, documento passo a passo como investiguei a causa raiz, contornei gargalos de espaço em disco e apliquei as travas de segurança necessárias.

1. Triagem inicial e contenção de rede#

A primeira dúvida técnica era clara: a exclusão partiu de fora (porta 3306 exposta na internet) ou de dentro (execução local da aplicação)?

Auditando as conexões de rede ativas com netstat:

netstat -tunap | grep 3306

Saída no terminal:

tcp6       0      0 :::3306                 :::*                    LISTEN      104467/mysqld

O MySQL estava escutando em IPv6 global (:::3306). No Linux, quando o sysctl net.ipv6.bindv6only está desativado, o socket aceita também conexões IPv4 mapeadas. Se as regras de firewall não cobrirem IPv6 explicitamente, o banco fica vulnerável a varreduras externas.

A primeira ação corretiva foi forçar o bind exclusivamente na interface de loopback no my.cnf:

[mysqld]
bind-address = 127.0.0.1
mysqlx-bind-address = 127.0.0.1

Em seguida, ativei o general_log e configurei o auditd para monitorar chamadas de sistema no diretório de dados do MySQL:

SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE';
# Monitorar alterações no datadir real do MySQL/aaPanel (/www/server/data/)
auditctl -w /www/server/data/ -p wa -k mysql_data_change

# Tornar a regra persistente entre reinicializações
echo "-w /www/server/data/ -p wa -k mysql_data_change" | sudo tee /etc/audit/rules.d/mysql.rules
sudo augenrules --load

2. A pista definitiva nos logs binários do MySQL#

Como o evento já havia ocorrido minutos antes da ativação do log geral, recorri aos logs binários (binlogs) em /www/server/data/:

mysqlbinlog --no-defaults -v /www/server/data/mysql-bin.000010 | grep -iE "DROP|DELETE|TRUNCATE|RENAME" -B 5 -A 5

A saída revelou as queries executadas às 00:04:12:

#260403  0:04:12 server id 1  Query  thread_id=211
SET TIMESTAMP=1775174652/*!*/;
SET @@session.foreign_key_checks=0/*!*/;
DROP TABLE `blacklist_cache`,`blacklist_history`,`domain_dns`,`domain_whois`,`failed_jobs`,`ip_histories`,`jobs`,`migrations`,`monitors`,`users` /* generated by server */;

#260403  0:04:13 server id 1  Query  thread_id=211
alter table `user_agreements` add constraint `user_agreements_user_id_foreign` foreign key (`user_id`) references `users` (`id`) on delete cascade;

A evidência forense era incontestável:

  1. Um comando DROP TABLE em lote limpou todas as tabelas (incluindo a tabela migrations).
  2. Imediatamente após o DROP, novas queries tentavam recriar chaves estrangeiras (foreign keys).
  3. Esse comportamento é a assinatura exata do comando php artisan migrate:fresh ou da trait RefreshDatabase utilizada em rotinas de teste.

3. Investigando rotas web e snifando o tráfego local#

Para descartar se o comando havia sido disparado via exploit web, filtrei os logs do servidor web (/www/wwwlogs/) no segundo exato do log binário (00:04:12):

grep "03/Apr/2026:00:04" /www/wwwlogs/dash.domain.com.br_ols.access_log

O retorno foi completamente vazio. Nenhuma requisição HTTP externa ocorreu no momento do DROP.

Para capturar a origem das consultas em tempo real, montei um sniffer de pacotes na interface de loopback:

tcpdump -i lo -n -s 0 -l -A dst port 3306 | grep -iE "DROP|DELETE|TRUNCATE|RENAME|SCHEMA"

O sniffer capturou queries de limpeza de filas (delete from failed_jobs) vindas diretamente de 127.0.0.1, confirmando que a execução partia de workers ou rotinas agendadas em segundo plano do PHP.

4. O gargalo de disco e a limpeza antes do restore#

Ao planejar a restauração do backup, o comando df -h revelou um risco crítico: a partição raiz estava com 92% de ocupação (apenas 8.5 GB livres). Restaurar um banco InnoDB volumoso com pouco espaço pode corromper tabelas ou travar o processo por falta de espaço em transações temporárias.

Mapeando os maiores arquivos no disco:

find / -type f -exec du -h {} + 2>/dev/null | sort -rh | head -n 10

Identifiquei e limpei arquivos desnecessários:

# Remoção dos swaps extras
swapoff /swapfile_novo 2>/dev/null && rm -f /swapfile_novo
swapoff /home/www/swap 2>/dev/null && rm -f /home/www/swap

# Limpeza de lixeira e logs
rm -rf /.Recycle_bin/*
rm -f /www/server/panel/logs/sqlite_easy

O espaço livre saltou de 8.5 GB para 28 GB livres (73% de uso), liberando a margem necessária para a recuperação.

5. Correção de permissões no Nginx#

Os logs do Nginx apresentavam falhas recorrentes de escrita em diretórios temporários:

[crit] rename() "/www/server/nginx/proxy_temp_dir/..." to "/www/server/nginx/proxy_cache_dir/..." failed (13: Permission denied)

Ajustei a propriedade e permissões para o usuário www:

chown -R www:www /www/server/nginx/proxy_cache_dir /www/server/nginx/proxy_temp_dir
chmod -R 755 /www/server/nginx/proxy_cache_dir /www/server/nginx/proxy_temp_dir

6. Hardening definitivo e restauração segura#

Com o ambiente limpo e isolado, apliquei as travas de segurança antes de restaurar o backup:

A. Revogação de privilégios destrutivos no MySQL#

O usuário do banco utilizado pela aplicação em produção não deve possuir permissão para apagar tabelas. No MySQL, o privilégio DROP cobre comandos TRUNCATE TABLE (que não existe como privilégio independente na sintaxe de GRANT/REVOKE):

-- Revoga comandos estruturais destrutivos (DROP cobre TRUNCATE)
REVOKE DROP, ALTER ON *.* FROM 'laravel_user'@'localhost';
FLUSH PRIVILEGES;

B. Trava contra comandos destrutivos no Laravel#

No arquivo app/Providers/AppServiceProvider.php, ativei a proteção nativa do Laravel no método boot():

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\DB;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Bloqueia db:wipe, migrate:fresh, migrate:refresh e migrate:reset em produção
        DB::prohibitDestructiveCommands($this->app->isProduction());
    }
}

C. Limpeza de filas e restauração do backup#

Como sql_log_bin é uma variável com escopo estrito de sessão no MySQL (não existe SET GLOBAL sql_log_bin), realizei a carga desativando a geração de binlogs apenas dentro da sessão do restore para acelerar o processo sem poluir os logs binários:

# Limpar filas residuais de workers
php artisan queue:clear
php artisan cache:clear

# Restaurar dump desativando o binlog na sessão de importação
(echo "SET SESSION sql_log_bin = 0;"; cat /caminho/do/backup.sql; echo "SET SESSION sql_log_bin = 1;") | mysql -u root -p database_name

Ou no console interativo do MySQL:

SET SESSION sql_log_bin = 0;
SOURCE /caminho/do/backup.sql;
SET SESSION sql_log_bin = 1;

Ao encerrar a conexão, qualquer nova sessão volta a registrar os binlogs normalmente conforme o my.cnf.

Lições práticas e hardening pós-incidente#

  1. Princípio do menor privilégio no banco: O usuário da aplicação web em produção deve possuir apenas privilégios de leitura e escrita (SELECT, INSERT, UPDATE, DELETE), nunca DROP ou ALTER (lembrando que DROP cobre TRUNCATE).
  2. Ative a trava do framework: Inclua DB::prohibitDestructiveCommands() como padrão obrigatório no AppServiceProvider em todas as aplicações Laravel.
  3. Logs binários são essenciais: Sem o mysqlbinlog, seria impossível determinar o segundo exato e a assinatura das queries que apagaram as tabelas.
  4. Margem de disco em bancos relacionais: Mantenha alertas preventivos de espaço em disco; restaurações de InnoDB exigem espaço livre substancial para logs de transação e reconstrução de índices.

Conclusão#

Neste artigo documentei a trilha forense completa — do socket em :::3306 ao DROP TABLE no log binário — e como a assinatura do Laravel (migrate:fresh) se revelou na combinação de foreign_key_checks=0 com / generated by server /. Com o bind restrito ao loopback, os privilégios podados no MySQL e a trava do framework ativada em produção, o incidente foi solucionado e documentado para evitar qualquer reincidência.

Este artigo foi útil?

Deixe uma reação rápida para apoiar o conteúdo:

CC BY-NC

Este post está licenciado sob CC BY-NC.

Comentários

Participe da discussão abaixo.

0 comentários