Skip to content
This page has been auto-translated and may contain errors.View in English

Injeção de SQL

Uma verificação de login é uma pergunta feita ao banco de dados. Existe uma linha em que o nome de usuário e a senha correspondem ao que foi digitado?

Vulnerable
js
const query = `
  SELECT * FROM users
  WHERE username = '${username}' AND password = '${password}'
`

Uma linha retorna, as credenciais estavam corretas. Nenhuma linha, elas não estavam.

Isso parece uma pergunta. Para o banco de dados, é uma frase, e o visitante teve a chance de escrever parte dela.

Uma query construída colando strings permite que quem a envia decida o que ela significa.

O bug

SQL é uma linguagem, e essa query é um programa escrito do zero a cada requisição. A maior parte vem de você. Duas partes vêm de quem estiver no teclado.

O banco de dados nunca vê essa costura. Quando a query chega, ela é um único pedaço contínuo de texto, e o banco não tem como saber quais caracteres você escreveu e quais foram escritos por um estranho. Ele interpreta tudo e faz o que está escrito.

Isso é injeção de SQL: uma entrada que é interpretada como parte da query em vez de como um valor dentro dela. As consequências decorrem de tudo o que SQL é capaz de expressar, que é bastante coisa:

  • Ler linhas que quem fez a requisição jamais deveria ver.
  • Alterar ou apagar dados.
  • Executar comandos administrativos contra o sistema de banco de dados.
  • Em algumas configurações, alcançar o sistema operacional por trás do banco.
JunoO bug A template literal é o que torna isso difícil de perceber. Parece que você está preenchendo uma lacuna, como faria em uma frase.

O que você está realmente fazendo é entregar ao banco de dados uma frase já pronta, na esperança de que as palavras fornecidas por outra pessoa sejam apenas palavras.

JunoO bug Na revisão de código, o sinal de alerta é qualquer query montada com concatenação de strings ou uma template literal carregando uma variável. Procure por crases e por + perto de SELECT, INSERT, UPDATE e DELETE.

Estar logado também não ajuda. Um usuário autenticado enviando um valor malicioso é o mesmo bug, e muitas vezes ele tem acesso a tabelas ainda mais interessantes.

JunoO bug Essa classe de bug é mais ampla do que SQL, e reconhecer o padrão é o que realmente se aplica em outros contextos. Toda vez que um valor cruza a fronteira de dado para uma linguagem com um parser, é esse parser que decide o que o valor significa: um comando de shell, um documento XML, um template, um caminho de arquivo.

A injeção de segunda ordem é a variante que sobrevive a uma correção parcial. Um valor é armazenado com segurança por meio de um insert parametrizado, e depois lido novamente e concatenado em outra query por um código que assume que tudo o que já está no banco é confiável.

O payload fica inerte em uma linha por meses e é disparado a partir de uma rotina de relatório. Parametrize todas as queries, não apenas as que lidam diretamente com entrada do usuário.

O ataque

Deixe a senha em paz. Digite isto como nome de usuário:

text
' OR 1=1 --

Substituído no template, o banco de dados recebe:

sql
SELECT * FROM users
WHERE username = '' OR 1=1 --' AND password = ''

Três caracteres fizeram todo o trabalho:

  1. A ' inicial fecha a string do nome de usuário antes da hora, então tudo que vem depois é lido como sintaxe de query, e não como um nome.
  2. OR 1=1 é uma condição sempre verdadeira, então a cláusula WHERE inteira é satisfeita para todas as linhas.
  3. -- inicia um comentário SQL, então a verificação de senha que vem depois é apenas texto que o banco ignora.

Todos os usuários retornam. A aplicação considera a primeira linha como prova de um login bem-sucedido e deixa o atacante entrar como outra pessoa.

Aponte isso apenas para o seu próprio banco de dados

Esses payloads alteram e destroem dados. Isso os torna perigosos em qualquer lugar com registros reais, incluindo uma cópia de staging da produção. Use um banco de dados local que você possa apagar e recriar, ou um sistema para o qual você tenha permissão por escrito para testar.

JunoO ataque Leia o payload como três movimentos em vez de uma única string, e ele deixa de parecer mágica.

Feche as aspas para passar a escrever query em vez de texto. Adicione uma condição sempre verdadeira. Comente tudo aquilo com que você não quer lidar.

JunoO ataque Note que o ataque não precisou de uma senha, nem de adivinhar uma. Ele simplesmente removeu a verificação de senha da query.

É por isso que "nosso login é seguro porque as senhas são hasheadas" não resolve o problema aqui. O hash protege o valor armazenado caso a tabela vaze. Ele não faz nada quando a própria comparação nunca chega a ser executada.

JunoO ataque Um detalhe de dialeto vale a pena guardar, porque já produziu conclusões erradas em testes reais. No MySQL, -- só inicia um comentário quando é seguido por um espaço em branco.

Então --' não é um comentário nesse caso, e um payload copiado de um exemplo em PostgreSQL pode falhar contra o MySQL mesmo com o bug subjacente totalmente presente. # é o outro marcador de comentário do MySQL.

E essa é a verdadeira lição: um payload que não faz nada praticamente não te disse nada. Ele descarta uma string em um dialeto específico, não a vulnerabilidade.

A outra metade da resposta é com que privilégio a query é executada. Uma conta de aplicação com permissões de DROP, ou acesso de leitura a tabelas que a funcionalidade nunca deveria tocar, transforma uma injeção em um incidente muito maior.

O princípio do menor privilégio no usuário do banco de dados é o que limita o estrago quando a parametrização acaba esquecida em algum lugar.

A correção

Pare de construir a frase. Descreva a estrutura dela uma única vez e entregue os valores separadamente:

Fixed
js
const query = 'SELECT * FROM users WHERE username = ? AND password = ?'

const [rows] = await db.execute(query, [username, password])

As marcas ? são placeholders. A sintaxe exata varia de banco para banco e de biblioteca para biblioteca — $1 e $2 em alguns casos, parâmetros nomeados em outros —, mas o formato é sempre o mesmo: o texto da query é fixado antes de qualquer valor do usuário entrar em cena.

Isso é chamado de prepared statement com variable binding, ou, mais comumente, query parametrizada.

JunoA correção A diferença está em quando o banco de dados aprende a estrutura. Na versão colada, estrutura e dados chegam juntos, e o banco precisa deduzir o formato a partir do que recebe.

Aqui o formato é definido primeiro. Os valores chegam depois, como valores, e nada neles pode mudar uma decisão que já foi tomada.

JunoA correção Essa é uma daquelas raras correções de segurança que também resultam em um código mais agradável. Sem malabarismo com aspas, sem função de escape para lembrar, e o driver cuida da conversão de tipos para você.

Trate qualquer query concatenada que ainda restar como algo que precisa de uma justificativa por escrito. Um ORM, o mapeador objeto-relacional que gera SQL a partir do seu código, parametriza automaticamente nos seus métodos de consulta normais.

Sua válvula de escape para SQL bruto não faz isso, então é por aí que você deve olhar primeiro.

JunoA correção Placeholders vinculam valores, nunca identificadores. Nomes de tabela, nomes de coluna e a direção em ORDER BY não podem ser parametrizados, porque fazem parte da estrutura que é informada ao banco de dados de antemão.

Por isso, um endpoint de ordenação que recebe um nome de coluna de uma query string continua vulnerável mesmo com todos os valores parametrizados. A correção nesse caso é uma allowlist: mapear a entrada do usuário para um identificador conhecido e confiável, e rejeitar tudo que não corresponder.

Construa essa allowlist com um Map, Object.hasOwn() ou Object.create(null). Um objeto literal comum é permeável, porque ALLOWED[userInput] retorna Object.prototype.toString para a entrada toString, que é truthy e passa direto por uma verificação ingênua.

Por que a correção funciona

Nada aqui inspeciona a entrada ou remove qualquer coisa dela. ' OR 1=1 -- ainda chega exatamente como foi digitado.

O que mudou foi onde esse valor pousa. O banco de dados já sabe que está procurando um nome de usuário e uma senha, então o payload é comparado como um nome de usuário, caractere por caractere, contra uma coluna de nomes.

Nenhuma conta se chama ' OR 1=1 --. Nenhuma linha retorna, o login falha, e essa é a resposta correta.

Essa é a defesa na camada do banco de dados, e ela se soma às outras que esta seção vem construindo:

CamadaO que ela decide
Validação no navegadorSe o formulário é enviado, apenas para usuários honestos
Validação de schema no servidorSe a requisição tem o formato certo para sequer ser aceita
Saída seguraSe um valor armazenado pode virar código em uma página
Queries parametrizadasSe um valor pode se tornar parte de uma query
JunoPor que a correção funciona O payload não é desarmado. Ele passa a ser lido como um nome agora, e é um nome bem incomum, que ninguém tem.

Vale a pena guardar essa ideia: as correções mais seguras costumam mudar como um valor é tratado, em vez de tentar avaliar se ele parece perigoso.

JunoPor que a correção funciona É por isso que escapar aspas manualmente é o instinto errado, mesmo quando parece funcionar. Você acaba mantendo um palpite sobre as regras de escape de aspas de um banco específico.

Esse palpite quebra em um dialeto diferente, em uma codificação de caracteres diferente, ou em um campo numérico onde nem havia aspas envolvidas desde o início.

A parametrização elimina a pergunta, em vez de tentar respondê-la.

JunoPor que a correção funciona Vale a pena ser preciso sobre o que a parametrização cobre e o que não cobre, porque "a gente usa um ORM" costuma ser tratado como uma resposta definitiva.

Ela cobre valores em uma query cuja estrutura você fixou. Não cobre identificadores, válvulas de escape para SQL bruto, fragmentos de WHERE montados dinamicamente, nem uma stored procedure que concatena internamente. Cada um desses é um ponto onde a garantia deixa de valer.

O motivo pelo qual a validação de schema ainda merece seu lugar por cima disso é que as duas coisas respondem perguntas diferentes. A parametrização torna um valor seguro para o banco de dados. A validação decide se você sequer queria um nome de usuário com 4.000 caracteres, e essa é uma resposta sobre a qual a camada do banco de dados não tem opinião nenhuma.

Coloque em prática

Um formulário de cadastro insere um nome e um email:

Vulnerable
js
const query = `
  INSERT INTO users (name, email)
  VALUES ('${name}', '${email}')
`

Assuma o papel do atacante. Crie um valor para o campo de email que apague completamente a tabela users.

Três coisas para resolver: como sair de dentro do valor entre aspas, como sair de dentro dos parênteses, e como encerrar uma instrução para que uma segunda possa começar.

Compare suas respostas

O valor do email é:

text
'); DROP TABLE users; --

O que produz:

sql
INSERT INTO users (name, email)
VALUES ('Dave', ''); DROP TABLE users; --')

Quatro movimentos, correspondendo às três perguntas mais uma arrumação final:

  • ' fecha a string dentro da qual o email estava.
  • ) fecha o parêntese do VALUES, completando o INSERT como uma instrução válida.
  • ; encerra essa instrução, então o que vem depois é lido como uma nova.
  • -- comenta o ') que sobra solto no final, o que de outra forma seria um erro de sintaxe e faria tudo ser rejeitado.

O último movimento é o que costuma passar despercebido. Sem ele, a instrução fica malformada, o banco de dados rejeita tudo, e o ataque falha por motivos que não têm nada a ver com suas defesas.

Vale saber: muitos drivers rejeitam múltiplas instruções em uma única chamada por padrão, então esse payload exato nem sempre funciona. Isso é uma configuração te protegendo, não uma correção. A mesma brecha ainda permite ler qualquer dado que o atacante queira por meio de um UNION SELECT, que não precisa de uma segunda instrução alguma.

Parametrize a query e a string inteira vira apenas o email incomum de alguém, armazenado exatamente como foi digitado.

Para onde isso vai a partir daqui

Três capítulos, três bugs, uma única causa. Nada verificou o que chegava antes de a aplicação agir sobre isso, então um valor escolhido por um estranho acabou decidindo o que o código fazia.

Cada correção até aqui aconteceu no ponto de uso: a propriedade certa para renderização, um limite de tamanho, um placeholder em uma query. Elas são necessárias, mas também chegam tarde: precisam ser repetidas em cada destino, e é fácil esquecer uma delas.

Fundamentos de Zod começa a outra metade da solução: descrever o formato esperado uma única vez, na fronteira, e recusar tudo que não corresponder a ele.