Содержание:
- OAuth 2.0 и OIDC в трех абзацах
- Token Confusion: ID Token как Access Token
- Cross-service Relay: aud не проверяется
- ALBeast: AWS ALB + Cognito
- DPoP: привязка токена к клиенту
- OIDC Discovery как карта и вектор
- Что проверять на пентесте
- Что дальше
JWT сам по себе - одно. JWT в экосистеме OAuth 2.0 с десятком микросервисов, тремя IdP и пятью типами токенов (Access Token, ID Token, Refresh Token, Authorization Code, Logout Token) - совсем другое. Большинство атак из статей 3-8 работают и здесь - JWT остаётся JWT, даже если его выпустил Keycloak. Но на стыках компонентов появляются свои атаки.
OAuth 2.0 и OIDC в трех абзацах
OAuth 2.0 - протокол авторизации. Не аутентификации, а именно авторизации: “разрешить приложению X доступ к ресурсам пользователя Y”. Результат - Access Token, который приложение предъявляет API. Формат токена стандартом не определен - может быть opaque-строкой, может быть JWT.
OpenID Connect (OIDC) - надстройка над OAuth 2.0, которая добавляет аутентификацию: “это точно пользователь Y”. Результат - ID Token, который всегда JWT. ID Token содержит информацию о пользователе и предназначен для клиентского приложения.
Итого два основных типа JWT-токенов: Access Token (для API, авторизация) и ID Token (для клиента, аутентификация). Refresh Token тоже бывает JWT (Keycloak, некоторые конфигурации Auth0) - и это отдельная поверхность атаки. AT и IT часто подписаны одним ключом от одного issuer - особенно в Keycloak. Auth0, Azure AD и Okta чаще используют разные ключи, но проверять стоит в любом случае. И вот тут начинаются проблемы.
Token Confusion: ID Token как Access Token
Самая частая ошибка в OIDC. Access Token предназначен для API (“какие права у этого клиента”). ID Token предназначен для клиентского приложения (“кто этот пользователь”). Разные назначения, но оба - подписанные JWT от одного IdP, верифицируемые одними ключами.
Атакующий получает ID Token (он всегда доступен клиенту) и отправляет его к API вместо Access Token. Если resource server не проверяет тип токена - он примет его. Это направление ID-как-AT - самое распространённое. Обратное тоже бывает: AT подсовывают вместо ID Token клиентскому приложению, и оно доверяет claims оттуда как identity-данным. CVE-2024-10318 (NGINX OIDC) - как раз token confusion, позволявший обойти аутентификацию.
RFC 9068 решает проблему: Access Token обязан иметь typ: "at+jwt" (или полная форма application/at+jwt) в заголовке. Проверяем:
echo "$TOKEN" | cut -d. -f1 | tr '-_' '+/' | \
awk '{while(length%4)$0=$0"=";print}' | base64 -d 2>/dev/null \
| python3 -c "import json,sys;print(json.load(sys.stdin).get('typ'))"
Результат null или "JWT" вместо "at+jwt" - token confusion возможен. Подставляй ID Token вместо Access Token и смотри, что вернет API.
Cross-service Relay: aud не проверяется
Помнишь claim aud из статьи 2? Я говорил, что он критически важен для безопасности. Вот конкретный пример почему.
Микросервисная архитектура. Общий IdP (Keycloak, Auth0, Cognito). Service A и Service B оба доверяют токенам от этого IdP. Атакующий получает токен для Service A и отправляет его на Service B. Если Service B не проверяет aud - токен принят. У тебя доступ к сервису, для которого токен не предназначался.
Шаг 1: получаем токен для service-a (параметры зависят от IdP; Auth0 использует нестандартный audience= вместо scope):
POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=my-client&client_secret=secret&scope=service-a
HTTP/1.1 200 OK
Content-Type: application/json
{"access_token":"eyJhbGciOiJSUzI1NiIs...","token_type":"Bearer","expires_in":3600}
Шаг 2: отправляем токен service-a на service-b:
GET /api/admin HTTP/1.1
Host: service-b.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
HTTP/1.1 200 OK
Content-Type: application/json
{"role":"admin","data":"..."}
200 OK = сервис B не проверяет aud. Токен, выданный для service-a, принят service-b.
CVE-2026-23552 (CVSS 9.1, Apache Camel 4.15.0-4.17.x): компонент KeycloakSecurityPolicy в Apache Camel не валидировал iss против сконфигурированного realm. Сервис с Camel-интеграцией принимал токены из чужого realm. Это не баг самого Keycloak - это баг в том, как Camel проверял его токены. Исправлено в Camel 4.18.0.
Для Keycloak в целом проверяем: нестандартные claims realm_access и resource_access - если проверяется resource_access без привязки к конкретному client_id, можно зарегистрировать свой client в том же realm и контролировать роли.
ALBeast: AWS ALB + Cognito
Красивая атака на стыке AWS-сервисов, найденная Miggo Research в 2024 году. Суть: все AWS ALB в одном регионе используют общую инфраструктуру подписи JWT. Токен, подписанный любым ALB, проходит криптографическую верификацию на любом другом ALB в том же регионе.
Атакующий создаёт свой Cognito User Pool (свой ALB не нужен), получает валидный JWT. Этот токен содержит поле signer в заголовке - ARN конкретного ALB. Проблема: приложения за ALB не валидировали это поле. Они проверяли подпись (валидна - общие ключи региона) и claims, но не проверяли, что токен выдан именно ИХ ALB.
Фикс: валидация поля signer в заголовке JWT против ARN своего ALB. Это не jku spoofing из статьи 6 - здесь ключ не подгружается с URL атакующего, а используется легитимная общая инфраструктура AWS.
DPoP: привязка токена к клиенту
Bearer token - кто украл, тот и пользуется. Перехватил в логах, в Burp History, через XSS (статья 13) - и используешь как свой. DPoP (Demonstrating Proof-of-Possession, RFC 9449) - механизм, который это ломает.
Идея: токен привязан к криптографическому ключу клиента. При каждом запросе клиент доказывает владение приватным ключом.
Как это работает:
- Клиент генерирует асимметричную пару ключей (обычно ES256)
- При запросе токена клиент создает DPoP proof - отдельный JWT с
typ: "dpop+jwt", публичным ключом в заголовкеjwk, и полямиjti(уникальный ID для replay protection),htm(HTTP method),htu(URL),iat(timestamp). При запросах к resource server добавляетсяath- хэш Access Token - Authorization server выдает Access Token с claim
cnf.jkt- thumbprint публичного ключа клиента - При каждом API-запросе клиент отправляет и Access Token (в
Authorization: DPoP <token>), и свежий DPoP proof (в заголовкеDPoP) - Resource server проверяет: ключ в proof совпадает с thumbprint в Access Token
Украл Access Token? Бесполезно без приватного ключа клиента. Не можешь создать DPoP proof - сервер отклонит запрос.
Но DPoP не серебряная пуля:
- Downgrade DPoP на Bearer: убери заголовок
DPoPиз запроса и замениAuthorization: DPoP <token>наAuthorization: Bearer <token>. Если сервер принимает - привязки нет. Самый простой и частый баг - XSS в браузере: приватный ключ хранится в CryptoKey API как non-extractable, но атакующий может создавать DPoP proof-ы пока жертва online через monkey-patch fetch/XHR
- Pre-generation: если сервер не требует nonce, можно заранее создать proof-ы с будущими timestamp-ами
- Replay window: без серверного nonce proof можно реюзать в пределах временного окна. RFC 9449 не фиксирует точное окно - Auth0 даёт ~120 секунд, Okta ~300 секунд. Это не “несколько секунд”, а минуты реальной эксплуатируемости
- ath bypass: если сервер не проверяет
ath(хэш Access Token) в proof, можно реюзать один proof с разными токенами
OIDC Discovery как карта и вектор
/.well-known/openid-configuration - это JSON с полной конфигурацией IdP. Для пентестера - карта всех возможностей:
GET /.well-known/openid-configuration HTTP/1.1
Host: target.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"issuer": "https://target.example.com",
"authorization_endpoint": "https://target.example.com/authorize",
"token_endpoint": "https://target.example.com/token",
"jwks_uri": "https://target.example.com/.well-known/jwks.json",
"grant_types_supported": ["authorization_code","client_credentials","implicit"],
"token_endpoint_auth_methods_supported": ["client_secret_post","client_secret_basic"],
"code_challenge_methods_supported": ["plain","S256"],
"response_types_supported": ["code","token","id_token"]
}
Ищем:
jwks_uri- endpoint с публичными ключами. Вектор для algorithm confusion (статья 4): забираешь публичный ключ и подписываешь им токен через HS256. Также потенциальный SSRF-вектор - если сервер фетчит этот URL, можно направить его на внутренние сервисыgrant_types_supported- если естьimplicit- дополнительные векторы (токен в URL fragment)token_endpoint_auth_methods_supported- еслиclient_secret_postилиclient_secret_basic- ищи слабые client secretscode_challenge_methods_supported- если нет или естьplain- PKCE (RFC 7636) не enforced или downgrade возможен. PKCE обязателен в OAuth 2.1 и это одна из самых частых находок на пентестах
Discovery endpoint - не только карта, но и вектор: jwks_uri, request_uri, sector_identifier_uri могут быть SSRF-точками, если сервер фетчит их при динамической регистрации клиента.
Mix-Up Attack (Fett, Küsters, Schmitz, 2016): клиент работает с несколькими IdP. Атакующий контролирует один из них (вредоносный IdP). Пользователь начинает flow через вредоносный IdP, тот перенаправляет на легитимный. Пользователь авторизуется у легитимного, получает authorization code. Клиент думает, что flow шёл через вредоносный IdP, и отправляет code на его token endpoint. Код у атакующего. Защита: RFC 9207, проверяй iss в ответе авторизации, чтобы знать с каким IdP шёл flow.
Issuer Confusion: вариант Mix-Up Attack. Вредоносный Authorization Server (AS) объявляет свой token endpoint равным endpoint легитимного AS. Результат тот же: код уходит атакующему.
Что проверять на пентесте
Token Confusion и Cross-service:
- Отправь ID Token вместо Access Token (и наоборот)
- Отправь токен для сервиса A на сервис B
- Проверь
typв header: должен бытьat+jwtилиapplication/at+jwt - Проверь
aud: должен быть конкретным, не wildcard
OAuth flow:
redirect_uri: подмена, open redirect, path traversal в callback URL- PKCE: убери
code_challenge. Если сервер принимает без неё, нет enforcement stateparameter: убери или подмени. CSRF на OAuth flownonceв ID Token: убери или реюзай. Replay protectionscope: запроси повышенный scope при token refresh
Провайдер-специфичное:
- Keycloak: cross-realm токены,
resource_accessбез привязки кclient_id - AWS ALB: валидация поля
signer(ALB ARN) в заголовке JWT - Discovery:
jwks_uri,grant_types,code_challenge_methods_supported
DPoP:
- Замени
Authorization: DPoPнаBearer. Если принимает, привязки нет - Реюзай DPoP proof с другим Access Token. Проверка
ath - Убери заголовок
DPoPцеликом
Что дальше
Мы разобрали как атаковать JWT (статьи 3-8 - подделка подписей, статьи 9-10 - крипто и JWE) и как это работает в контексте OAuth/OIDC (эта статья). Но зачем подделывать, если можно просто украсть? В следующей статье - XSS + JWT: как один Reflected XSS превращается в полный захват всех аккаунтов. localStorage, sessionStorage, HttpOnly cookie - threat model каждого варианта.