Рубрики
Без рубрики

SQL-инъекция и как Ее предотвратить?

Изучите ошибки кодирования в Java, которые могут привести к уязвимому приложению, и способы их предотвращения с помощью API, доступных в стандартной библиотеке времени выполнения JVM.

Автор оригинала: Philippe Sevestre.

1. введение

Несмотря на то, что SQL-инъекция является одной из самых известных уязвимостей, она по – прежнему занимает первое место в печально известном списке OWASP Top 10 – теперь это часть более общего списка Инъекция класс.

В этом уроке мы рассмотрим распространенные ошибки кодирования в Java, которые приводят к уязвимому приложению, и как их избежать с помощью API, доступных в стандартной библиотеке времени выполнения JVM. Мы также рассмотрим, какие средства защиты мы можем получить от ORM, таких как JPA, Hibernate и других, и о каких слепых зонах нам все равно придется беспокоиться.

2. Как приложения становятся уязвимыми для SQL-инъекций?

Инъекционные атаки работают, потому что для многих приложений единственный способ выполнить данное вычисление-это динамически генерировать код, который, в свою очередь, выполняется другой системой или компонентом . Если в процессе создания этого кода мы используем ненадежные данные без надлежащей очистки, мы оставляем открытую дверь для хакеров.

Это утверждение может показаться немного абстрактным, поэтому давайте рассмотрим, как это происходит на практике, на примере учебника:

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);
    // ...
}

Проблема с этим кодом очевидна: мы поместили значение CustomerID в запрос без какой-либо проверки . Ничего плохого не произойдет, если мы будем уверены, что эта ценность будет исходить только из надежных источников, но можем ли мы?

Давайте представим, что эта функция используется в реализации REST API для ресурса account . Использование этого кода тривиально: все, что нам нужно сделать, это отправить значение, которое при объединении с фиксированной частью запроса изменит его предполагаемое поведение:

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

Предполагая, что значение параметра CustomerID остается непроверенным до тех пор, пока не достигнет нашей функции, вот что мы получим:

abc' or '1' = '1

Когда мы соединяем это значение с фиксированной частью, мы получаем окончательный оператор SQL, который будет выполнен:

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

Вероятно, это не то, чего мы хотели…

Умный разработчик (разве не все мы?) сейчас бы подумал: “Это глупо! Я бы никогда не использовал конкатенацию строк для создания такого запроса”.

Не так быстро… Этот канонический пример действительно глуп, но есть ситуации, когда нам все еще может понадобиться это сделать :

  • Сложные запросы с динамическими критериями поиска: добавление предложений ОБЪЕДИНЕНИЯ в зависимости от предоставленных пользователем критериев
  • Динамическая группировка или упорядочение: API REST, используемые в качестве серверной части таблицы данных графического интерфейса

2.1. Я использую JPA. Я В Безопасности, Верно?

Это распространенное заблуждение . JPA и другие формы освобождают нас от создания вручную закодированных операторов SQL, но они не помешают нам писать уязвимый код .

Давайте посмотрим, как выглядит версия JPA предыдущего примера:

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());        
}

Та же проблема , на которую мы указывали ранее, также присутствует здесь: мы используем непроверенные входные данные для создания запроса JPA , поэтому здесь мы подвергаемся такому же эксплойту.

3. Методы профилактики

Теперь, когда мы знаем, что такое SQL-инъекция, давайте посмотрим, как мы можем защитить наш код от такого рода атак. Здесь мы сосредоточимся на нескольких очень эффективных методах, доступных в Java и других языках JVM, но аналогичные концепции доступны и в других средах, таких как PHP, .Net, Ruby и так далее.

Для тех, кто ищет полный список доступных методов, в том числе специфичных для баз данных , проект OWASP поддерживает Шпаргалку по предотвращению инъекций SQL , которая является хорошим местом, чтобы узнать больше об этом предмете.

3.1. Параметризованные запросы

Этот метод заключается в использовании подготовленных операторов с заполнителем вопросительного знака (“?”) в наших запросах всякий раз, когда нам нужно вставить пользовательское значение. Это очень эффективно и, если нет ошибки в реализации драйвера JDBC, невосприимчиво к эксплойтам.

Давайте перепишем наш пример функции, чтобы использовать эту технику:

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
}

Здесь мы использовали метод prepareStatement () , доступный в экземпляре Connection , чтобы получить Подготовленный оператор . Этот интерфейс расширяет обычный интерфейс Statement несколькими методами, которые позволяют нам безопасно вставлять пользовательские значения в запрос перед его выполнением.

Для JPA у нас есть аналогичная функция:

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

При запуске этого кода под Spring Boot мы можем установить свойство logging.level.sql для ОТЛАДКИ и просмотра того, какой запрос на самом деле построен для выполнения этой операции:

// 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=?

Как и ожидалось, слой ORM создает подготовленный оператор, используя заполнитель для параметра CustomerID . Это то же самое, что мы сделали в простом случае JDBC, но с несколькими утверждениями меньше, что приятно.

В качестве бонуса этот подход обычно приводит к более эффективному выполнению запроса, поскольку большинство баз данных могут кэшировать план запроса, связанный с подготовленным оператором.

Пожалуйста, обратите внимание , что этот подход работает только для заполнителей, используемых в качестве значений . Например, мы не можем использовать заполнители для динамического изменения имени таблицы:

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

Здесь JPA тоже не поможет:

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

В обоих случаях мы получим ошибку во время выполнения.

Основная причина этого заключается в самой природе подготовленного оператора: серверы баз данных используют их для кэширования плана запроса, необходимого для извлечения результирующего набора, который обычно одинаков для любого возможного значения. Это не относится к именам таблиц и другим конструкциям, доступным на языке SQL, таким как столбцы, используемые в предложении order by .

3.2. API критериев JPA

Поскольку явное построение запросов JQL является основным источником SQL-инъекций, мы должны по возможности использовать API запросов Jpa.

Для получения краткого руководства по этому API, пожалуйста, обратитесь к статье о запросах критериев гибернации . Также стоит прочитать нашу статью о метамодели JPA , в которой показано, как создавать классы метамоделей, которые помогут нам избавиться от строковых констант, используемых для имен столбцов, и ошибок во время выполнения, возникающих при их изменении.

Давайте перепишем наш метод запроса JPA, чтобы использовать API критериев:

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)

Здесь мы использовали больше строк кода, чтобы получить тот же результат, но преимущество в том, что теперь нам не нужно беспокоиться о синтаксисе JQL .

Еще один важный момент: несмотря на свою многословность, API критериев делает создание сложных сервисов запросов более простым и безопасным. Для полного примера, который показывает, как это сделать на практике, пожалуйста, взгляните на подход, используемый JHipster -сгенерированными приложениями.

3.3. Очистка Пользовательских Данных

Очистка данных-это метод применения фильтра к предоставленным пользователем данным, чтобы он мог безопасно использоваться другими частями нашего приложения . Реализация фильтра может сильно отличаться, но в целом мы можем классифицировать их на два типа: белые и черные списки.

Черные списки , состоящие из фильтров, которые пытаются идентифицировать недопустимый шаблон, обычно имеют мало значения в контексте предотвращения SQL – инъекций, но не для обнаружения! Подробнее об этом позже.

Белые списки , с другой стороны, работают особенно хорошо, когда мы можем точно определить, что является допустимым вводом.

Давайте улучшим наш метод безопасного поиска учетных записей по идентификатору клиента , чтобы теперь вызывающий абонент также мог указать столбец, используемый для сортировки результирующего набора. Поскольку мы знаем набор возможных столбцов, мы можем реализовать белый список с помощью простого набора и использовать его для очистки полученного параметра:

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
}

Здесь мы объединяем подход подготовленных операторов и белый список, используемый для очистки аргумента OrderBy //. Конечным результатом является безопасная строка с заключительным оператором SQL. В этом простом примере мы используем статический набор, но мы также могли бы использовать функции метаданных базы данных для его создания.

Мы можем использовать тот же подход для JPA, также используя преимущества API критериев и метаданных, чтобы избежать использования String констант в нашем коде:

// 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)

Этот код имеет ту же базовую структуру, что и в обычном JDBC. Сначала мы используем белый список для очистки имени столбца, а затем приступаем к созданию запроса Criteria для извлечения записей из базы данных.

3.4. Теперь Мы В Безопасности?

Предположим, что мы везде использовали параметризованные запросы и/или белые списки. Можем ли мы теперь пойти к нашему менеджеру и гарантировать, что мы в безопасности?

Ну… не так быстро. Даже не рассматривая Проблему остановки Тьюринга , есть другие аспекты, которые мы должны рассмотреть:

  1. Хранимые процедуры : Они также подвержены проблемам с внедрением SQL ; по возможности, пожалуйста, применяйте санитарию даже к значениям, которые будут отправлены в базу данных с помощью подготовленных операторов
  2. Триггеры: Та же проблема, что и с вызовами процедур, но еще более коварная, потому что иногда мы понятия не имеем, что они есть…
  3. Небезопасные прямые ссылки на объекты : Даже если наше приложение не содержит SQL-инъекций, все равно существует риск, связанный с этой категорией уязвимостей – основной момент здесь связан с различными способами, которыми злоумышленник может обмануть приложение, поэтому оно возвращает записи, к которым он или она не должны были иметь доступа – есть хорошая шпаргалка по этой теме доступна в репозитории GitHub OWASP

Короче говоря, наш лучший вариант здесь-осторожность. Многие организации в настоящее время используют “красную команду” именно для этого. Пусть они делают свою работу, которая заключается именно в том, чтобы найти любые оставшиеся уязвимые места.

4. Методы Борьбы С Повреждениями

В качестве хорошей практики безопасности мы всегда должны реализовывать несколько уровней защиты – концепция, известная как глубокая защита . Основная идея заключается в том, что даже если мы не сможем найти все возможные уязвимости в нашем коде – обычный сценарий при работе с устаревшими системами – мы должны, по крайней мере, попытаться ограничить ущерб, который может нанести атака.

Конечно, это было бы темой для целой статьи или даже книги, но давайте назовем несколько мер:

  1. Примените принцип наименьших привилегий: Максимально ограничьте привилегии учетной записи, используемой для доступа к базе данных
  2. Используйте методы, относящиеся к конкретной базе данных, доступные для добавления дополнительного уровня защиты; например, база данных H2 имеет параметр уровня сеанса, который отключает все литеральные значения в запросах SQL
  3. Используйте кратковременные учетные данные: Заставьте приложение часто менять учетные данные базы данных; хороший способ реализовать это с помощью Spring Cloud Vault
  4. Регистрируйте все: Если приложение хранит данные клиентов, это необходимо; существует множество доступных решений, которые интегрируются непосредственно в базу данных или работают в качестве прокси-сервера, поэтому в случае атаки мы можем, по крайней мере, оценить ущерб
  5. Используйте WAFs или аналогичные решения для обнаружения вторжений: это типичные примеры blacklist – обычно они поставляются с большой базой данных известных сигнатур атак и запускают программируемое действие при обнаружении. Некоторые также включают в себя агенты в JVM, которые могут обнаруживать вторжения с помощью некоторых инструментов-основное преимущество этого подхода заключается в том, что возможную уязвимость становится намного легче исправить, поскольку у нас будет доступна полная трассировка стека.

5. Заключение

В этой статье мы рассмотрели уязвимости SQL – инъекций в Java – приложениях-очень серьезную угрозу для любой организации, которая зависит от данных для своего бизнеса, – и как предотвратить их с помощью простых методов.

Как обычно, полный код этой статьи доступен на Github .