Перейти к основному содержимому

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

· 11 мин. чтения

Задача: Сумма двух чисел

Напишите функцию twoSum. Которая получает массив целых чисел nums и целую сумму target, а возвращает индексы двух чисел, сумма которых равна target. Любой набор входных данных имеет ровно одно решение, и вы не можете использовать один и тот же элемент дважды. Ответ можно возвращать в любом порядке...

ANDROMEDA

1. Введение

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

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

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

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

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

public List<AccountDTO>
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 для ресурса учетной записи . Использование этого кода тривиально: все, что нам нужно сделать, это отправить значение, которое при объединении с фиксированной частью запроса меняет свое предполагаемое поведение:

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'

Наверное, не то, что мы хотели…

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

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

  • Сложные запросы с динамическими критериями поиска: добавление предложений UNION в зависимости от заданных пользователем критериев
  • Динамическая группировка или упорядочение: REST API, используемые в качестве серверной части для таблицы данных графического интерфейса.

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

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

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

public List<AccountDTO> unsafeJpaFindAccountsByCustomerId(String customerId) {    
String jql = "from Account where customerId = '" + customerId + "'";
TypedQuery<Account> 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 поддерживает Cheat Sheet по предотвращению инъекций SQL , который является хорошим местом для получения дополнительной информации по этому вопросу.

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

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

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

public List<AccountDTO> 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 , чтобы получить PreparedStatement . Этот интерфейс расширяет обычный интерфейс Statement несколькими методами, которые позволяют нам безопасно вставлять пользовательские значения в запрос перед его выполнением.

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

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

При запуске этого кода под Spring Boot мы можем установить для свойства logging.level.sql значение DEBUG и посмотреть, какой запрос фактически построен для выполнения этой операции:

// 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-инъекций, мы должны отдавать предпочтение использованию JPA Query API, когда это возможно.

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

Давайте перепишем наш метод запроса JPA для использования Criteria API:

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

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

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

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

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

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

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

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

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

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

public List<AccountDTO> 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, также воспользовавшись Criteria API и метаданными, чтобы избежать использования строковых констант в нашем коде:

// Map of valid JPA columns for sorting
final Map<String,SingularAttribute<Account,?>> 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<Account,?> orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy);
if (orderByAttribute == null) {
throw new IllegalArgumentException("Nice try!");
}

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

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

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

3.4. Теперь мы в безопасности?

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

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

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

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

4. Методы контроля повреждений

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

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

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

5. Вывод

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

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