Кнопка “Войти через Google” стоит на каждом втором сайте. Снаружи это один клик. Под капотом - редиректы, code flow, токены, проверка state, привязка учётной записи и несколько доверенных сторон, каждая из которых может всё испортить.
Сами RFC здесь не главная проблема. База давно описана в
Ссылка скрыта от гостей
, а современная защитная практика уже отдельно собрана в
Ссылка скрыта от гостей
. OAuth 2.1 ещё не стал RFC, но направление там давно понятное: меньше легаси, меньше двусмысленностей, жёстче требования к безопасной реализации. Ломается всё в другом месте - там, где приложение склеивает библиотеку, поставщика удостоверения и собственную бизнес-логику. Именно поэтому уязвимости в OAuth так часто заканчиваются захватом аккаунта. Не потому что протокол “дырявый”, а потому что разработчик делегировал слишком много доверия библиотеке, внешнему поставщику или удобной, но плохой логике привязки пользователя.
Какие OAuth flow ещё встречаются в реальных интеграциях
В проде важны не названия, а то, какие сценарии ещё живут в интеграциях и где у каждого слабое место.Authorization Code
Это основной сценарий для серверных приложений. Пользователь проходит авторизацию через браузер, клиент получает authorization code, а потом меняет его на токен уже по прямому запросу к серверу авторизации.Именно здесь и лежит большая часть практических ошибок:
- мягкая проверка redirect_uri
- отсутствие PKCE там, где он уже должен быть обязательным
- отсутствие или бесполезная проверка state
- хранение кода в URL дольше, чем нужно
Implicit
Исторический сценарий, где токен возвращается прямо в браузер. Формально он давно считается плохой идеей, но в живых системах всё ещё встречается - особенно в старых SPA, плохо переживших миграцию.Если в запросе до сих пор видно response_type=token, это не экзотика и не “поддержка для совместимости”, а повод разбирать интеграцию внимательно. Токен в браузере - это почти всегда лишняя поверхность: история, JavaScript, postMessage, небрежные обработчики и утечки там, где их не ждали. RFC 9700 вычищает менее безопасные режимы из актуальной практики, а draft OAuth 2.1 идёт в ту же сторону. (
Ссылка скрыта от гостей
)Client Credentials
Здесь нет браузера и нет классических атак на редиректы. Но это не делает сценарий безопасным автоматически. Поверхность просто смещается в секреты, конфиги, журналы, репозитории и автоматизацию.Для захвата пользовательского аккаунта этот сценарий не главный. Для компрометации приложения и его доступа к API - вполне рабочий.
redirect_uri как точка захвата OAuth-потока
redirect_uri выглядит как чисто служебный параметр: адрес, куда вернуть пользователя после авторизации. В реальности это одна из самых опасных точек всего OAuth-процесса.Как только сервер авторизации проверяет redirect_uri мягче, чем exact match, начинается охота.
Exact match и ошибки валидации redirect_uri
Код:
https://app.example.com/callback/../other-page
https://app.example.com/callback?next=https://evil.example
https://app.example.com.attacker.com/callback
Как только проверка уходит в сторону “похож на нужный путь”, “начинается с нужной строки” или “содержит нужный домен”, начинают работать старые трюки:
Если проверка строится на префиксе, ломается путь и параметры запроса. Если на вхождении домена как подстроки - ломается хост. Если через wildcard по поддоменам - в игру заходят забытые поддомены и перехват чужого поддомена.
Проблема здесь не только в “неправильном редиректе”. В OAuth-контексте через этот редирект едет code или токен. А значит, цена ошибки сразу выше.
Open redirect как усилитель критичности
Отдельно redirect_uri может быть привязан жёстко и выглядеть безопасно. Но на разрешённом домене уже живёт открытый редирект, и этого достаточно.Цепочка в таком случае очень короткая:
- атакующий собирает запрос авторизации с валидным redirect_uri на домене приложения;
- внутри этого redirect_uri уже встроен переход на подконтрольный адрес;
- сервер авторизации честно возвращает пользователя с code;
- приложение само же и перебрасывает его дальше вместе с этим кодом.
Subdomain takeover и захват валидного callback
Если приложение разрешает любой *.example.com, а один из поддоменов давно висит на мёртвой инфраструктуре, появляется ещё один рабочий путь. Достаточно перехватить этот поддомен через dangling CNAME или аналогичную ошибку в DNS/облаке - и он внезапно становится валидной точкой возврата.Для старого Implicit это почти прямой захват токена. Для Authorization Code всё зависит от клиента, PKCE и логики обмена кода, но поверхность атаки уже появилась.
OAuth почти никогда не ломается в одном-единственном параметре. Чаще это сбой всей модели доверия: что считать личностью пользователя, чему доверять в токене и где заканчивается удобство, а начинается дыра. Если хочется посмотреть на эту тему шире - через IAM, OIDC и архитектуру идентификации, ознакомьтесь со статьей: "Управление идентификацией и доступом: Современные подходы".
Утечки code и token вне основного потока авторизации
OAuth любит утечки второго порядка. Не обязательно ломать весь flow. Иногда приложение само слишком небрежно обращается с code или token уже после успешной авторизации.Утечка через Referer и callback URL
Классический сценарий: callback-страница получила ?code=..., отрисовала интерфейс и потянула внешнюю картинку, аналитику, виджет или iframe. Браузер честно отправил Referer, а вместе с ним и URL, в котором всё ещё живёт authorization code.Это выглядит так:
HTTP:
GET /image.png HTTP/1.1
Host: cdn.analytics-provider.com
Referer: https://app.example.com/callback?code=abc123&state=xyz
Защита здесь скучная, но обязательная: принять code, сразу обменять, сразу очистить URL. Либо серверным 302, либо на клиенте через history.replaceState, если архитектура это допускает.
Утечка fragment через JavaScript и postMessage
С токеном во fragment история другая. В Referer он не уходит, но прекрасно доступен JavaScript. И как только рядом появляется небрежный postMessage, iframe-обновление токена или плохая проверка origin, токен начинает ездить туда, куда не должен.Типичный баг - проверка происхождения сообщения через подстроку вместо строгого совпадения. В этот момент “внутренний служебный обмен между окнами” превращается в утечку.
Browser history, access logs и побочные каналы
Code в query string попадает в историю браузера, журналы веб-сервера, WAF, обратного прокси и систем наблюдения. Fragment туда не уходит, но остаётся в истории и доступен фронтенду.OAuth плохо переносит не только активную атаку, но и обычную операционную небрежность.
State и nonce: защита контекста OAuth-запроса
Как только OAuth проходит через браузер, без привязки ответа к исходному запросу вся схема начинает работать не на того пользователя. И здесь до сих пор слишком много интеграций, где state и nonce формально есть, а реальной защиты от них нет.Login CSRF при отсутствии state
Если клиент принимает callback, не связывая его с тем, что сам только что отправил, получается очень неприятная атака.Атакующий проходит flow со своим аккаунтом, получает code и не использует его. Вместо этого подсовывает жертве ссылку на callback со своим code. Если приложение бездумно меняет его на токен и создаёт сессию, жертва оказывается залогинена в аккаунт атакующего.
Дальше начинается зло не в момент входа, а позже: жертва загружает документы, вводит платёжные данные, привязывает другие сервисы - и всё это оказывается в аккаунте, который контролирует атакующий.
state здесь существует ровно для того, чтобы этого не произошло.
State fixation и слабая генерация state
Второй класс ошибок тоньше: state формально присутствует, но его либо можно предсказать, либо он живёт слишком долго, либо переиспользуется, либо сравнивается не там, где должен.Если значение зависит от времени, от сессии слишком прозрачно или переживает несколько попыток входа, защита остаётся только на бумаге.
Ошибки проверки nonce в OpenID Connect
С nonce история похожая. На схеме он должен защищать от повторного использования ID-токена. В плохой интеграции он уезжает в запрос, потом возвращается в токене и нигде по-настоящему не сверяется.Как только nonce превращается в “ещё один параметр, который библиотека вроде бы поддерживает”, его практическая ценность исчезает.
Account linking и takeover через неверную модель идентичности
Самые неприятные баги часто живут вообще не в редиректах и не в PKCE, а в том, как приложение связывает локальную учётную запись с внешней личностью от поставщика удостоверения.Pre-account takeover через незавершённую регистрацию
Паттерн старый и до сих пор рабочий.Атакующий регистрирует аккаунт на адрес жертвы через обычную форму. Почта не подтверждается или без подтверждения аккаунтом всё равно можно пользоваться. Позже жертва входит через Google, Microsoft или другой внешний вход с тем же адресом. Приложение видит совпадение почты и решает, что это один и тот же пользователь. Внешний вход привязывается к уже существующей локальной учётной записи. Пароль от неё по-прежнему знает атакующий.
На бумаге это выглядит как удобная автопривязка. На деле - как захват учётной записи через плохую модель доверия.
Email как ненадёжный идентификатор пользователя
Главная ошибка здесь - использовать email как устойчивый идентификатор пользователя. Для отображения он полезен. Для сопоставления личностей - нет.У Microsoft Entra это уже давно проговорено достаточно прямо: mutable claims вроде email и upn не стоит использовать как устойчивый идентификатор. Там же существует xms_edov, который показывает, верифицирован ли адрес на уровне владельца домена, а через authenticationBehaviors можно управлять выдачей непроверенных адресов в клеймах. Но всё это не превращает email в надёжный первичный ключ. Правильный ориентир - неизменяемый идентификатор вроде sub или его эквивалента у конкретного поставщика. (
Ссылка скрыта от гостей
)Именно на этом месте в 2023 году и выстрелил nOAuth: приложение брало email как источник истины, а дальше вся логика доверия строилась на поле, которое не должно было играть эту роль.
PKCE: защита authorization code и типовые ошибки реализации
С PKCE обычно происходит одна и та же путаница. Все знают, что он “нужен”. Намного меньше людей могут быстро объяснить, от чего он защищает и как реализация умудряется всё сломать.PKCE нужен для защиты authorization code от использования тем, кто code перехватил, но не владеет code_verifier.
Механика короткая:
- клиент генерирует случайный code_verifier;
- вычисляет code_challenge;
- отправляет его в запрос авторизации;
- при обмене code на токен отправляет исходный code_verifier;
- сервер авторизации сверяет их и только тогда выдаёт токен.
Базовая механика PKCE
Код:
code_verifier -> случайная строка длиной 43-128 символов
code_challenge -> BASE64URL(SHA256(code_verifier))
Код:
code_challenge=...
code_challenge_method=S256
Код:
code_verifier=...
PKCE downgrade при необязательном code_challenge
Самый неприятный класс багов здесь - не “PKCE забыли включить”, а “PKCE вроде поддерживается, но его можно обойти”.Если сервер авторизации принимает запрос без code_challenge, а потом token endpoint всё равно готов выдать токен, потому что не связывает обе части flow друг с другом, защита существует только на бумаге.
Именно такой баг подтверждён cloudflare как
Ссылка скрыта от гостей
: реализация позволяла обойти PKCE полностью. Это хороший пример того, почему фраза “у нас PKCE поддерживается” сама по себе ничего не гарантирует.Ослабление проверки через fallback на plain
Ещё один плохой сценарий - мягкое обращение с code_challenge_method. На бумаге plain существует для особых случаев. На практике это почти всегда лишняя поверхность.Если сервер не жёстко помнит, какой метод использовался при выдаче code, и допускает отступления, PKCE быстро превращается из защиты в набор опций, из которых атаке подходит самая слабая.
Практика тестирования OAuth-интеграций
Очень легко уйти либо в хаотичный перебор параметров, либо в длинный чеклист без приоритета. На практике быстрее работает другой порядок: сначала собрать картину flow, потом бить по точкам склейки.Burp Suite: проверка redirect_uri, state и PKCE
Базовый порядок проверки выглядит так:- пройти весь OAuth-процесс через прокси;
- зафиксировать запрос на /authorize, callback и обмен на /token;
- отдельно проверить redirect_uri:
- другой домен
- другой путь
- параметры запроса
- неожиданные символы и кодировку
- поддомены
- проверить state:
- переживает ли callback другую сессию
- принимается ли повторно
- проверить PKCE:
- выдаётся ли code без code_challenge
- примет ли token endpoint запрос без code_verifier
- что происходит при чужом code_verifier
Browser DevTools: анализ flow на клиентской стороне
Здесь полезнее всего быстро понять форму интеграции:- используется code или token
- остаётся ли что-то чувствительное в URL
- чистится ли callback после обработки
- не живёт ли токен во фронтенде дольше, чем должен
Требования к безопасной реализации OAuth
Хорошая реализация OAuth обычно выглядит скучно. И это её сильная сторона.- redirect_uri - только exact match
- PKCE - обязателен, без “поддержки старых клиентов”
- state - случайный, одноразовый, жёстко связанный с сессией
- Implicit - выключен
- email - не идентификатор пользователя
- sub - да, если именно он у тебя выбран как главный ключ
- привязка внешнего входа - только через явное подтверждение
- code - живёт секунды и не задерживается в URL
- refresh token - под ротацией, повторное использование старого считается признаком компрометации
Где OAuth ломается в больших интеграциях
Самые неприятные OAuth-баги всплывают не на демо-интеграции с одним поставщиком удостоверения, а в большом проде, где есть несколько внешних входов, старые клиенты, своё правило привязки аккаунтов и куски легаси, которые “пока не успели переписать”.Там и начинается настоящая цена архитектурных сокращений.
Один поставщик удостоверения возвращает верифицированную почту почти всегда, другой - только в части сценариев. Один клиент уже перешёл на Authorization Code + PKCE, другой всё ещё живёт на старой модели. Один backend чистит callback URL сразу, другой оставляет code в истории браузера и журналах. Формально у компании “есть OAuth”. Практически у неё есть несколько разных систем доверия, которые случайно называют одним словом.
Именно поэтому OAuth-аудит почти никогда не заканчивается чтением RFC. Настоящие баги живут в местах склейки: redirect_uri, state, nonce, привязка учётных записей, клеймы, жизненный цикл токенов, выход, легаси-flow, который “пока не выключили”.
Последнее редактирование: