SQL Injection em Profundidade: Exploração, Bypass e Prevenção Real na Prática Já leu

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. --- 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: O problema: se um atacante fornecer e qualquer senha, a query se torna: O comentário ignora o resto

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:

  1. Entrada do usuário — dados que vêm de fora da aplicação (formulários, URLs, APIs)
  2. Construção dinâmica — a query é montada em tempo de execução, não pré-compilada
  3. 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:

  1. 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.

  2. 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.

  3. 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

  1. OWASP SQL Injection — https://owasp.org/www-community/attacks/SQL_Injection
  2. OWASP Cheat Sheet: SQL Injection — https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
  3. CWE-89: SQL Injection (MITRE) — https://cwe.mitre.org/data/definitions/89.html
  4. PortSwigger: SQL Injection — https://portswigger.net/web-security/sql-injection
  5. The OWASP Testing Guide v4.1 — https://owasp.org/www-project-web-security-testing-guide/

Artigos relacionados