Привет, Хабр! Меня зовут Игорь, я занимаюсь разработкой серверной части в команде 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-инъекции чуть больше, я подобрал три материала: