Как передать пароль по Интернету? Обычно приобретается сертификат SSL, а TLS выполняет задачу безопасной перемещения пароля от клиента к серверу. Разумеется, всё не так сухо, как пытаюсь представить я, но в целом это так и подобный подход прошёл проверку временем. Однако так было не всегда, и один невероятно популярный онлайн-магазин предпочёл добавить к этому процессу что-то своё. В этой статье я расскажу об уникальном способе входа в систему пользователей Steam и исследую глубокую кроличью нору удивительных подробностей его реализации.
Выявляем очевидное
Я нашёл на StackOverflow датированный 2013 годом вопрос о том, как безопасно передавать пароль по HTTP. Ответы оказались достаточно единодушными: надо получить сертификат SSL. Проведите эксперимент: настройте любимый прокси перехвата трафика, зайдите в сервис, которым вы часто пользуетесь, выполните вход со своим аккаунтом (а лучше каким-нибудь одноразовым) и изучите результаты. С большой вероятностью вы увидите, что имя пользователя и пароль передаются в открытом виде в теле запроса HTTP. Единственная причина того, что это работает, заключается в том, что ваше соединение с сервером зашифровано при помощи TLS.
Странно думать, что когда-то это было проблемой
Однако в начале 2010-х, не говоря уже о более давнем времени, Интернет был иным. Теперь у нас есть сервисы наподобие Let’s Encrypt, выдающие бесплатные сертификаты SSL с трёхмесячным сроком действия и возможностью автоматического обновления. Тогда почти не было иных вариантов, кроме приобретения сертификата SSL за деньги, но обычно при этом можно было получить более длительные сроки действия и поддержку. Конечно, вы можете сказать, что за безопасность и приватность пользователей стоит платить, но это не мешает появляться вопросам наподобие приведённого выше.
Итак, мы пришли к понимаю, что TLS важен, а теперь давайте его заменим. Представим, что мы не можем передавать пароли через HTTPS и нам каким-то образом нужно реализовать это на чистом HTTP, обеспечив при этом какой-то уровень безопасности. Существует стандартизованный и широко применяемый заголовок Authorization
. Однако, в сочетании со схемой аутентификации HTTP «Basic» при использовании с чистым HTTP он не обеспечивает никакой защиты.
Есть проверенные и протестированные алгоритмы запроса-ответа, наиболее примечательным из которых является SRP, предназначенный для парольной аутентификации без передачи пароля, но их, вероятно, придётся реализовывать самостоятельно, и даже небольшой недосмотр может привести к серьёзному ущербу. Также можно поручить аутентификацию внешнему сервису. Схема «вход при помощи сервиса XYZ» широко распространена, но она связана с определёнными последствиями. С учётом всего этого, можно сказать, что передача секретов по не предназначенному для безопасности соединению — это нетривиальная задача.
Поэтому когда мы с другом решили изучить Steam в поисках следов информации, позволяющей идентифицировать людей, меня удивило то, что для обеспечения безопасности страница логина Steam использует только TLS.
Крипто-вишенка на торте
Снова запустим любимый прокси перехвата трафика и перейдём на страницу логина Steam. Введём имя пользователя и пароль, после чего нас попросят (по крайней мере, должны) ввести одноразовый токен, сгенерированный выбранным вами способом двухфакторной аутентификации. На этом можно остановиться, потому что магия, которую я хочу продемонстрировать, уже произошла. Вы заметите, что при нажатии кнопки логина запускается запрос странной конечной точки: /login/getrsakey
, за которой следует /login/dologin
.
Последовательность всех соответствующих ресурсов и запросов
Если изучить запрос к /login/getrsakey
, то мы обнаружим ответ в формате JSON, содержащий поля с названиями, хорошо знакомыми всем, кто хотя бы немного знает о криптографии с открытым ключом. Нам передаётся открытый ключ RSA, хотя конкретные значения могут выглядеть немного странно. Очевидно, что publickey_mod
и publickey_exp
определяют используемые в шифровании модуль и экспонента, однако первый задаётся в шестнадцатеричном виде, а вторая — в двоичном (я вернусь к этому позже). Есть также метка времени, начальную точку которой можно определить не сразу. С назначением token_gid
я пока не разобрался.
{
"success":true,
"publickey_mod":"c85ba44d5a3608561cb289795ac93b34d4b9b4326f9c09d1d19a9923e2d136b8...",
"publickey_exp":"010001",
"timestamp":"1260462250000",
"token_gid":"2701e0b0a4be3635"
}
Страница логина при загрузке подгружает скрипты. В совершенно необфусцированном login.js
содержится основной обработчик логина, поэтому любой может просто проанализировать его и разобраться, что он делает. Кроме того, сайт загружает дополнительные зависимости, в частности, jsbn.js
и rsa.js
.
Поиск по имени, указанному в первой строке jsbn.js
, позволил определить, что эти два скрипта написаны выпускником MIT и Стэнфорда Томом Ву, любящим проектирование ПО и компьютерную криптографию. Он выпустил jsbn.js
и rsa.js
как реализацию на чистом JavaScript целых чисел с произвольной точностью и шифрования/дешифрования RSA. Также мы можем выяснить, что эти библиотеки в последний раз обновлялись в 2005 и 2013 годах, но к этому я вернусь позже. Пока просто запомним это.
Спускаемся в кроличью нору
Итак, у нас есть все нужные ресурсы, и мы можем углубиться в login.js
. Его код довольно хаотичен, со множеством обратных вызовов и вызовов прокси-функций, но самые интересные части можно найти довольно быстро. По сути, скрипт можно свести к паре шагов. Каждый из шагов предполагает, что на предыдущем всё прошло правильно.
- Пользователь вводит своё имя пользователя и пароль, а затем нажимает кнопку логина.
- Вызывается
DoLogin
, который проверяет, правильно ли заполнена маска логина и выполняет запрос к/login/getrsakey
. - Вызывается
OnRSAKeyResponse
. Он проверяет, правильно ли сформирован запрос. - Вызывается
GetAuthCode
. Он выполняет какой-то платформно-зависимый код в случае, если для аккаунта пользователя включены способы двухфакторной авторизации. - Вызывается
OnAuthCodeResponse
. Здесь пароль шифруется RSA, подготавливается и выполняется запрос к/login/dologin
. - Вызывается
OnLoginResponse
. Пользователь выполняет вход и перенаправляется в магазин Steam.
Код в OnAuthCodeResponse
показывает, почему запрашиваемый открытый ключ форматирован именно таким образом. Начиная со строки 387 исходного файла модуль и экспонента ответа /login/getrsakey
передаются в неизменном виде библиотеке RSA. Затем пароль пользователя шифруется переданным открытым ключом и добавляется в запрос к /login/dologin
на следующем этапе логина.
var pubKey = RSA.getPublicKey(results.publickey_mod, results.publickey_exp);
var username = this.m_strUsernameCanonical;
var password = form.elements['password'].value;
password = password.replace(/[^x00-x7F]/g, ''); // remove non-standard-ASCII characters
var encryptedPassword = RSA.encrypt(password, pubKey);
Я скопировал файлы исходников на локальную машину, чтобы подробнее изучить библиотеку RSA. Модуль и экспонента передаются функции RSAPublicKey
, которая ведёт себя как конструктор в «доклассовой» эпохе JavaScript. RSAPublicKey
просто оборачивает значения в экземпляры BigInteger
предоставленные скриптом jsbn.js
. К моему удивлению, экспонента представлена не в двоичном виде, а как и модуль, в шестнадцатеричном. (Кроме того, выяснилось, что 0x010001
— это очень популярная экспонента шифрования в реализациях RSA.) То есть теперь понятно, что шифрование паролей основано на 2048-битном RSA с экспонентой шифрования, равной 65537.
let r = RSA.getPublicKey("c85ba44d5a360856..." /* insert your own long modulus here */, "010001");
console.log(r.encryptionExponent.toString()); // => "65537"
console.log(r.modulus.bitLength()); // => 2048
Перейдём к полю timestamp
. Ответ /login/getrsakey
содержит заголовок Expires
. Он ссылается на дату в прошлом, то есть ответ совершенно не должен каким-то образом кэшироваться или сохраняться. Если следить за /login/getrsakey
дольше, то мы заметим, что открытый ключ постоянно часто меняется, как и метка времени. Это означает, что есть ограниченное окно времени, в течение которого конкретный открытый ключ RSA, выданный Steam, может использоваться для аутентификации.
Это становится ещё очевиднее при изучении последующего запроса к /login/dologin
. Среди всего остального он содержит имя пользователя, зашифрованный пароль, а также метку времени выданного открытого ключа RSA. Попытка выполнить логин при изменении метки времени, как и ожидалось, оканчивается неудачей. Но важнее то, что невозможно повторно использовать старый открытый ключ даже при правильном шифровании пароля.
Я сделал ещё один шаг и написал простой скрипт на Python для сбора открытых ключей одноразового аккаунта на протяжении трёх дней. При помощи cronjob я запускал его каждые пять минут. Я хотел проверить, как часто меняется открытый ключ Steam и если удастся, понять, как ведёт себя поле timestamp
.
Целая куча открытых ключей
Я выяснил, что открытый ключ меняется через каждые 12 попыток ввода, то есть можно логично предположить, что они заменяются каждый час. Экспонент шифрования остаётся тем же, здесь ничего неожиданного. Однако более интригующим оказалось вышеупомянутое поле timestamp
. Для каждых 12 открытых ключей значение timestamp
увеличивается на определённую величину, а именно на 3600000000. Кроме того, как видно на изображении выше, это число спустя определённое время зацикливается. Предупреждаю, дальнейшие рассуждения полны неподтверждённых догадок.
Поле timestamp зацикливается
Я выяснил, что 3600000000 микросекунд равно одному часу, поэтому предположил, что значение поля timestamp
на самом деле задаётся в микросекундах. Однако я уже говорил, что значение метки времени с каждым новым открытым ключом не увеличивается ровно на час. На основании собранных данных я заметил, что разница между двумя последовательными метками времени равна одному часу плюс 1-2,6 секунд, и большинство из них имеет порядок 1,05-1,25 секунд. Но в таком случае возникает ещё одна интересная возможность.
Предположим, что новый открытый ключ генерируется каждый час плюс одна секунда. Если выполнять запрос к конечной точке ровно каждые пять минут (пока полностью игнорируя сетевую задержку), то есть вероятность, что я встречу один и тот же открытый ключ не 12, а 13 раз подряд. Это должно произойти, когда запрос совпадает по времени с генерацией нового открытого ключа. К счастью, поскольку это время порядка секунды, допуск на ошибку не так уж мал.
Разными цветами показаны уникальные открытые ключи (без соблюдения масштаба!)
Изучив мой собственный набор открытых ключей, я выяснил, что не сталкивался ни с одним таким пограничным случаем. Возможно, мне просто не повезло, или дело в том, что я рассуждал гипотетически, ожидая какого-нибудь откровения. Кроме того, при увеличении колебаний значений меток времени становится сложнее прогнозировать конкретный момент возможности наблюдения пограничного случая — если, конечно, такая ситуация вообще возникает.
Но помните, эта разница в час и одну-две секунды находится между двумя уникальными открытыми ключами. Вернёмся к допущению о том, что новый открытый ключ создаётся через каждый час и секунду. Тогда после 3600 публичных ключей все эти дополнительные секунды суммируются в полный час, что приведёт к описанному в предыдущем параграфе пограничному случаю. Если разница времени происходит между полным часом на часах и меткой времени открытого ключа, то всё становится понятным и эти дополнительные секунды связаны с задержкой сети. Однако для собранных мной данных это не так, поэтому ситуация озадачивает, если не сказать больше.
Итак, подведём итог: если все мои допущения были верны, то поле timestamp
и его временная разница между открытыми ключами невероятно загадочны. Нужна ли она для учёта високосных лет? Или для компенсации какой-то другой задержки? Может, это просто ошибка реализации, которую Valve оставила? Возможно, она выражена не в микросекундах, а в чём-то более произвольном? Может, она нужна просто для того, чтобы поломали голову любопытные нерды вроде меня? Я склоняюсь к последней версии.
Я понимаю, что пропустил странное зацикливание значения поля timestamp
и даже не касался назначения поля token_gid
. Думаю, что первое нужно из-за какого-то технического ограничения, а последнее — предположительно, какой-то способ защиты от CSRF (межсайтовой подделки запроса) или уникальный идентификатор. Это совершенно необоснованные догадки, потому что я и так уже выяснил из этого исследования больше, чем ожидал. Если хотите изучить вопрос самостоятельно и поделиться своими находками, буду рад, если вы свяжетесь со мной, или по электронной почте, или в Twitter.
Ещё один достойный упоминания аспект: при запросе конечной точки открытого ключа с разными именами пользователей получаешь разные ответы. Непонятно, или открытые ключи берутся из пула и каждому пользователю присваивается своё смещение метки времени, или они на самом деле генерируются на лету. Кроме того, в запросе к /login/getrsakey
можно использовать любое произвольное имя пользователя. Оно не обязательно должно быть зарегистрировано в Steam. Можете использовать эту информацию по своему усмотрению.
Хорошо, но что это значит?
В процессе исследования этой темы во мне зародилась странная любовь к механизму логина Steam. Теперь я знаю, что поверх использования TLS (который и нужно применять) при выполнении входа пользователя Steam также использует 2048-битный RSA для шифрования паролей пользователей при помощи системы чередующихся открытых ключей, которая корректно признаёт недействительными старые ключи и для каждого пользователя действует по-своему. Все эти труды кажутся очень избыточными, потому что для защищённого логина пользователей вполне достаточно сертификата SSL.
Поэтому возникает вопрос: зачем заморачиваться созданием такой странно замысловатой системы поверх механизма, вполне достаточного самого по себе? У меня есть теория, но помните — это всего лишь догадка.
Помните даты выпуска библиотек BigInteger
и RSA? Кроме того, страница логина по-прежнему использует в качестве источника jQuery версии 1.8.3, выпущенный в ноябре 2012 года. Всё это указывает на простой факт — механизм логина практически не менялся в течение почти десятка лет. А как я сказал в начале этого поста, в те времена Интернет был совершенно иным.
О! Я нашёл опечатку в changelog jQuery 1.8.3! Есть ли какая-нибудь программа вознаграждений за поиск грамматических ошибок?
В современном вебе развивается концепция «HTTPS повсюду», однако современная ситуация стала результатом долгого и мучительного процесса. Моя теория заключается в том, что в былые времена так Steam обеспечивала слой безопасности пользователей, которые случайно или намеренно не попали на SSL/TLS-версию сайта логина. Благодаря этому даже если третья сторона сможет проанализировать все данные, передаваемые между пользователем и серверами Steam, она, по крайней мере, не сможет узнать его пароль (без мощных вычислительных ресурсов).
Я попытался связаться с сотрудником Valve, который точно работает над магазином Steam. Я кратко изложил ему свой анализ и теорию. Я спросил его, может ли он подтвердить это, или, может быть, знает кого-нибудь, работавшего в компании во время создания этого способа логина. Разумеется, я знаю, что у сотрудников Valve есть дела поважнее, чем отвечать на несрочную и неделовую просьбу какого-то нерда. На момент написания статьи я по-прежнему ожидаю ответа, поэтому могу только предложить свою собственную обоснованную догадку. Как бы то ни было, это исследование оказалось очень, действительно очень интересным. Исследовано ещё не всё и на этом я не закончу.