
Reprodutor de prova de conceito executável para CVE-2026-95663, em que o camel-keycloak do Apache Camel servia resultados de introspecção de token em cache além da expiração do token, com demos em Spring Boot e Quarkus.
Prova de conceito executável que reproduz a vulnerabilidade do Apache Camel em que KeycloakSecurityPolicy com
introspecção de token habilitada (useTokenIntrospection=true) continuava aceitando um token de acesso depois de ele ter
expirado, porque o resultado de introspecção em cache sobrevivia ao token.
| Runtime | Diretório | 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) |
No caminho de introspecção, o Keycloak reporta um token como inativo assim que seu exp passa — portanto, a
flag active é o que impõe a expiração. Os resultados de introspecção são armazenados em cache por padrão
(introspectionCacheEnabled=true, introspectionCacheTtl=60), e ambos os caches fornecidos
(ConcurrentMapTokenCache, CaffeineTokenCache) expiravam uma entrada um tempo fixo após ela ter sido inserida,
sem limitar esse tempo de vida pela claim exp que a resposta em cache já carregava.
O KeycloakSecurityProcessor, por sua vez, verificava apenas active e nunca comparava exp com o horário atual.
Uma resposta armazenada em cache pouco antes de o token expirar era, portanto, servida a requisições posteriores que portavam o mesmo
token, as quais eram admitidas com as roles e permissões que o token expirado carregava — embora a RFC 7662
determine que uma resposta contendo exp não deve ser armazenada em cache além desse momento.
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
Saída em um build afetado (idêntica em ambos os 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: ...
O contador de chamadas de introspecção é a evidência: ele não se altera entre a sonda 1 e a sonda 2. O
provedor de identidade nunca foi consultado uma segunda vez, portanto o seu veredito — que a essa altura é "inativo" — nunca
chegou à política. A sonda 3 prova que esse veredito existe: o mesmo token expirado contra uma rota com
cache desativado é rejeitado, o que é também a solução alternativa indicada no aviso para implantações que ainda não podem
atualizar. Reduzir introspectionCacheTtl apenas estreita a janela; não a fecha.
Sem servidor Keycloak e sem rede. A aplicação hospeda um substituto para o endpoint de introspecção do Keycloak, no
caminho exato que KeycloakTokenIntrospector constrói
({serverUrl}/realms/{realm}/protocol/openid-connect/token/introspect), que se comporta como o Keycloak:
ativo até exp ter passado, inativo depois disso, com exp, iss e realm_access.roles na
resposta. O KeycloakSecurityPolicy real, o KeycloakTokenIntrospector real e o cache real são executados.
A introspecção não exige que o token seja um JWT, portanto o token aqui é uma string opaca — o exp
que importa está na resposta de introspecção, que é exatamente o que o cache estava falhando em respeitar.
O problema não concede nada além do que o token já carregava: sem falsificação de token e sem escalada de
privilégios. A janela é limitada por introspectionCacheTtl — 60 segundos por padrão, 300 a 600 onde a
recomendação da documentação para serviços internos foi seguida — e abre apenas para um token que o
mesmo processo Camel introspectou e colocou em cache enquanto ainda estava ativo. A introspecção de token não está ativada por
padrão. Daí a severidade média.
O caminho de verificação JWT local, usado quando a introspecção está desativada, não é afetado.
| Propriedade | Valor |
|---|---|
| Componente | camel-keycloak (KeycloakSecurityPolicy com useTokenIntrospection=true) |
| CWE | CWE-613 (Expiração de Sessão Insuficiente) |
| Severidade | Média |
| Vetor de ataque | Um chamador reapresentando um token bearer que era válido momentos antes, dentro do TTL do cache de introspecção |
| Impacto | Requisições admitidas após a expiração do token, com as funções e permissões que esse token carregava |
| Versões Afetadas | Da 4.16.0 antes da 4.18.5, da 4.19.0 antes da 4.22.2 |
| Versões Corrigidas | 4.18.5, 4.22.2, 4.23.0 |
| JIRA | CAMEL-24902 |
| Crédito | Andrea Cosentino da Apache Software Foundation |
Aviso: https://camel.apache.org/security/CVE-2026-95663.html
Duas alterações independentes:
introspectionCacheTtl
e o tempo restante até o exp do token, e um resultado cujo token já expirou nunca é
servido a partir do cache.KeycloakSecurityProcessor agora rejeita um token cujo exp está no passado
em todos os caminhos que usam introspecção, portanto a expiração é aplicada ali como já era no caminho local
de verificação JWT.Uma resposta que não carrega a claim exp mantém o tempo de vida configurado.
Corrigido em main em apache/camel#26747 (7bfacb70), com backport
para camel-4.22.x em #27182 (66fd7034) e para
camel-4.18.x em #27183 (6be02c19).
Defina camel.version como 4.22.2 e execute novamente. Verificado contra 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.
Observe o contador passando de 1 para 2: o tempo de vida da entrada de cache foi limitado pelo exp do token, então a
segunda requisição fez nova introspecção e obteve o veredito real do endpoint.
Este repositório é publicado para fins educacionais e defensivos: ajudar usuários do Apache Camel a entender a vulnerabilidade, verificar se são afetados e confirmar que a atualização a resolve. Não use este material contra sistemas que você não possui ou opera.