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:
- Um comando
DROP TABLEem lote limpou todas as tabelas (incluindo a tabelamigrations). - Imediatamente após o
DROP, novas queries tentavam recriar chaves estrangeiras (foreign keys). - Esse comportamento é a assinatura exata do comando
php artisan migrate:freshou da traitRefreshDatabaseutilizada 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:
- Swaps duplicados:
/swapfile_novo(8 GB) e/home/www/swap(2 GB). - Log anômalo do SQLite do painel:
/www/server/panel/logs/sqlite_easy(3 GB). - Lixeira do aaPanel:
/.Recycle_bin/(3.6 GB). - Temporários do Docker:
/var/lib/docker/volumes/.../tmp/(3.8 GB).
# 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#
- 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), nuncaDROPouALTER(lembrando queDROPcobreTRUNCATE). - Ative a trava do framework: Inclua
DB::prohibitDestructiveCommands()como padrão obrigatório noAppServiceProviderem todas as aplicações Laravel. - 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. - 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:
Este post está licenciado sob CC BY-NC.



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