В этой статье поговорим о слепых SQL-иньекциях. Я расскажу, как автоматировать Blind SQL-инъекции.
Еще по теме: Поиск и взлом сервера базы данных MSSQL
Что такое «слепые» SQL-инъекции
Здесь мы разберем случаи, при которых возникают Blind SQL-инъекции, а также описана базовая техника их эксплуатации. Если ты уже c ними знаком и понимаешь, что такое бинарный поиск, — смело переходи к следующему разделу.
Blind SQL-инъекции возникают, когда мы не можем напрямую достать данные из приложения, но можем различить два разных состояния веб‑приложения в зависимости от условия, которое мы определяем в SQL-запросе.
Как работает Blind SQL-инъекция
Представим следующую инъекцию (для простоты используем язык PHP, а в качестве СУБД возьмем MySQL):
1 |
$query = "SELECT id,name FROM products ORDER BY ".$_GET['order']; |
Здесь мы можем указать в параметре order не только имя колонки, но и некоторое условие, например:
1 2 |
order=(if( (select 1=1),id,name)) order=(if( (select 1=0),id,name)) |
В зависимости от истинности логического выражения (1=1) или (1=0) результат будет отсортирован СУБД либо по колонке id, либо по колонке name. В ответе веб‑приложения мы увидим, по какой колонке отсортированы products, и сможем отличить истинное условие от ложного. Это позволяет нам «задавать вопросы» СУБД, на которые мы будем получать ответы «да» или «нет».
Например, «спросим» у СУБД — в таблице information_schema.tables больше 50 строк или нет?
1 |
order=(if( (select count(*)>50 from information_schema.tables),id,name))` |
Если говорить более формально, то за один запрос мы можем получить 1 бит информации из базы данных. Для получения текстовой информации можно делать полный перебор всех возможных символов следующим образом:
1 2 3 4 |
order=(if( (select substring(password,1,1)='a' from users where username='admin'),id,name)) order=(if( (select substring(password,1,1)='b' from users where username='admin'),id,name)) order=(if( (select substring(password,1,1)='c' from users where username='admin'),id,name)) ... |
Но это долго — нужно сделать столько запросов, сколько букв мы хотим проверить. Именно поэтому, чтобы ускорить процесс, прибегают к бинарному поиску.
Как выполнить бинарный поиск
Переведем символ искомой строки в его ASCII-код и сделаем некоторое предположение о возможных значениях этого символа. Например, можно предположить, что он лежит в нижней половине ASCII-таблицы, то есть имеет код в диапазоне от 0x00 до 0x7f. Разделим этот диапазон пополам и спросим у БД — в какой половине диапазона находится символ?
1 |
order=(if( (select ascii(substring(password,1,1))>0x3f from users where username='admin'),id,name)) |
Если символ окажется больше 0x3f, значит, целевой диапазон сужается до 0x40-0x7f, если меньше — до 0x00-0x3f. Далее находим середину диапазона и снова спрашиваем у БД: целевой символ больше середины или меньше? Потом снова сужаем диапазон и продолжаем до тех пор, пока в диапазоне не останется ровно одно значение. Это значение и будет ответом.
В данном случае для поиска точного значения символа нам потребуется ровно
запросов.
Еще один алгоритм оптимизации основывается на идее объединения всего ответа в одну строку через разделители. Это позволяет определить длину строки один раз и сэкономить запросы, однако при этом каждый разделитель увеличивает количество символов, которые необходимо определить.
Идея алгоритма
-
Для соединения колонок мы можем использовать операцию конкатенации соответствующей СУБД, ставя между ними редко используемый разделитель (например, 0x01). Строки (rows) мы также можем объединить с использованием специальных функции ( group_concat для MySQL, string_agg для PostgreSQL). В качестве разделителя строк будем использовать тот же самый разделитель 0x01.
-
Анализируя полученный ответ, мы сможем отличить начало новой строки (row), подсчитывая количество колонок. На определение длины строки мы потратим чуть больше запросов. Однако количество запросов примерно равно логарифму длины строки и, соответственно, растет медленнее, чем сумма запросов, которые мы потратили бы на определение длин каждой строки в отдельности.
-
Также в данном случае мы можем не определять длину строки вовсе, а просто обнаруживать символы, пока они не кончатся, как описано в разделе «Определение количества row и длины строк». Однако тогда мы не сможем оценить, сколько времени займет получение данных, что, скорее всего, неприемлемо на практике.
-
Количество запросов, которые будут потрачены на определение разделителей, зависит от используемого диапазона подбора. Разделитель сам по себе добавляется как один символ в диапазон. Суммарное количество разделителей равно количеству возвращаемых строк минус 1. Таким образом, можно сказать, что мы заменяем затраты на определение длин строк на затраты на определение разделителей.
-
Если считать, что на определение длины строки в среднем тратится 7 запросов, как указано в разделе «Определение количества записей и длины строк», то это позволяет сэкономить 111 запросов на каждую строку (где — диапазон поиска). Также мы теряем 111 запросов на каждый символ, т.к. добавляем разделитель в диапазон.
- Использовать разные диапазоны для поиска в разных колонках мы можем лишь ограниченно — так как мы используем многопоточность и не можем заранее знать — перевалим мы через разделитель при подборе очередного символа и, соответственно, попадем в другую колонку или нет. Поэтому либо мы должны использовать широкие диапазоны, что увеличивает 111 и снижает выигрыш от отсутствия необходимости определения длин строк, либо мы можем использовать соответствующие текущей колонке диапазоны и получать дополнительное количество ошибок — попаданий в канареечные символы с последующим повторным поиском.
Вывод: данная техника применима только в определенных случаях, например когда все целевые колонки имеют узкий диапазон.
Работа с UNICODE
В случае, когда в искомом тексте встречаются символы, отсутствующие в стандартной английской ASCII-таблице, СУБД применяют для хранения Unicode. Причем в некоторых СУБД (например, MySQL) по умолчанию применяется UTF-8, а в других (например, PostgreSQL) — UTF-16. В обоих случаях преобразование символа с помощью функции ASCII/UNICODE приведет к получению значения больше 128. Получение значения больше 128 и будет для нас триггером того, что в строке встречаются Unicode-символы.
Unicode-символы имеют характерный вид для различных языков. Например, русские буквы в UTF-8 в качестве первого байта имеют 0xDO или 0xD1. Если мы работаем с UTF-8, то мы можем предположить, что после одного UTF-8 символа с русской буквой будет идти еще один. Тогда первый байт этого символа мы можем эффективно обнаружить, используя алфавит из двух значений [0xD0,0xD1], а для второго байта мы можем ограничить поиск только значениями, которые соответствуют русским буквам.
Однако такой подход не очень хорошо совместим с многопоточным поиском: вероятность того, что символ, идущий через
символов после текущего, также является русским символом UTF-8, падает с ростом N.
В слове на русском языке в UTF-8 каждый второй символ будет 0xD0 или 0xD1, но после конца слова, скорее всего, будет стоять однобайтовый символ пробела или знака препинания. Это снижает вероятность того, что через четное число символов после встреченного 0xD0 также будет 0xD0 или 0xD1.
Вывод: при работе с Unicode возможным решением является использование одного потока на каждую строку.
Сжатие данных
Ряд СУБД поддерживают встроенные функции сжатия (например, COMPRESS для MySQL и UTL_COMPRESS.LZ_COMPRESS для Oracle). Соответственно, их применение позволяет уменьшить количество символов, которые нам требуется получить. При этом нам не нужно делать предположения об используемом алфавите: результат сжатия — BLOB. Мы будем использовать полный диапазон из 256 значений.
Нужно учитывать, что функции сжатия добавляют дополнительные данные в начало сжатого BLOB: размер изначальной строки и таблицу обратного преобразования. Таким образом, эффект имеется только для длинных строк, а для совсем коротких строк эффект вообще может быть отрицательным:
1 2 |
SELECT LENGTH(COMPRESS('123')) >> 15 |
Вывод: подобную технику имеет смысл применять совместно с техникой соединения строк. Объединенная строка будет длинной, и алгоритмы сжатия смогут сжать ее достаточно эффективно. Однако мы будем заставлять СУБД выполнять процедуру сжатия длинной строки при каждом запросе, чем можем увеличить время выполнения каждого запроса.
А что там sqlmap?
Работа с потоками
Sqlmap не использует многопоточность при определении количества записей или длин строк. Одновременно он ищет только символы одной строки.
Определение длины строки
Sqlmap ищет длину строки, если используется несколько потоков. Если используется один поток, то он считает, что строка закончилась, как только обнаружен первый символ 0x00.
Поиск символов
При поиске символов sqlmap использует следующую технику:
Первый символ ищется полным перебором из 128 символов (7 запросов).
Далее выбирается символ, с которого начнется следующий поиск на основании предыдущего обнаруженного символа:
если это цифра, то sqlmap выбирает 48 (ASCII-код минимального символа, цифры 0);
если строчная буква, то он выбирает 96 (код a);
если заглавная буква, то 64 (код A).
Далее происходит сравнение с этим определенным числом и используется обычный бинарный поиск в оставшемся диапазоне. То есть если предыдущий символ — буква, то делается сначала сравнение с 48, а затем, если искомое значение больше, делается поиск в диапазоне 48-127.
Единственное исключение: если оказывается, что слева от текущего сравниваемого символа нет ожидаемых, то он сразу переходит к сравнению с 1. Если сравнение с 1 показывает, что целевой символ меньше, то sqlmap считает, что нашел конец строки, и заканчивает поиск.
К плюсам данного подхода можно отнести эффективное обнаружение конца строки, особенно если последний символ строки — число. Тогда конец строки будет определен всего за 3 запроса. Но эта техника имеет и недостаток — низкая эффективность поиска в части случаев.
Предположим, что предыдущий символ был цифрой. Тогда первое сравнение делается с 48, и если следующий символ существует и больше 48 (а это наиболее вероятная ситуация), то на его определение будет использован диапазон из
символов, то есть
запросов. При этом один запрос уже будет сделан для сравнения с 48, так что суммарно sqlmap потратит 7,4 запроса (другими словами, появляется вероятность, что он потратит 8 запросов). При этом, если бы sqlmap делал полный бинарный поиск, то потратил бы ровно 7 и никак не больше.
Выбор диапазона символов
У sqlmap есть параметр --charset, с помощью которого можно задать диапазон символов, среди которых будет проводится бинарный поиск.
Unicode
Sqlmap умеет автоматически обнаруживать неоднобайтовые символы, однако работа с ними крайне медленная. В моем эксперименте на поиск одной русской буквы в среднем уходило 34 запроса.
Другие рассмотренные варианты оптимизации в sqlmap не реализованы (по крайней мере, я их не обнаружил).
Выводы
В этой статье я привел теоретический обзор основных способов оптимизации эксплуатации Blind SQL-инъекций. Какие‑то из них более эффективны, какие‑то менее, но я попытался привести наиболее полный перечень. Если у тебя есть другие идеи и техники оптимизации — пиши мне в телеграм @sorokinpf, обсудим!
Постепенно я планирую реализовать некоторые из описанных способов в своем фреймворке для эксплуатации Blind SQL-инъекций — sqli_blinder (да, с фантазией у меня так себе). На данный момент фреймворк реализует банальный бинарный поиск, поддерживает работу с SQLite, MSSQL, Oracle, MySQL и PostgreSQL. Для работы с ним необходимо написать одну функцию на python, которая определит, куда фреймворку подставлять запросы и как анализировать ответы