SQL Injection: Como Atacantes Quebram Seu Banco de Dados e Como o ORM do Django os Para

SQL Injection: Como Atacantes Quebram Seu Banco de Dados e Como o ORM do Django os Para

Django Security Series — Post 1 | Série I: Ataques de Injeção
OWASP A03:2021 — Injection | Tempo de leitura: ~12 min

A Django Security Series é o meu projeto contínuo de documentar o que aprendo sobre segurança de aplicações — mapeando os ataques web mais perigosos para sua prevenção concreta em Python e Django. Cada post é fundamentado em incidentes reais, código real e no OWASP Top 10: escrevo para entender, e publico para que você possa percorrer o mesmo caminho. As técnicas de ataque são mapeadas para táticas e técnicas do MITRE ATT&CK, as orientações de prevenção são ancoradas na documentação oficial de segurança do Django, e cada post se apoia em dois livros de referência para profissionais: Web Security for Developers (Malcolm McDonald) e Secure Web Application Development with Python and Django (Matthew Baker, Apress).

SQL Injection é por onde começamos. Está presente em todas as edições do OWASP Top 10 desde que a lista foi criada em 2003 e, no relatório de 2021, falhas de injeção foram encontradas em 94% das aplicações testadas. Duas décadas de vazamentos públicos, frameworks e educação não eliminaram o problema — porque o mal-entendido não é sobre ignorância do ataque, mas sobre uma falsa sensação de segurança por usar um ORM.

O ORM do Django protege por padrão. Mas desenvolvedores que contornam esse padrão — via raw(), extra(), formatação de string ou integrações de terceiros — abrem uma brecha que atacantes encontram de forma confiável há tanto tempo quanto aplicações web existem. Entender onde a proteção do ORM termina é tão importante quanto saber que ela existe.

Ao final deste post você vai entender como o SQL Injection funciona no nível da query, por que a parametrização do Django é estruturalmente mais robusta do que qualquer abordagem de escaping, e exatamente quais padrões de código quebram essa garantia.


O Ataque: O Que É e Como Funciona

SQL Injection ocorre quando um atacante insere sintaxe SQL em um campo de entrada que a aplicação web embute diretamente em uma consulta ao banco de dados — sem a devida separação entre código e dados.

A causa raiz é enganosamente simples: o desenvolvedor tratou a entrada do usuário como parte da lógica do programa. O banco de dados não consegue distinguir entre o SQL pretendido pelo desenvolvedor e o payload injetado pelo atacante.

Como Funciona — Passo a Passo

Considere uma view de login que verifica credenciais assim:

SELECT * FROM users
WHERE username = '[ENTRADA DO USUÁRIO]'
  AND password = '[ENTRADA DO USUÁRIO]'

Um atacante digita admin' -- como nome de usuário. A aspas simples fecha o literal de string, e -- comenta o restante da consulta. O banco executa:

SELECT * FROM users
WHERE username = 'admin' --' AND password = ''

A verificação de senha está comentada. Se existir um usuário chamado admin, o atacante entra — sem saber a senha.

Técnicas mais avançadas:

Técnica O que faz
Tautologia ' OR '1'='1 — torna a cláusula WHERE sempre verdadeira
Injeção UNION Acrescenta um segundo SELECT para extrair dados de outras tabelas
Blind SQLi (booleana) Infere dados com perguntas verdadeiro/falso: AND 1=1 -- vs AND 1=2 --
Blind SQLi (baseada em tempo) Usa pg_sleep() / SLEEP() para extrair dados pela demora na resposta
Consultas empilhadas Acrescenta um segundo comando: '; DROP TABLE users; --

Incidentes Reais

Heartland Payment Systems (2008): SQL injection comprometeu a rede de processamento de pagamentos de 250.000 estabelecimentos, expondo 130 milhões de números de cartão de crédito. Custo total: mais de US$ 140 milhões em acordos. Na época, a maior violação de dados já registrada.

TalkTalk (Reino Unido, 2015): Um adolescente de 15 anos usou SQL injection clássica para roubar registros de 157.000 clientes — incluindo dados bancários. A violação custou à TalkTalk £77 milhões e uma multa de £400.000 do ICO. A ironia: o banco de dados vulnerável rodava software de 2009.

Yahoo Voices (2012): O grupo hacktivista D33Ds Company extraiu 450.000 credenciais em texto puro via SQL injection e as publicou, demonstrando simultaneamente duas falhas: uma vulnerabilidade de injeção e armazenamento inadequado de senhas.

MITRE ATT&CK: T1190 (Explorar Aplicação Voltada ao Público) cobre o acesso inicial. Resultados pós-exploração comuns incluem T1005 (Dados do Sistema Local) e coleta de credenciais para movimentação lateral.


Proteções Padrão do Django

O ORM do Django parametriza todas as consultas que gera. Essa é a proteção central.

Quando você escreve:

Post.objects.filter(title=user_input)

Django gera:

SELECT * FROM blog_post WHERE title = %s

…e passa user_input como um parâmetro separado para o driver do banco de dados (psycopg3 para PostgreSQL, sqlite3 para SQLite). O driver transmite a estrutura da consulta e os dados de forma independente. O banco de dados processa user_input como um valor literal de string, nunca como sintaxe SQL.

Um payload como ' OR '1'='1 é tratado como a string de busca ' OR '1'='1 — não como lógica SQL. O ataque falha estruturalmente, independentemente dos caracteres que a entrada contenha.

Essa proteção não é obtida por escapamento ou filtragem. Ela funciona porque o SQL e os dados nunca compartilham o mesmo canal. A parametrização é categoricamente mais robusta do que qualquer abordagem de escapamento.


Padrão Vulnerável: O Que NÃO Fazer

# INSEGURO — Nunca construa SQL com formatação de string
from django.db import connection

def search_posts(request):
    query = request.GET.get('q', '')

    # PERIGO: f-string embute a entrada do usuário diretamente no SQL
    sql = f"SELECT id, title, slug FROM blog_post WHERE title LIKE '%{query}%'"

    with connection.cursor() as cursor:
        cursor.execute(sql)  # o atacante controla a estrutura do SQL
        rows = cursor.fetchall()

    return render(request, 'search.html', {'results': rows})

Payload de ataque: q=%' UNION SELECT id, password, email FROM auth_user LIMIT 10 --

A consulta resultante despeja até 10 linhas da tabela de usuários. O atacante agora tem senhas com hash e endereços de e-mail.

A mesma vulnerabilidade aparece em todos esses padrões:

# Concatenação de string — igualmente perigosa
sql = "SELECT * FROM blog_post WHERE slug = '" + slug + "'"

# Formatação com % — NÃO é parametrização, ainda vulnerável
sql = "SELECT * FROM blog_post WHERE slug = '%s'" % slug

# .format() — também vulnerável
sql = "SELECT * FROM blog_post WHERE slug = '{}'".format(slug)

# ORM.extra() com entrada não sanitizada — obsoleto E perigoso
Post.objects.extra(where=[f"title = '{user_input}'"])

# ORM.raw() com formatação de string — perigoso
Post.objects.raw(f"SELECT * FROM blog_post WHERE slug = '{slug}'")

Nota: Post.objects.extra() é há muito tempo desaconselhado — o time do Django o descreve como uma API antiga com descontinuação planejada para o futuro, e não corrige mais bugs nele. Se ainda há chamadas extra() na sua base de código, este é o momento de removê-las — o Post 2 desta série cobre as alternativas seguras em detalhes.


Implementação Segura: O Jeito Django

Preferido: Querysets do ORM

Para a grande maioria dos casos de uso, o ORM cuida de tudo com segurança:

# SEGURO — o ORM parametriza automaticamente
def search_posts(request):
    query = request.GET.get('q', '').strip()
    results = Post.objects.filter(
        title__icontains=query,
        publish=True,
        language='pt',
    ).order_by('-created')[:20]
    return render(request, 'search.html', {'results': results})

icontains se traduz em ILIKE %s (sem distinção de maiúsculas/minúsculas; LIKE %s em bancos sem suporte a ILIKE) com o valor parametrizado. O Django adiciona os curingas % na camada do ORM — a entrada do usuário nunca toca a string SQL.

Quando SQL Bruto é Necessário: Placeholders Parametrizados

Ocasionalmente você precisa de SQL bruto — para consultas complexas, recursos específicos do banco ou busca full-text. Sempre use placeholders %s:

# SEGURO — SQL bruto com placeholders parametrizados
from django.db import connection

def full_text_search(query):
    with connection.cursor() as cursor:
        cursor.execute(
            """
            SELECT id, title, slug,
                   ts_rank(to_tsvector('portuguese', body), plainto_tsquery(%s)) AS rank
            FROM blog_post
            WHERE to_tsvector('portuguese', body) @@ plainto_tsquery(%s)
              AND publish = TRUE
            ORDER BY rank DESC
            LIMIT 20
            """,
            [query, query]  # ← parâmetros passados separadamente, nunca interpolados
        )
        return cursor.fetchall()

RawSQL() para Anotações do ORM

# SEGURO — entrada do usuário passada via lista de parâmetros
from django.db.models.expressions import RawSQL

Post.objects.annotate(
    rank=RawSQL(
        "ts_rank(to_tsvector('portuguese', body), plainto_tsquery(%s))",
        [user_search_term]  # ← parametrizado
    )
).filter(publish=True)

Resumo das Regras

Faça Não Faça
Post.objects.filter(title=value) f"SELECT ... WHERE title = '{value}'"
cursor.execute("... WHERE id = %s", [value]) cursor.execute("... WHERE id = " + value)
RawSQL("... %s ...", [value]) Post.objects.extra(where=[f"... '{value}'"])

Princípio do Menor Privilégio

A parametrização é sua defesa primária, mas Malcolm McDonald faz um ponto importante em Web Security for Developers (Capítulo 6): defesa em profundidade significa proteger cada camada da pilha, não apenas a construção da query.

Para SQL injection especificamente, isso significa que a conta de banco de dados usada pela sua aplicação Django em tempo de execução deve ter apenas as permissões de que realmente precisa — tipicamente SELECT, INSERT, UPDATE e DELETE (o subconjunto DML do SQL). Ela não deve ter privilégios de CREATE, DROP ou ALTER (DDL). Se um atacante conseguir contornar a parametrização, uma conta com menor privilégio limita o raio de impacto: ele não consegue apagar tabelas ou modificar o schema, apenas operar dentro dos dados que já pode acessar.

Na prática para um projeto Django:

-- PostgreSQL: criar um usuário restrito para execução
CREATE USER blogtech_app WITH PASSWORD 'senhaforte';
GRANT CONNECT ON DATABASE blogtech TO blogtech_app;
GRANT USAGE ON SCHEMA public TO blogtech_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO blogtech_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO blogtech_app;
-- Sem CREATE, DROP ou ALTER

Execute as migrations com uma conta privilegiada separada; deixe a aplicação usar a conta restrita em tempo de execução.


Testando Sua Defesa

Teste Unitário

# blog/tests.py
from django.test import TestCase, Client
from blog.models import Post

class SQLInjectionTests(TestCase):
    def setUp(self):
        Post.objects.create(
            title="Post Normal",
            slug="post-normal",
            body="Conteúdo",
            publish=True,
            language='pt',
        )

    def test_payload_tautologia_nao_retorna_resultado(self):
        """A string de injeção é tratada como literal — não como lógica SQL."""
        payload = "' OR '1'='1"
        self.assertEqual(Post.objects.filter(title=payload).count(), 0)

    def test_payload_union_nao_retorna_linhas_extras(self):
        """Payload UNION não deve extrair dados de outras tabelas."""
        payload = "x' UNION SELECT id, title, slug FROM blog_post--"
        self.assertEqual(Post.objects.filter(title__icontains=payload).count(), 0)

    def test_view_trata_injecao_com_seguranca(self):
        """HTTP 200 sozinho não prova nada — uma view vulnerável também retorna 200.
        É preciso verificar que o payload foi tratado como string literal: resultados devem ser zero."""
        client = Client()
        response = client.get('/pt/blog/', {'q': "' OR '1'='1"})
        self.assertEqual(response.status_code, 200)
        # Se a tautologia tivesse funcionado, o banco teria retornado todos os posts.
        # Zero resultados confirma que a entrada nunca foi interpretada como lógica SQL.
        self.assertEqual(len(response.context['results']), 0)

Verificação Manual

python manage.py shell
>>> from blog.models import Post
>>> Post.objects.filter(title="' OR '1'='1").count()
0   # correto: tratado como string literal, não como SQL

Varredura Automatizada

SQLMap é a ferramenta padrão da indústria para detectar SQL injection. Execute-a apenas em ambiente de homologação:

# Varredura básica de um endpoint de busca
sqlmap -u "https://homolog.exemplo.com.br/blog/?q=teste" --level=2 --risk=1

# Uma aplicação Django corretamente parametrizada produz:
# [INFO] GET parameter 'q' does not appear to be injectable

SQL Injection não é um ataque sofisticado — é uma consequência previsível de misturar dados e código na mesma string. O ORM do Django elimina esse risco por design, mas apenas enquanto você permanece dentro da sua interface parametrizada. Cada vez que você usa um f-string, formatação com % ou concatenação de string bruta em uma consulta ao banco de dados, você está saindo dessa garantia. A regra é simples: SQL e entrada do usuário nunca devem compartilhar o mesmo canal. O Post 2 desta série analisa exatamente onde os desenvolvedores quebram essa regra sem perceber — em raw(), extra() e integrações de terceiros que parecem seguras, mas não são.

Leitura Complementar

Próximo nesta série → Post 2: O Perigo Oculto de raw() e extra(): Contornando o ORM com Segurança usando RawSQL()

← Voltar para todos os posts