
Reproducteur exécutable Spring Boot et Quarkus pour CVE-2026-94386, démontrant la réutilisation de jetons OAuth entre portées dans le cache de jetons statique de camel-oauth d'Apache Camel.
Reproducteur de preuve de concept exécutable pour la vulnérabilité Apache Camel où
OAuthClientCredentialsTokenResolver conservait les jetons client-credentials qu'il obtenait dans un cache statique
partagé par chaque instance de resolver et chaque CamelContext du JVM, et indexait ce cache sur le point de terminaison
du jeton et l'ID client uniquement — bien que la requête de jeton transporte également le client secret et
la portée demandée.
// affected
record TokenCacheKey(String tokenEndpoint, String clientId) { }
| Runtime | Répertoire | Stack |
|---|---|---|
| Camel Spring Boot | camel-spring-boot/ | Spring Boot 3.5.13 + camel-oauth 4.22.1 |
| Camel Quarkus | camel-quarkus/ | Camel Quarkus 3.39.0 (Quarkus 3.39.0, Camel 4.22.0) |
Deux profils qui partagent un endpoint de jeton et un ID client mais demandent des scopes différents, ou s'authentifient avec des secrets client différents, mappés vers la même entrée de cache. Le profil résolu en premier l'a peuplée ; l'autre s'est ensuite vu remettre ce jeton, sans qu'aucune requête de jeton ne soit effectuée selon sa propre configuration, tant que le jeton restait valide.
La mise en cache des jetons est activée par défaut, et pointer plusieurs profils vers un même client de fournisseur d'identité est un usage ordinaire du support des profils plutôt qu'une adhésion à un paramètre non sécurisé.
cd camel-spring-boot # or: cd camel-quarkus
mvn clean package
docker compose up -d --build
until curl -sf http://localhost:8080/exploit/ready >/dev/null; do sleep 1; done
curl -s http://localhost:8080/exploit/attack
docker compose down
Sur une version affectée (identique sur les deux runtimes) :
1) Profile 'invoices' resolves first
token handed to it: TOKEN-FOR[scope=invoice.read;secret=...aaaa]
token requests the IdP has received: 1
2) Profile 'admin' resolves next, through a DIFFERENT resolver instance
token handed to it: TOKEN-FOR[scope=invoice.read;secret=...aaaa]
token requests the IdP has received: 1 (unchanged - no request was made under its own config)
it was handed the token issued for the OTHER profile's scope: true
3) Profile 'partner' - same endpoint and clientId, DIFFERENT client secret (...-zzzz)
token handed to it: TOKEN-FOR[scope=invoice.read;secret=...aaaa]
it was served the token obtained with the other secret, without ever
authenticating with its own: true
4) CONTROL - profile 'admin' again with cache-tokens=false
token handed to it: TOKEN-FOR[scope=admin.write;secret=...aaaa]
it got a token for its own scope: true
Everything the IdP was actually asked for:
- client_id=camel-integration-client scope=invoice.read;secret=...aaaa
- client_id=camel-integration-client scope=admin.write;secret=...aaaa
>>> PROVEN: ...
La valeur du jeton nomme la portée et le secret pour lesquels il a été émis, ce qui vous permet de voir de quel profil provient réellement la configuration de chaque jeton. Le journal des requêtes à la fin constitue la preuve irréfutable : avec la mise en cache activée, le fournisseur d'identité n'a jamais été sollicité pour le jeton du profil admin — seules la requête du premier profil et celle du contrôle apparaissent.
La sonde 2 utilise délibérément une seconde instance de resolver : le cache est static, donc une instance distincte — et un CamelContext distinct — le partagent.
Le resolver obtient des jetons pour le compte des composants qui authentifient leurs appels sortants avec un profil OAuth nommé — camel-openai, camel-langchain4j-agent, camel-huggingface, camel-docling, camel-a2a, camel-spring-ai-chat et les composants IBM watsonx — ainsi que des applications appelant directement le SPI OAuthClientAuthenticationFactory.
Atteindre le problème nécessite un déploiement qui définit plusieurs profils partageant un endpoint de jeton et un ID client tout en différant par la portée ou le secret client.
Les profils sont configurés par le déploiement, qui est de confiance, donc aucune entrée non fiable n'atteint le cache. La partie qui en bénéficie est le service qui reçoit un jeton qu'il n'était pas censé recevoir, lequel, s'il est hostile ou compromis, peut rejouer ce jeton dans sa portée. Le résultat est une requête sortante portant un jeton émis pour la portée d'un autre profil — une autorité plus large que celle pour laquelle le profil appelant était configuré, ou un service pour lequel le jeton n'a pas été émis. Avec des fournisseurs d'identité qui dérivent l'audience d'un jeton à partir de la portée demandée, une portée différente peut signifier un jeton pour un service entièrement différent.
Aucun fournisseur d'identité et aucun réseau. L'application héberge un substitut de l'endpoint de jeton du fournisseur servant le grant client-credentials, qui émet un jeton dont la valeur nomme la portée et le secret client pour lesquels il a été émis, et enregistre chaque requête. Le véritable OAuthClientCredentialsTokenResolver et le véritable cache statique s'exécutent.
Le substitut lit les identifiants client depuis l'en-tête d'autorisation HTTP Basic, et non depuis les paramètres de formulaire, car c'est ainsi que
camel-oauthles envoie.
| Propriété | Valeur |
|---|---|
| Composant | camel-oauth (OAuthClientCredentialsTokenResolver) |
| CWE | CWE-524 (Utilisation d'un cache contenant des informations sensibles) |
| Gravité | Moyenne |
| Vecteur d'attaque | Deux profils OAuth ou plus partageant un endpoint de jeton et un ID client tout en différant par la portée ou le secret client |
| Impact | Une requête sortante portant un jeton émis pour la portée d'un autre profil ou obtenu avec le secret d'un autre profil |
| Versions affectées | À partir de 4.19.0 avant 4.22.2 |
| Versions corrigées | 4.22.2, 4.23.0 |
| JIRA | CAMEL-24872 |
| Crédit | Andrea Cosentino de l'Apache Software Foundation |
Avis : https://camel.apache.org/security/CVE-2026-94386.html
La clé de cache contient désormais chaque entrée de la requête de jeton : l'endpoint de jeton, l'ID client, une empreinte SHA-256 du secret client — afin que le secret brut ne soit pas conservé dans le cache statique — et la portée demandée. Une portée absente, vide ou blanche compte comme une seule valeur, car chacune d'elles omet le paramètre scope de la requête ; toute autre portée est mise en correspondance exacte. Les profils qui concordent sur les quatre partagent toujours un jeton entre les instances de resolver, tandis que les profils qui diffèrent par les identifiants ou la portée obtiennent des jetons distincts.
Corrigé sur main dans apache/camel#26681 (5e896b2d), rétroporté vers camel-4.22.x dans #27779 (b2d9957b).
Définissez camel.version sur 4.22.2 et exécutez-le à nouveau. Vérifié sur 4.22.1 → 4.22.2 :
2) Profile 'admin' resolves next, through a DIFFERENT resolver instance
token handed to it: TOKEN-FOR[scope=admin.write;secret=...aaaa]
token requests the IdP has received: 2
it was handed the token issued for the OTHER profile's scope: false
3) Profile 'partner' ... token handed to it: TOKEN-FOR[scope=invoice.read;secret=...zzzz]
it was served the token obtained with the other secret ...: false