
Рабочий воспроизводитель proof-of-concept для CVE-2026-95663, где Apache Camel camel-keycloak выдавал кэшированные результаты интроспекции токенов после истечения срока действия токена, с демонстрациями на Spring Boot и Quarkus.
Запускаемый воспроизводитель proof-of-concept для уязвимости Apache Camel, при которой KeycloakSecurityPolicy с
включённой интроспекцией токена (useTokenIntrospection=true) продолжал принимать access-токен после истечения его
срока действия, поскольку кэшированный результат интроспекции переживал токен.
| Среда выполнения | Каталог | Стек |
|---|---|---|
| Camel Spring Boot | camel-spring-boot/ | Spring Boot 3.5.13 + camel-keycloak 4.22.1 |
| Camel Quarkus | camel-quarkus/ | Camel Quarkus 3.39.0 (Quarkus 3.39.0, Camel 4.22.0) |
На пути интроспекции Keycloak сообщает о токене как о неактивном после того, как прошёл его exp — таким образом,
именно флаг active обеспечивает соблюдение срока действия. Результаты интроспекции кэшируются по умолчанию
(introspectionCacheEnabled=true, introspectionCacheTtl=60), и оба поставляемых кэша
(ConcurrentMapTokenCache, CaffeineTokenCache) истекали запись через фиксированное время после её вставки,
не ограничивая это время жизни утверждением exp, которое уже содержал кэшированный ответ.
KeycloakSecurityProcessor, в свою очередь, проверял только active и никогда не сравнивал exp с текущим временем.
Таким образом, ответ, закэшированный незадолго до истечения срока действия токена, выдавался последующим запросам с тем же
токеном, которые допускались с ролями и разрешениями, которые нёс истёкший токен — хотя RFC 7662
утверждает, что ответ, содержащий exp, не должен кэшироваться дольше этого времени.
cd camel-spring-boot # or: cd camel-quarkus
mvn clean package
docker compose up -d --build
until [ "$(curl -s http://localhost:8080/exploit/ready)" = "Started" ]; do sleep 1; done
curl -s http://localhost:8080/exploit/attack # takes ~4s: it waits for the token to expire
docker compose down
Вывод на уязвимой сборке (идентичен в обеих средах выполнения):
1) Request while the token is still valid
introspection calls so far: 1
outcome: [ADMITTED - the protected route ran]
... the token's exp has now passed. The cache entry is still inside its 60s TTL.
2) The same token again, now expired, against the SAME route
introspection calls so far: 1 (unchanged - the endpoint was never asked again)
outcome: [ADMITTED - the protected route ran]
an expired token was admitted: true
3) CONTROL - the same expired token against a route with introspectionCacheEnabled=false
introspection calls so far: 2 (the endpoint was asked)
outcome: [REJECTED - CamelAuthorizationException: Token is not active (may be revoked or expired)]
rejected once the endpoint was actually consulted: true
>>> PROVEN: ...
Счётчик вызовов интроспекции служит доказательством: он не меняется между проверкой 1 и проверкой 2. Провайдер идентификации не был запрошен повторно, поэтому его вердикт — который к тому моменту уже «неактивен» — так и не дошёл до политики. Проверка 3 доказывает, что этот вердикт существует: тот же истёкший токен на маршруте с отключённым кэшированием отклоняется, что также является обходным решением, которое рекомендует бюллетень для развёртываний, не имеющих возможности обновиться. Уменьшение introspectionCacheTtl лишь сужает окно; оно его не закрывает.
Ни сервера Keycloak, ни сети. Приложение размещает заглушку эндпоинта интроспекции Keycloak по тому самому пути, который строит KeycloakTokenIntrospector
({serverUrl}/realms/{realm}/protocol/openid-connect/token/introspect), и она ведёт себя так же, как Keycloak:
активен, пока не прошёл exp, после — неактивен, с exp, iss и realm_access.roles в
ответе. Работают настоящие KeycloakSecurityPolicy, настоящий KeycloakTokenIntrospector и настоящий кэш.
Интроспекция не требует, чтобы токен был JWT, поэтому токен здесь — непрозрачная строка; важный exp
находится в ответе интроспекции, и именно его кэш не учитывал.
Проблема не даёт ничего сверх того, что токен уже нёс: ни подделки токена, ни повышения
привилегий. Окно ограничено introspectionCacheTtl — 60 секунд по умолчанию, 300–600 там, где
следовали рекомендации документации для внутренних сервисов — и оно открывается только для токена, который
тот же процесс Camel интроспектировал и закэшировал, пока тот был ещё активен. Интроспекция токенов не включена
по умолчанию. Отсюда средняя степень серьёзности.
Путь локальной проверки JWT, используемый при отключённой интроспекции, не затронут.
| Свойство | Значение |
|---|---|
| Компонент | camel-keycloak (KeycloakSecurityPolicy с useTokenIntrospection=true) |
| CWE | CWE-613 (Недостаточное истечение сессии) |
| Серьёзность | Средняя |
| Вектор атаки | Вызывающая сторона повторно предъявляет bearer-токен, действительный мгновением ранее, в пределах TTL кэша интроспекции |
| Воздействие | Запросы допускаются после истечения срока действия токена с ролями и разрешениями, которые нёс этот токен |
| Затронутые версии | С 4.16.0 до 4.18.5, с 4.19.0 до 4.22.2 |
| Исправленные версии | 4.18.5, 4.22.2, 4.23.0 |
| JIRA | CAMEL-24902 |
| Благодарность | Andrea Cosentino из Apache Software Foundation |
Бюллетень: https://camel.apache.org/security/CVE-2026-95663.html
Два независимых изменения:
introspectionCacheTtl
и времени, оставшегося до exp токена, а результат, токен которого уже истёк, никогда
не выдаётся из кэша.KeycloakSecurityProcessor теперь отклоняет токен, у которого exp лежит в прошлом,
на каждом пути, использующем интроспекцию, так что срок действия проверяется там так же, как уже
проверялся на пути локальной проверки JWT.Ответ, не содержащий утверждения exp, сохраняет настроенное время жизни.
Исправлено в main в apache/camel#26747 (7bfacb70), перенесено
в camel-4.22.x в #27182 (66fd7034) и в
camel-4.18.x в #27183 (6be02c19).
Установите camel.version в 4.22.2 и запустите снова. Проверено на 4.22.1 → 4.22.2:
2) The same token again, now expired, against the SAME route
introspection calls so far: 2
outcome: [REJECTED - CamelAuthorizationException: Token is not active (may be revoked or expired)]
an expired token was admitted: false
>>> NOT reproduced: the expired token was rejected.
Обратите внимание, что счётчик увеличивается с 1 до 2: время жизни записи кэша было ограничено значением exp токена, поэтому
второй запрос повторно выполнил интроспекцию и получил реальный вердикт эндпоинта.
Этот репозиторий опубликован в образовательных и защитных целях: чтобы помочь пользователям Apache Camel понять уязвимость, проверить, затронуты ли они, и убедиться, что обновление устраняет её. Не используйте этот материал против систем, которыми вы не владеете и которые не эксплуатируете.