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á chamadasextra()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
- Documentação de Segurança do Django — Proteção contra SQL Injection
- Django Docs — Consultas SQL brutas (
cursor.execute,raw()) - Django Docs — Expressão
RawSQL() - OWASP A03:2021 — Injection
- OWASP SQL Injection Prevention Cheat Sheet
- PortSwigger Web Security Academy — SQL Injection
- SQLMap — Ferramenta de SQL Injection Automático
- MITRE ATT&CK — T1190 Exploit Public-Facing Application
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Capítulo 6: Injection Attacks
- Secure Web Application Development: A Hands-On Guide with Python and Django (Matthew Baker, Apress) — [capítulo relevante]
Próximo nesta série → Post 2: O Perigo Oculto de raw() e extra(): Contornando o ORM com Segurança usando RawSQL()