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: ~18 min
🧪 Rode você mesmo. Este ataque tem um laboratório complementar no django-security-lab: uma view Django vulnerável e uma segura. Os analisadores estáticos — o Bandit e o conjunto de regras Django da comunidade Semgrep — as escaneiam em CI a cada commit (ambos sinalizam a vulnerável e ficam silenciosos na segura), e uma execução do sqlmap contra o laboratório no ar extrai uma flag CTF direto pela injeção. Clone o repositório e reproduza cada passo.
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. Sou advogado de formação, fiz a transição para desenvolvimento de software, obtive as certificações CompTIA Network+, Security+ e CySA+ e agora construo aplicações Django em produção. Para cada ataque, construo um pequeno laboratório executável — uma view vulnerável e uma segura — e o escaneio com as ferramentas padrão do mercado que um analista de segurança de fato usa: analisadores estáticos como o Bandit e o Semgrep e, quando a classe justifica, um scanner dinâmico como o sqlmap ou o OWASP ZAP. Tudo isso é publicado no repositório complementar django-security-lab, com seus testes verdes em CI. Escrevo estes posts para consolidar o que estou aprendendo e publico, junto com os laboratórios, para que outros no mesmo caminho possam clonar o repositório e reexecutar tudo por conta própria. 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 — e onde algo me surpreendeu. Eu mantenho o Petição Brasil, uma plataforma civic-tech para petições públicas com validade jurídica via assinatura digital ICP-Brasil. A plataforma armazena CPFs, dados de certificados digitais e opiniões políticas — todos classificados como dados pessoais sensíveis pela LGPD. Uma única query ali que vazasse esses campos seria um incidente reportável, não apenas um bug, e é essa consequência que me fez querer entender por quê o ORM do Django mantém o caminho seguro como padrão — o que exatamente o ORM faz, e o que seria necessário para acidentalmente sair dessa proteção. Então construí o laboratório para este caso e tracei exatamente onde essa proteção se mantém e onde uma única linha descuidada a quebra.
SQL Injection está presente em todas as edições do OWASP Top 10 desde 2003. No relatório de 2021, 94% das aplicações foram testadas para alguma forma de injeção — a categoria mais amplamente avaliada do conjunto de dados, com taxa de incidência média de 3,37% e picos chegando a 19%. Duas décadas de vazamentos, 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. Neste post, vou detalhar 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.
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. O ponto de entrada não foi um exploit sofisticado — foi um SQL injection básico em um formulário web, o mesmo padrão de vulnerabilidade que aparece em tutoriais introdutórios 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.
Em Web Security for Developers (Capítulo 6: Injection Attacks), Malcolm McDonald define uma consulta parametrizada como aquela em que o código SQL e os valores dos dados são enviados ao banco de dados por canais completamente separados. O template da query é compilado primeiro — o banco analisa sua estrutura e monta um plano de execução — e os valores dos dados são vinculados depois. Como o banco já decidiu o que é código e o que é dado antes de os valores chegarem, é estruturalmente impossível que a entrada do usuário altere a lógica da query, independentemente dos caracteres que ela contenha. McDonald contrasta essa abordagem com as técnicas de construção de strings (concatenação, f-strings, formatação com %), nas quais código e dado compartilham um único canal e o banco não consegue distinguir um do outro. Ele descreve a parametrização não como uma técnica de sanitização, mas como uma separação arquitetural — a causa raiz do SQL Injection é removida, não remendada.
Essa distinção é importante. Escapar caracteres especiais é uma mitigação; a parametrização elimina a classe de vulnerabilidade por completo ao tornar a superfície de ataque estruturalmente inalcançável.
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). 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
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}'")
O que todos esses padrões têm em comum — e por que diferem da parametrização
Todo padrão acima tem o mesmo problema de raiz: todos constroem uma única string que mistura sintaxe SQL com dados fornecidos pelo usuário antes de enviá-la ao banco de dados. Concatenação de string, f-strings, formatação com % e .format() são apenas sintaxes Python diferentes para o mesmo erro — produzem uma string SQL final em que código e dado já estão fundidos. Quando essa string chega ao banco, o parser não consegue distinguir quais caracteres foram escritos pelo desenvolvedor e quais foram fornecidos pelo usuário. Uma aspas, um marcador de comentário ou a palavra-chave UNION na entrada é idêntica aos mesmos caracteres no seu próprio SQL.
A parametrização funciona de forma diferente em cada etapa:
- O template SQL é enviado primeiro, sem nenhum dado do usuário. O driver do banco transmite uma query como
WHERE slug = %s— uma estrutura com placeholders tipados, não valores preenchidos. O banco faz o parse dessa estrutura e monta o plano de execução antes de ter visto qualquer entrada do usuário. - Os valores são enviados separadamente, por um segundo canal. O driver transmite então os valores dos parâmetros como dados tipados. O banco os vincula aos placeholders que já processou — mas nesse ponto, o parse já terminou. Não existe etapa em que a entrada do usuário possa influenciar a estrutura da query, pois ela foi finalizada antes de os dados chegarem.
- O mecanismo do banco impõe a fronteira. Mesmo que um valor contenha
' OR '1'='1,--ou um comandoUNION SELECTcompleto, o mecanismo trata o valor inteiro como um escalar — uma string a ser comparada, não sintaxe a ser executada.
A distinção é mais importante para a confusão com %s: o operador % do Python ("WHERE slug = '%s'" % slug) e cursor.execute("WHERE slug = %s", [slug]) se parecem superficialmente, mas são estruturalmente opostos. O operador % produz uma string finalizada em Python — o banco recebe um único canal. cursor.execute() com lista de parâmetros transmite template e dados por canais separados no nível do protocolo — o banco recebe dois. Usar formatação com % em uma string SQL bruta antes de passá-la para execute() é exatamente tão perigoso quanto um f-string.
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). Vale ser preciso sobre o que acontece com os curingas %, porque esse é um ponto fácil de confundir com o padrão vulnerável.
O Django não gera ILIKE '%{query}%' — isso seria idêntico à vulnerabilidade de f-string que acabamos de ver. Em vez disso, ele gera ILIKE %s como template SQL e passa %query% como o valor do parâmetro. Os curingas são adicionados ao dado, não ao SQL. O banco recebe uma estrutura de query contendo um único placeholder tipado, e separadamente recebe a string %query% para vincular a ele. Mesmo que query contenha uma aspas, um comentário ou UNION SELECT, esse conteúdo faz parte do valor vinculado — a estrutura da query já foi finalizada antes de o valor chegar.
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. Quando isso ocorrer, sempre use placeholders %s e passe os valores como uma lista separada — nunca os interpole na string SQL.
O %s em uma chamada cursor.execute() não é formatação de string do Python. É um sinal ao driver do banco de dados de que um parâmetro chegará pelo segundo argumento. O driver transmite o template SQL e a lista de parâmetros de forma independente pelo protocolo de comunicação com o banco. O mecanismo do banco os recebe em campos separados do protocolo, faz o parse da estrutura da query primeiro e vincula os valores depois — a mesma garantia de dois canais que o ORM fornece automaticamente. A única diferença é que aqui você está escrevendo o template SQL manualmente, então a responsabilidade de manter código e dados separados recai sobre você.
# 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)
Quando Usar raw() — e Como Usá-lo com Segurança
raw() deve ser um último recurso. O ORM deve lidar com a grande maioria das queries, e cursor.execute() cobre qualquer coisa que precise de resultados fora de um model. raw() ocupa uma zona intermediária estreita: você precisa escrever SQL diretamente, mas também quer de volta um queryset de instâncias do model em vez de tuplas brutas. Motivos legítimos comuns:
- Recursos específicos do banco que o ORM não consegue expressar — window functions do PostgreSQL, CTEs, lateral joins, operadores JSONB
- Busca full-text com ranking —
ts_rankeplainto_tsquerycomo no exemplo acima - Queries em que o ORM gera um plano ineficiente e você precisa controlar o SQL exato após profiling
As formas segura e insegura seguem exatamente a mesma lógica de cursor.execute():
# INSEGURO — formatação de string produz uma string SQL finalizada (canal único)
Post.objects.raw(f"SELECT ... WHERE slug = '{slug}'")
# SEGURO — template SQL + lista de parâmetros (dois canais)
Post.objects.raw(
"SELECT id, title, slug FROM blog_post WHERE slug = %s AND publish = TRUE",
[slug] # ← passado separadamente, nunca interpolado na string SQL
)
Na forma insegura, slug é incorporado na string antes de raw() vê-la — o banco recebe um único canal, e o mesmo risco de injeção de um f-string se aplica. Na forma segura, o driver transmite o template SQL e a lista de parâmetros de forma independente; o banco já fez o parse da estrutura da query antes de o valor chegar.
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() — Quando Você o Encontra e Por Que Substituí-lo
extra() era a válvula de escape original do Django para queries que o ORM não conseguia expressar — injetando fragmentos SQL brutos na cláusula WHERE, na lista SELECT ou no ORDER BY de um queryset ORM. Você o encontrará em codebases mais antigas filtrando por colunas computadas pelo banco, adicionando predicados específicos do banco, ou contornando limitações de versões antigas do Django.
Obsoleto. O time do Django declara explicitamente que não está mais melhorando nem corrigindo bugs em
extra()e que ele está direcionado para remoção futura. Construir sobre ele significa quebras sem aviso em uma versão futura do Django.
Todo caso de uso que extra() cobre tem uma alternativa suportada:
# Antes — obsoleto
Post.objects.extra(where=["slug = %s"], params=[slug])
# Depois
Post.objects.filter(slug=slug) # ORM (preferido)
Post.objects.raw("SELECT ... WHERE slug = %s", [slug]) # raw() para queries complexas
Post.objects.annotate(val=RawSQL("...", [param])) # RawSQL() para colunas computadas
Substitua qualquer chamada extra() que encontrar. Se precisar tocar em uma antes de migrá-la, nunca embuta valores diretamente na string where — sempre use placeholders %s com uma lista params separada.
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
A coerção de tipo é a forma mais forte de validação de entrada contra SQL injection porque elimina completamente a superfície de ataque para uma determinada entrada. SQL injection exige que o atacante forneça caracteres como ', --, espaços e palavras-chave como UNION ou SELECT. Um valor restrito a conter apenas dígitos decimais — pelo sistema de tipos, não por um filtro — não pode conter nenhum desses caracteres. Não há nada para injetar.
Os conversores de URL do Django aplicam essa restrição na camada de roteamento, 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),
Se o segmento da URL não for uma sequência de dígitos, o Django retorna 404 e a view nunca executa. O valor pk que chega em post_detail já é um int Python — não uma string, não dados brutos da requisição. Não existe vetor de SQL injection aqui porque não há string para injetar.
Para IDs recebidos como parâmetros de query (?id=42), o conversor de URL não se aplica, então imponha o tipo explicitamente na view:
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})
int() lança ValueError para qualquer entrada que não seja um inteiro válido — ' OR '1'='1, 1; DROP TABLE ou mesmo 1.5 falham antes de tocar o banco. Se o parâmetro id estiver ausente da query string, request.GET.get('id', '') retorna '', e int('') também lança ValueError — a view retorna 404, que é o comportamento correto. O ORM então recebe um inteiro Python puro e o parametriza com segurança. Duas camadas independentes precisam falhar ao mesmo tempo para que qualquer exploit chegue ao banco de dados.
Django forms para entradas de texto livre
Para campos de busca e outras entradas de texto livre, não é possível eliminar a superfície de ataque da forma que a coerção de tipo faz para inteiros — uma query de busca é uma string e precisa aceitar qualquer caractere. Aqui a defesa está em garantir que a view nunca opere diretamente sobre dados brutos da requisição.
O framework de forms do Django fornece um pipeline de limpeza que já está embutido no framework — você não o implementa, apenas declara o que espera e o Django aplica. Ao chamar form.is_valid(), o Django passa cada campo pelos seus validadores e coerções de tipo embutidos em sequência. Somente se todos os campos passarem é que cleaned_data fica disponível. cleaned_data sempre contém valores Python normalizados, nunca strings brutas de request.GET ou request.POST.
Para um campo de busca:
from django import forms
class SearchForm(forms.Form):
q = forms.CharField(max_length=200, 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'] # ← str Python normalizado, não dado bruto da requisição
results = Post.objects.filter(title__icontains=query, publish=True)
return render(request, 'search.html', {'results': results})
forms.CharField faz o seguinte automaticamente: remove espaços no início e no fim, converte o valor para str Python, aplica max_length, e substitui um valor vazio ou ausente pelo empty_value declarado ('' aqui). O ORM recebe então uma string Python limpa e a parametriza — nenhum dado bruto da requisição toca a query. O form não substitui a parametrização; é uma barreira que garante que você sempre trabalha com um valor normalizado ao chegar na chamada ao ORM.
A Visão do Analista
Tudo acima é trabalho de desenvolvedor, mas o material do CySA+ enquadra isso num vocabulário que vale emprestar — principalmente porque muda a forma como você defende esse trabalho. A parametrização é um controle preventivo: ela não detecta SQL injection nem reduz seu impacto, ela elimina a classe de vulnerabilidade, tornando o ataque estruturalmente inalcançável. A validação de entrada é uma segunda camada preventiva, mais fraca sozinha — um payload bem construído passa por um filtro — mas valiosa porque a coerção de tipo elimina completamente a superfície de ataque em entradas numéricas, em vez de apenas estreitá-la. O menor privilégio não é nenhum dos dois: é um controle compensatório. Ele assume que os dois primeiros falharam e limita o que o atacante alcança — sem DROP, sem alteração de schema, apenas os dados que a aplicação já acessa.
Essa taxonomia é a metade prática da "defesa em profundidade", e é por isso que vale manter as três camadas mesmo que a primeira, sozinha, já seja estruturalmente suficiente. Cada uma se sustenta quando as outras são contornadas, então um analista revisando este código não para em "a query está parametrizada". A pergunta é o que acontece quando um controle falha — e uma conta de banco restrita a SELECT, INSERT, UPDATE e DELETE é a diferença entre um incidente feio de exposição de dados e uma tabela derrubada. O que a lista deixa de fora diz tanto quanto o que está nela. Um web application firewall é o controle que a maioria dos times aciona primeiro e, no vocabulário do analista, ele mitiga em vez de remediar — faz correspondência de padrões na borda, o que ganha tempo e pode ser contornado por truques de codificação, quebra com comentários ou um endpoint que as regras nunca cobriram. A consequência prática: quando uma varredura reporta esse achado e alguém responde "o WAF bloqueia isso", o achado não é fechado. Ele ganha um controle compensatório e continua aberto até a query ser parametrizada. E nenhuma das três camadas avisa que houve uma tentativa — isso é um controle detectivo, e ele mora em logging e monitoramento, não na camada de query.
Detectando Automaticamente
Três camadas de defesa só valem o quanto você consegue provar que estão ativas e flagrar o momento em que alguém silenciosamente sai delas. Esta seção é a casa durável disso: o teste que você escreve para provar o controle no seu próprio código e a análise estática que sinaliza o padrão perigoso em todo um codebase antes que ele chegue à produção.
Testando Sua Defesa
# 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_vaza_dados_via_view(self):
"""Um payload UNION não deve fazer linhas de blog_post aparecerem nos resultados de busca.
O payload injeta um UNION contra a própria blog_post, então se a injeção
fosse interpretada como SQL retornaria todas as linhas — incluindo o post do setUp.
O ORM trata a string inteira como termo de busca literal, então nenhuma linha corresponde."""
client = Client()
payload = "x' UNION SELECT id, title, slug FROM blog_post--"
response = client.get('/pt/blog/', {'q': payload})
self.assertEqual(response.status_code, 200)
# O payload aponta para blog_post diretamente via UNION, então se fosse interpretado
# como SQL, 'post-normal' apareceria nos resultados. Sua ausência prova que a
# parametrização tratou o payload inteiro como string literal, não como sintaxe SQL.
result_slugs = [p.slug for p in response.context['results']]
self.assertNotIn('post-normal', result_slugs)
def test_view_trata_injecao_com_seguranca(self):
"""HTTP 200 sozinho não prova nada — uma view vulnerável também retorna 200.
Uma tautologia como ' OR '1'='1 corresponderia a todas as linhas se interpretada como SQL.
Confirme que foi tratada como literal: o post do setUp não deve aparecer nos resultados."""
client = Client()
response = client.get('/pt/blog/', {'q': "' OR '1'='1"})
self.assertEqual(response.status_code, 200)
# Se a tautologia tivesse sido interpretada como SQL, todos os posts corresponderiam —
# incluindo o criado no setUp. Sua ausência prova que a entrada foi tratada
# como string literal, não como lógica SQL.
result_slugs = [p.slug for p in response.context['results']]
self.assertNotIn('post-normal', result_slugs)
Fazendo a Varredura
O teste unitário prova que o seu código está seguro. A varredura é a outra metade — e é o trabalho do analista no sentido do CySA+: não escrever a lógica de detecção, mas rodar as ferramentas de prateleira e ler o que elas dizem. Dois tipos servem para esta classe. A análise estática (SAST) lê o código-fonte e sinaliza a construção perigosa antes que ela vá para produção. A análise dinâmica (DAST) ataca a aplicação em execução do jeito que um atacante real faria. Rodei as duas contra o laboratório versionado, para que cada resultado abaixo seja reproduzível a partir de um clone.
Lendo o código-fonte — Bandit e Semgrep
As duas são analisadores estáticos: leem o código-fonte sem executá-lo. O Bandit, do projeto PyCQA, percorre a árvore sintática de um arquivo Python procurando construções sabidamente arriscadas — cada verificação tem um número B, e SQL montado por formatação de string é o B608. Não precisa de configuração; você o aponta para um caminho e ele reporta. O Semgrep é um scanner de padrões multilíngue: em vez de casar texto, ele casa código por estrutura (uma f-string dentro de uma chamada execute(), independentemente de nomes de variáveis ou espaçamento) e consegue seguir um valor contaminado (taint) de onde ele entra no programa até onde é usado. Você o roda contra um ruleset; seus packs da comunidade são rulesets curados e mantidos pela comunidade, e o p/django é o do Django. Apontei os dois para as duas views do laboratório:
bandit labs/post_01_sql_injection/views_vulnerable.py
semgrep scan --config p/django labs/post_01_sql_injection/
Ambos pegam a view vulnerável. O Bandit sinaliza a f-string como B608:
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
Severity: Medium Confidence: Medium
CWE: CWE-89 (https://cwe.mitre.org/data/definitions/89.html)
Location: labs/post_01_sql_injection/views_vulnerable.py:26:14
25 cursor.execute(
26 f"SELECT id, title, body FROM sqli_note WHERE title LIKE '%{q}%'"
)
O Semgrep chega à mesma linha por outro caminho. O B608 do Bandit é uma heurística: sinaliza qualquer query montada com f-string, %, .format() ou +, seja ou não que a entrada do usuário chegue até ela. A regra Django da comunidade no Semgrep é baseada em taint — dispara apenas quando consegue traçar um caminho de uma fonte (dados da request) até um sink (cursor.execute()), então sua mensagem nomeia o fluxo real:
python.django.security.injection.sql.sql-injection-using-db-cursor-execute
User-controlled data from a request is passed to 'execute()'. This could lead to a SQL
injection ... Instead, use django's QuerySets, which are built with query parameterization.
20┆ q = request.GET.get("q", "")
...
26┆ f"SELECT id, title, body FROM sqli_note WHERE title LIKE '%{q}%'"
As duas são complementares, não redundantes. A heurística do Bandit lança a rede mais ampla — vai sinalizar uma string SQL construída até mesmo em uma função helper sem nenhum request à vista, que é exatamente o código que você encontra assim que uma view repassa a entrada do usuário para uma camada de repositório ou serviço; o custo é o eventual falso positivo em SQL constante que nunca carrega dados do usuário. O rastreamento de taint do Semgrep é mais preciso — permanece silencioso a menos que exista um caminho real de request até o sink — mas essa mesma precisão significa que ele pode perder o helper que o Bandit pegaria, porque sem request no escopo não há fonte de onde rastrear. Na view segura com ORM ambos reportam zero achados: nenhuma string SQL crua é construída, então não há o que sinalizar. As execuções completas estão versionadas no laboratório em scans/.
Atacando ao vivo — sqlmap
A análise estática lê o código e raciocina sobre ele; o sqlmap é análise dinâmica — ele nunca vê o código-fonte. É uma ferramenta automatizada de SQL injection que dispara payloads elaborados contra um endpoint ao vivo, observa como as respostas mudam e, uma vez encontrado um parâmetro injetável, consegue fingerprintar o banco e extrair tabelas dele. Isso o torna o mais próximo do que um atacante real faz, e a prova de que o bug não é só teórico.
Dei o comando óbvio — apontar para o endpoint, entregar o parâmetro e deixar rodar:
sqlmap -u 'http://127.0.0.1:8000/sql-injection/vulnerable/?q=1' \
-p q --batch --dump -T sqli_flag
Não precisa de ajuste nenhum. O sqlmap identifica o back-end como PostgreSQL nas primeiras sondas, confirma que q é injetável de quatro formas diferentes e então entra direto na única tabela que esta busca não tem por que tocar:
Parameter: q (GET)
Type: boolean-based blind
Title: PostgreSQL AND boolean-based blind - WHERE or HAVING clause (CAST)
Payload: q=1' AND (SELECT (CASE WHEN (1267=1267) THEN NULL ELSE CAST((CHR(89)||CHR(69)||CHR(100)||CHR(107)) AS NUMERIC) END)) IS NULL-- iJxI
Type: error-based
Title: PostgreSQL AND error-based - WHERE or HAVING clause
Payload: q=1' AND 7677=CAST((…(SELECT (CASE WHEN (7677=7677) THEN 1 ELSE 0 END))::text…) AS NUMERIC)-- XUkB
Type: stacked queries
Title: PostgreSQL > 8.1 stacked queries (comment)
Payload: q=1';SELECT PG_SLEEP(5)--
Type: time-based blind
Title: PostgreSQL > 8.1 AND time-based blind
Payload: q=1' AND 4539=(SELECT 4539 FROM PG_SLEEP(5))-- hnyv
back-end DBMS: PostgreSQL
Table: sqli_flag
[1 entry]
+----+--------------------------------------------------+
| id | value |
+----+--------------------------------------------------+
| 1 | FLAG{sql_injection_via_raw_string_interpolation} |
+----+--------------------------------------------------+
Cada técnica é uma forma diferente de fazer ao banco uma pergunta que a caixa de busca nunca deveria responder: PG_SLEEP(5) faz o servidor pausar cinco segundos quando uma condição é verdadeira (cega, cronometrada), os payloads com CAST(... AS NUMERIC) forçam a resposta a sair por uma mensagem de erro, e o ;SELECT empilhado entra na mesma conexão. Aponte o mesmo comando para /sql-injection/secure/ e o sqlmap desiste — "all tested parameters do not appear to be injectable" — porque a parametrização do ORM não lhe dá nada com que trabalhar. Um artefato honesto que vale nomear: a view de SQL cru retorna HTTP 500 nas sondas malformadas do sqlmap (aspas desbalanceadas viram erros de sintaxe no banco), que o sqlmap registra enquanto roda — em si um sinal de que a view está entregando entrada em formato de ataque direto ao banco. As duas execuções — a extração bem-sucedida e a varredura limpa da view segura — estão versionadas em scans/, para que você possa lê-las sem subir nada.
Essa é toda a história de detecção desta classe, e ela não exige nenhum heroísmo: SQL injection é antiga, bem compreendida e coberta de ponta a ponta por ferramentas que um analista já tem à mão — o SAST lê o código, o DAST prova o exploit. Você não precisa escrever uma regra personalizada para pegá-la — você precisa rodar as padrão e ler o que cada uma reporta. Um projeto só supera as regras de prateleira em um padrão genuinamente específico do Django que nem o Bandit nem os rulesets da comunidade modelam (mass assignment, IDOR, uma forma de escalação de privilégio). É aí que uma regra Semgrep escrita à mão ganha seu lugar — e onde um post posterior mostrará uma. SQLi não é esse caso.
A regra que tirei ao escrever este post é simples: SQL e entrada do usuário nunca devem compartilhar o mesmo canal. O ORM do Django impõe isso por padrão — 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 strings em uma consulta ao banco, você sai dessa garantia. Defesa em profundidade significa aplicar as três camadas: parametrização, validação de entrada e menor privilégio. O Post 2 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
- django-security-lab — o laboratório de SQL Injection (views vulnerável/segura executáveis + flag CTF)
- 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
- Semgrep — análise estática baseada em padrões (rulesets da comunidade)
- Bandit — B608: expressões SQL hardcoded
- 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)
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