SQL injection (SQLi) acontece quando uma aplicação monta uma consulta SQL colando a entrada do usuário dentro da string. O banco de dados não consegue distinguir o que foi escrito pelo desenvolvedor e o que veio do atacante, e o atacante acaba reescrevendo a consulta. É uma das vulnerabilidades web mais antigas e continua em toda parte: o OWASP Top 10:2025 mantém Injection na lista (A05) e aponta mais de 14.000 CVEs ligados só a SQL injection.
A boa notícia é que existe uma correção quase perfeita. Neste guia você vai entender o que é SQL injection, como funciona, os principais tipos, código vulnerável e corrigido em cinco stacks, as armadilhas de ORM que ainda pegam times experientes e como encontrar o problema no seu próprio código.
O que é SQL injection, na prática
Pense em um endpoint de login que monta a consulta assim:
query = "SELECT * FROM users WHERE email = '" + email + "' AND password_hash = '" + hash + "'"Um usuário comum digita [email protected] e tudo funciona. Um atacante digita isto no campo de email:
' OR '1'='1' -- A consulta que chega ao banco fica assim:
SELECT * FROM users WHERE email = '' OR '1'='1' -- ' AND password_hash = '...'A aspa fecha a string antes da hora, OR '1'='1' torna a condição sempre verdadeira e -- transforma a verificação de senha em comentário. O banco devolve todos os usuários e a aplicação autentica o atacante como o primeiro deles, muitas vezes um admin.
A causa raiz é sempre a mesma: dados sendo tratados como código. Escapar aspas na mão, bloquear palavras como UNION ou depender de um WAF só dificultam o ataque. O que resolve de verdade é separar dados de código.
Tipos de SQL injection
| Tipo | Como o atacante obtém dados | Ideia de payload |
|---|---|---|
| In-band: UNION-based | Anexa um segundo SELECT cujas linhas aparecem na resposta normal | ' UNION SELECT email, password_hash FROM users -- |
| In-band: error-based | Força um erro do banco cuja mensagem vaza dados | Erros de conversão de tipo que imprimem um valor |
| Blind: boolean-based | Faz perguntas de sim/não e observa a página mudar | ' AND 1=1 -- vs ' AND 1=2 -- |
| Blind: time-based | Faz o banco esperar quando uma condição é verdadeira | SLEEP(5) no MySQL, pg_sleep(5) no PostgreSQL |
| Out-of-band | Faz o banco enviar dados para um servidor externo (DNS ou HTTP) | Funções de rede específicas de cada banco |
| Second-order | O payload é salvo com segurança e usado de forma insegura depois | Um username como admin' -- reutilizado em outra consulta |
In-band (UNION e error-based)
É o tipo mais fácil de explorar, porque os resultados voltam pelo mesmo canal da resposta normal. Com UNION, o atacante descobre quantas colunas a consulta original retorna e depois anexa UNION SELECT para ler qualquer tabela que o usuário do banco possa acessar. O error-based funciona quando a aplicação exibe erros crus: o atacante monta uma entrada que força uma mensagem de erro contendo o dado que ele quer.
Blind (boolean e time-based)
Mesmo sem erros nem resultados na tela, dá para extrair dados bit a bit. No boolean-based, o atacante compara respostas: se AND 1=1 mostra a página do produto e AND 1=2 mostra "não encontrado", ele pode perguntar coisas como "o primeiro caractere do hash do admin é maior que m?". No time-based, usa atrasos: se a resposta demora cinco segundos, a resposta é sim. Na mão é lento, automatizado é trivial. Por isso "não mostramos erros" não é defesa.
Second-order
É o tipo que escapa dos code reviews. A entrada é salva corretamente, por exemplo com um INSERT parametrizado. Depois, outra parte do código (um job em background, um relatório de admin, o fluxo de reset de senha) lê esse valor do banco e o concatena em uma nova consulta, confiando porque "veio do nosso banco". Todo valor que veio originalmente de um usuário é não confiável, não importa de onde você o leia.
Exemplos: código vulnerável vs corrigido
A correção é a mesma em qualquer linguagem: consultas parametrizadas (prepared statements ou bind variables). A estrutura da consulta vai primeiro para o banco e os valores vão separados, então nunca conseguem alterar a consulta.
Node.js (pg / mysql2)
// Vulnerável
const { rows } = await db.query(
`SELECT * FROM orders WHERE user_id = ${req.params.id}`
);
// Corrigido (pg usa $1, $2... ; mysql2 usa ?)
const { rows } = await db.query(
"SELECT * FROM orders WHERE user_id = $1",
[req.params.id]
);Python (psycopg / sqlite3)
# Vulnerável: f-strings e formatação com % montam a string SQL
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")
# Corrigido: valores passados como segundo argumento
cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # psycopg
cur.execute("SELECT * FROM users WHERE email = ?", (email,)) # sqlite3Atenção à armadilha sutil: cur.execute("... = %s" % email) parece parametrizado, mas é formatação de string comum. O valor precisa ir como argumento separado.
Ruby on Rails (ActiveRecord)
# Vulnerável: interpolação dentro de um fragmento SQL
User.where("email = '#{params[:email]}'")
# Corrigido: condições em hash ou placeholders
User.where(email: params[:email])
User.where("email = ?", params[:email])
# Identificadores não podem ser parametrizados: allowlist
SORTABLE = %w[created_at name].freeze
column = SORTABLE.include?(params[:sort]) ? params[:sort] : "created_at"
User.order(column)Versões recentes do Rails rejeitam muitas strings de SQL cru em order, mas find_by_sql, pluck com Arel.sql, joins com strings e exists? com strings continuam fáceis de usar errado.
PHP (PDO)
// Vulnerável
$result = $pdo->query("SELECT * FROM users WHERE id = " . $_GET['id']);
// Corrigido
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_GET['id']]);
$user = $stmt->fetch();mysqli_real_escape_string não substitui parâmetros: não faz nada em contextos numéricos como WHERE id = 1 OR 1=1, onde não há aspas para escapar.
Java (JDBC e JPA)
// Vulnerável
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(
"SELECT * FROM accounts WHERE owner = '" + owner + "'");
// Corrigido
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM accounts WHERE owner = ?");
ps.setString(1, owner);
ResultSet rs = ps.executeQuery();
// JPA: JPQL também é injetável se você concatenar
List<Account> list = em.createQuery(
"SELECT a FROM Account a WHERE a.owner = :owner", Account.class)
.setParameter("owner", owner)
.getResultList();Armadilhas de ORM: quando "usamos ORM" deixa de proteger
ORMs parametrizam valores quando você usa o query builder. O problema é que todos têm uma saída para SQL cru, e é por ali que o SQL injection volta:
- Prisma:
$queryRaw`... ${id}`(tagged template) é parametrizado.$queryRawUnsafe("... " + id)não é. - Sequelize / Knex:
sequelize.query()eknex.raw()só são seguros comreplacements,bindou bindings?, nunca com template strings. - Django:
Model.objects.raw(),.extra()ecursor.execute()precisam da lista de params.raw(f"...")é vulnerável. - SQLAlchemy:
text("... WHERE id = :id")com params vinculados está ok;text(f"... {id}")não. - ActiveRecord: condições em string em
where,order,group,having,joinsefind_by_sql.
Mais duas armadilhas. Primeiro, identificadores não podem ser parametrizados: nomes de tabelas, colunas e direção de ordenação (ASC/DESC) devem ser validados contra uma allowlist. Segundo, stored procedures não são seguras automaticamente: uma procedure que monta SQL dinâmico concatenando por dentro é tão injetável quanto.
NoSQL injection, rapidamente
Migrar para MongoDB não elimina a injeção, só muda o formato. O caso clássico em Node.js é a injeção de operadores:
// Vulnerável: req.body.password pode ser um objeto como {"$ne": null}
const user = await User.findOne({
email: req.body.email,
password: req.body.password
});
// Corrigido: validar tipos antes de consultar
if (typeof req.body.email !== "string" || typeof req.body.password !== "string") {
return res.status(400).end();
}Se o body é JSON, o atacante pode enviar {"email": "[email protected]", "password": {"$ne": null}} e bater com qualquer senha. Valide a entrada com um schema (Zod, Joi, JSON Schema), converta valores para o tipo esperado e descarte chaves que começam com $. Evite $where e qualquer coisa que avalie JavaScript no servidor.
Defesa em profundidade
- Privilégio mínimo: o usuário de banco da aplicação não deveria conseguir apagar tabelas, ler outros schemas nem executar funções de sistema.
- Erros genéricos: registre os erros do banco no servidor e devolva uma mensagem genérica. Isso acaba com a extração error-based.
- Validação de entrada: rejeite cedo um
idnão numérico. Não substitui a parametrização, mas reduz a superfície de ataque. - WAF é cinto de segurança, não correção: bloqueia payloads comuns, mas truques de encoding e técnicas blind passam. Corrija o código.
Como encontrar SQL injection no seu código
- Procure os padrões óbvios. Palavras-chave SQL ao lado de
+, template literals, f-strings, formatação com%ou#{}, e métodos crus:queryRawUnsafe,.raw(,find_by_sql,createStatement,executeQuery,.extra(. - Revise o fluxo de dados, não só linhas soltas. O código perigoso costuma ser um helper que monta o WHERE a três arquivos de distância do controller. Um secure code review segue a entrada desde o request até a consulta.
- Rode SAST. A análise estática rastreia a entrada contaminada até o banco através de vários arquivos e pega o que um humano deixa passar. Se a diferença não estiver clara, leia SAST e DAST.
- Adicione testes de segurança. Para cada endpoint que acessa o banco, um teste que envia
' OR '1'='1,1 OR 1=1e um payload com operador JSON, e verifica que a resposta é 4xx ou vazia, nunca dados a mais. - Teste a aplicação rodando. Ferramentas DAST e o sqlmap confirmam se é explorável, mas use-as apenas em sistemas seus ou com autorização explícita.
Para automatizar os três primeiros passos, escaneie seu repositório em busca de vulnerabilidades. Com a Nurbak você conecta o GitHub e escaneia um repo; o modelo de IA próprio e self-hosted da Nurbak analisa o código (a análise não envia seu código para OpenAI nem Anthropic) e aponta problemas exploráveis como SQL injection com o arquivo e a linha exatos, além de uma explicação em linguagem simples. Ela também pode abrir um Pull Request com a correção e um teste de regressão de segurança. O scan gratuito mostra completos os 3 achados mais importantes.
Checklist de prevenção de SQL injection
- Toda consulta usa parâmetros ou o query builder do ORM. Zero concatenação ou interpolação em SQL.
- Métodos crus (
$queryRawUnsafe,raw(),find_by_sql,text()) foram revisados e usam params vinculados. - Nomes de colunas, tabelas e direção de ordenação vêm de uma allowlist.
- Valores lidos do seu próprio banco são tratados como não confiáveis ao serem reutilizados (second-order).
- Consultas NoSQL validam tipos e rejeitam operadores
$vindos do usuário. - O usuário de banco tem privilégio mínimo; produção não conecta como superusuário.
- Erros crus do banco nunca chegam ao cliente.
- O CI roda SAST em cada pull request e há testes de regressão com payloads de injeção.
- Dependências (drivers, ORMs) estão atualizadas e verificadas contra CVEs conhecidos.
SQL injection é só uma peça do quebra-cabeça. Veja o OWASP Top 10 2025 para as outras categorias, o que é DevSecOps para colocar esses controles no seu pipeline e o que é pentest para entender como um atacante encadeia SQLi com outras falhas. Quando quiser, encontre as vulnerabilidades do seu código antes de outra pessoa.
