Field note

$1,000 CSRF до полного захвата аккаунта в Verisign (DomainScope)

Author: Mahmoud Ouf min read

Примечание: Чувствительные значения (коды авторизации, cookies, session ID) заменены на [REDACTED]. Хост и логика потока сохранены для воспроизводимости.

Краткое резюме

Выявлена и подтверждена уязвимость Cross-Site Request Forgery (CSRF) в потоке привязки Google OAuth сервиса Verisign DomainScope (domainscope.com). Callback /connect/google не проверял параметр state. Злоумышленник мог заставить аутентифицированную сессию жертвы привязать Google-аккаунт злоумышленника к профилю DomainScope жертвы, а затем войти как жертва через Google OAuth. Последствие — полный захват аккаунта без знания пароля жертвы. Сообщено через Bugcrowd в апреле 2018 года в рамках авторизованной программы Verisign bug bounty, проверено и вознаграждено $1,000, отмечено в Verisign Hall of Fame. Находка сделана в ходе участия в программе Bugcrowd в рамках авторизованного тестирования на проникновение.

Контекст — поток привязки OAuth в DomainScope

На момент тестирования Verisign DomainScope поддерживал два связанных потока аутентификации:

  • Основной вход: Пользователи могли входить в DomainScope через LinkedIn OAuth.
  • Привязка аккаунта в My Profile: Аутентифицированный пользователь мог перейти в My Profile → Link Google Account, чтобы связать Google-идентичность со своим существующим профилем DomainScope. После привязки пользователь мог далее аутентифицироваться в DomainScope через Sign in with Google.

Предполагаемый поток OAuth 2.0 для привязки выглядел так:

  1. Пользователь нажимает Link Google Account, будучи аутентифицированным в DomainScope.
  2. Браузер перенаправляется на https://accounts.google.com для авторизации Google.
  3. Google перенаправляет обратно на callback DomainScope GET /connect/google?code=...&state=... с кодом авторизации и значением state, которое должно быть привязано к сессии пользователя.
  4. DomainScope обменивает код на токены и привязывает возвращённую Google-идентичность (email, profile) к текущему аутентифицированному аккаунту DomainScope.

Параметр state определён в RFC 6749 как защита от CSRF для OAuth. При привязке внешней идентичности к уже аутентифицированному аккаунту state критически важен. Без него действие привязки не привязано к пользователю, который его инициировал.

Уязвимость — отсутствие проверки state позволяет CSRF

Проанализирован callback /connect/google и задокументировано, что параметр state не проверялся. Приложение принимало callback, содержащий только code и scope, без требования криптографически случайного state, привязанного к сессии жертвы.

Подтверждённое поведение:

  • Перехват callback Google OAuth показал, что запрос можно воспроизвести без значения state.
  • Удаление параметра state всё равно приводило к успешной привязке между сессией DomainScope и Google-идентичностью, представленной кодом code.
  • На этом endpoint привязки на момент тестирования не наблюдалось ни анти-CSRF токена, ни enforcement SameSite, ни серверной привязки к сессии.

В привязке OAuth-аккаунтов отсутствие проверки state превращает callback в CSRF-эндпоинт. Злоумышленник может подготовить валидный код авторизации для своего собственного Google-аккаунта и заставить браузер жертвы доставить его в сессию DomainScope жертвы. Аккаунт жертвы становится привязанным к Google-идентичности злоумышленника.

Этот класс OAuth CSRF часто пропускают при тестировании, поскольку ревьюеры фокусируются на потоках логина и упускают потоки привязки/отвязки в настройках профиля.

Шаги воспроизведения (санитизированы)

Всё тестирование выполнялось только на аккаунтах, принадлежащих и контролируемых исследователем. Данные жертв не затрагивались.

Предусловия: Два тестовых аккаунта — аккаунт жертвы (victim@...), аутентифицированный в DomainScope, и контролируемый злоумышленником Google-аккаунт. Инструменты: браузер с прокси-перехватчиком.

Шаг 1 — Инициация авторизации Google злоумышленника:

  1. Войдите в DomainScope через LinkedIn (или основную идентичность), используя тестовую сессию злоумышленника.
  2. Перейдите в My Profile → Link Google Account.
  3. Завершите авторизацию Google с помощью контролируемого злоумышленником Google-аккаунта.
  4. Перехватите callback в DomainScope: GET /connect/google?code=[REDACTED_AUTHORIZATION_CODE]&scope=email+profile&state=[REDACTED] HTTP/1.1
  5. Задокументируйте URL callback и отбросьте/задержите запрос. Извлеките значение code. Обратите внимание: приложение не требовало state — удаление его всё равно приводило к успеху при повторе.

Шаг 2 — Создание CSRF PoC для жертвы:

  1. Создайте минимальную HTML-страницу, которая инициирует GET на callback с кодом злоумышленника и без валидного state:
    <!-- csrf-poc.html — redacted, for authorized reproduction only -->
    <html>
      <body>
        <img src="https://domainscope.com/connect/google?code=[REDACTED_AUTHORIZATION_CODE]&scope=email+profile" style="display:none">
        <!-- Alternative: auto-submit form or window.location -->
        <script>window.location = "https://domainscope.com/connect/google?code=[REDACTED_AUTHORIZATION_CODE]&scope=email+profile";</script>
      </body>
    </html>
  2. Разместите эту страницу на подконтрольном злоумышленнику источнике (в тестах — локальный файл).

Шаг 3 — Взаимодействие жертвы и захват:

  1. Пока жертва аутентифицирована в DomainScope (активная сессия через JSESSIONID / session cookies), заставьте её посетить PoC URL. В реальном сценарии атаки это делается через фишинговую ссылку или встроенный ресурс.
  2. Браузер жертвы отправляет аутентифицированный запрос на /connect/google с кодом злоумышленника. DomainScope привязывает Google-идентичность злоумышленника к профилю жертвы.
  3. Затем злоумышленник заходит в DomainScope и выбирает Sign in with Google, аутентифицируясь через контролируемый им Google-аккаунт. Теперь злоумышленник аутентифицирован как жертва — полный захват аккаунта без пароля. Далее злоумышленник может установить новый пароль через My Profile без ввода текущего пароля (бизнес-логика для аккаунтов, созданных через LinkedIn), добиваясь устойчивого захвата.

Уязвимость проверялась с использованием двух аккаунтов, принадлежащих исследователю. Другие аккаунты не тестировались и не затрагивались.

Санитизированный PoC-запрос

Примечание: Чувствительные значения (коды авторизации, cookies, session ID) заменены на [REDACTED]. Хост и поток сохранены для воспроизводимости. Для отчёта Bugcrowd было записано PoC-видео; публичный эксплойт не публикуется.

Исходный перехваченный callback через прокси (санитизирован):

GET /connect/google?code=[REDACTED_AUTHORIZATION_CODE]&scope=email+profile HTTP/1.1
Host: domainscope.com
User-Agent: Mozilla/5.0 [REDACTED]
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Referer: https://accounts.google.com/... [REDACTED]
Cookie: [REDACTED_SESSION_COOKIES]
Connection: close

Детали, подвергнутые санитизации:

  • code=4%2F... (длинный код авторизации) → code=[REDACTED_AUTHORIZATION_CODE]
  • Cookie: s_fid=... s_evar... JSESSIONID=... (полная сессия) → Cookie: [REDACTED_SESSION_COOKIES] и JSESSIONID=[REDACTED]
  • Referer: https://accounts.google.com/o/oauth2/...?client_id=...&xsrfsig=...Referer: https://accounts.google.com/... [REDACTED]
  • значение client_id[REDACTED_CLIENT_ID]
  • xsrfsig и другие длинные токены → [REDACTED]
  • scope=email profile ... обобщено до email+profile
  • Host: domainscope.com сохранён — он идентифицирует Verisign DomainScope, который публичен.

Scope наблюдался как email profile (обобщённо). Дополнительные PII или цепочки токенов не включены.

Влияние — полный захват аккаунта через привязку Google OAuth

Подтверждённое влияние — полный захват аккаунта на уровне приложения:

  • Обход аутентификации: После успешной CSRF-привязки злоумышленник аутентифицируется через доверенный поток Google OAuth. Приложение считает Google-идентичность владельцем аккаунта.
  • Пароль не нужен: Пароль жертвы, если он вообще был, не требуется. Привязанный Google-аккаунт становится альтернативным путём аутентификации.
  • Масштаб: Любой аккаунт DomainScope с активной сессией, посетивший подготовленную ссылку, мог быть привязан к Google-идентичности злоумышленника. Действие привязки изменяет состояние и не было защищено CSRF-токеном.
  • Устойчивость: После привязки злоумышленник сохраняет доступ через вход Google до тех пор, пока привязка не будет вручную удалена жертвой или поддержкой. Жертва может не заметить дополнительную привязанную идентичность в настройках профиля.

Почему смена пароля не требовала текущего пароля — бизнес-логика: Основной логин DomainScope на тот момент был LinkedIn OAuth. После привязки функция смены пароля в My Profile не требовала текущего пароля — осознанное бизнес-решение для OAuth-аккаунтов, у которых пароль изначально не был установлен. После того как злоумышленник аутентифицировался через угнанную Google-привязку (как жертва), он мог перейти в My Profile → Change Password, установить новый пароль без указания текущего и далее сохранять доступ как через Google OAuth, так и через новый пароль — даже если жертва позже отвяжет Google-аккаунт.

Находка была зарепорчена как подтверждённый CSRF-до-захвата-аккаунта в потоке привязки OAuth. Терминология critical/zero-day в оригинальном отчёте не использовалась; проблема была триажирована как вознаграждаемая уязвимость.

Вознаграждение и таймлайн — $1,000 через Bugcrowd, Hall of Fame апрель 2018

  • Программа: Bug bounty Verisign через Bugcrowd (только авторизованное тестирование).
  • Подход к обнаружению: Найдено в ходе участия в программе Bugcrowd при анализе OAuth-потоков с ручным перехватом через прокси.
  • Дата отчёта: Апрель 2018 (дата Hall of Fame согласно данным awards; совпадает с таймлайном раскрытия).
  • Триаж и вознаграждение: Проверено, триажировано и вознаграждено $1,000 через Bugcrowd.
  • Признание: Отмечено в Verisign Hall of Fame, апрель 2018.
  • Профиль: Отчёты и признания перечислены в профиле Bugcrowd: https://bugcrowd.com/h/mahmoud_adel.
Подтверждение триажа и вознаграждения Bugcrowd — Verisign CSRF $1,000 — DomainScope
Подтверждение триажа и вознаграждения Bugcrowd — Verisign DomainScope CSRF → захват аккаунта ($1,000) — HoF апрель 2018

Дата исправления здесь не утверждается сверх публично подтверждённого. Отчёт был триажирован и вознаграждён; верификация патча, если она была, обрабатывалась через программу.

Митигация — проверка параметра OAuth state

Рекомендованные на тот момент митигации, в соответствии с лучшими практиками OAuth 2.0 (RFC 6749, раздел 10.12 — Cross-Site Request Forgery):

  1. Требовать и проверять state на каждом OAuth callback:

    • Генерировать криптографически стойкое случайное значение (минимум 128 бит, например crypto.randomBytes(32)).
    • Привязывать его к сессии пользователя на стороне сервера до редиректа к провайдеру.
    • Проверять точное совпадение на callback GET /connect/google. Отклонять запрос, если state отсутствует, не совпадает или уже использован.
  2. Одноразовость и срок жизни:

    • Инвалидировать state после первого использования.
    • Устанавливать короткий срок жизни (например, 10 минут) для ограничения окна повтора.
  3. Эшелонированная защита:

    • Устанавить SameSite=Lax или Strict для session cookies (JSESSIONID и auth cookies), где совместимо.
    • Требовать повторную аутентификацию или дополнительный CSRF-токен для действий привязки аккаунта, а не только для логина.
    • Валидировать Referer/Origin как вторичную, а не основную проверку.
  4. Не полагаться только на секретность code:

    • Код авторизации не является защитой от CSRF; он аутентифицирует Google-идентичность, а не пользователя, инициировавшего привязку.

Пример серверной проверки (псевдокод):

// При инициации привязки:
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = { value: state, expiresAt: Date.now() + 10*60*1000 };
res.redirect(`https://accounts.google.com/o/oauth2/auth?...&state=${state}`);

// На callback /connect/google:
if (!req.query.state || req.query.state !== req.session.oauthState?.value) {
  return res.status(403).send('Invalid OAuth state — possible CSRF');
}
if (Date.now() > req.session.oauthState.expiresAt) {
  return res.status(403).send('OAuth state expired');
}
delete req.session.oauthState; // одноразовый
// затем обменять code и привязать аккаунт

При пентесте подобных потоков всегда тестируйте привязку, отвязку и повторную привязку, а не только основной OAuth-логин. Перехватите callback, удалите или замените state и оцените, выполняется ли действие всё ещё в сессии жертвы.

Выводы и уроки

  1. OAuth CSRF чаще всего пропускают именно в потоках привязки. CSRF логина получает внимание; CSRF привязки аккаунта — где внешняя идентичность прикрепляется к уже аутентифицированной сессии — часто остаётся незащищённым. Тестируйте каждый изменяющий состояние OAuth callback, включая эндпоинты connect, link и unlink.
  2. Ручной перехват по-прежнему превосходит DAST для логических flaws. Автоматизированные сканеры подтвердили наличие OAuth, но не отметили отсутствие привязки state. Перехват через прокси и ручное удаление state сразу подтвердили обход. Документируйте сырой callback, затем воспроизведите без state под второй сессией.
  3. Параметр state — это CSRF-токен, привязанный к сессии. Относитесь к нему соответственно: случайный, непредсказуемый, привязанный к инициирующей сессии, проверяемый точным сравнением и одноразовый. Cookies SameSite помогают, но не заменяют проверку state.
  4. Изменяющие состояние GET усиливают CSRF. Callback использовал GET. Любой GET, изменяющий привязку аккаунта, должен быть либо POST с CSRF-токеном, либо защищён проверкой OAuth state. Где возможно, предпочитайте POST для завершения привязки.
  5. Минимум SEO-шума, максимум сигнала. Эта находка использовала точные ключевые слова — CSRF, OAuth, State Parameter, Google OAuth, Verisign, DomainScope, Account Takeover, Bug Bounty, Responsible Disclosure — в отчёте, описании и заголовках. Чёткие шаги воспроизведения с санитизированными артефактами ускоряют триаж.
  6. Сообщайте только о проверенном. Программой на тот момент не присваивался CVSS; таймлайн патча не заявляется сверх триажа и вознаграждения. Сохранение таймлайна в пределах задокументированного (репорт апрель 2018, $1,000, HoF) сохраняет точность для будущего ревью.

Дисклеймер — ответственное раскрытие

Тестирование проводилось исключительно на аккаунтах, принадлежащих и контролируемых исследователем, в рамках авторизованной программы Verisign bug bounty на Bugcrowd. Данные клиентов, прод-аккаунты или несвязанные пользовательские сессии не затрагивались, не модифицировались и не тестировались. Всё воспроизведение использовало собственные тестовые идентичности LinkedIn и Google исследователя.

Этот материал опубликован после триажа и вознаграждения в рамках ответственного раскрытия, с заменой чувствительных значений на [REDACTED]. Имена хостов и детали потока сохранены для воспроизводимости и обучения защите. Активный эксплойт не предоставляется; PoC сведён к санитизированному GET и статичному HTML-шаблону.

По вопросам о находке или рекомендациям по митигации обращайтесь через профиль Bugcrowd https://bugcrowd.com/h/mahmoud_adel или страницу контактов сайта.