SQL Injection é a vulnerabilidade mais explorada em aplicações web há
mais de duas décadas — e continua no topo do OWASP Top 10.
O problema não é o SQL em si: é construir SQL por concatenação de strings,
misturando código e dados no mesmo canal. A solução é igualmente simples:
PreparedStatement — separa o código SQL dos dados ao
nível do protocolo de rede, tornando a injecção estruturalmente impossível.
Este artigo explica o mecanismo do ataque, por que o
PreparedStatement o resolve de forma definitiva,
e os padrões correctos para os casos onde a parametrização directa
não é suficiente.
-- ── O código vulnerável ──────────────────────────────────────────────────────
// Input do utilizador via formulário de login:
String email = request.getParameter("email");
String pass = request.getParameter("password");
// Construção da query por concatenação — NUNCA fazer isto:
String sql = "SELECT id, nome FROM utilizador " +
"WHERE email = '" + email + "' " +
"AND password_hash = '" + pass + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
if (rs.next()) {
// login bem-sucedido
}
-- ── Ataque 1: Bypass de autenticação ─────────────────────────────────────────
-- Input malicioso:
-- email: admin@example.com' --
-- pass: qualquer_coisa
-- Query resultante após concatenação:
SELECT id, nome FROM utilizador
WHERE email = 'admin@example.com' --' AND password_hash = 'qualquer_coisa'
-- ↑↑
-- -- é um comentário SQL — tudo depois é ignorado
-- Query efectivamente executada:
SELECT id, nome FROM utilizador
WHERE email = 'admin@example.com'
-- A condição da password foi removida — login sem saber a password!
-- ── Ataque 2: Extracção de dados com UNION ────────────────────────────────────
-- email: ' UNION SELECT id, password_hash FROM utilizador --
-- pass: x
-- Query resultante:
SELECT id, nome FROM utilizador
WHERE email = '' UNION SELECT id, password_hash FROM utilizador --'
AND password_hash = 'x'
-- Resultado: a aplicação recebe todos os ids e hashes de passwords da tabela!
-- ── Ataque 3: Destruição de dados (DROP TABLE) ────────────────────────────────
-- email: '; DROP TABLE utilizador; --
-- Query resultante (se o driver executar múltiplos statements):
SELECT id, nome FROM utilizador WHERE email = '';
DROP TABLE utilizador;
-- A tabela utilizador é eliminada.
-- (PostgreSQL JDBC não executa múltiplos statements por default — mas outros
-- drivers ou interfaces podem)
-- ── Ataque 4: Blind SQL Injection (sem mensagem de erro visível) ──────────────
-- O atacante não vê o resultado da query, mas infere dados por comportamento:
-- email: admin@example.com' AND SUBSTRING(password_hash,1,1)='a' --
-- Se login bem-sucedido → primeiro char do hash é 'a'
-- Se falhou → não é 'a' → tentar 'b', 'c', ...
-- Automatizado com ferramentas como sqlmap: extrai a BD inteira em minutos
-- A solução não é "escapar" strings — é separar código de dados ao nível do protocolo.
--
-- Com Statement (concatenação):
-- ┌─────────────────────────────────────────────────────────────┐
-- │ Rede → PostgreSQL: "SELECT ... WHERE email = 'x' OR '1'='1'"│
-- │ PostgreSQL vê uma string de texto e faz parse │
-- │ Não distingue o que é código do que é dados │
-- └─────────────────────────────────────────────────────────────┘
--
-- Com PreparedStatement (Extended Query Protocol do PostgreSQL):
-- ┌─────────────────────────────────────────────────────────────┐
-- │ Mensagem 1 — Parse: │
-- │ query: "SELECT ... WHERE email = $1 AND password = $2" │
-- │ O PostgreSQL faz parse e compila o plano de execução │
-- │ │
-- │ Mensagem 2 — Bind: │
-- │ param $1: "admin@example.com' OR '1'='1" (valor literal)│
-- │ param $2: "qualquer_coisa" │
-- │ Os parâmetros são enviados como dados binários separados │
-- │ │
-- │ Mensagem 3 — Execute │
-- └─────────────────────────────────────────────────────────────┘
--
-- O PostgreSQL já compilou o plano antes de receber os parâmetros.
-- Os parâmetros são SEMPRE tratados como dados — NUNCA como código SQL.
-- ' OR '1'='1 no parâmetro $1 é simplesmente uma string com aspas e espaços.
-- Não há parse adicional. Injecção é estruturalmente impossível.
-- ── O código correcto ─────────────────────────────────────────────────────────
String sql = "SELECT id, nome FROM utilizador WHERE email = ? AND password_hash = ?";
try (
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)
) {
ps.setString(1, email); // email é sempre um valor, nunca código SQL
ps.setString(2, passwordHash); // idem para a password
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
// login bem-sucedido
long userId = rs.getLong("id");
String nome = rs.getString("nome");
}
}
}
// Qualquer valor em email ou passwordHash — incluindo ' OR '1'='1 —
// é tratado como string literal pelo PostgreSQL.
// Injecção é impossível independentemente do conteúdo dos parâmetros.
// ── Mapeamento Java → SQL ────────────────────────────────────────────────────
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO produto (nome, preco, stock, activo, criado_em, atributos) " +
"VALUES (?, ?, ?, ?, ?, ?)"
);
ps.setString (1, "Teclado Mecânico"); // VARCHAR, TEXT, CHAR
ps.setBigDecimal(2, new BigDecimal("89.99")); // NUMERIC — usar BigDecimal para dinheiro
ps.setInt (3, 50); // INTEGER, INT4
ps.setBoolean (4, true); // BOOLEAN
ps.setTimestamp(5, Timestamp.from(Instant.now())); // TIMESTAMPTZ
ps.setObject (6, "{\"cor\":\"preto\"}", Types.OTHER); // JSONB, tipos custom do PostgreSQL
// Valores NULL:
ps.setNull(3, Types.INTEGER); // NULL explícito
// ou:
ps.setObject(3, null); // null Java → NULL SQL
// ── Arrays PostgreSQL ────────────────────────────────────────────────────────
Array tags = conn.createArrayOf("text", new String[]{"electrónica", "periférico"});
ps.setArray(7, tags);
// Equivalente a: INSERT ... tags = '{"electrónica","periférico"}'
// ── Batch inserts — múltiplos registos com um só PreparedStatement ────────────
String sql = "INSERT INTO linha_encomenda (encomenda_id, produto_id, quantidade, preco_unit) " +
"VALUES (?, ?, ?, ?)";
try (
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)
) {
conn.setAutoCommit(false);
for (LinhaEncomenda linha : linhas) {
ps.setLong (1, linha.getEncomendaId());
ps.setLong (2, linha.getProdutoId());
ps.setInt (3, linha.getQuantidade());
ps.setBigDecimal(4, linha.getPrecoUnit());
ps.addBatch(); // acumula no buffer — não executa ainda
if (linhas.indexOf(linha) % 500 == 0) {
ps.executeBatch(); // envia 500 linhas de uma vez
conn.commit();
}
}
ps.executeBatch(); // enviar o último batch (resto < 500)
conn.commit();
}
// Sem batching: 1000 linhas = 1000 round-trips à base de dados
// Com batching (500/batch): 1000 linhas = 2 round-trips
// Com reWriteBatchedInserts=true (driver PG): agrupa em INSERT ... VALUES (...),(...)
// → ainda mais rápido (1 mensagem de rede em vez de 500)
-- PreparedStatement parametriza VALORES — não nomes de tabelas, colunas ou operadores.
-- Para queries com estrutura dinâmica, é necessária outra abordagem.
-- ── Problema: ordenação dinâmica (ORDER BY coluna escolhida pelo utilizador) ──
// ❌ Vulnerável — ORDER BY não pode ser parametrizado:
String col = request.getParameter("sort"); // input: "nome; DROP TABLE produto; --"
String sql = "SELECT * FROM produto ORDER BY " + col;
// Injecção possível!
// ✅ Solução: whitelist de colunas permitidas
private static final Set COLUNAS_ORDEM = Set.of("nome", "preco_base", "criado_em");
private static final Set DIRECOES = Set.of("ASC", "DESC");
String col = request.getParameter("sort");
String dir = request.getParameter("dir");
if (!COLUNAS_ORDEM.contains(col)) col = "nome"; // fallback seguro
if (!DIRECOES.contains(dir.toUpperCase())) dir = "ASC";
// Só valores da whitelist chegam à query:
String sql = "SELECT * FROM produto ORDER BY " + col + " " + dir;
// Mesmo que col/dir venham do utilizador, só valores permitidos são aceites.
// A whitelist é o mecanismo de segurança — não a parametrização.
-- ── Problema: cláusula IN com lista dinâmica de valores ──────────────────────
// ❌ Tentação errada: construir "IN (?,?,?)" dinamicamente com concatenação
// (isto é parametrizado mas trabalhoso)
// ✅ Solução A: construir os placeholders programaticamente
List ids = List.of(1, 5, 10, 42);
String placeholders = ids.stream()
.map(i -> "?")
.collect(Collectors.joining(", ")); // "?, ?, ?, ?"
String sql = "SELECT * FROM produto WHERE id IN (" + placeholders + ")";
PreparedStatement ps = conn.prepareStatement(sql);
for (int i = 0; i < ids.size(); i++) {
ps.setInt(i + 1, ids.get(i)); // todos os valores parametrizados
}
// Os valores são sempre parâmetros — só a estrutura (número de ?) é dinâmica.
// ✅ Solução B (PostgreSQL): usar array e ANY
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM produto WHERE id = ANY(?)"
);
Array arr = conn.createArrayOf("integer", ids.toArray());
ps.setArray(1, arr);
// Mais limpo — um único parâmetro independentemente do número de IDs.
-- ── Problema: nome de schema ou tabela dinâmico (multi-tenant) ───────────────
// ❌ Nunca construir nomes de schema com input do utilizador directamente
String tenant = request.getHeader("X-Tenant");
String sql = "SELECT * FROM " + tenant + ".produto"; // injecção possível!
// ✅ Solução: lookup do schema num mapa de tenants validado
private static final Map TENANT_SCHEMAS = Map.of(
"cliente-a", "tenant_cliente_a",
"cliente-b", "tenant_cliente_b"
);
String schema = TENANT_SCHEMAS.get(request.getHeader("X-Tenant"));
if (schema == null) throw new UnauthorizedException("Tenant desconhecido");
// schema é um valor do nosso mapa interno — nunca input directo do utilizador
String sql = "SELECT * FROM " + schema + ".produto";
// Ou usar SET search_path via PreparedStatement antes da query:
try (PreparedStatement setSchema = conn.prepareStatement("SET search_path = " + schema)) {
setSchema.execute();
}
// Agora as queries sem schema explícito usam o schema do tenant
-- Frameworks de alto nível (Hibernate, jOOQ, Spring Data) são seguros por default
-- — usam PreparedStatement internamente para todos os parâmetros.
-- O risco volta quando se usa SQL nativo com interpolação de strings.
// ── Hibernate / Spring Data JPA ───────────────────────────────────────────────
// ✅ Seguro — JPQL com parâmetros nomeados:
TypedQuery q = em.createQuery(
"SELECT p FROM Produto p WHERE p.categoriaId = :catId AND p.activo = true",
Produto.class
);
q.setParameter("catId", categoriaId);
// ✅ Seguro — Spring Data repository (gera PreparedStatement automaticamente):
List findByCategoriaIdAndActivoTrue(Integer categoriaId);
// ❌ Vulnerável — native query com concatenação:
em.createNativeQuery(
"SELECT * FROM produto WHERE nome LIKE '%" + nomeInput + "%'"
);
// nomeInput pode conter SQL injection!
// ✅ Seguro — native query com parâmetros posicionais:
em.createNativeQuery("SELECT * FROM produto WHERE nome LIKE ?1")
.setParameter(1, "%" + nomeInput + "%");
// nomeInput é sempre dados — os % são parte do valor, não do SQL
// ── jOOQ — type-safe SQL ──────────────────────────────────────────────────────
// ✅ jOOQ é seguro por design — tudo é parametrizado:
dsl.selectFrom(PRODUTO)
.where(PRODUTO.NOME.containsIgnoreCase(nomeInput)) // gera ILIKE ? com parâmetro
.and(PRODUTO.ACTIVO.isTrue())
.fetch();
// ⚠️ Excepção: DSL.field() e DSL.table() com strings — não usar com input do utilizador:
dsl.select(DSL.field(nomeColuna)) // vulnerável se nomeColuna vem do utilizador
// ✅ Usar a whitelist pattern (como no ORDER BY dinâmico acima)
-- ── SQL Injection de segunda ordem ────────────────────────────────────────────
-- Dados guardados na BD que são lidos e usados noutras queries sem parametrização.
-- Exemplo:
-- 1. Utilizador regista username: admin'--
-- 2. Username é guardado correctamente (parametrizado)
-- 3. Mais tarde, outra parte da aplicação faz:
-- "UPDATE utilizador SET ultimo_login = NOW() WHERE nome = '" + username + "'"
-- (obtendo o username da BD sem re-parametrizar)
-- 4. A query resultante: UPDATE utilizador SET ultimo_login = NOW() WHERE nome = 'admin'--'
-- Solução: parametrizar SEMPRE — mesmo dados que vêm da própria base de dados.
-- PreparedStatement é a defesa principal — mas não a única camada.
-- Defesa em profundidade: múltiplas camadas independentes.
-- ── 1. Princípio do mínimo privilégio na base de dados ────────────────────────
-- O utilizador da aplicação não deve ter permissões que não precisa:
-- Criar utilizador de aplicação com apenas o necessário:
CREATE ROLE app_user LOGIN PASSWORD 'password';
GRANT CONNECT ON DATABASE loja TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
-- Apenas SELECT, INSERT, UPDATE, DELETE nas tabelas necessárias:
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
utilizador, produto, encomenda, linha_encomenda TO app_user;
-- Sem permissões para:
-- DROP TABLE, CREATE TABLE, ALTER TABLE → não pode destruir schema
-- TRUNCATE → não pode apagar tudo de uma tabela
-- pg_read_server_files, pg_write_server_files → não pode ler ficheiros do SO
-- SUPERUSER → nunca para a conta da aplicação
-- Se a aplicação for comprometida com SQL injection:
-- O atacante opera com as permissões limitadas do app_user
-- Não consegue fazer DROP TABLE, ler outros schemas, ou aceder ao SO
-- ── 2. Validação de input na camada de aplicação ──────────────────────────────
// Validar ANTES de usar — mesmo com PreparedStatement:
public void criarUtilizador(String email, String nome) {
if (email == null || !email.matches("^[^@]+@[^@]+\\.[^@]+$")) {
throw new ValidationException("Email inválido");
}
if (nome == null || nome.length() < 2 || nome.length() > 100) {
throw new ValidationException("Nome deve ter entre 2 e 100 caracteres");
}
// ... PreparedStatement seguro ...
}
// Validação não previne SQL injection (PreparedStatement já faz isso)
// mas previne dados inválidos e dá feedback útil ao utilizador
-- ── 3. WAF (Web Application Firewall) — última linha de defesa ───────────────
-- Um WAF detecta e bloqueia payloads de SQL injection no tráfego HTTP.
-- Não é substituto para código correcto — é uma rede de segurança adicional.
-- Ferramentas: ModSecurity, AWS WAF, Cloudflare WAF.
-- ── 4. Logging e detecção de anomalias ────────────────────────────────────────
-- Activar log de queries lentas / erros no PostgreSQL:
-- postgresql.conf:
-- log_min_duration_statement = 1000 -- log queries > 1s
-- log_error_verbosity = default -- log erros com detalhe
-- log_connections = on -- log novas conexões
--
-- Monitorizar erros SQL na aplicação:
-- Múltiplos erros de sintaxe SQL de um mesmo IP podem indicar tentativa de injecção.
PreparedStatement com setXxx().PreparedStatement resolve SQL injection ao nível do protocolo (Extended Query Protocol) — os parâmetros são enviados separados do SQL, tratados sempre como dados.IN (?) com lista dinâmica: construir os placeholders programaticamente, ou usar = ANY(?) com um array PostgreSQL.createNativeQuery ou DSL.field() com input do utilizador.SELECT/INSERT/UPDATE/DELETE nas tabelas necessárias — nunca DROP ou SUPERUSER.BigDecimal para valores monetários — nunca float ou double (perda de precisão em aritmética de vírgula flutuante).addBatch()/executeBatch() e reWriteBatchedInserts=true no driver PostgreSQL para inserções massivas eficientes.