SQL-инъекции JDBC, JPA, Spring Data
Вступление
В данной статье речь пойдёт о таком типе уязвимости приложений как внедрение SQL кода (SQL injection / SQL-инъекция). Информации по данной теме в сети очень много, как в общем, так и с учётом специфики Java разработки:
- SQL Injection Prevention Cheat Sheet
- Чем опасны SQL-инъекции и как от них защититься
- SQL Injection in Java: Practices to Avoid
- Testing for SQL Injection
Суть уязвимости в том, что злоумышленник вместо простого текста, в котором система ожидает его имя, адрес, поисковый запрос и так далее, вводит специальные символы и обрывки SQL кода. Этот текст служит параметром какого-то SQL запроса в приложении. Если приложение уязвимо, то исходный SQL запрос удаётся подменить. Это может позволить получить информацию, к которой пользователь не имеет доступа, изменить данные ради своей выгоды, а также полностью уничтожить данные.
Кроме чудовищной опасности данной уязвимости, удивительна и лёгкость, с которой ей можно воспользоваться: не требуется специальных инструментов и сложных навыков, не нужен физический доступ к серверу или поддельный сайт.
С другой стороны, в мире Java разработки существует несколько простых правил, позволяющих практически полностью исключить возможности внедрения SQL:
- Не использовать конкатенацию строк в запросах к базам данных.
- Использовать PreparedStatement/CallableStatement вместо Statement. Библиотеки spring-jdbc, Hibernate, MyBatis, JOOQ и прочие также внутри используют PreparedStatement.
- Передавать параметры запросов с помощью методов setParameter, а не в самом запросе. (Автоматически следует из первых двух правил.)
Первое правило может быть ослаблено, так чтобы запрет конкатенации касался только параметров запроса, но ради выразительности и читаемости кода я предпочитаю его в таком виде.
Существуют и другие причины всегда писать программы следуя этим требованиям: корректность передачи параметров различных типов, производительность, краткость и выразительность кода. Поэтому кажется, что большинство разработчиков уже следует этим правилам и риск подверженности SQL-инъекциям в Java приложениях преувеличен. К сожалению, контрпримеры существуют, и поэтому сохраняется необходимость в повышенном внимании к данной проблеме. Например, на первой же странице поиска по Github c запросом "createStatement( ' language:java" мне удалось найти код в серьёзном проекте, потенциально подверженный внедрению SQL: github.com/stanfordnlp/CoreNLP... Возможно, автор предпринял усилия для защиты кода с помощью экранирования, но выбранный метод не является рекомендованным и надежным. Подробнее позже, иначе вступление будет слишком громоздким.
Я хочу провести несколько экспериментов, чтобы выяснить на практике, насколько просто получится воспроизвести уязвимость и устранить её, насколько критичен возможный ущерб, а также как обнаружить незащищенный код. Подопытной базой данных будет PostgreSQL, а в качестве клиента - Spring Jdbc (JdbcTemplate) и JPA (Hibernate).
Тестовый проект
Изучение уязвимости я буду проводить исключительно на слое работы с базой данных, то есть в рамках реализации DAO (Data Access Object) классов. В контексте веб-приложений контроллер или сервисный класс также может содержать логику проверки параметров (validate, sanitize), что безусловно снижает риск различных ошибок, в том числе косвенно и сценариев внедрения SQL. Это хорошая практика, но реализация DAO должна быть сама по себе устойчива к атакам.
Для начала рассмотрю самый простой и классический сценарий: поиск на равенство по строковому полю. Для иллюстрации принципов возникновения SQL-инъекций и защиты от них этого будет достаточно.
Предположительно, в приложении существует необходимость искать книгу по точному совпадению названия, которое пользователь вводит на сайте в текстовом поле. На уровне DAO это означает выполнение такого кода:
При использовании конкатенации строк в реализации возможно в названии книги указать символ одинарной кавычки, а далее прервать запрос, изменить условие поиска, выполнить произвольный запрос. Нужно лишь, чтобы результирующий SQL скрипт был синтаксически корректен, что достигается просто и элегантно: title =
Первое значение позволяет заменить условие поиска на безусловно верное, то есть получить всё содержимое таблицы. Второе приводит к неверному синтаксису запроса, потому что кавычка не закрыта, в результате чего он не выполняется. Но если его немного поправить, как в третьем случае, то очищается содержимое таблицы book. Это самый опасный вариант, в котором можно писать произвольные запросы, ограниченные лишь фантазией взломщика и правами пользователя базы данных.
В тестовом проекте попытка выполнить метод с параметром title = "';truncate book; select '1" изначально была успешна и, несмотря на исключение ниже, таблица очищалась. Исключение выбрасывается уже после успешного выполнения скрипта потому что JdbcTemplate ожидает только один результат, а на самом деле их несколько, ведь мы превратили один запрос в три.
После добавления аннотации @Transactional в классе JdbcBookRepositoryImpl очищение таблицы больше не происходит. Поведение программы при атаке стало таким:
- Атака изменяет запрос на такой: select * from book where title = '';truncate book; select '1'
- Запрос успешно выполняется
- При обработке результатов выполнения запроса выбрасывается исключение
- Транзакция откатывается
Можно сказать, что благодаря правильному использованию JdbcTemplate и Spring, вред от атаки удалось существенно уменьшить.
Реализация с использованием JPA при включении параметров в запрос с помощью конкатенации строк также подвержена внедрению SQL:
Хотя в этом случае атака сложнее: нужно действовать в рамках синтаксиса JPQL запросов, желательно знать имена классов и свойств. Синтаксис более строгий и менее функциональный. Из вышеперечисленных атак удаётся только изменение условия на полную выборку (title = "' or '1'='1"). Попытка завершить запрос и выполнить другой приводит к исключению:
Экранирование
Справедливо отметить, что реализации, рассмотренные ранее, не только уязвимы к внедрению SQL, но и вообще не полностью корректны. Поиск книг, содержащих апостроф (например, O'Reilly), приводит к некорректному синтаксису запроса. Это общая проблема представления текстовой информации: в URL, Html, CSS, Json, yaml и т.д. существуют служебные символы, которые необходимо экранировать. В Java, чтобы включить в строковую константу символ двойной кавычки, нужно использовать символ обратного слэша ("\""). В html чтобы отобразить символы '<' или '>' используются конструкции < и >. Иными словами, задача экранирования служебных символов встречается повсеместно. Решение исключительно прямолинейно: изучить формат представления текста, идентифицировать логику экранирования и... использовать библиотеку, в которой всё уже реализовано. Например, для html подойдут apache-commons, guava, HtmlUtils в Spring, OWASP encoder. Для экранирования одинарной кавычки в SQL запросе в соответствии со стандартом нужно её удвоить (''). В некоторых СУБД также используется обратный слэш (\').
Такая реализация корректно обрабатывает поиск книг с апострофом в названии и не подвержена SQL-инъекциям, по крайней мере тем, которые я пробовал ранее. Существует ли способ сломать такую защиту? Возможно. Точного ответа на этот вопрос найти не удалось, но многие сходятся в выводе, что такой метод защиты ненадежен и не рекомендован. В одной старой презентации на сайте OWASP описывается как можно обойти экранирование апострофа: https://owasp.org/www-pdf-archive/OWASP_IL_2007_SQL_Smuggling.pdf. Для этого используется символ апострофа в какой-то нестандартной локали, который может быть преобразован автоматически в нормальный на стороне базы данных, минуя проверку в коде программы: (U+02BC -> U+0027). Разобраться в деталях безумно интересно, но слишком уводит в сторону от основной темы.
Можно ли реализовать тот же приём, но с использованием проверенной надёжной библиотеки, учитывающей все служебные символы и отличия разных СУБД? Судя по всему, нет. Существует библиотека OWASP Esapi, которая, кроме прочего, призвана решить эту задачу и даже иногда применяется на практике:
Кодека для PostgreSQL нет, но реализация OracleCodec выполняет в точности то же, что функция escapeQuotes выше. Поэтому код работает, хотя выглядит очень странно - база данных ведь не Oracle, а PostgreSQL!
Причина, по которой я рассмотрел экранирование как возможное решение в том, что такой подход можно встретить. Например, в старом унаследованном коде. Предполагаю, что когда-то во многих проектах массово использовалась конкатенация строк для включения параметров в запросы. Потом в какой-то момент приходилось так же массово добавлять экранирование, потому что требовалось внести правки быстро и с минимальными изменениями, а переписывать код на использование параметризованных запросов намного более затратное и рискованное мероприятие. Не буду дальше углубляться в эту тему и перейду к правильному и общепринятому решению - параметризованным запросам.
Параметризованные запросы
Параметризованные запросы - концепция, которая противопоставляется динамически генерируемым запросам. Для вторых каждый раз, когда нужно выполнить поиск, система выполняет все шаги: формирование запроса конкатенацией, доставка запроса до базы данных средствами JDBC драйвера. На стороне БД: синтаксический разбор запроса, применение правил (rewrite system), оптимизация и выбор плана выполнения, выполнение.
Для параметризованных запросов на всех уровнях реализовано разделение статического запроса и его динамических параметров:
- В Java коде: connection.prepareStatement(..) отделено от pstmt.setParameter(...)
- Драйвер базы данных передает параметры отдельно от запроса
- База данных выделяет параметры из запроса и кэширует план выполнения без учета параметров
Используя такой подход, мы как разработчики, полагаемся на корректность реализации классов и библиотек Java, JDBC драйвера и самой СУБД. В контексте SQL-инъекций для работы такого понимания достаточно, но, безусловно, за внешней простотой скрывается большое количество тонкостей и нюансов. Например, в PostgreSQL существуют серверные параметризованные запросы, требующие пары запросов PREPARE - EXECUTE. Драйвер выбирает, сколько запросов кэшировать и в какой момент переходить с клиентского на серверный варианты. По умолчанию, четыре запроса будут клиентскими, а последующие - серверными. Изменть данное поведение можно вызовом метода pgStatement.setPrepareThreshold(...); Подробнее можно почитать здесь https://dev.to/yugabyte/postgres-query-execution-jdbc-prepared-statements-51e2
Далее приведу несколько реализаций с использованием параметризованных запросов. Все они корректны и не подвержены внедрению SQL. Работу непосредственно с Connection и PreparedStatement без использования вспомогательных библиотек я пропускаю - такой код слишком многословен, требует большого внимания к корректному управлению ресурсами (ResultSet, Statement и Connection), поэтому подвержен ошибкам.
Spring JdbcTemplate
JPA EntityManager, Named Query
Spring Data query methods, @Query
Синтаксис отличается, но во всех случаях присутствует общая семантика: параметр title отделён от запроса. Существует ещё множество способов доступа к данным в Java, можно использовать любой наиболее подходящий конкретному приложению. Снова возникает резонный вопрос - почему столько внимания уделяется SQL-инъекциям, если лучшее решение - просто применение современных подходов к работе с данными?
А есть ли проблема?
Согласно сайту www.statista.com, 23% всех критических уязвимостей в 2023 года относится к SQL-инъекциям, удерживающим лидерство в таблице. По другому отчёту 29% всех приложений всё ещё уязвимы к подобным атакам (Imperva Web Application Attack Report, WAAR, February 2020). Причин может быть много: унаследованный код, устаревшие примеры кода в сети, экономия. Возможно, что среди приложений на Java статистика выглядит лучше среднего, а на PHP - хуже. Но уже понятно, что в рамках мировой IT инфраструктуры проблема внедрения SQL сохраняется. Теперь следующий важный вопрос - а есть ли проблема у конкретного программиста? Как убедиться, что код, который добавляется в систему безопасен? Как убедиться, что нет старого кода, который уязвим? Как доказать начальству или внешнему аудиту, что приложение не подвержено данному типу атак?
В случае с новым кодом помогает процесс рецензирования (code review). Старый код тоже следует изучить и по возможности привести в соответствие со стандартами, принятыми в компании. Если старый код содержит большое количество потенциальных кандидатов для SQL-инъекций, и этом он плохо покрыт регрессионным тестированием, то можно применить экранирование, чтобы снизить риски атак до полноценного рефакторинга. Для полноценного аудита и уверенности в защищенности приложения применяются специальные инструменты, сканирующие код на предмет уязвимостей. Часть из них направлена на поиск уязвимостей методом черного ящика. Такие инструменты производят стандартные атаки на работающем приложении и собирают отчет из всех удачных попыток. Другая часть сканирует исходные файлы приложения, не требуя его запуска. Наверное, самый известный такой инструмент для Java - это SonarQube.
Поиск уязвимостей с помощью SonarQube
К сожалению, публичная версия SonarQube не обнаруживает SQL-инъекции. Другой мощный инструмент - VeraCode - вообще не имеет публичной версии. Возможно, удастся заполнить данный раздел позже. Хотелось бы проверить, сколько уязвимостей будет обнаружено в тестовом проекте, а также посчитает ли SonarQube экранирование достаточной защитой.
LIKE injection
Используя параметризованные запросы, об SQL-инъекциях можно забыть. Примерно такое заявление я встретил в одной из статей в сети. Давайте порассуждаем, насколько справедливо данное утверждение. Действительно, такой подход надежно защищает от классического "полноценного" внедрения SQL с подменой условия, прерыванием запроса, превращением его в синтаксически некорректный. С другой стороны, иногда сам параметр может содержать в себе служебные символы, которые в зависимости от логики работы приложения, разрешено или запрещено вводить пользователю. Например, запросы с оператором LIKE могут содержать символы подстановки (wildcard) '%' или '_'. Предположим, что в программе есть функционал поиска книги по первым символам названия, то есть по префиксу поля title. С точки зрения SQL это означает выполнение следующего запроса:
'Some Title' - значение, которое вводит пользователь, а '%' в конце программа добавляет самостоятельно. Пользователь может сам использовать символы подстановки, например, так: '%Java'. Тогда в результате вместо поиска по префиксу будет произведён поиск по подстроке. В отдельных случаях это может быть разрешено намеренно в качестве функциональности расширенного поиска. Но в большинстве случаев это ошибка и потенциальная уязвимость. Кроме неверных результатов поиска может пострадать производительность запросов: самый распространённый тип индекса btree работает при поиске по началу строки, но никак не помогает при поиске по подстроке. Существуют другие типы индексов, которые помогут, если нужен именно поиск по подстроке, подробнее можно почитать здесь https://www.cybertec-postgresql.com/en/postgresql-more-performance-for-like-and-ilike-statements/. Но будем исходить из того, что разрешён лишь поиск по префиксу и служебные символы нужно запретить или экранировать. Экранирование означает добавление обратной косой черты перед символами подстановки: '\%' и '\_':
Конструкция "escape '\'" позволяет выбрать символ экранирования, что необязательно для PostgreSQL если '\' нам подходит. Однако стандарт SQL не предусматривает никакого символа экранирования по умолчанию, поэтому так код становиться более универсальным. В частности, такие запросы можно выполнять одновременно на тестовой HSQLDB и эксплуатационной PostgreSQL.
Предлагаю для всех использованных ранее способов доступа к базе данных реализовать поиск по началу названия книги. Для экранирования будет использована функция escape из модуля spring-data.
Spring JdbcTemplate
JPA EntityManager, Named Query
Spring Data query methods, @Query
В примерах с использованием JdbcTemplate и EntityManager-NamedQuery добавлен явный вызов функции escape. Spring Data @Query требует довольно громоздкого выражения, которое к тому же не валидируется текущей версии IDE, но всё же работает корректно. Интереснее всего тот факт, что Spring Data Query Methods производит экранирование неявно. Такое поведение справедливо для методов 'StartingWith', 'EndingWith', 'Containing', 'NotContaining', причём судя по исходному коду текущей версии не похоже, чтобы его можно было отключить в конфигурации (см. класс org.springframework.data.jpa.repository.query.ParameterMetadataProvider).
Regular expression injection
Если речь зашла о внедрении SQL в LIKE запросы, то следует отметить, что такая же проблема будет актуальна для запросов типа title ~ 'expr' и 'title SIMILAR TO expr'.
Регулярное выражение обычно выполняется быстро, но может быть сформулировано так, чтобы потреблять произвольный объём памяти и времени исполнения. Правильное экранирование всех служебных символов в регулярном выражении также выглядит слишком сложной и потому сомнительной задачей. Думаю разумно в данном случае избегать сценариев, в которых введённый пользователем текст напрямую используется в запросе. Можно разрешить вводить строго типизированные данные или вообще выбирать только из списка разрешённых значений. Общие рекомендации по разработке защищенных приложений включают внимательное отношение к любым данным, приходящим от пользователя. Уязвимость log4shell показала, что опасность может представлять даже такая невинная операция как логирование.
Динамические запросы
Рассмотрим ещё один сценарий, который заслуживает внимания и связан с SQL-инъекциями. Речь пойдёт о запросах, в которых набор условий формируется в процессе выполнения. Например, из нескольких параметров нужно выбрать только те, которые заполнены. Подобные сценарии периодически возникают на практике и удачно, что на stackoverflow существует как раз такой вопрос: https://stackoverflow.com/questions/59855519/cannot-mitigate-sql-injections-using-owasp-esapi-veracode Автор вопроса использует StringBuilder для построения запроса. Статический анализатор кода VeraCode выявил уязвимость такого кода, после чего разработчик попытался использовать библиотеку OWASP Esapi для обработки пользовательского ввода и защиты от SQL-инъекций. Это не помогло, и в качестве рекомендаций все единогласно посоветовали не использовать конкатенацию и воспользоваться параметризованными запросами.
Рассмотрим несколько вариантов решения подобной задачи. Применительно к тестовому проекту сформулируем её так: найти книги, названия которых содержат хотя бы одно из указанных значений. Запрос будет выглядеть так:
Напрашивается прямолинейное решение с конкатенацией строк и методом Collectors.joining:
Такой код подвержен внедрению SQL, что можно исправить экранированием кавычек вручную или с использованием OWASP Encoder.encodeForSQL(...). Как мы уже знаем, этот способ не рекомендуется и статические анализаторы кода, вероятно, не воспринимают его всерьёз. Можно попробовать выйти из положения малой кровью: оставить конкатенацию, но параметры передавать при помощи PreparedStatement:
Просто формируем запрос типа "... like ? or like ? or like ?...", а затем вызываем метод setParameter нужное количество раз. Код работает корректно и защищён от внедрения SQL. Но его не назвать очень лаконичным и понятным, кроме того неизвестно как его воспримут статические анализаторы. Даже если в данный момент уязвимость не будет обнаружена, в будущем может появиться более строгая проверка. В таком случае можно провести внимательное рецензирование кода и исключить найденную потенциальную уязвимость как ложно положительную вручную в веб интерфейсе статического анализатора. Это очень неудобно, если Вы не сами принимаете решение, а отчитываетесь, например, перед внешним аудитом, потому что нужно идти по списку потенциальных уязвимостей и демонстрировать каждый отрывок кода, доказывая корректность его работы. Как на экзамене. К счастью, есть способ решения задачи, который намного лучше подходит для динамических запросов - JPA Criteria API. Тогда код будет выглядеть, например, следующим образом:
Субъективно минус Criteria API в том, что логика выборки данных размазана по нескольким строчкам кода, а не собрана в единый запрос. Поэтому по возможности я предпочитаю использовать SQL или JPQL запросы. Чтобы избежать конкатенации можно создать несколько константных запросов и выбирать подходящий условным оператором if или конструкцией switch в зависимости от комбинации параметров. Но это разумно только если таких констант понадобится две, три, максимум - четыре, и такой подход быстро выйдет из-под контроля при добавлении новых условий и параметров.
Заключение
В данной статье мы рассмотрели одну из самых опасных и часто встречающихся уязвимостей при разработке приложений, работающих с базой данных и получающих какую-то информацию от пользователя. Выяснили, что правильный способ защиты - использование параметризованных запросов. Было показано, как реализовать такой способ с применением различных библиотек. Но это не является "серебрянной пулей" и к данным, полученным от пользователя, всегда стоит относиться с подозрением. Дополнительной мерой защиты может служить экранирование служебных символов.