SQL Injection em Profundidade: Exploração, Bypass e Prevenção Real
SQL Injection (SQLi) permanece como uma das vulnerabilidades mais críticas e exploradas em aplicações web, mesmo após décadas de existência. Não é um problema "antigo" — é um problema negligenciado. A razão é simples: desenvolvedores continuam construindo queries dinâmicas concatenando strings do usuário diretamente no código SQL. Neste artigo, você aprenderá não apenas como a exploração funciona, mas especialmente como implementar defesas reais que funcionam em produção.
1. Entendendo o Mecanismo Fundamental de SQL Injection
SQL Injection ocorre quando dados não confiáveis são inseridos em uma query SQL sem sanitização adequada, permitindo que o atacante altere a semântica da query e execute comandos não autorizados. O banco de dados não diferencia entre código SQL legítimo e dados manipulados — ambos são tratados como instruções.
Considere uma aplicação de login básica. O desenvolvedor escreve:
# CÓDIGO VULNERÁVEL - NÃO USE EM PRODUÇÃO
import sqlite3
def authenticate_user(username, password):
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
# Concatenação direta - VULNERÁVEL
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
cursor.execute(query)
user = cursor.fetchone()
conn.close()
return user is not None
O problema: se um atacante fornecer username = "admin' --" e qualquer senha, a query se torna:
SELECT * FROM users WHERE username = 'admin' --' AND password = 'qualquer_coisa'
O comentário -- ignora o resto da query, e a autenticação ocorre sem verificar a senha. O invasor entra como admin com qualquer senha.
O Triângulo da Vulnerabilidade
Três elementos precisam estar presentes simultaneamente para SQLi existir:
- Entrada do usuário — dados que vêm de fora da aplicação (formulários, URLs, APIs)
- Construção dinâmica — a query é montada em tempo de execução, não pré-compilada
- Execução sem distinção — o interpretador SQL não separa código de dados
Remova qualquer um desses elementos e SQLi desaparece.
2. Técnicas de Exploração e Bypass
2.1 SQL Injection Clássica (In-band)
É o tipo mais direto: o atacante injeta código SQL e recebe o resultado na resposta HTTP.
Suponha uma aplicação de busca de produtos vulnerável:
# CÓDIGO VULNERÁVEL
def search_products(search_term):
query = f"SELECT id, name, price FROM products WHERE name LIKE '%{search_term}%'"
cursor.execute(query)
return cursor.fetchall()
Um atacante pode buscar por %' OR '1'='1 para retornar todos os produtos:
SELECT id, name, price FROM products WHERE name LIKE '%%' OR '1'='1%'
Ou extrair dados usando UNION-based injection:
# Entrada maliciosa
search_term = "' UNION SELECT username, password, 1 FROM users --"
# Query resultante
# SELECT id, name, price FROM products WHERE name LIKE '%' UNION SELECT username, password, 1 FROM users --%'
Isso retorna credenciais de usuários junto com os resultados de produtos.
2.2 Blind SQL Injection (Inferência de Dados)
Quando a aplicação não retorna erros ou dados diretamente, o atacante infere informações através de comportamento booleano.
# CÓDIGO VULNERÁVEL
def check_user_exists(user_id):
query = f"SELECT COUNT(*) FROM users WHERE id = {user_id}"
cursor.execute(query)
count = cursor.fetchone()[0]
# A página carrega diferente se count > 0
return count > 0
Um atacante pode usar queries como:
-- Se retorna verdadeiro, o primeiro caractere da senha começa com 'a'
SELECT COUNT(*) FROM users WHERE id = 1 AND password LIKE 'a%'
-- Se retorna verdadeiro, o primeiro caractere é 's'
SELECT COUNT(*) FROM users WHERE id = 1 AND password LIKE 's%'
Repetindo esse processo, extraem a senha caractere por caractere.
2.3 Time-based Blind SQL Injection
Quando nem comportamento booleano está disponível, usa-se timing para inferir dados:
# CÓDIGO VULNERÁVEL - SQL Server
def vulnerable_endpoint(user_input):
query = f"SELECT * FROM users WHERE name = '{user_input}'"
cursor.execute(query) # Sem tratamento de erro
return "Done"
O atacante injeta:
' OR IF(1=1, SLEEP(5), 0) --
Se a resposta demora 5 segundos, a condição é verdadeira. Variando 1=1 para condições específicas, extrai dados por timing.
2.4 Bypass de Filtros Básicos
Muitos desenvolvedores tentam "filtrar" SQL Injection com técnicas inadequadas:
Filtro ingênuo: input = input.replace("'", "")
Bypass: ' OR 1=1 -- vira OR 1=1 --, que em alguns contextos ainda funciona. Também, em MySQL, aspas duplas funcionam: " OR 1=1 --.
Filtro um pouco melhor: if "OR" in input or "UNION" in input: reject()
Bypass: Usar espaços e comentários /**/ — ' /*! OR */ '1'='1 funciona em MySQL com sintaxe inline de comentários.
Filtro com encoding: Bloquear aspas, mas aceitar encoded
Bypass: Hexadecimal. Em MySQL, 0x61 é o byte 'a'. Então username = 0x61646d696e (admin em hex) funcionaria se a aplicação não usar prepared statements.
3. Prevenção Real: Defesas que Funcionam
3.1 Prepared Statements (Parametrized Queries) — A Solução Correta
Prepared statements separam código SQL de dados. O SQL é compilado antes dos dados chegarem:
# CÓDIGO SEGURO - Python com sqlite3
import sqlite3
def authenticate_user_safe(username, password):
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
# ? são placeholders - dados vêm após
query = "SELECT * FROM users WHERE username = ? AND password = ?"
cursor.execute(query, (username, password))
user = cursor.fetchone()
conn.close()
return user is not None
Agora, se um usuário fornece username = "admin' --", o interpretador SQL trata isso como um valor literal, não como código:
-- Internamente (pseudocódigo)
statement = compile("SELECT * FROM users WHERE username = ? AND password = ?")
statement.bind(1, "admin' --") # Tratado como string pura
statement.bind(2, "qualquer")
O -- nunca é interpretado como comentário.
Exemplo com MySQL (usando mysql-connector-python):
from mysql.connector import connect
def search_products_safe(search_term):
conn = connect(host='localhost', user='app', password='secret', database='shop')
cursor = conn.cursor()
# Prepared statement
query = "SELECT id, name, price FROM products WHERE name LIKE %s"
# Note: LIKE ainda precisa do % no valor, não na query
search_param = f"%{search_term}%"
cursor.execute(query, (search_param,))
results = cursor.fetchall()
conn.close()
return results
# Teste com entrada maliciosa
print(search_products_safe("' OR '1'='1"))
# Funciona normalmente, procurando por produto com nome literal "' OR '1'='1"
3.2 Validação de Entrada — Segunda Camada
Enquanto prepared statements resolvem 99% do problema, validação rigorosa adiciona defesa em profundidade:
import re
from typing import Optional
def validate_username(username: str) -> Optional[str]:
# Aceitar apenas alphanumericos e underscore
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
raise ValueError("Username inválido")
return username
def validate_email(email: str) -> Optional[str]:
# Validação básica de email
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
raise ValueError("Email inválido")
return email
def validate_integer_id(value: str) -> int:
try:
int_val = int(value)
if int_val < 0:
raise ValueError("ID não pode ser negativo")
return int_val
except ValueError:
raise ValueError("ID deve ser um inteiro válido")
# Uso seguro
def get_user_by_id_safe(user_id: str):
validated_id = validate_integer_id(user_id)
query = "SELECT * FROM users WHERE id = ?"
cursor.execute(query, (validated_id,))
return cursor.fetchone()
3.3 Princípio do Menor Privilégio (Database Level)
Não execute queries com usuário de admin. Crie usuários restritos para a aplicação:
-- Criação de usuário apenas para SELECT
CREATE USER 'app_readonly'@'localhost' IDENTIFIED BY 'senha_forte';
GRANT SELECT ON shop.* TO 'app_readonly'@'localhost';
-- Usuário apenas para operações de escrita específicas
CREATE USER 'app_write'@'localhost' IDENTIFIED BY 'outra_senha';
GRANT INSERT, UPDATE ON shop.products TO 'app_write'@'localhost';
GRANT UPDATE ON shop.users TO 'app_write'@'localhost';
-- Nunca fazer isso:
-- GRANT ALL PRIVILEGES ON *.* TO 'app'@'localhost';
Mesmo que um invasor execute SQLi, só consegue fazer o que esse usuário pode fazer. Não consegue dropar tabelas ou acessar dados de outras aplicações.
3.4 Web Application Firewall (WAF) — Terceira Camada
Ferramentas como ModSecurity detectam padrões de SQLi:
# Exemplo de regra ModSecurity
SecRule ARGS "@rx (?:union|select|insert|delete|drop|create|alter)" \
"id:1001,phase:2,deny,status:403,msg:'SQL Injection Attempt'"
WAF não é uma defesa primária (prepared statements são), mas pega ataques não sofisticados e fornece logs de ataque.
3.5 Exemplo Completo: Aplicação Flask Segura
from flask import Flask, request, jsonify
from mysql.connector import connect
from mysql.connector.errors import Error
import re
app = Flask(__name__)
def get_db_connection():
return connect(
host='localhost',
user='app_user',
password='app_pass',
database='secure_app'
)
def validate_email(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
raise ValueError("Email inválido")
return email
def validate_product_id(product_id):
try:
pid = int(product_id)
if pid <= 0:
raise ValueError()
return pid
except (ValueError, TypeError):
raise ValueError("ID de produto inválido")
@app.route('/api/user/register', methods=['POST'])
def register():
try:
data = request.get_json()
email = validate_email(data.get('email', ''))
password = data.get('password', '')
if len(password) < 8:
return jsonify({'error': 'Senha muito curta'}), 400
conn = get_db_connection()
cursor = conn.cursor()
# Prepared statement
query = "INSERT INTO users (email, password_hash) VALUES (%s, SHA2(%s, 256))"
cursor.execute(query, (email, password))
conn.commit()
conn.close()
return jsonify({'success': True, 'message': 'Usuário criado'}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Error as e:
# Log do erro, nunca retornar ao cliente
print(f"Database error: {e}")
return jsonify({'error': 'Erro na operação'}), 500
@app.route('/api/products/<product_id>', methods=['GET'])
def get_product(product_id):
try:
pid = validate_product_id(product_id)
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT id, name, price FROM products WHERE id = %s"
cursor.execute(query, (pid,))
product = cursor.fetchone()
conn.close()
if not product:
return jsonify({'error': 'Produto não encontrado'}), 404
return jsonify({
'id': product[0],
'name': product[1],
'price': product[2]
}), 200
except ValueError as e:
return jsonify({'error': str(e)}), 400
if __name__ == '__main__':
app.run(debug=False, ssl_context='adhoc') # HTTPS obrigatório
4. Testes e Validação de Segurança
4.1 Testando Sua Própria Aplicação
Antes de colocar em produção, teste sua aplicação contra SQLi:
# Script de teste de vulnerabilidade (use em ambiente de teste apenas)
import sqlite3
def test_injection_payloads():
conn = sqlite3.connect('test.db')
cursor = conn.cursor()
# Setup
cursor.execute("CREATE TABLE IF NOT EXISTS test_users (id INTEGER, username TEXT)")
cursor.execute("INSERT INTO test_users VALUES (1, 'admin')")
conn.commit()
# Payloads comuns para testar
payloads = [
"' OR '1'='1",
"admin' --",
"' UNION SELECT 1, 'hack' --",
"1' AND SLEEP(5) --",
"'; DROP TABLE test_users; --"
]
for payload in payloads:
# Teste VULNERÁVEL (nunca use em produção)
try:
vulnerable_query = f"SELECT * FROM test_users WHERE username = '{payload}'"
print(f"[VULNERABLE] Testing: {payload}")
cursor.execute(vulnerable_query)
results = cursor.fetchall()
print(f" Results: {results}\n")
except sqlite3.Error as e:
print(f" SQL Error (pode indicar sucesso): {e}\n")
# Teste SEGURO
try:
safe_query = "SELECT * FROM test_users WHERE username = ?"
print(f"[SAFE] Testing: {payload}")
cursor.execute(safe_query, (payload,))
results = cursor.fetchall()
print(f" Results: {results} (nenhum match - correto!)\n")
except sqlite3.Error as e:
print(f" Error: {e}\n")
conn.close()
test_injection_payloads()
4.2 Ferramentas Automatizadas
Use ferramentas profissionais para testes:
- sqlmap — Automatiza exploração de SQLi
- Burp Suite Community — Proxy para testar aplicações web
- OWASP ZAP — Scanner de segurança gratuito
- Acunetix — Scanner comercial de alta qualidade
Exemplo com sqlmap (use contra seu servidor de teste):
# Testar um parâmetro específico
sqlmap -u "http://localhost:5000/api/products/1" --dbs
# Testar todos os parâmetros automaticamente
sqlmap -u "http://localhost:5000/search?q=test" --risk=1 --level=1
Conclusão
SQL Injection não é complexa de explorar, mas é igualmente simples de prevenir. Os três aprendizados fundamentais são:
-
Prepared statements são obrigatórios — Não é opcional, não é "um dos métodos". É a solução. Se você não está usando, está vulnerável. Ponto. Toda linguagem moderna (Python, PHP, Java, Node.js) suporta nativamente. Não há desculpa.
-
Validação é defesa em profundidade, não substituição — Mesmo com prepared statements, validar entrada (verificar se é realmente um inteiro quando esperado, se email tem formato válido) reduz superfície de ataque. Combine com prepared statements, não em seu lugar.
-
Princípio do menor privilégio funciona — Um usuário de banco de dados que só pode SELECT em uma tabela específica limita o dano de qualquer vulnerabilidade. Isso salva vidas em produção quando (não "se") um zero-day aparece. Sempre use contas restritas para aplicações.
Referências
- OWASP SQL Injection — https://owasp.org/www-community/attacks/SQL_Injection
- OWASP Cheat Sheet: SQL Injection — https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
- CWE-89: SQL Injection (MITRE) — https://cwe.mitre.org/data/definitions/89.html
- PortSwigger: SQL Injection — https://portswigger.net/web-security/sql-injection
- The OWASP Testing Guide v4.1 — https://owasp.org/www-project-web-security-testing-guide/