Como prevenir SQL Injection em formulários legados do site do seu servidor de MU Online
Identifique e corrija vulnerabilidades de SQL Injection em painéis PHP legados de servidores de MU Online — de formulários de login e ranking a páginas de doação — usando prepared statements, validação de entrada e testes práticos.
Muitos sites de servidores de MU Online rodam sobre painéis PHP escritos há anos, adaptados de templates da comunidade, com formulários de login, ranking, doação e recuperação de senha que nunca foram auditados. Esse tipo de código legado é o alvo mais comum de SQL Injection: um atacante insere um t
Muitos sites de servidores de MU Online rodam sobre painéis PHP escritos há anos, adaptados de templates da comunidade, com formulários de login, ranking, doação e recuperação de senha que nunca foram auditados. Esse tipo de código legado é o alvo mais comum de SQL Injection: um atacante insere um trecho de SQL malicioso em um campo de formulário e manipula a consulta para extrair senhas, criar contas administrativas ou até derrubar tabelas inteiras. Este tutorial mostra como identificar os pontos vulneráveis mais comuns nesses painéis, corrigi-los com prepared statements, e validar que a correção realmente funcionou.
Como o SQL Injection acontece na prática
A vulnerabilidade nasce quando a entrada do usuário é concatenada diretamente dentro de uma string SQL. Um exemplo clássico encontrado em painéis legados de MU:
// VULNERÁVEL - não faça isso
$user = $_POST['username'];
$pass = $_POST['password'];
$query = "SELECT * FROM MEMB_INFO WHERE memb___id = '$user' AND memb__pwd = '$pass'";
$result = mysql_query($query);
Se um atacante enviar no campo de usuário o valor ' OR '1'='1' -- , a query final se torna SELECT * FROM MEMB_INFO WHERE memb___id = '' OR '1'='1' -- ' AND memb__pwd = '', que retorna a primeira linha da tabela — geralmente uma conta administrativa — sem precisar da senha correta. Esse é o ataque de bypass de login mais comum contra painéis de MU antigos.
Onde procurar: os formulários de maior risco
Nem todo formulário tem o mesmo risco. Priorize a auditoria por esta ordem:
| Formulário | Risco | Motivo |
|---|---|---|
| Login do painel/conta | Crítico | Bypass de autenticação, acesso a conta alheia ou admin |
| Recuperação de senha | Crítico | Muitas vezes usa email/pergunta em query concatenada |
| Ranking / busca de personagem | Alto | Parâmetro de busca livre, comum em GET sem sanitização |
| Doação / integração de pagamento | Alto | Cruza com dados financeiros e webhooks |
| Comentários / fórum embutido | Médio | Menor acesso a dados sensíveis, mas ainda explorável |
| Contador de visitas / estatística | Baixo | Geralmente sem dado sensível, mas pode servir de ponto de entrada |
Passo 1 — Levantar todos os pontos de entrada
Faça um grep no código-fonte do painel procurando por chamadas de banco antigas e concatenação de variável de request:
grep -rn "mysql_query\|mysqli_query" ./painel/ | grep '\$_'
grep -rn "\$_GET\|\$_POST\|\$_COOKIE" ./painel/ --include="*.php" | grep -i "select\|insert\|update\|delete"
Cada linha retornada é um candidato a ponto vulnerável. Em painéis legados de MU é comum encontrar dezenas de ocorrências, especialmente em arquivos como login.php, ranking.php, busca_char.php e recuperar_senha.php.
Passo 2 — Migrar de mysql_* para mysqli ou PDO
A extensão mysql_* não suporta prepared statements nativos e foi removida do PHP desde a versão 7. Se o painel ainda usa essa extensão, a migração para mysqli com bind de parâmetros é o primeiro passo obrigatório:
// SEGURO - com mysqli e prepared statement
$user = $_POST['username'];
$pass = $_POST['password'];
$stmt = $mysqli->prepare("SELECT * FROM MEMB_INFO WHERE memb___id = ? AND memb__pwd = ?");
$stmt->bind_param("ss", $user, $pass);
$stmt->execute();
$result = $stmt->get_result();
Com o parâmetro vinculado via bind_param, o valor enviado pelo atacante nunca é interpretado como parte do comando SQL — ele é tratado sempre como dado literal, independentemente do conteúdo.
Passo 3 — Corrigir o formulário de busca/ranking
Formulários de busca por nome de personagem costumam usar LIKE com concatenação direta, um ponto frequentemente esquecido na correção:
// VULNERÁVEL
$nome = $_GET['nome'];
$query = "SELECT * FROM Character WHERE Name LIKE '%$nome%'";
// SEGURO
$nome = $_GET['nome'];
$stmt = $pdo->prepare("SELECT * FROM Character WHERE Name LIKE :nome");
$stmt->execute(['nome' => '%' . $nome . '%']);
Note que o wildcard % é adicionado no valor do parâmetro, não na string da query — isso preserva a proteção do prepared statement.
Passo 4 — Tratar nomes de tabela e coluna dinâmicos com whitelist
Prepared statements protegem valores, mas não protegem nomes de tabela ou coluna usados dinamicamente (por exemplo, em um ranking que ordena por coluna escolhida pelo usuário). Nesse caso, use whitelist explícita em vez de bind:
$colunasPermitidas = ['Level', 'Resets', 'PkCount', 'Money'];
$ordem = $_GET['ordem'];
if (!in_array($ordem, $colunasPermitidas, true)) {
$ordem = 'Level'; // valor padrão seguro
}
$query = "SELECT * FROM Character ORDER BY $ordem DESC LIMIT 100";
Nunca aceite o nome da coluna diretamente do request sem essa validação contra uma lista fechada de valores possíveis.
Passo 5 — Validar e normalizar tipos antes de qualquer coisa
Além do prepared statement, valide o tipo esperado de cada campo antes de usá-lo, como camada extra de defesa:
$id = $_GET['id'];
if (!ctype_digit($id)) {
die('Parâmetro inválido.');
}
$id = (int) $id;
Isso não substitui o prepared statement, mas reduz a superfície de ataque e evita que erros de aplicação processem tipos inesperados em outros pontos do código.
Passo 6 — Auditar a integração de pagamento/doação
Páginas de doação frequentemente recebem parâmetros de retorno de gateways de pagamento (valor, referência, status) via GET/POST e os usam diretamente em updates de saldo. Garanta que:
- O valor do crédito nunca venha diretamente do parâmetro da URL — sempre confirme o valor real com a API do gateway (webhook assinado), nunca confie no que o navegador do usuário envia de volta.
- A query de atualização de saldo do jogador use prepared statement com o valor validado do lado do servidor, não do request.
- Logs de toda transação de crédito sejam mantidos para auditoria posterior.
Passo 7 — Testar a correção
Depois de corrigir, teste manualmente com payloads clássicos para confirmar que o comportamento mudou:
| Payload de teste | Resultado esperado após correção |
|---|---|
' OR '1'='1 no campo usuário | Login falha normalmente (usuário não encontrado) |
'; DROP TABLE Character; -- | Erro tratado ou nenhuma alteração — nunca execução do DROP |
admin' -- | Login falha (comentário SQL não tem efeito no prepared statement) |
1 UNION SELECT memb__pwd FROM MEMB_INFO -- no parâmetro de busca | Busca retorna vazio ou erro tratado, sem vazar dados de outra tabela |
Se qualquer um desses payloads ainda alterar o comportamento esperado da aplicação, a correção não foi aplicada corretamente nesse ponto.
Passo 8 — Reduzir a superfície com princípio do menor privilégio no banco
Como camada adicional, garanta que o usuário de banco de dados usado pelo painel PHP não tenha permissão de DROP, ALTER ou acesso a outras bases além da necessária:
CREATE USER 'painel_web'@'localhost' IDENTIFIED BY 'senha-forte-aqui';
GRANT SELECT, INSERT, UPDATE ON MuOnline.MEMB_INFO TO 'painel_web'@'localhost';
GRANT SELECT, INSERT, UPDATE ON MuOnline.Character TO 'painel_web'@'localhost';
-- Sem GRANT DROP, ALTER ou acesso a outras bases
FLUSH PRIVILEGES;
Mesmo que uma injeção passe despercebida em algum ponto não auditado, esse usuário restrito limita o dano possível.
Erros comuns e soluções
| Sintoma | Causa provável | Solução |
|---|---|---|
Login aceita ' OR '1'='1 como usuário válido | Concatenação direta de string SQL | Migrar para prepared statement com bind_param |
| Busca de ranking retorna dados de outras tabelas | Falta de validação em coluna de ordenação dinâmica | Restringir a whitelist fixa de colunas permitidas |
| Saldo de doação alterado sem pagamento real | Valor de crédito confiado a partir do request do navegador | Validar sempre via webhook assinado do gateway |
Painel ainda usa mysql_query | Código legado nunca migrado | Migrar para mysqli/PDO com prepared statements |
Usuário de banco do painel consegue DROP TABLE | Permissão de banco excessiva | Restringir grants ao mínimo necessário (SELECT/INSERT/UPDATE) |
Checklist de auditoria de SQL Injection
- Todos os formulários de login, recuperação de senha e busca revisados.
- Todo uso de
mysql_query/concatenação direta migrado para prepared statements. - Nomes de coluna/tabela dinâmicos validados por whitelist, nunca por bind direto.
- Valores financeiros de doação validados via API/webhook, nunca via parâmetro do request.
- Testes manuais com payloads clássicos executados após cada correção.
- Usuário de banco do painel restrito ao mínimo de permissões necessárias.
- Logs de acesso revisados em busca de payloads suspeitos já utilizados no passado.
Com o painel protegido contra SQL Injection, o próximo passo natural é revisar também o acesso administrativo que gerencia esse mesmo banco de dados — veja o guia de criação de servidor de MU Online para revisar a infraestrutura completa por trás do painel.
Perguntas frequentes
Meu painel é antigo e usa mysql_query, isso já é um risco?
Sim. A extensão mysql_* foi removida do PHP a partir da versão 7 e, mesmo quando ainda funciona em versões antigas, não oferece prepared statements nativos, o que incentiva concatenação direta de string SQL — a principal causa de SQL Injection em painéis de MU. Migre para mysqli ou PDO com prepared statements.
Prepared statements são suficientes para eliminar SQL Injection?
Na grande maioria dos casos, sim, desde que usados corretamente — ou seja, todo dado vindo do usuário (GET, POST, cookie, header) deve passar como parâmetro bind, nunca concatenado na string da query. A exceção são nomes de tabela/coluna dinâmicos, que exigem whitelist em vez de bind.
Como sei se meu painel já foi explorado por SQL Injection?
Procure por padrões suspeitos nos logs de acesso do servidor web: parâmetros de URL contendo UNION SELECT, ' OR '1'='1, --, ou SLEEP(. Também verifique se contas administrativas foram criadas sem seu conhecimento ou se dados de outras contas apareceram expostos.
Um WAF (Web Application Firewall) substitui a correção do código?
Não. Um WAF é uma camada adicional de defesa que pode bloquear payloads conhecidos, mas não corrige a vulnerabilidade — um atacante com payload não catalogado ainda passa. Trate o WAF como mitigação temporária enquanto corrige o código-fonte, nunca como solução definitiva.
Vale a pena reescrever todo o painel do zero por causa disso?
Depende do tamanho do painel e do orçamento. Para painéis pequenos e antigos, geralmente é mais rápido corrigir ponto a ponto os formulários vulneráveis com prepared statements do que reescrever tudo. Para painéis grandes com múltiplas vulnerabilidades sistêmicas, uma reescrita gradual módulo por módulo costuma ser mais segura no médio prazo.