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 A05:2025 — Injection | Tempo de leitura: ~19 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 o desenvolvimento de software, desenvolvo aplicações Django há uma década e tenho as certificações CompTIA Network+, Security+ e CySA+. Para cada ataque, construo um pequeno laboratório executável — uma view vulnerável e uma segura — e o escaneio com os analisadores estáticos padrão do mercado que um analista de segurança de fato usa, o Bandit e o Semgrep. 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 e dados de certificados digitais junto com as petições que cada pessoa assinou — e uma assinatura numa petição política revela uma opinião política, que a LGPD classifica como dado pessoal sensível. Uma única query ali que vazasse esses campos poderia ser um incidente que a plataforma precisa comunicar à ANPD e às pessoas afetadas, 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 caixa de busca que procura notas pelo título, montada assim:
SELECT id, title, body FROM notes WHERE title LIKE '%[ENTRADA DO USUÁRIO]%'
O atacante busca por ' UNION SELECT id, username, password FROM auth_user --. A aspa fecha o literal do LIKE, o UNION SELECT acrescenta as linhas de uma segunda consulta, e o -- comenta o %' que sobra no fim. O banco executa:
SELECT id, title, body FROM notes WHERE title LIKE '%' UNION SELECT id, username, password FROM auth_user -- %'
A segunda consulta precisa devolver tantas colunas quanto a primeira, de tipos compatíveis, o que o atacante descobre por tentativa ou lê na própria página. A partir daí os resultados da busca trazem as linhas de uma tabela que a busca nunca deveria tocar, todos os nomes de usuário e hashes de senha, e a página os exibe como se fossem notas.
A mesma string sem separação cede em outras direções também. Um formulário de login montado assim cai com admin' --, que comenta a verificação de senha, ou com uma tautologia como ' OR '1'='1. Quando a página não mostra resultados da consulta, a injeção cega faz perguntas de verdadeiro ou falso e lê a resposta numa página que muda ou numa resposta que demora (pg_sleep()), e a injeção baseada em erro lê dados nas próprias mensagens de erro do banco. Consultas empilhadas acrescentam um segundo comando inteiro depois de um ;. E o próprio ORM do Django já teve suas bordas: a CVE-2025-64459, corrigida no Django 5.2.8, foi um SQL injection por um dicionário malicioso expandido em filter(), get() ou Q().
Incidentes Reais
Heartland Payment Systems (2007–2008)
A partir do fim de 2007, 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 em um formulário web para comprometer a rede corporativa da Heartland, passaram quase seis meses abrindo caminho até a rede separada de processamento de pagamentos e então 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. Pela conspiração da Heartland, Gonzalez foi condenado a 20 anos e um dia de prisão federal, cumpridos simultaneamente a uma pena separada de 20 anos pelas invasões da TJX. 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, embora avaliadores qualificados a tivessem certificado como conforme ao PCI várias vezes enquanto a vulnerabilidade existia — a violação expôs a distância entre passar numa avaliação de conformidade e encontrar um SQL injection explorável. Para desenvolvedores Django, a lição é direta: um único SQL injection num formulário web custou à Heartland acordos com múltiplas partes e sua validação como processadora de pagamentos. Uma consulta parametrizada não é uma otimização de desempenho; é uma proteção técnica central.
Fonte: Heartland Payment Systems: Lessons Learned from a Data Breach
Proteções Padrão do Django
Os lookups padrão do ORM passam os valores fornecidos pelo usuário ao banco como parâmetros, nunca como texto SQL. Essa é a proteção central, e ela para onde o SQL é escrito à mão, no SQL bruto e no extra(), que é onde vivem os padrões abaixo.
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 é analisado primeiro 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. O escaping que você mesmo escreve é uma mitigação, e falha justamente no lugar em que você esqueceu dele; um parâmetro tira essa decisão do seu código por completo.
Quando você escreve:
Post.objects.filter(title=user_input)
Django gera:
SELECT * FROM blog_post WHERE title = %s
…e entrega user_input ao driver do banco de dados como um parâmetro separado. Seu código nunca monta a string SQL. O que o driver faz em seguida é um detalhe que eu tinha errado quando este post foi publicado, então vale ser preciso. Com o psycopg — a versão 2, ou a 3 com as configurações padrão do Django —, o próprio driver converte o valor em um literal SQL corretamente entre aspas e envia um único comando. No PostgreSQL, os dois canais separados que McDonald descreve só chegam ao servidor com o psycopg 3 e "server_side_binding": True nas OPTIONS do banco. A proteção vale de qualquer forma, porque a conversão é feita pelo driver, por tipo, para cada valor, e nunca por uma string que o seu código montou.
O payload de UNION da seção O Ataque é tratado como a string de busca literal que parece ser — não como lógica SQL. O ataque falha estruturalmente, independentemente dos caracteres que a entrada contenha.
O Padrão Vulnerável e a Correção
Padrão 1 — SQL bruto montado com f-string
A view de busca do laboratório faz o que um desenvolvedor com pressa faz quando uma query do ORM parece desajeitada: desce para um cursor e formata o termo de busca dentro do SQL. A correção é a view segura do laboratório, e ela cabe em uma linha:
def search(request):
q = request.GET.get("q", "")
- with connection.cursor() as cursor:
- cursor.execute(
- f"SELECT id, title, body FROM sqli_note WHERE title LIKE '%{q}%'"
- )
- rows = cursor.fetchall()
+ results = Note.objects.filter(title__icontains=q).values_list("id", "title", "body")
Esta é a busca da seção O Ataque, e o payload do laboratório tem a mesma forma: ' UNION SELECT id, 'x', value FROM sqli_flag -- acrescenta as linhas da tabela que guarda a flag CTF. As duas views estão completas no laboratório: views_vulnerable.py e views_secure.py.
À primeira vista, a correção teria o mesmo problema, já que icontains é um LIKE com % dos dois lados do termo. Não tem. No PostgreSQL, o Django gera UPPER("sqli_note"."title"::text) LIKE UPPER(%s) (LIKE %s no MySQL, LIKE %s ESCAPE '\' no SQLite) e passa %q% como parâmetro: os curingas entram no valor, não no SQL. O Django ainda escapa qualquer % ou _ que o usuário tenha digitado, então uma busca por 50% procura exatamente esses três caracteres em vez de virar um curinga próprio.
Padrão 2 — Formatação com % não é placeholder
Concatenação de strings, .format() e Note.objects.raw(f"...") são o mesmo erro com sintaxes diferentes: cada um monta uma única string pronta antes de o banco vê-la. A grafia que engana é a do %, porque a forma segura usa os mesmos dois caracteres:
-cursor.execute("SELECT id, title FROM sqli_note WHERE title = '%s'" % title)
+cursor.execute("SELECT id, title FROM sqli_note WHERE title = %s", [title])
As duas linhas diferem por poucos caracteres e são estruturalmente opostas. O operador % do Python preenche o valor antes de execute() ser chamado, então o driver recebe uma string pronta com a aspa do atacante já lá dentro. Na segunda linha, o %s não é formatação do Python. É um placeholder, e o valor viaja até o driver em uma lista separada, para virar literal lá. Quando você escreve SQL à mão, essa separação é trabalho seu, e a vírgula que entrega [title] à parte é tudo o que ela exige. (Também não ponha aspas em volta do placeholder: a própria documentação do Django aponta '%s' como injetável mesmo quando o valor vai em params.)
Toda porta de entrada para SQL bruto recebe os parâmetros do mesmo jeito: cursor.execute(sql, [params]) para resultados que não são instâncias de model, Note.objects.raw(sql, [params]) quando você quer instâncias de model a partir de um SQL que o ORM não consegue expressar (uma CTE recursiva, por exemplo) e RawSQL(sql, [params]) para uma única expressão calculada dentro de uma query montada pelo ORM. O ORM continua sendo o padrão; os outros três servem para o que ele não sabe dizer.
Padrão 3 — extra() com o valor dentro da string
extra() foi a válvula de escape original do Django para fragmentos de SQL dentro de uma query do ORM, e codebases antigas estão cheias dele. A documentação hoje o chama de "an old API that we aim to deprecate at some point in the future" e diz que bugs nele não são mais corrigidos, então a correção de segurança e a migração são a mesma edição:
-Note.objects.extra(where=[f"title = '{title}'"])
+Note.objects.filter(title=title)
Se você precisar mexer em uma chamada extra() antes de conseguir substituí-la, tire o valor de dentro da string: extra(where=["title = %s"], params=[title]).
Outros dois controles não corrigem nenhum padrão específico. Eles existem para o dia em que alguém esquecer uma das correções acima. O primeiro é o menor privilégio, que McDonald enquadra como defesa em profundidade em cada camada da pilha (Web Security for Developers, Capítulo 6): a conta de banco que a aplicação usa em tempo de execução não é superusuária nem proprietária das tabelas — uma conta separada e privilegiada as cria e roda as migrations — e só tem permissões nas tabelas que a aplicação usa. Um UNION só lê o que essa conta pode ler, então uma tabela sem permissão para nenhuma conta de execução fica fora do alcance de uma injeção que passar.
O segundo é a validação de entrada, que estreita o que chega à query sem substituir a parametrização. Quando a entrada é um número, ela elimina a superfície de ataque por completo. Um conversor de URL <int:pk> responde 404 a tudo que não for dígito, então a view só recebe um int do Python; para um parâmetro de query ?id=, um int() na view faz o mesmo trabalho, desde que você transforme o ValueError dele em um 404. Não sobra string onde injetar. Uma busca de texto livre não pode ser restringida assim, porque precisa aceitar aspas e palavras-chave. Um forms.CharField(max_length=200) ainda entrega uma str aparada e com tamanho limitado em cleaned_data no lugar do dado bruto da requisição, mas a proteção contra injeção continua sendo a query parametrizada por trás dele.
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 é uma terceira camada, voltada ao impacto e não à injeção: ele assume que os dois primeiros falharam e limita o que o atacante consegue ler às tabelas que a própria conta da aplicação pode ler.
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 execução limitada às tabelas de que precisa é a diferença entre expor o que a aplicação guarda e expor todo o resto do servidor de banco. 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
Testando Sua Defesa
Um único teste basta para provar a correção: enviar o payload UNION à view de busca e conferir que a flag não aparece na resposta. Este é o que o laboratório usa:
UNION_PAYLOAD = "' UNION SELECT id, 'x', value FROM sqli_flag -- "
def test_secure_view_does_not_leak_flag(self):
resp = self.client.get("/sql-injection/secure/", {"q": UNION_PAYLOAD})
self.assertEqual(resp.status_code, 200)
self.assertNotContains(resp, "FLAG{") # payload tratado como termo de busca literal
O setUp cria uma nota comum e a linha da flag. A checagem de status sozinha não prova nada, porque a view vulnerável também responde 200; a asserção que importa é a ausência da flag. Aponte o mesmo teste para /sql-injection/vulnerable/ e ele falha, e é isso que o torna um teste do controle, e não da página. No seu projeto, use a URL da sua busca e uma linha que ela nunca deveria devolver. O tests.py do laboratório cobre as outras duas metades: o exploit funciona na view vulnerável, e a view segura continua encontrando os resultados de verdade.
Fazendo a Varredura
O teste unitário prova a correção contra o payload demonstrado. 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 suas regras em modo taint conseguem seguir um valor 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 -r labs/post_01_sql_injection/
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 não é uma regra em modo taint, mas é mais estreita: seus padrões só disparam quando um valor lido de request e a chamada execute() que o recebe estão na mesma função, então sua mensagem aponta a request como fonte:
python.django.security.injection.sql.sql-injection-using-db-cursor-execute.sql-injection-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. A regra do Semgrep é mais precisa — permanece silenciosa a menos que a leitura da request e a chamada execute() apareçam juntas numa mesma função — mas essa mesma precisão significa que ela perde o helper que o Bandit pegaria, porque sem request no escopo não há de onde seus padrões partirem. 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} |
+----+--------------------------------------------------+
Nenhuma das quatro é o UNION que o teste do laboratório envia: o sqlmap achou os caminhos cego, baseado em erro e empilhado citados no fim da seção O Ataque, e leu a tabela por eles. 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.
Para este laboratório, essa é toda a história de detecção, 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 aqui — você precisa rodar as padrão e ler o que cada uma reporta. Um projeto supera as regras de prateleira quando suas queries passam por APIs ou wrappers próprios que os rulesets não modelam, ou num padrão que nenhuma regra genérica enxerga (mass assignment, IDOR, uma forma de escalação de privilégio). É aí que uma regra Semgrep escrita à mão ganha seu lugar — e onde posts posteriores mostram várias. Este laboratório não é esse caso.
O hábito que adquiri ao escrever este post: sempre que uma query estiver sendo montada com f-string, %, + ou .format(), pare e passe o valor como parâmetro. No laboratório, é a única diferença entre as duas views que importa. 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 A05:2025 — 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