Apache APISIX `jwe-decrypt` Integrity-Check Bypass → Unauthenticated Gateway Auth Bypass (CVE-2026-49230)
Published: 2026-07-27 • Researcher: BiiTts (Caio Fabrício)
- Severity
- Critical
- CVE
- CVE-2026-49230
- Category
- web
- Affected product
- Apache APISIX — jwe-decrypt auth plugin (apisix/plugins/jwe-decrypt.lua)
- Affected versions
- 3.8.0 – 3.16.0
- Disclosed
- 2026-07-27
- Patch status
- Patched
References
Archive entry
intelseclab/poc-archiveOn this page
Metadata
| Field | Value |
|---|---|
| Date Added | 2026-07-27 |
| Last Updated | N/A |
| Author / Researcher | BiiTts (Caio Fabrício) |
| CVE / Advisory | CVE-2026-49230 |
| Category | web |
| Severity | Critical |
| CVSS Score | 9.1 (CVSSv3.1, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) |
| Status | Weaponized |
| Tags | apache-apisix, jwe, jwt, integrity-bypass, cwe-354, unauthenticated, api-gateway, lua |
| Related | N/A |
Affected Target
| Field | Value |
|---|---|
| Software / System | Apache APISIX — jwe-decrypt auth plugin (apisix/plugins/jwe-decrypt.lua) |
| Versions Affected | 3.8.0 – 3.16.0 |
| Language / Platform | Lua (OpenResty/Nginx), via resty.aes / OpenSSL AES-256-GCM |
| Authentication Required | No — attacker needs only a valid consumer kid (public, rides unencrypted in every legitimate token’s header), never the AES secret |
| Network Access Required | Yes — direct HTTP(S) access to a route protected by jwe-decrypt |
Summary
The jwe-decrypt plugin is an auth-type APISIX plugin that decrypts an incoming JWE token with a per-consumer AES-256-GCM secret and forwards the plaintext upstream as proof of authentication. Its internal helper jwe_decrypt_with_obj() returns only the decrypted value from aes:decrypt(), discarding OpenSSL’s GCM-tag verification result. The call site binds a second err variable that is therefore always nil, so the guard if err ~= nil then return 400 never triggers. An attacker who knows any consumer’s kid (public metadata, present in cleartext in every legitimate token) can submit a JWE with a valid kid but completely garbage ciphertext and tag, and the gateway authenticates the request anyway — forwarding it (with an empty identity header) to the upstream. No knowledge of the AES secret is required, fully defeating the authentication boundary the plugin exists to enforce.
Vulnerability Details
Root Cause
CWE-354 (Improper Validation of Integrity Check Value). In apisix/plugins/jwe-decrypt.lua (tag 3.16.0):
| |
| |
resty.aes:decrypt() (OpenSSL EVP_DecryptFinal_ex under GCM) returns nil when the authentication tag fails verification — that is precisely the integrity check the whole scheme relies on. But jwe_decrypt_with_obj only returns that first value, so the caller’s err is unconditionally nil and the rejection branch is unreachable. The kid used to look up the consumer is read from the JWE header via load_jwe_token, which only base64url-decodes and JSON-decodes it — no cryptographic binding — so kid is attacker-controlled and public. 3.16.0 additionally exposes an unauthenticated plugin API, GET /apisix/plugin/jwe/encrypt?key=<kid>&payload=<data>, which mints a fully valid JWE (correct tag) for a chosen consumer and payload — a conditional escalation to full identity forgery when the route is exposed via the public-api plugin (removed in 3.17.0 along with the fix).
Attack Vector
Unauthenticated HTTP(S) request to any route gated by jwe-decrypt, with an Authorization header containing a forged JWE of the form base64url(header{kid}).<empty>.<iv>.<garbage-ciphertext>.<garbage-tag>. The header must contain a kid that resolves to a real consumer; everything after it can be arbitrary attacker-chosen bytes.
Impact
Complete bypass of the jwe-decrypt authentication gate for any upstream that relies on it, without knowledge of the per-consumer AES secret — only a valid, publicly-visible kid is required. Because decryption failure yields plaintext = nil, the forwarded identity header is empty, but the request still reaches the upstream as if authenticated. Combined with the exposed token-minting API (when routable), an attacker can forge tokens carrying arbitrary chosen plaintext/identity for a known consumer.
Environment / Lab Setup
OS: Linux (host networking; no Docker bridge in the lab's tested environment)
Target: apache/apisix:3.16.0-debian (vulnerable) / apache/apisix:3.17.0-debian (patched, for boundary proof)
Backing: quay.io/coreos/etcd:v3.5.17 (APISIX config store)
Attacker: Python 3 (stdlib only — urllib, base64, json)
Tools: exploit.py (this folder), lab/setup.sh, lab/backend.py, lab/config.yaml, lab/teardown.shSetup Steps
| |
setup.sh runs everything with --network host (etcd on :2379, APISIX data plane on :9080, admin API on :9180, backend on :8080) since the lab’s tested environment had no Docker bridge network. It provisions consumer alice with kid=alice-key and a 32-char AES secret the attacker never sees, and a /protected* route gated by jwe-decrypt with default settings (strict: true).
Proof of Concept
Step-by-Step Reproduction
Stand up the vulnerable lab
Shell script1cd lab && ./setup.shConfirm the route is actually gated (baseline)
Shell script1 2curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9080/protected/x # expect 403 "missing JWE token in request"Send the forged JWE — valid
kid, garbage ciphertext/tag, no secret knownShell script1python3 exploit.py http://127.0.0.1:9080/protected/x --kid alice-keyCross-check the fix boundary — repeat step 3 against
apache/apisix:3.17.0-debian; the identical forged token is now rejected with HTTP 400.
Exploit Code
See
exploit.py(full, unmodified) in this folder — mirrored from BiiTts/CVE-2026-49230-APISIX-jwe-decrypt-Auth-Bypass. Verified before ingestion: read the full script,ANALYSIS.md,EVIDENCE.txt, and all four lab files — the exploit forges a structurally valid JWE (header.enckey.iv.ciphertext.tag) with a realkidand deliberately bogus 12-byte IV / ciphertext / 16-byte GCM tag, requiring no AES secret, then diffs a baseline (no token) request against the forged-token request to confirm an actual 403→200 authentication bypass rather than a misconfigured/open route. No obfuscation, no unrelated network calls, no destructive behavior.
| |
| |
Expected Output
[*] target : http://127.0.0.1:9080/protected/secret
[*] consumer kid : alice-key (NO AES secret used)
[*] forged JWE : eyJhbGciOiAiZGlyIiwgImVuYyI6ICJBMjU2R0NNIiwgImtpZCI6ICJhbGljZS1rZXkifQ..MTIzNDU2Nzg5MDEy.Rk9SR0VELUJZLUNWRS0yMDI2LTQ5MjMw.MDAwMDAwMDAwMDAwMDAwMA
[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.Screenshots / Evidence
EVIDENCE.txt— full transcript: discriminant matrix (no-token → malformed → invalid-kid → valid-kid+garbage-crypto bypass), public mint-endpoint check (404 by default), end-to-endexploit.pyrun, and the 3.16.0-vs-3.17.0 boundary proof (identical forged token: 200 vs 400).ANALYSIS.md— full code-level walkthrough of the request path, the discarded-error bug, the secondary token-minting API, and the 3.17.0 fix diff.
Detection & Indicators of Compromise
SIEM / IDS Rule (example):
alert http any any -> any any (msg:"Possible APISIX jwe-decrypt CVE-2026-49230 forged token"; \
content:"Authorization|3a| Bearer eyJ"; http_header; \
content:"alg"; content:"dir"; content:"A256GCM"; \
sid:9000002;)Remediation
| Action | Detail |
|---|---|
| Patch | Upgrade Apache APISIX to 3.17.0 or later, where jwe_decrypt_with_obj() propagates err and the guard checks if not plaintext then return 400. |
| Workaround | Do not rely on jwe-decrypt as the sole authentication control; layer an independent auth plugin (key-auth, jwt-auth) or an upstream-side check that validates the forwarded identity is non-empty and well-formed. |
| Config Hardening | Ensure the plugin’s api() routes (e.g. /apisix/plugin/jwe/encrypt) are never exposed through public-api on affected versions; audit for accidental exposure. |
References
Notes
Verified before ingestion: real file contents were read in full — exploit.py, ANALYSIS.md, EVIDENCE.txt, and all four lab/ files (setup.sh, teardown.sh, backend.py, config.yaml) — and confirmed byte-identical to the upstream repository (no paraphrasing or rewriting). The mechanism was cross-checked against the NVD/CVE description and against the actual apisix/plugins/jwe-decrypt.lua diff between the 3.16.0 and 3.17.0 tags: the vulnerable version’s jwe_decrypt_with_obj() returns a single value from aes:decrypt(), making the caller’s err guard permanently dead code, and 3.17.0 fixes this by returning (decrypted, err) and checking plaintext directly. The PoC is reproducible end-to-end against official Docker images (apache/apisix:3.16.0-debian / 3.17.0-debian + quay.io/coreos/etcd:v3.5.17) and includes a genuine before/after boundary proof rather than a single unverifiable claim. The author, BiiTts (Caio Fabrício), has a track record of other verified-real PoCs ingested into this archive this session (Budibase CVE-2026-54350, Crawl4AI CVE-2026-53753), which further supports treating this submission as credible rather than a phantom/templated PoC.
The upstream README claims an MIT license but no LICENSE file was present in the cloned repository at time of ingestion; only the actual files present (exploit.py, ANALYSIS.md, EVIDENCE.txt, README.md, lab/*, .gitignore) were mirrored.
| |