Injeção de SQL e como evitá-lo?

Injeção de SQL e como evitá-lo?

1. Introdução

Apesar de ser uma das vulnerabilidades mais conhecidas, SQL Injection continua a se classificar no primeiro lugar do infameOWASP Top 10’s list - agora parte da classeInjection mais geral.

Neste tutorial, vamos explorarcommon coding mistakes in Java that lead to a vulnerable application and how to avoid them usando as APIs disponíveis na biblioteca de tempo de execução padrão da JVM. Também abordaremos quais proteções podemos obter de ORMs como JPA, Hibernate e outros e com quais pontos cegos ainda teremos que nos preocupar.

2. Como os aplicativos se tornam vulneráveis ​​à injeção de SQL?

Injection attacks work because, for many applications, the only way to execute a given computation is to dynamically generate code that is in turn run by another system or component. Se, no processo de geração desse código, usarmos dados não confiáveis ​​sem higienização adequada, deixaremos uma porta aberta para os hackers explorarem.

Esta afirmação pode parecer um pouco abstrata, então vamos dar uma olhada em como isso acontece na prática com um exemplo de livro didático:

public List
  unsafeFindAccountsByCustomerId(String customerId)
  throws SQLException {
    // UNSAFE !!! DON'T DO THIS !!!
    String sql = "select "
      + "customer_id,acc_number,branch_id,balance "
      + "from Accounts where customer_id = '"
      + customerId
      + "'";
    Connection c = dataSource.getConnection();
    ResultSet rs = c.createStatement().executeQuery(sql);
    // ...
}

O problema com este código é óbvio:we’ve put the customerId‘s value into the query with no validation at all. Nada de ruim acontecerá se tivermos certeza de que esse valor virá apenas de fontes confiáveis, mas podemos?

Vamos imaginar que esta função seja usada em uma implementação da API REST para umaccount resource. Explorar esse código é trivial: tudo o que precisamos fazer é enviar um valor que, quando concatenado com a parte fixa da consulta, altere o comportamento pretendido:

curl -X GET \
  'http://localhost:8080/accounts?customerId=abc%27%20or%20%271%27=%271' \

Supondo que o valor do parâmetrocustomerId fique desmarcado até atingir nossa função, aqui está o que receberíamos:

abc' or '1' = '1

Quando juntamos esse valor à parte fixa, obtemos a instrução SQL final que será executada:

select customer_id, acc_number,branch_id, balance
  from Accounts where customerId = 'abc' or '1' = '1'

Provavelmente não é o que queríamos ...

Um desenvolvedor inteligente (não somos todos?) Estaria pensando: “Isso é bobagem! Eunever usaria concatenação de string para construir uma consulta como esta ”.

Não tão rápido ... Este exemplo canônico é realmente bobo, masthere are situations where we might still need to do it:

  • Consultas complexas com critérios dinâmicos de pesquisa: adicionando cláusulas UNION dependendo dos critérios fornecidos pelo usuário

  • Agrupamento ou pedido dinâmico: APIs REST usadas como back-end para uma tabela de dados da GUI

2.1. Estou usando JPA. Estou seguro, certo?

This is a common misconception. JPA e outros ORMs nos livram da criação de instruções SQL codificadas manualmente, mas eleswon’t prevent us from writing vulnerable code.

Vamos ver como fica a versão JPA do exemplo anterior:

public List unsafeJpaFindAccountsByCustomerId(String customerId) {
    String jql = "from Account where customerId = '" + customerId + "'";
    TypedQuery q = em.createQuery(jql, Account.class);
    return q.getResultList()
      .stream()
      .map(this::toAccountDTO)
      .collect(Collectors.toList());
}

O mesmo problema que apontamos antes também está presente aqui:we’re using unvalidated input to create a JPA query, então estamos expostos ao mesmo tipo de exploração aqui.

3. Técnicas de Prevenção

Agora que sabemos o que é injeção de SQL, vamos ver como podemos proteger nosso código desse tipo de ataque. Aqui, estamos nos concentrando em algumas técnicas muito eficazes disponíveis em Java e outras linguagens JVM, mas conceitos semelhantes estão disponíveis para outros ambientes, como PHP, .Net, Ruby e assim por diante.

Para quem procura uma lista completa das técnicas disponíveis, inclusive específicas de bancos de dados, oOWASP Project mantém umSQL Injection Prevention Cheat Sheet, que é um bom local para aprender mais sobre o assunto.

3.1. Consultas parametrizadas

Essa técnica consiste em usar instruções preparadas com o espaço reservado para ponto de interrogação (“?”) Em nossas consultas sempre que precisarmos inserir um valor fornecido pelo usuário. Isso é muito eficaz e, a menos que haja um bug na implementação do driver JDBC, imune a explorações.

Vamos reescrever nossa função de exemplo para usar esta técnica:

public List safeFindAccountsByCustomerId(String customerId)
  throws Exception {

    String sql = "select "
      + "customer_id, acc_number, branch_id, balance from Accounts"
      + "where customer_id = ?";

    Connection c = dataSource.getConnection();
    PreparedStatement p = c.prepareStatement(sql);
    p.setString(1, customerId);
    ResultSet rs = p.executeQuery(sql));
    // omitted - process rows and return an account list
}

Aqui, usamos o métodoprepareStatement() disponível na instânciaConnection para obter umPreparedStatement. Essa interface estende a sinterfaceStatement regular com vários métodos que nos permitem inserir com segurança valores fornecidos pelo usuário em uma consulta antes de executá-la.

Para o JPA, temos um recurso semelhante:

String jql = "from Account where customerId = :customerId";
TypedQuery q = em.createQuery(jql, Account.class)
  .setParameter("customerId", customerId);
// Execute query and return mapped results (omitted)

Ao executar este código no Spring Boot, podemos definir a propriedadelogging.level.sql como DEBUG e ver qual consulta é realmente criada para executar esta operação:

// Note: Output formatted to fit screen
[DEBUG][SQL] select
  account0_.id as id1_0_,
  account0_.acc_number as acc_numb2_0_,
  account0_.balance as balance3_0_,
  account0_.branch_id as branch_i4_0_,
  account0_.customer_id as customer5_0_
from accounts account0_
where account0_.customer_id=?

Como esperado, a camada ORM cria uma instrução preparada usando um espaço reservado para o parâmetrocustomerId. Este é o mesmo que fizemos no caso JDBC simples - mas com algumas instruções a menos, o que é bom.

Como um bônus, essa abordagem geralmente resulta em uma consulta com melhor desempenho, pois a maioria dos bancos de dados pode armazenar em cache o plano de consulta associado a uma instrução preparada.

Observethat this approach only works for placeholders used asvalues. Por exemplo, não podemos usar marcadores de posição para alterar dinamicamente o nome de uma tabela:

// This WILL NOT WORK !!!
PreparedStatement p = c.prepareStatement("select count(*) from ?");
p.setString(1, tableName);

Aqui, o JPA também não ajudará:

// This WILL NOT WORK EITHER !!!
String jql = "select count(*) from :tableName";
TypedQuery q = em.createQuery(jql,Long.class)
  .setParameter("tableName", tableName);
return q.getSingleResult();

Em ambos os casos, obteremos um erro de tempo de execução.

A principal razão por trás disso é a própria natureza de uma instrução preparada: os servidores de banco de dados os usam para armazenar em cache o plano de consulta necessário para obter o conjunto de resultados, que geralmente é o mesmo para qualquer valor possível. Isso não é verdade para nomes de tabelas e outras construções disponíveis na linguagem SQL, como colunas usadas em uma cláusulaorder by.

3.2. API de critérios JPA

Uma vez que a construção de consulta JQL explícita é a principal fonte de Injeções SQL, devemos favorecer o uso da API de consulta JPA, quando possível.

Para uma introdução rápida sobre esta API,refer to the article on Hibernate Criteria queries. Também vale a pena ler nossoarticle about JPA Metamodel, que mostra como gerar classes de metamodelo que nos ajudarão a nos livrar de constantes de string usadas para nomes de coluna - e os bugs de tempo de execução que surgem quando eles mudam.

Vamos reescrever nosso método de consulta JPA para usar a API Criteria:

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery cq = cb.createQuery(Account.class);
Root root = cq.from(Account.class);
cq.select(root).where(cb.equal(root.get(Account_.customerId), customerId));

TypedQuery q = em.createQuery(cq);
// Execute query and return mapped results (omitted)

Aqui, usamos mais linhas de código para obter o mesmo resultado, mas a vantagem é que agorawe don’t have to worry about JQL syntax.

Outro ponto importante: apesar de seu detalhamento,the Criteria API makes creating complex query services more straightforward and safer. Para um exemplo completo que mostra como fazer isso na prática, dê uma olhada na abordagem usada por aplicativos gerados porJHipster.

3.3. Sanitização de Dados do Usuário

Data Sanitization is a technique of applying a filter to user supplied-data so it can be safely used by other parts of our application. A implementação de um filtro pode variar muito, mas geralmente podemos classificá-los em dois tipos: listas de permissões e listas negras.

Blacklists, que consistem em filtros que tentam identificar um padrão inválido, geralmente têm pouco valor no contexto de prevenção de injeção de SQL - mas não para a detecção! Mais sobre isso mais tarde.

Whitelists, por outro lado, funciona particularmente bem quandowe can define exactly what is a valid input.

Vamos aprimorar nosso métodosafeFindAccountsByCustomerId para que agora o chamador também possa especificar a coluna usada para classificar o conjunto de resultados. Como conhecemos o conjunto de colunas possíveis, podemos implementar uma lista de permissões usando um conjunto simples e usá-lo para limpar o parâmetro recebido:

private static final Set VALID_COLUMNS_FOR_ORDER_BY
  = Collections.unmodifiableSet(Stream
      .of("acc_number","branch_id","balance")
      .collect(Collectors.toCollection(HashSet::new)));

public List safeFindAccountsByCustomerId(
  String customerId,
  String orderBy) throws Exception {
    String sql = "select "
      + "customer_id,acc_number,branch_id,balance from Accounts"
      + "where customer_id = ? ";
    if (VALID_COLUMNS_FOR_ORDER_BY.contains(orderBy)) {
        sql = sql + " order by " + orderBy;
    } else {
        throw new IllegalArgumentException("Nice try!");
    }
    Connection c = dataSource.getConnection();
    PreparedStatement p = c.prepareStatement(sql);
    p.setString(1,customerId);
    // ... result set processing omitted
}

Aqui,we’re combining the prepared statement approach and a whitelist used to sanitize the orderBy argument. O resultado final é uma sequência segura com a instrução SQL final. Neste exemplo simples, estamos usando um conjunto estático, mas também poderíamos ter usado funções de metadados de banco de dados para criá-lo.

Podemos usar a mesma abordagem para JPA, também aproveitando a API de critérios e metadados para evitar o uso de constantesString em nosso código:

// Map of valid JPA columns for sorting
final Map> VALID_JPA_COLUMNS_FOR_ORDER_BY = Stream.of(
  new AbstractMap.SimpleEntry<>(Account_.ACC_NUMBER, Account_.accNumber),
  new AbstractMap.SimpleEntry<>(Account_.BRANCH_ID, Account_.branchId),
  new AbstractMap.SimpleEntry<>(Account_.BALANCE, Account_.balance))
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

SingularAttribute orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy);
if (orderByAttribute == null) {
    throw new IllegalArgumentException("Nice try!");
}

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery cq = cb.createQuery(Account.class);
Root root = cq.from(Account.class);
cq.select(root)
  .where(cb.equal(root.get(Account_.customerId), customerId))
  .orderBy(cb.asc(root.get(orderByAttribute)));

TypedQuery q = em.createQuery(cq);
// Execute query and return mapped results (omitted)

Esse código tem a mesma estrutura básica que no JDBC simples. Primeiro, usamos uma lista de permissões para limpar o nome da coluna e, a seguir, criamos umCriteriaQuery para buscar os registros do banco de dados.

3.4. Estamos seguros agora?

Vamos supor que usamos consultas parametrizadas e / ou listas de permissões em todos os lugares. Agora podemos ir ao nosso gerente e garantir que estamos seguros?

Bem ... não tão rápido. Mesmo sem considerarTuring’s halting problem, existem outros aspectos que devemos considerar:

  1. Stored Procedures:These are also prone to SQL Injection issues; sempre que possível, aplique saneamento mesmo para valores que serão enviados para o banco de dados por meio de declarações preparadas

  2. Triggers: O mesmo problema das chamadas de procedimento, mas ainda mais insidioso porque às vezes não temos ideia de que eles estão lá ...

  3. Insecure Direct Object References: Mesmo que nosso aplicativo seja livre de injeção de SQL, ainda há um risco associado a esta categoria de vulnerabilidade - o ponto principal aqui está relacionado às diferentes maneiras como um invasor pode enganar o aplicativo, para que ele retorne registros que ele ou ela não deveria ter acesso a - há uma boa folha de dicas sobre este tópicoavailable at OWASP’s GitHub repository

Em suma, nossa melhor opção aqui é cautela. Atualmente, muitas organizações usam uma "equipe vermelha" exatamente para isso. Deixe que eles façam seu trabalho, o que é exatamente para encontrar as vulnerabilidades restantes.

4. Técnicas de controle de danos

As a good security practice, we should always implement multiple defense layers - um conceito conhecido comodefense in depth. A ideia principal é que, mesmo se não formos capazes de encontrar todas as vulnerabilidades possíveis em nosso código - um cenário comum ao lidar com sistemas legados - devemos pelo menos tentar limitar os danos que um ataque infligiria.

Claro, este seria um tópico para um artigo inteiro ou mesmo um livro, mas vamos citar algumas medidas:

  1. Aplique o princípio do menor privilégio:Restrict as much as possible the privileges of the account used to access the database

  2. Use métodos específicos do banco de dados disponíveis para adicionar uma camada de proteção adicional; por exemplo, o banco de dados H2 possui uma opção no nível da sessão que desativa todos os valores literais nas consultas SQL

  3. Use credenciais de curta duração:Make the application rotate database credentials often; uma boa maneira de implementar isso é usandoSpring Cloud Vault

  4. Log de tudo:If the application stores customer data, this is a must; thá muitas soluções disponíveis que se integram diretamente ao banco de dados ou funcionam como proxy, portanto em caso de ataque podemos pelo menos avaliar os danos

  5. UseWAFs ou soluções de detecção de intrusão semelhantes: esses são os exemplos típicos deblacklist - geralmente, eles vêm com um banco de dados considerável de assinaturas de ataques conhecidas e irão disparar uma ação programável após a detecção. Alguns também incluem agentes in-JVM que podem detectar intrusões aplicando alguma instrumentação - a principal vantagem desta abordagem é que uma eventual vulnerabilidade se torna muito mais fácil de corrigir, pois teremos um rastreamento de pilha completo disponível.

5. Conclusão

Neste artigo, cobrimos as vulnerabilidades de injeção de SQL em aplicativos Java - uma ameaça muito séria para qualquer organização que depende de dados para seus negócios - e como evitá-las usando técnicas simples.

Como de costume, o código completo para este artigo éavailable on Github.