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

TipoComo o atacante obtém dadosIdeia de payload
In-band: UNION-basedAnexa um segundo SELECT cujas linhas aparecem na resposta normal' UNION SELECT email, password_hash FROM users --
In-band: error-basedForça um erro do banco cuja mensagem vaza dadosErros de conversão de tipo que imprimem um valor
Blind: boolean-basedFaz perguntas de sim/não e observa a página mudar' AND 1=1 -- vs ' AND 1=2 --
Blind: time-basedFaz o banco esperar quando uma condição é verdadeiraSLEEP(5) no MySQL, pg_sleep(5) no PostgreSQL
Out-of-bandFaz o banco enviar dados para um servidor externo (DNS ou HTTP)Funções de rede específicas de cada banco
Second-orderO payload é salvo com segurança e usado de forma insegura depoisUm 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,))   # sqlite3

Atençã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() e knex.raw() só são seguros com replacements, bind ou bindings ?, nunca com template strings.
  • Django:Model.objects.raw(), .extra() e cursor.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, joins e find_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 id nã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

  1. 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(.
  2. 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.
  3. 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.
  4. Adicione testes de segurança. Para cada endpoint que acessa o banco, um teste que envia ' OR '1'='1, 1 OR 1=1 e um payload com operador JSON, e verifica que a resposta é 4xx ou vazia, nunca dados a mais.
  5. 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.