# Root Cause Analysis — CVE-2026-39999

## Summary

Apache APISIX's `jwt-auth` plugin selected the JWT signature verification primitive
from the **attacker-controlled JWT header `alg`** field, while independently selecting
the verification **key** from trusted consumer configuration. When a consumer was
configured with an RS256 (asymmetric) algorithm, the plugin used the consumer's RSA
**public key** as the verification key. An attacker who knew that public key could
forge a JWT whose header declared `alg=HS256` and whose HMAC-SHA256 signature was
computed over the token using the public key PEM as the HMAC secret. The plugin then
verified the forged token with HMAC-SHA256 — using the same public key as the secret —
and accepted it, bypassing authentication entirely. This is the classic JWT algorithm
confusion attack (CVE-2015-9235 pattern) realized in APISIX's `jwt-auth` verifier.

## Impact

- **Package/component affected:** `apisix/plugins/jwt-auth` (the `jwt-auth` authentication
  plugin), specifically the `find_consumer()` → `get_auth_secret()` → `verify_signature()`
  path in `apisix/plugins/jwt-auth.lua` and `apisix/plugins/jwt-auth/parser.lua`.
- **Affected versions:** Apache APISIX 2.2.0 through 3.16.0 (all versions that supported
  asymmetric algorithms in `jwt-auth` and lacked the algorithm-match enforcement).
- **Fixed in:** 3.17.0 (commit `e4de423fc583fdf82f47a1dda6c666aa2f2b3dca` —
  "fix(jwt-auth): enforce algorithm match before signature verification (#13182)").
- **Risk level:** Critical. An unauthenticated remote attacker who can obtain a
  consumer's configured public key (which is, by definition, public) can forge a
  valid-looking JWT and access any route protected by that consumer's `jwt-auth`
  configuration. No private key material is required.

## Impact Parity

- **Disclosed/claimed maximum impact:** Authentication bypass / authorization bypass
  (`authz_bypass`) on remote API requests authenticated via `jwt-auth` with an
  RS256-style consumer.
- **Reproduced impact from this run:** Full authentication bypass demonstrated
  end-to-end against a running Apache APISIX 3.16.0 gateway. A forged HS256 JWT
  (signed with the consumer's RSA public key as the HMAC secret) was accepted by the
  protected route (HTTP **200**, request proxied to upstream), while the identical
  forged token was rejected by the fixed 3.17.0 gateway (HTTP **401**,
  `"failed to verify jwt: algorithm mismatch, expected RS256"`).
- **Parity:** **full**. The remote API authentication bypass described in the advisory
  was reproduced exactly, and the negative control on the fixed version confirms the
  fix closes the bypass.
- **Not demonstrated:** N/A — the claim is authorization bypass, not code execution,
  and the bypass itself was demonstrated in full.

## Root Cause

In the vulnerable `find_consumer()` function (`apisix/plugins/jwt-auth.lua`, 3.16.0):

1. The JWT is parsed and the consumer is located by the `key` claim in the payload
   (`consumer_mod.find_consumer(plugin_name, "key", user_key)`).
2. `get_auth_secret(consumer)` returns the verification key based on the **consumer's
   configured** algorithm:
   ```lua
   local function get_auth_secret(consumer)
       if not consumer.auth_conf.algorithm or
          consumer.auth_conf.algorithm:sub(1, 2) == "HS" then
           return get_secret(consumer.auth_conf)     -- HMAC secret
       else
           return consumer.auth_conf.public_key      -- RSA public key (for RS256)
       end
   end
   ```
   For an RS256 consumer, the returned key is the **RSA public key**.
3. `jwt:verify_signature(auth_secret)` in `parser.lua` selects the verification
   primitive from **`self.header.alg`** — the JWT header, which is attacker-controlled:
   ```lua
   function _M.verify_signature(self, key)
       return alg_verify[self.header.alg](self.raw_header .. "." ..
                  self.raw_payload, base64_decode(self.signature), key)
   end
   ```
4. There was **no check** that `self.header.alg` matched `consumer.auth_conf.algorithm`.
   So when the attacker sets `alg=HS256`, `alg_verify["HS256"](data, signature, public_key)`
   is invoked, which computes `HMAC-SHA256(data, public_key)` and compares it to the
   attacker-supplied signature. Because the public key is public, the attacker can
   compute the identical HMAC and produce a matching signature → **authentication
   bypass**.

**The fix** (commit `e4de423f`, released in 3.17.0) inserts an algorithm-match
enforcement **before** `verify_signature`:
```lua
-- Enforce that the JWT header's "alg" matches the consumer's configured algorithm
local expected_alg = consumer.auth_conf.algorithm or "HS256"
local token_alg = jwt.header and jwt.header.alg
if token_alg ~= expected_alg then
    local err = "failed to verify jwt: algorithm mismatch, expected " .. expected_alg
    ...
    return nil, nil, "failed to verify jwt"
end
```
This rejects any token whose header `alg` differs from the consumer's configured
algorithm (e.g. an HS256 token against an RS256 consumer) before signature
verification, closing the confusion.

## Reproduction Steps

1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).
2. **What it does:**
   - Generates a fresh RSA-2048 keypair with `openssl`.
   - Deploys the **real** Apache APISIX gateway inside Docker containers on an
     isolated bridge network: `bitnamilegacy/etcd:3.6` (config store),
     `python:3-slim` (upstream HTTP backend + client helper), and
     `apache/apisix:3.16.0-debian` (vulnerable) then `apache/apisix:3.17.0-debian`
     (fixed).
   - Uses the APISIX **Admin API** to create a consumer (`jack`) with the `jwt-auth`
     plugin configured for **RS256** using the generated RSA public key, and a route
     (`/`) protected by `jwt-auth` with the upstream backend.
   - Verifies the route is genuinely protected: a request **without** a JWT returns
     HTTP 401.
   - **Forges** a JWT with header `{"typ":"JWT","alg":"HS256"}` and payload
     `{"key":"jack-key"}`, signing the `header.payload` input with
     `HMAC-SHA256(..., RSA_public_key_PEM)` — using the consumer's **public key** as
     the HMAC secret.
   - Sends the forged token to the protected route via `Authorization: Bearer <jwt>`.
   - On the **vulnerable 3.16.0** gateway: the request is **accepted** (HTTP 200,
     proxied to upstream) → authentication bypass.
   - On the **fixed 3.17.0** gateway (fresh etcd, same consumer/route): the identical
     forged token is **rejected** (HTTP 401, `"failed to verify jwt"`) with the log
     `algorithm mismatch, expected RS256` → fix confirmed.
3. **Expected evidence:** Two consecutive runs both show vulnerable→200 (bypass) and
   fixed→401 (reject); APISIX error logs on 3.17.0 contain the
   `jwt-auth.lua:338: ... algorithm mismatch, expected RS256` line.

## Evidence

- `bundle/logs/harness_vulnerable.log` — exploit harness output on 3.16.0
  (forged HS256 JWT → HTTP **200**, `[+] VULNERABLE: forged HS256 JWT accepted ->
  authentication BYPASS`).
- `bundle/logs/harness_fixed.log` — exploit harness output on 3.17.0
  (forged HS256 JWT → HTTP **401**, `[+] FIXED: forged HS256 JWT rejected`).
- `bundle/logs/apisix_fixed.log` — APISIX 3.17.0 nginx error log containing:
  `[lua] jwt-auth.lua:338: find_consumer(): failed to verify jwt: algorithm mismatch,
  expected RS256`.
- `bundle/artifacts/results_vulnerable.json` —
  `{"forged_jwt_code": 200, "no_jwt_code": 401, ...}`.
- `bundle/artifacts/results_fixed.json` —
  `{"forged_jwt_code": 401, "no_jwt_code": 401, "forged_jwt_body":
  "{\"message\":\"failed to verify jwt\"}"}`.
- `bundle/artifacts/rsa_public.pem` / `rsa_private.pem` — the RSA keypair used.
- `bundle/artifacts/apisix_config.yaml` — the APISIX deployment config (etcd + admin key).
- `bundle/repro/runtime_manifest.json` — structured runtime evidence manifest.

Key excerpt (vulnerable, 3.16.0):
```
[*] no-jwt: 401 (expect 401)
[*] forged HS256 JWT: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkiOiJqYWNrLWtleSJ9...
[*] forged-jwt: 200  body=<!DOCTYPE HTML>...
[+] VULNERABLE: forged HS256 JWT accepted -> authentication BYPASS
```
Key excerpt (fixed, 3.17.0):
```
[*] forged-jwt: 401  body={"message":"failed to verify jwt"}
[+] FIXED: forged HS256 JWT rejected (401) -> algorithm mismatch enforced
```

**Environment:** Docker (Apache APISIX official images), isolated bridge network,
OpenResty/LuaJIT runtime inside the `apache/apisix:*-debian` images; APISIX versions
verified via `apisix version` (3.16.0 / 3.17.0).

## Recommendations / Next Steps

- **Upgrade** to Apache APISIX 3.17.0 or later, which enforces that the JWT header
  `alg` matches the consumer's configured algorithm before signature verification.
- **Defense in depth:** Consider also key-type/algorithm binding at the verifier
  primitive level (e.g. refuse to use an asymmetric key material with an HMAC
  primitive, and vice versa), so that a single missing upper-layer check cannot
  reintroduce the confusion.
- **Audit** existing `jwt-auth` consumers: any consumer configured with an asymmetric
  algorithm (RS*/ES*/PS*/EdDSA) whose public key is exposed was exploitable prior to
  the fix; rotate keys and review access logs for forged-token usage.
- **Testing:** Add a regression test that submits an HS256-forged token against an
  RS256 consumer and asserts a 401 (the fix commit `e4de423f` already added such tests
  in `t/plugin/jwt-auth.t`).

## Additional Notes

- **Idempotency:** The script cleans up all containers/networks on exit and was run
  **twice consecutively** with identical results (vulnerable→200, fixed→401) —
  reproducibility confirmed.
- The forged JWT contains **only** the `key` claim (matching the consumer) and omits
  `exp`/`nbf`; on 3.16.0 `verify_claims` only validates those claims when present, so
  the token passes claim validation. This keeps the proof focused on the algorithm
  confusion and avoids confounding it with expiry behavior.
- The attack requires only the consumer's **public key** (which is public by design
  for RS256); no private key, no secrets, and no prior authentication are needed.
- Networking note: the sandbox runs Docker-in-Docker where host port publishing is not
  directly reachable from the shell; the script therefore drives all HTTP traffic
  through a `python:3-slim` client container on the same bridge network, addressing
  the APISIX gateway by its container alias.
