
Reproducteur de preuve de concept exécutable pour CVE-2026-95663, où Apache Camel camel-keycloak servait des résultats d'introspection de jetons mis en cache au-delà de l'expiration du jeton, avec des démos Spring Boot et Quarkus.
Reproducteur de preuve de concept exécutable pour la vulnérabilité Apache Camel où KeycloakSecurityPolicy avec
l'introspection de jeton activée (useTokenIntrospection=true) continuait à honorer un jeton d'accès après son
expiration, car le résultat d'introspection mis en cache survivait au jeton.
| Runtime | Répertoire | Stack |
|---|---|---|
| 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) |
Sur le chemin d'introspection, Keycloak signale un jeton comme inactif une fois son exp dépassé — c'est donc
l'indicateur active qui fait respecter l'expiration. Les résultats d'introspection sont mis en cache par défaut
(introspectionCacheEnabled=true, introspectionCacheTtl=60), et les deux caches fournis
(ConcurrentMapTokenCache, CaffeineTokenCache) faisaient expirer une entrée après un délai fixe à compter de son insertion,
sans borner cette durée de vie par la revendication exp que la réponse mise en cache portait déjà.
KeycloakSecurityProcessor, quant à lui, ne vérifiait que active et ne comparait jamais exp avec l'heure courante.
Une réponse mise en cache peu avant l'expiration du jeton était donc servie aux requêtes ultérieures portant le même
jeton, qui étaient admises avec les rôles et permissions que le jeton expiré portait — bien que la RFC 7662
stipule qu'une réponse contenant exp ne doit pas être mise en cache au-delà de ce moment.
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
Sortie sur une build affectée (identique sur les deux runtimes) :
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: ...
Le compteur d'appels d'introspection constitue la preuve : il ne bouge pas entre la sonde 1 et la sonde 2. Le fournisseur d'identité n'a jamais été interrogé une seconde fois, donc son verdict — qui entre-temps est « inactif » — n'a jamais atteint la politique. La sonde 3 prouve que ce verdict existe : le même jeton expiré, confronté à une route dont la mise en cache est désactivée, est rejeté, ce qui correspond également au contournement indiqué dans l'avis pour les déploiements qui ne peuvent pas encore être mis à jour. Réduire introspectionCacheTtl ne fait que restreindre la fenêtre ; cela ne la ferme pas.
Aucun serveur Keycloak et aucun réseau. L'application héberge un substitut du point de terminaison d'introspection de Keycloak, sur le chemin exact que construit KeycloakTokenIntrospector
({serverUrl}/realms/{realm}/protocol/openid-connect/token/introspect), qui se comporte comme Keycloak : actif jusqu'à ce que exp soit dépassé, inactif ensuite, avec exp, iss et realm_access.roles dans la réponse. Le véritable KeycloakSecurityPolicy, le véritable KeycloakTokenIntrospector et le véritable cache sont exécutés.
L'introspection n'exige pas que le jeton soit un JWT, donc le jeton ici est une chaîne opaque — l'exp qui compte se trouve dans la réponse d'introspection, ce que le cache ne respectait précisément pas.
Le problème n'accorde rien au-delà de ce que le jeton portait déjà : aucune falsification de jeton ni élévation de privilèges. La fenêtre est bornée par introspectionCacheTtl — 60 secondes par défaut, 300 à 600 lorsque la recommandation de la documentation pour les services internes a été suivie — et elle ne s'ouvre que pour un jeton que le même processus Camel a introspecté et mis en cache alors qu'il était encore actif. L'introspection de jeton n'est pas activée par défaut. D'où la sévérité moyenne.
Le chemin de vérification JWT local, utilisé lorsque l'introspection est désactivée, n'est pas affecté.
| Propriété | Valeur |
|---|---|
| Composant | camel-keycloak (KeycloakSecurityPolicy avec useTokenIntrospection=true) |
| CWE | CWE-613 (Expiration de session insuffisante) |
| Sévérité | Moyenne |
| Vecteur d'attaque | Un appelant qui représente un jeton bearer valide quelques instants plus tôt, dans le TTL du cache d'introspection |
| Impact | Requêtes admises après l'expiration du jeton, avec les rôles et permissions que ce jeton portait |
| Versions affectées | À partir de 4.16.0 avant 4.18.5, à partir de 4.19.0 avant 4.22.2 |
| Versions corrigées | 4.18.5, 4.22.2, 4.23.0 |
| JIRA | CAMEL-24902 |
| Crédit | Andrea Cosentino de l'Apache Software Foundation |
Avis : https://camel.apache.org/security/CVE-2026-95663.html
Deux changements indépendants :
introspectionCacheTtl et du temps restant jusqu'à l'exp du jeton, et un résultat dont le jeton a déjà expiré n'est jamais servi depuis le cache.KeycloakSecurityProcessor rejette désormais un jeton dont l'exp est dans le passé sur tous les chemins qui utilisent l'introspection, de sorte que l'expiration y est appliquée comme elle l'était déjà sur le chemin de vérification JWT local.Une réponse qui ne porte aucune revendication exp conserve la durée de vie configurée.
Corrigé sur main dans apache/camel#26747 (7bfacb70), rétroporté vers camel-4.22.x dans #27182 (66fd7034) et vers camel-4.18.x dans #27183 (6be02c19).
Définissez camel.version sur 4.22.2 et exécutez-le à nouveau. Vérifié de 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.
Notez le compteur passant de 1 à 2 : la durée de vie de l'entrée de cache était limitée par l'exp du jeton, de sorte que
la seconde requête a ré-introspecté et a obtenu le verdict réel du point de terminaison.
Ce dépôt est publié à des fins éducatives et défensives : pour aider les utilisateurs d'Apache Camel à comprendre la vulnérabilité, à vérifier s'ils sont affectés, et à confirmer que la mise à niveau la résout. N'utilisez pas ce matériel contre des systèmes que vous ne possédez pas ou n'exploitez pas.