JWT: Key Confusion (RS256 to HS256)
Сервер подписывает JWT через RS256, но при валидации читает алгоритм из заголовка токена. Атакующий меняет alg на HS256, подписывает токен публичным RSA-ключом как HMAC-секретом и получает admin.
hardphpPro
Задача
# JWT Key Confusion: подмена асимметричного алгоритма симметричным
## Сценарий
Вы — аудитор безопасности **SecureShop** (PHP + Apache + `firebase/php-jwt`). Аутентификация построена на JWT-токенах с **асимметричной подписью**: сервер хранит приватный RSA-ключ, подписывает токены `RS256` и публикует соответствующий публичный ключ через служебные API (это нормальная практика для систем с асимметричной подписью — клиенты и микросервисы могут проверять подпись локально, без обращения к секретам).
По данным внутреннего аудита, в админ-панели хранится конфиденциальная метка (CTF-флаг), доступная только пользователям с ролью `admin`. Пароль администратора атакующему неизвестен; ваша задача — получить admin-доступ и извлечь флаг, имея только обычную учётную запись `demo` / `demo`.
## Цель
1. Залогиньтесь как `demo` / `demo` и изучите токен в cookie `token`: декодируйте header (увидите `alg: RS256`) и payload (увидите `user_id`, `username`, `role: user`).
2. Найдите служебный эндпоинт, через который сервер раздаёт публичный ключ (стандартные имена — `/api/public-key`, `/api/jwks`, `/.well-known/jwks.json`).
3. Сформируйте JWT-токен, который сервер примет за валидный и который даст административные права, **не используя приватный ключ сервера**. Для этого нужно понимать класс атаки **JWT Algorithm Confusion** и точный формат материала, который сервер использует как HMAC-секрет в уязвимой ветке.
4. Подмените cookie `token`, откройте `/admin` и извлеките CTF-флаг.
## Теория
**JWT Key Confusion** (Algorithm Confusion) — атака на JWT-валидацию, основанная на смешении классов криптографических алгоритмов:
- **RSA-подписи** (`RS256`, `RS384`, `RS512`) — асимметричная криптография: приватный ключ подписывает, публичный верифицирует.
- **HMAC-подписи** (`HS256`, `HS384`, `HS512`) — симметричный ключ: один и тот же секрет используется и для подписи, и для проверки.
Когда серверный верификатор читает поле `alg` из заголовка токена и сам выбирает по нему ветку проверки, атакующий получает контроль над тем, каким алгоритмом сервер будет проверять подпись. Указав в заголовке `alg: HS256`, он заставляет сервер выполнить HMAC-проверку. Если в HMAC-ветке сервер использует материал публичного ключа (PEM-строку, DER-байты или хеш от них) как HMAC-секрет, атакующий может получить те же байты с публичного эндпоинта и подписать любой токен.
В этой лабе сервер использует библиотеку `firebase/php-jwt`, корректно подписывает токены `RS256`, но в верификаторе:
- читает `alg` из заголовка приходящего токена;
- если `alg = RS256` — проверяет подпись публичным ключом (правильно);
- если `alg = HS256` — использует **DER-байты публичного ключа** (`base64_decode` тела PEM без заголовков) как HMAC-секрет — это и есть путь к атаке.
DER-байты публичного ключа атакующий получает тривиально: скачивает PEM с `/api/public-key`, снимает заголовки `-----BEGIN/END PUBLIC KEY-----`, делает `base64_decode` тела.
**Уязвимый PHP-паттерн:**
```php
// Verifier reads alg from the token header (attacker-controlled data)
$header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true);
$alg = $header['alg'] ?? 'RS256';
if ($alg === 'RS256') {
$decoded = JWT::decode($token, new Key($_publicKeyPem, 'RS256'));
} else {
// VULNERABILITY: HS256 path — DER bytes of the public key as HMAC secret
$decoded = JWT::decode($token, new Key($_publicKeyDer, 'HS256'));
}
// VULNERABILITY: role from claims, not from DB
$user['role'] = $payload['role'];
```
Дополнительно — роль пользователя берётся напрямую из claim'ов, а не из БД, поэтому любая успешная подмена подписи автоматически даёт ровно те привилегии, которые атакующий записал в payload.
## Таблица атак на JWT
| Класс атаки | Идея | Применима в этой лабе |
|-------------|------|----------------------|
| HS256 ↔ RS256 (key confusion) | Подменить алгоритм с асимметричного на симметричный, подписать токен публичным ключом как HMAC-секретом | **Да** — основной вектор |
| `alg: none` | Объявить отсутствие подписи; сервер пропускает проверку | Нет — `firebase/php-jwt` отклоняет `alg: none` по умолчанию |
| Brute-force HMAC-секрета | Подобрать слабый секрет офлайн (hashcat) | Нет — сервер изначально использует RSA, HMAC-секрета нет |
| JWK header injection | Передать собственный публичный ключ прямо в заголовке токена | Нет — верификатор использует серверный ключ из памяти, не доверяет заголовку |
| `kid` path traversal | Подсунуть в заголовке путь к контролируемому атакующим файлу как ключ | Нет — поле `kid` верификатор не использует |
## Точка входа атаки
| Параметр | Значение |
|----------|----------|
| Обычная учётная запись | `demo` / `demo` |
| Эндпоинт логина | `POST /login` (выдаёт cookie `token` с RS256-JWT) |
| Эндпоинт публичного ключа | `GET /api/public-key` (PEM-формат) или `GET /api/jwks` (JWKS) |
| Имя cookie | `token` |
| Привилегированный эндпоинт | `GET /admin` (требует `role: admin` в подменённом токене) |
| Где находится флаг | в HTML-ответе `/admin` в карточке Admin Flag, формат `flag{...}` |