# Variant RCA Report — CVE-2026-39999 (Apache APISIX jwt-auth algorithm confusion)

## Summary

No bypass of the 3.17.0 fix was found, and no materially-distinct entry point to
the vulnerable sink exists. The fix (commit `e4de423f`, "enforce algorithm match
before signature verification") inserts a single guard in `find_consumer` that
compares the JWT header `alg` to the consumer's configured algorithm **before**
`verify_signature`. Because that check and `verify_signature` both read the same
`self.header.alg` field, no token can simultaneously pass the check and dispatch
to a mismatched (HMAC) primitive. The guard sits on the only path to the sink
(`verify_signature` is called only from `find_consumer`), and is generic over all
supported consumer algorithms. Runtime testing confirmed that the same forged-HS256
attack that bypasses **RS256** consumers on 3.16.0 also bypasses **ES256 (ECDSA)**
and **EdDSA (Ed25519)** consumers on 3.16.0 (alternate triggers, same root cause),
and that the fix rejects all three families on 3.17.0 with explicit
`algorithm mismatch, expected <alg>` log lines. These alternate triggers share the
identical sink, entry point and trust boundary as the original CVE and are covered
by the generic fix — they are not distinct variants under the strict definition and
do **not** constitute a bypass.

## Fix Coverage / Assumptions

**Invariant the fix relies on:** the JWT header field compared in the new guard
(`jwt.header.alg`) is the *same* field that `parser.lua`'s `verify_signature`
dispatches on (`alg_verify[self.header.alg]`). Both are populated once by
`resty.jwt`'s `load_jwt`/`parse_jwt` as `cjson.decode(base64url_decode(raw_header)).alg`
with no normalization, so the compared value and the dispatched value can never
diverge.

**Code paths explicitly covered:** `find_consumer` is the sole caller of
`jwt:verify_signature` (verified by source-wide search), and `find_consumer` is
called only from `_M.rewrite`. The guard therefore covers every runtime path:
header / query-string / cookie token delivery, standalone jwt-auth, jwt-auth under
`multi-auth`, and all consumer algorithms in the schema enum (HS*, RS*, ES*, PS*,
EdDSA) — because the check compares the generic `consumer.auth_conf.algorithm`
string rather than a hardcoded value.

**What the fix does NOT cover:** no gap was found. Candidate bypass paths were
evaluated and ruled out: (a) parsing quirks — `load_jwt` does no `alg`
normalization/whitelist, but the check and dispatch share the same field so no
value can pass the check and trigger HMAC; (b) an alternate entry point to the
sink — none exists (`verify_signature` has one caller); (c) key-selection
divergence — `get_auth_secret` keys off the consumer's algorithm, which the fix
forces equal to the token's; (d) anonymous-consumer / multi-auth fallback — by
design, grants only the configured anonymous privileges (same as no token). The
only residual item is a defense-in-depth recommendation (bind key type to
primitive type inside `alg_verify`), not an exploitable gap. See
`bundle/vuln_variant/patch_analysis.md` for the full analysis.

## Variant / Alternate Trigger

**Bypass attempted:** forge an HS256 JWT and submit it against a consumer
configured with an asymmetric algorithm other than RS256 — specifically **ES256**
(ECDSA / prime256v1) and **EdDSA** (Ed25519) — signing the token with that
consumer's *public key* PEM as the HMAC secret. If the fix only handled RS256 (or
hardcoded an RS256 expectation), these would slip through on 3.17.0.

**Entry point (identical for all candidates):** HTTP `GET /` to the APISIX gateway
(port 9080) with `Authorization: Bearer <forged JWT>`, against a route protected by
the `jwt-auth` plugin. The forged token's payload `key` claim selects the targeted
consumer (and thus the public key used as the HMAC secret).

**Code path involved (same for every candidate):**
- `apisix/plugins/jwt-auth.lua` → `find_consumer`:
  `fetch_jwt_token` → `jwt_parser.new` → `consumer_mod.find_consumer("key", …)` →
  `get_auth_secret` (returns `consumer.auth_conf.public_key` for any non-HS
  algorithm) → **[fix guard]** `jwt.header.alg ~= consumer.auth_conf.algorithm` →
  `jwt:verify_signature(auth_secret)`.
- `apisix/plugins/jwt-auth/parser.lua` → `_M.verify_signature`:
  `alg_verify[self.header.alg](raw_header.."."..raw_payload, sig, key)`. For the
  forged token `self.header.alg == "HS256"`, so `alg_verify["HS256"]` computes
  `HMAC-SHA256(data, public_key_PEM)` and compares it to the attacker-supplied
  signature.

**Tested variants (all same root cause / sink / entry point / trust boundary):**
1. RS256 consumer + HS256 forged token — original CVE (control). 3.16.0: 200; 3.17.0: 401.
2. ES256 consumer + HS256 forged token — EC public key as HMAC secret. 3.16.0: 200; 3.17.0: 401.
3. EdDSA consumer + HS256 forged token — Ed25519 public key as HMAC secret. 3.16.0: 200; 3.17.0: 401.

Candidates #2 and #3 are **alternate triggers** (they prove the confusion is not
RS256-specific) but are **not distinct variants**: the entry point, sink
(`alg_verify["HS256"]` with the consumer public key), trust boundary (remote
untrusted caller → authenticated route) and technique (forge HS256, sign with the
public key PEM) are identical to the original CVE; only the consumer's configured
algorithm and key material differ. Per the variant guidance, relabeling the same
sink via a different consumer configuration is the same bug, not a new variant.
Critically, none reproduce on the fixed version, so there is **no bypass**.

## Impact

- **Package/component affected:** `apisix/plugins/jwt-auth` (`find_consumer`,
  `get_auth_secret`) and `apisix/plugins/jwt-auth/parser.lua` (`verify_signature`).
- **Affected versions (as tested):** Apache APISIX 3.16.0 (vulnerable — confirmed
  bypass for RS256, ES256, EdDSA); Apache APISIX 3.17.0 (fixed — all rejected).
  Advisory scope: 2.2.0–3.16.0; fixed in 3.17.0.
- **Risk level:** Critical for the vulnerable version (unauthenticated remote
  authentication bypass). For the **variant stage's bypass question**: none — the
  fix holds, so there is no residual risk from the tested paths on 3.17.0.

## Impact Parity

- **Disclosed/claimed maximum impact (parent CVE):** authentication/authorization
  bypass (`authz_bypass`) on remote API requests authenticated via `jwt-auth` with
  an asymmetric consumer config.
- **Reproduced impact from this variant run:** On 3.16.0 the alternate triggers
  (ES256, EdDSA) yield the same full auth bypass (HTTP 200, request proxied to
  upstream) as the original RS256 bypass — confirming the vulnerability's breadth
  across asymmetric key families. On 3.17.0 all forged tokens are rejected (401).
- **Parity (bypass question):** `none` — no bypass reproduced on the fixed version.
- **Parity (alternate-trigger question on vulnerable):** `full` — the same
  authz_bypass impact as the parent is reproduced for ES256 and EdDSA consumers on
  the vulnerable version.
- **Not demonstrated:** N/A — the claim is authorization bypass, not code
  execution; the (non-)bypass and the alternate triggers were both demonstrated
  directly.

## Root Cause

The underlying bug: `jwt-auth` selects the verification **key** from trusted
consumer configuration (`get_auth_secret` → consumer's `public_key` for asymmetric
algorithms) while selecting the verification **primitive** from the
attacker-controlled JWT header `alg` (`alg_verify[self.header.alg]`). When the two
disagree (token `alg=HS256`, consumer `algorithm=RS256`/`ES256`/`EdDSA`), the
consumer's *public* key is fed to HMAC-SHA256 as the secret. Because the public key
is public, the attacker can compute the identical HMAC and forge a valid token.

The fix enforces `jwt.header.alg == consumer.auth_conf.algorithm` before
`verify_signature`. Since the same `self.header.alg` is both checked and
dispatched, equality forces a consistent key/primitive pairing, eliminating the
confusion. This is correct and complete: there is no value of `header.alg` that
both satisfies the equality and selects HMAC against an asymmetric key. The same
logic is why the ES256 and EdDSA alternate triggers — which rely on the *same*
`alg_verify["HS256"]` dispatch with a *different* public key — are also blocked.

**Fix commit:** `e4de423fc583fdf82f47a1dda6c666aa2f2b3dca` (PR #13182, in 3.17.0).

## Reproduction Steps

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh` (self-contained,
   idempotent, Docker-based).
2. **What it does:**
   - Generates three keypairs with `openssl`: RSA-2048 (RS256), EC prime256v1
     (ES256), Ed25519 (EdDSA).
   - Deploys the real Apache APISIX gateway (3.16.0 then 3.17.0) in Docker on an
     isolated bridge network with etcd + a python http.server upstream + a client
     helper.
   - Via the Admin API, creates **three consumers** (`jack-rs` RS256, `jack-ec`
     ES256, `jack-ed` EdDSA), each with a distinct `key` claim and its respective
     public key, plus **one** route `/` protected by `jwt-auth` (the consumer is
     selected by the token's `key` claim).
   - Verifies the route is protected (no JWT → 401 on both versions).
   - For each consumer, **forges** an HS256 JWT (`{"typ":"JWT","alg":"HS256"}` /
     payload `{"key":"<that consumer's key>"}`) signed with
     `HMAC-SHA256(signing_input, <consumer public key PEM>)` and sends it to `/`.
   - On **3.16.0**: asserts each forged token is accepted (HTTP 200 = bypass).
   - On **3.17.0** (fresh etcd, same config): asserts each forged token is
     rejected (HTTP 401).
   - Exit 0 = a forged token accepted on the **fixed** 3.17.0 (true bypass); exit 1
     = no bypass (alternate triggers reproduced on vulnerable only).
3. **Expected evidence:** vulnerable harness shows RS256/ES256/EdDSA all `200
   [BYPASS]`; fixed harness shows all three `401 [REJECTED]`; fixed APISIX error
   log contains `algorithm mismatch, expected RS256`, `expected ES256`, and
   `expected EdDSA` at `jwt-auth.lua:338`. Script exits 1 (no bypass).

## Evidence

- `bundle/logs/variant_harness_vulnerable.log` — 3.16.0: RS256/ES256/EdDSA forged
  HS256 → **200 [BYPASS]** each; `[+] VULNERABLE: all three asymmetric consumers
  bypassed via HS256 confusion`.
- `bundle/logs/variant_harness_fixed.log` — 3.17.0: RS256/ES256/EdDSA forged
  HS256 → **401 [REJECTED]** each; `[+] FIXED: all forged HS256 tokens rejected
  (401) -> fix covers RS256/ES256/EdDSA`.
- `bundle/logs/variant_apisix_fixed.log` — APISIX 3.17.0 nginx error log with the
  three definitive lines (all from `jwt-auth.lua:338`):
  - `failed to verify jwt: algorithm mismatch, expected RS256`
  - `failed to verify jwt: algorithm mismatch, expected ES256`
  - `failed to verify jwt: algorithm mismatch, expected EdDSA`
- `bundle/logs/variant_apisix_vulnerable.log` — 3.16.0 startup/plugin-load log
  (no alg-mismatch lines — the guard does not exist in 3.16.0).
- `bundle/vuln_variant/artifacts/variant_results_vulnerable.json` — per-alg
  `forged_jwt_code: 200, bypass: true` for RS256/ES256/EdDSA.
- `bundle/vuln_variant/artifacts/variant_results_fixed.json` — per-alg
  `forged_jwt_code: 401, bypass: false` for RS256/ES256/EdDSA (with forged token
  strings).
- `bundle/vuln_variant/artifacts/{rsa,ec,ed}_public.pem` — the public keys used
  as HMAC secrets.
- `bundle/vuln_variant/artifacts/variant_harness.py` — the test harness.
- `bundle/vuln_variant/logs/{vulnerable,fixed}_version.txt` — `apisix version`
  output (3.16.0 / 3.17.0).
- `bundle/logs/variant_run1.stdout.log`, `bundle/logs/variant_run2.stdout.log` —
  two consecutive full runs (idempotency).

Key excerpt (vulnerable, 3.16.0):
```
[*] no-jwt control: 401 (expect 401)
[*] RS256  forged HS256 -> 200  [BYPASS]  body=<!DOCTYPE HTML>...
[*] ES256  forged HS256 -> 200  [BYPASS]  body=<!DOCTYPE HTML>...
[*] EdDSA  forged HS256 -> 200  [BYPASS]  body=<!DOCTYPE HTML>...
[+] VULNERABLE: all three asymmetric consumers bypassed via HS256 confusion
```
Key excerpt (fixed, 3.17.0):
```
[*] RS256  forged HS256 -> 401  [REJECTED]  body={"message":"failed to verify jwt"}
[*] ES256  forged HS256 -> 401  [REJECTED]  body={"message":"failed to verify jwt"}
[*] EdDSA  forged HS256 -> 401  [REJECTED]  body={"message":"failed to verify jwt"}
[+] FIXED: all forged HS256 tokens rejected (401) -> fix covers RS256/ES256/EdDSA
```

**Environment:** Docker (official `apache/apisix:3.16.0-debian` and
`apache/apisix:3.17.0-debian` images), isolated bridge network,
OpenResty/LuaJIT runtime inside the images; `apisix version` reported `3.16.0` and
`3.17.0` respectively. Image digests:
`3.16.0-debian@sha256:b921bdef…`, `3.17.0-debian@sha256:6cbf65f3…`.

## Recommendations / Next Steps

- **The fix needs no extension for the algorithm-confusion class** — it is complete
  and covers all consumer algorithms. No code change is required to close a bypass
  (none exists).
- **Defense in depth (recommended, not required):** bind key *type* to primitive
  *type* inside `parser.lua`'s `alg_verify`/`alg_sign` — e.g. refuse to construct an
  HMAC primitive when the supplied key parses as an asymmetric PEM (and vice-versa).
  This makes the confusion structurally impossible at the verifier layer, so a
  future regression that adds a second caller of `verify_signature` (or bypasses
  `find_consumer`) cannot reintroduce the bug. The repro RCA made the same
  recommendation.
- **Regression test coverage:** the fix's TEST 52 covers RS256. Add parallel cases
  for ES256 and EdDSA (forged HS256 → 401 with `expected ES256` / `expected EdDSA`)
  to pin the generic coverage demonstrated in this run.
- **Consumer audit:** any pre-3.17.0 consumer configured with an asymmetric
  algorithm (RS*/ES*/PS*/EdDSA) whose public key was exposed was exploitable;
  rotate keys and review access logs. This applies to all asymmetric families, not
  just RS256.

## Additional Notes

- **Idempotency:** the script was run **twice consecutively** with identical
  results (run1 and run2 both: vulnerable → 200×3, fixed → 401×3, exit 1). It
  cleans up all containers/network on exit via a trap.
- **Bounded search:** fewer than three *materially distinct* variants exist because
  `verify_signature` has exactly one caller (`find_consumer`, which has the fix) and
  the check/dispatch share the same `header.alg`. The three runtime candidates
  tested (RS256/ES256/EdDSA) are the same root cause/surface/entry point and differ
  only in consumer key material; HS384/HS512 HMAC-variant forgeries and query/cookie
  delivery channels are likewise the same sink and were ruled out by source
  analysis. Additional attempts would re-exercise the identical `alg_verify["HS*"]`
  sink, not a distinct path.
- **Why this is a negative (no-bypass) verdict, not a confirmed variant:** the
  ES256/EdDSA results reproduce only on the vulnerable version and reach the
  identical sink via the identical entry point/trust boundary as the parent CVE.
  They confirm the fix's breadth, not a gap. Claiming them as a "distinct variant"
  would be relabeling the same behavior through a different consumer configuration,
  which the variant guidance explicitly excludes.
