SQL Injection: Como Atacantes Quebram Seu Banco de Dados e Como o ORM do Django os Impede
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.
Neste post, vamos ver como o SQL Injection realmente acontece no nível da query, por que a parametrização nativa do Django bate o escaping manual de longe, e os hábitos de código que podem silenciosamente desativar essa proteção.
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 quase sempre se resume a um erro básico: tratar a entrada do usuário como código executável em vez de dado bruto. 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)
Em 2008, Albert Gonzalez e dois cúmplices invadiram a Heartland Payment Systems — uma empresa que processava mais de 100 milhões de transações de cartão por mês para 250.000 estabelecimentos nos Estados Unidos. Os invasores usaram SQL injection para comprometer a aplicação web interna da Heartland e instalaram malware de captura de rede para interceptar dados de cartão em texto puro. Quando investigadores forenses identificaram a violação, aproximadamente 130 milhões de números de cartão de crédito e débito já haviam sido roubados — a maior violação de cartão de pagamento já registrada na época. A parte absurda da violação da Heartland não foi algum zero-day sofisticado de estado-nação. Foi um SQL injection básico em um formulário web — a vulnerabilidade que ensinamos os desenvolvedores a evitar nos primeiros tutoriais de desenvolvimento web.
A Heartland reportou prejuízos de quase US$ 130 milhões e chegou a acordos separados com Visa, American Express e instituições financeiras emissoras. Gonzalez foi condenado a 20 anos de prisão federal com base no Computer Fraud and Abuse Act (CFAA). Em termos do MITRE ATT&CK, o acesso inicial se enquadra em T1190 (Explorar Aplicação Voltada ao Público); a pós-exploração incluiu coleta de credenciais e movimentação lateral para alcançar a infraestrutura de processamento de cartões. A empresa foi removida da lista de Prestadores de Serviços Validados PCI DSS da Visa e a violação impulsionou os requisitos mais rigorosos de testes de penetração do PCI DSS v2.0. Para desenvolvedores Django, a lição é direta: uma vulnerabilidade de SQL injection pode desencadear processos criminais com base no CFAA, litígios civis com múltiplas partes e perda de certificação de processamento de pagamentos. Uma consulta parametrizada não é uma otimização de desempenho — é uma obrigação legal.
Fonte: Heartland Payment Systems: Lessons Learned from a Data Breach
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 não apenas filtra caracteres maliciosos; ela separa completamente os dados da estrutura da query. É por isso que é muito superior a qualquer abordagem de escaping manual.
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
# Ainda vejo isso em code reviews com frequência alta demais.
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. As alternativas seguras são demonstradas na próxima seção.
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)
A Forma Segura de raw() — e Quando Usar Cada Abordagem Raw
Post.objects.raw() tem uma forma segura. Passe o template SQL como primeiro argumento e uma lista de parâmetros como segundo:
# SEGURO — raw() com lista de parâmetros
posts = Post.objects.raw(
"SELECT id, title, slug FROM blog_post WHERE slug = %s AND publish = TRUE",
[slug] # ← passado separadamente, nunca interpolado na string SQL
)
O valor de slug é transmitido ao driver do banco como um parâmetro separado — estruturalmente idêntico a cursor.execute() com %s. Use cada abordagem raw nas seguintes situações:
| Use | Quando |
|---|---|
Post.objects.raw(sql, [params]) |
Precisa de um queryset de instâncias do model mas a query é complexa demais para o ORM |
cursor.execute(sql, [params]) |
Precisa de dados fora de um model: agregações, resultados entre tabelas ou DDL durante migrations |
RawSQL(sql, [params]) em .annotate() |
Precisa de uma expressão SQL customizada dentro de uma query ORM |
| Querysets do ORM | Todo o resto — sempre o padrão |
extra() não tem forma segura. Ao contrário de raw(), ele intercala fragmentos de string diretamente na cláusula WHERE no nível do SQL, independente de como você os passa. Substitua qualquer chamada extra() restante:
# Antes — inseguro e obsoleto
Post.objects.extra(where=[f"slug = '{slug}'"])
# Depois — alternativas seguras
Post.objects.filter(slug=slug) # ORM (preferido)
Post.objects.raw("SELECT ... WHERE slug = %s", [slug]) # raw() com parâmetros
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.
Validação de Entrada: A Terceira Camada
A parametrização é a defesa estrutural contra SQL injection. O menor privilégio limita o raio de impacto. A validação de entrada adiciona uma terceira camada: rejeitar dados malformados antes mesmo de chegarem ao banco de dados.
Essa não é uma substituta da parametrização — payloads de SQLi bem elaborados são especificamente projetados para contornar filtros de validação. É defesa em profundidade.
Coerção de tipo via conversores de URL
Os conversores de URL do Django validam e convertem tipos antes mesmo de a view ser chamada:
# urls.py — <int:pk> rejeita qualquer coisa que não seja um inteiro válido
path('blog/<int:pk>/', views.post_detail),
Para IDs recebidos como parâmetros de query, imponha o tipo explicitamente:
def post_by_id(request):
try:
pk = int(request.GET.get('id', ''))
except (ValueError, TypeError):
raise Http404
post = get_object_or_404(Post, pk=pk, publish=True)
return render(request, 'blog/post_detail.html', {'post': post})
Django forms para entradas de texto
Use CharField com max_length e deixe a limpeza do form do Django tratar e validar a entrada:
from django import forms
class SearchForm(forms.Form):
q = forms.CharField(max_length=100, required=False, empty_value='')
def search_posts(request):
form = SearchForm(request.GET)
if not form.is_valid():
return render(request, 'search.html', {'results': []})
query = form.cleaned_data['q']
results = Post.objects.filter(title__icontains=query, publish=True)
return render(request, 'search.html', {'results': results})
Um limite de 100 caracteres não vai parar um atacante determinado — não é para isso que serve. Ele reduz o ruído, limita a complexidade dos payloads e documenta a intenção no nível do código.
| Camada de defesa | O que protege |
|---|---|
| Parametrização | SQL injection — SQL e dados nunca compartilham o mesmo canal |
| Validação de entrada | Tipos malformados e payloads excessivos chegando ao ORM |
| Menor privilégio | Danos catastróficos ao schema se algum bypass for encontrado |
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
No fundo, SQL Injection não é magia negra — é só o que acontece quando ficamos desleixados e deixamos os dados se misturarem às nossas strings de query. Use o ORM, parametrize suas queries raw, e você fecha a porta para toda essa classe de ataques. 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 — e defesa em profundidade significa aplicar as três camadas abordadas aqui: parametrização, validação de entrada e menor privilégio. O Post 2 desta série passa para o lado do navegador: Cross-Site Scripting (XSS), por que mark_safe é tão perigoso quanto uma query não parametrizada, e como renderizar Markdown enviado pelo usuário sem abrir um vetor de XSS.
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: Cross-Site Scripting (XSS): Ataques Armazenados, Refletidos e Baseados em DOM — e Por Que mark_safe e Markdown Inseguro São Igualmente Perigosos