Привет, Хабр! Меня зовут Игорь, я занимаюсь разработкой серверной части в команде RuBackup.
В процессе своей работы мы с коллегами уделяем большое внимание вопросам безопасности наших приложений. SQL-инъекция — одна из самых серьезных угроз этой безопасности. Она заняла третье место в списке 25 самых опасных проблем в программном обеспечении за последние два года. Именно поэтому я решил собрать весь свой накопленный опыт и рассказать о митигации SQL-инъекций.
Наверняка многие из вас знают, что валидирование пользовательского ввода — краеугольный камень процесса безопасной разработки. С одной стороны, нам как разработчикам не хочется ограничивать пользователей в наборе таких входных данных, как символы и выражения, которые они могут использовать в работе, например, в паролях. С другой стороны, нельзя допустить выполнения в СУБД вредоносного кода, который приводит к SQL-инъекциям. А такие случаи время от времени происходят и становятся достоянием гласности. PostgreSQL дает разработчику возможность решить эту проблему экранированием потенциально опасных символов, превращая их в безопасные. Таким образом, для PostgreSQL будет вполне безвредно, если пользователь в качестве пароля использует строку "password' OR 1=1"
.
Для защиты от SQL-инъекций в прикладных библиотеках PostgreSQL libpq и libpqxx применяется техника «эскейпинг» или экранирование строки. Она заключается в том, чтобы убрать лишние символы разрыва строк в строках, содержащих специальные символы. С помощью этой функций символы удваиваются и более не считаются окончанием строки, а интерпретируются как обычные символы. Я буду рассматривать только библиотеку libpqxx, так как она, по сути, является С++ оберткой над более низкоуровневой С библиотекой libpq, где и реализованы все функции, о которых далее пойдет речь.
Экранирование строки
Писать запросы в виде монолитных строк очень просто. Но иногда мы используем запросы вида: "SELECT id FROM user WHERE name = '" + name + "'"
, где используется переменная, содержащая пользовательский ввод.
Это опасно. Видите ошибку? Если переменная name будет содержать одинарные кавычки, то это может привести к SQL-инъекции, то есть у пользователя появится возможность ввести вредоносный код, например ".'; DROP TABLE user"
. Если вам повезет, это окажется просто неприятной ошибкой, которую вы обнаруживаете, когда переменная name имеет значение "Д'Артаньян".
Итак, вам нужно экранировать переменную name перед ее вставкой. Здесь кавычки и другие проблемные символы будут помечены как «это просто символ в строке, а не конец строки». В libpqxx есть несколько специальных функций, которые делают это за вас.
Пример SQL-инъекции
Чтобы понять, что такое SQL-инъекции и почему их следует предотвращать, воспользуемся популярным примером. Представьте, что вы используете следующую инструкцию SQL, где-нибудь в своей программе:
TX.exec(
"SELECT number,amount "
"FROM account "
"WHERE allowed_to_see('" + userid + "','" + password + "')");
Это показывает вошедшему в систему пользователю важную информацию обо всех учетных записях, для просмотра которых у него есть разрешение. Строки идентификатора пользователя и пароля являются переменными, введенными самим пользователем.
Теперь представьте, что будет, если пользователь на самом деле является злоумышленником, который знает (или может предположить) общую форму этого SQL-запроса и введет следующий пароль:
x') OR ('x' = 'x
На первый взгляд это выглядит бессмысленно и, возможно, безопасно. Но если это выражение вставлено в строку SQL с помощью кода C ++, приведенного выше, запрос становится:
SELECT number,amount
FROM account
WHERE allowed_to_see('user','x') OR ('x' = 'x')
Это то, чего бы нам хотелось? Вероятно, нет! Выражение allowed_to_see()
полностью обходится выражением "OR ('x' = 'x')"
, которое всегда истинно. Таким образом злоумышленник получит доступ ко всем учетным записям в базе данных!
Использование функций esc
Вот как вы можете устранить проблему в приведенном выше примере:
TX.exec(
"SELECT number,amount "
"FROM account "
"WHERE allowed_to_see('" + TX.esc(userid) + "', "
"'" + TX.esc(password) + "')");
Теперь кавычки, встроенные в строку вредоносного кода, будут аккуратно экранированы. Они уже не смогут «вырваться» из заключенной в кавычки строки SQL, в которую они должны были входить:
SELECT number,amount
FROM account
WHERE allowed_to_see('user', 'x'') OR (''x'' = ''x')
Если внимательно посмотреть, то можно увидеть, что благодаря добавленным дополнительным эскейп-символам (в SQL одинарная кавычка экранируется путем ее удвоения) все, что мы получаем, — это очень странно выглядящая строка пароля, но не изменение в команде SQL.
Вы можете спросить, зачем же использовать метод класса транзакции для того, чтобы просто добавить к строке одинарные кавычки? Дело в том, что корректное экранирование зависит от свойств соединения, в первую очередь от текущей кодировки символов, и, следовательно, при игнорировании этих параметров могут выдаваться неправильные результаты. Таким образом, вызов метода экранирования требует наличие активного соединения с БД. Даже если вы используете объект lazy connection для экранирования строк, думая, что это быстрый и дешевый способ экранирования строки без установления соединения, то вызов esc все равно активирует соединение. Отсюда следует, что требование установления соединения с БД при экранировании — причина того, почему методы подобные esc не объявлены как const.
Также можно использовать метод quote для того, чтобы сразу и экранировать специальные символы, и заключить результирующую строку в одинарные кавычки. Например, метод quote преобразует строку abc в 'abc'.
Экранирование двоичных данных
Также библиотека libpqxx имеет и другие методы для экранирования, например, esc_raw. Этот метод призван экранировать двоичные данные для использования в качестве строкового литерала SQL в текущей транзакции. Необработанные двоичные данные обрабатываются иначе, чем обычные строки. Двоичные данные никогда не интерпретируются как текст, поэтому они могут вполне легитимно включать в себя одиночный байт или целые последовательности байтов, которые не являются допустимыми символами в текущей кодировке символов. Двоичная строка не заканчивается на первом нулевом байте, как в случае с текстовой строкой. Вместо этого такие строки могут содержать нулевые байты в любом месте. Если двоичная строка содержит байты, которые выглядят, как одинарные кавычки или что-то подобное, что может нарушить их использование в SQL-запросах, то такие символы будут заменены на специальные эскейп-последовательности.
Для использования в приложении ранее экранированных двоичных данных требуется их обратное экранирование с помощью метода unesc_raw. Этот метод принимает двоичную строку, ранее экранированную средствами libpqxx, и возвращает восстановленную копию исходных двоичных данных.
Дополнительные методы экранирования
Для экранирования строк-идентификаторов библиотека libpqxx предлагает нам использовать метод quote_name. Это нужно в случае, если идентификатор, выбранный пользователем, может содержать специальные символы, которые в противном случае не интерпретировались бы синтаксическим анализатором SQL, как часть идентификатора. Также это может быть полезно, когда идентификатор содержит символы верхнего регистра, и его требуется сохранить для пользователя. Метод quote_name использует двойные кавычки в качестве символа экранирования.
Отдельно хотелось бы отметить такую функцию, как esc_like. Она подготавливает строку для использования в LIKE части SQL-запроса. В данном случае обрабатываются спецсимволы % и _. Например, функция esc_like преобразует строку "a%b_c"
в "a\\%b\\_c"
.
Уменьшение угрозы SQL-инъекций
Что же сделать, чтобы митигировать угрозу SQL-инъекций при разработке ПО? Один из подходов — сохранение чистоты типов данных. То есть если от пользователя приходит неотрицательное число, значит, его нужно хранить в коде как unsigned и в базе как integer. Таким образом, если мы это значение используем в запросе, нам достаточно преобразовать его к строке:
TX.exec( "SELECT name FROM account WHERE id="+std::to_string(id));
Также полезно при использовании ключевых слов SQL, таких как ASC, DESC, AND и других, которые могут приходить от пользователя, скажем, через URL, вообще не подставлять их в код, а использовать прием белых списков.
const std::string order = (user_data == "DESC") ? "DESC" : "ASC";
Это же можно использовать и для идентификаторов в БД, если таких идентификаторов немного.
Для тех, кто интересуется данной темой и хочет узнать про SQL-инъекции чуть больше, я подобрал три материала:
Комментарии (15)
ladutsko
07.07.2023 14:50+10prepared statement?
AlexeyK77
07.07.2023 14:50+6самый правильный ответ (туда же доступ к БД только через хранимки с фиксированными параметрами), но не молодежно, не модно. Это ж надо в SQL таки разобраться, а никто не хочет ;)
FanatPHP
07.07.2023 14:50-2"Хранимки" тупо неудобны в поддержке. Да, надо разбираться. А еще накатывать миграцию каждый раз, когда в запросе поменяется одно слово. И при этом непонятно — зачем, если параметризованный запрос и так решает проблему.
AlexeyK77
07.07.2023 14:50+2зависит от задачи, размера системы, критичности данных и т.п.. Хранимки и VIEW - по сути рализуют инкапсуляцию данных от пользователя и предоставляют унифицированный интерфейс работы с данными. В общем реализуют те же концепции деления зон ответственности, что и ООП\модули.
FanatPHP
07.07.2023 14:50-1Ну вот и надо использовать ООП и модули.
Заставлять программиста постоянно писать код на этом недоязыке будет только садист.Я понимаю, когда действительно есть причина использовать процедуру, для обработки большого массива данных на стороне БД например. Но писать КАЖДЫЙ запрос через процедуру- это уже что-то нечеловеческое.
Возможно, в каких-то легаси системах, где пользователей тысячи, это и может быть оправдано. В большинстве же современных приложений, где пользователей у БД один-два, то кого-то от чего-то изолировать — это просто наживать себе геморрой на пустом месте.Эээ… и кстати. Эти соображения в любом случае не имеют отношения к исходной теме поста. Собственно, вызов процедуры из кода тоже надо минимально защищать. Понятно, что там пространство для манёвра меньше, но если API позволяет выполнение больше одного запроса за раз, то здравствуй мальчик Bobby Tables! И в итоге получается двойная работа — сначала пишем процедуру, чтобы защитить запрос от инъекции, а потом вызываем её в приложении, и уже этот вызов тоже защищаем от инъекции.
AlexeyK77
07.07.2023 14:50+2просто я сужу из опыта банковских core db, где пользователей действительно тысячи и они очень разные от юзеров до внешних систем. И тут полагаться что абсолютно везде снаружи будет сделано как надо - утопично.
ColdPhoenix
07.07.2023 14:50+7Даже без prepared statement, просто используйте параметризированные запросы.
Сложнее когда запрос динамический, но даже это решаемо.
Априори, если зачем-то нужно следить вручную, рано или поздно упустите это.
saboteur_kiev
07.07.2023 14:50+9В процессе своей работы мы с коллегами уделяем большое внимание вопросам безопасности наших приложений. SQL-инъекция — одна из самых серьезных угроз этой безопасности
Капец. Десять-двадцать лет спустя, когда эскейп функции уже интегрированы штатно практически в любой язык/фреймворк, в вашем продукте это до сих пор одна из самых серьезных угроз безопасности? Может нужно просто почитать документацию, как сейчас оформляют запросы в базу?
Сарказм конечно, но статья - слишком школьный уровень
UncleAndy
07.07.2023 14:50А биндинг параметров разве не устраняет проблему SQL-инъекций в принципе?
FanatPHP
07.07.2023 14:50+1Ну формально ещё могут быть идентификаторы и ключевые слова, защита которых тоже упоминается. Но биндинг мог бы сделать эту статью значительно короче, и превратить её из ассорти дублирующихся и противоречивых советов в простую инструкцию: данные биндим, остальное фильтруем через белые списки.
FanatPHP
07.07.2023 14:50Мда, согласен с предыдущими комментаторами.
Статья — просто коллекция заблуждений и "вредных советов".Например, прямо сходу получаем утверждение, которое содержит сразу два заблуждения, и верно только в одном — в том, что очень многие разработчики действительно их разделяют:
многие из вас знают, что валидирование пользовательского ввода — краеугольный камень процесса безопасной разработки
… и только немногие понимают, что валидирование пользовательского ввода не имеет ни малейшего отношения к безопасности. Потому что во-первых, валидирование относится к бизнес-логике, а не к безопасности. А во-вторых, здесь вводится чрезвычайно вредное понятие источника данных. И фокусируясь на "пользовательском" вводе разработчик пропускает инъекцию во всех остальных. Казалось бы — какой смысл вообще упоминать источник данных? Какая разница, от пользователя пришли данные, или или нашей БД? Зачем вообще вводить такой параметр? Ведь код будет банально проще, если в нем будет унифицированная обработка данных, независимо от того, "пользовательские" они, или нет.
Любые элементы, которые добавляются в запрос динамически, разделяются только на две категории: данные и все остальные элементы. Данные добавляются через параметры, а любые другие элементы через белый список. Всю статью можно свести к этим двум простым правилам.
И получится не только проще, но и безопаснее. Без возможности пропустить инъекцию второго порядка и без дурацких идей, что "если список в HTML форме формируется на сервере, то это, наверное, не пользовательский ввод"
Экранирование строки
Формально здесь всё верно.
Но дьявол в деталях. Многие поколения программистов выросли с убеждением, что экранирование защищает от инъекций. И как результат написали тонны уязвимого кода.Любое упоминание экранирования должно всегда писать большими буквами, что работает оно только для SQL строк. А числам или идентификаторам оно поможет как мёртвому припарки. Именно большими буквами. Простого упоминания недостаточно. Оно проходит мимо ушей. И экранирование воспринимается просто как волшебная защита любых элементов запроса.
И ещё мелкое замечание. Я не знаю, что вы понимаете под словами "символы разрыва строк", но русскоязычный читатель скорее поймет это как символы перевода строки. И удивится — при чем здесь инъекции.
quote_name
не защищает от инъекции, когда атакующий передаст валидное имя поля, но такое, которое ему не разрешено менять. Поэтому квотить идентификаторы надо, просто чтобы не нарваться на ключевое слово, но проверять их всё равно по белому списку. То есть в контексте защиты quote_name не должна упоминаться
Уменьшение угрозы SQL-инъекций
Меня всегда умиляет наличие этого раздела в подобных статьях. Как, собственно, и сам термин "митигация". Инъекции надо предотвращать, полностью. А не "уменьшать угрозу", подразумевая, что она остаётся.
Тем более, что в этом разделе свалено всё в кучу — и паллиатив с "чистотой типов", который является скорее вредным советом, не имеющим отношения к защите от инъекций, и настоящая защита через белые списки.
sergiodev
07.07.2023 14:50+2Митигация
Извините, но это слово зучит как болезнь какая-то... Вы бы лучше по-русски написали, было бы понятнее всем - "предотвращение".
FanatPHP
07.07.2023 14:50+1Вот кстати я тоже всегда думал, что митигация — это предотвращение. А оказывается, это всего лишь "смягчение негативных эффектов". То есть тут дело даже не в уродливом транслите, а в том что смысл получается совсем другим. "Митигация" тут даром не нужна. А нужна защита.
xxxphilinxxx
Интересно вы собираете свой накопленный опыт копипастом библиотечной документации https://libpqxx.readthedocs.io/en/6.4/a01477.html
FanatPHP
Справедливости ради, текст по ссылке составляет достаточно небольшую часть статьи. То есть вполне подходит под опыт, накопленный в том числе и из документации.