# Variant Analysis — CVE-2026-82329: JFrog Artifactory Blank Join Key Unauthenticated Auth Bypass

## Summary

The parent reproduction exploited the unauthenticated endpoint `POST /access/api/v1/registry/join`. This variant stage performed static sink-coverage analysis on the extracted Access service (7.176.15 vulnerable vs 7.176.28 fixed) and found that the same vulnerable sink (`JoinServiceImpl.getValidatedJwtToken` → `getJoinKey` → `JoinKeyAccess.getTokenSignatureVerifiers`, which trusts the auto-registered **blank additional join key**, kid `sha256("") = e3b0c442…b855`) is reachable through **additional unauthenticated entry points and key-selection branches** the parent PoC did not exercise:

- **Variant A — `POST /access/api/v1/registry/join/router`** (second no-auth endpoint in `RegistryNoAuthResource`, `joinRouter()`). **Confirmed on 7.146.25**: HTTP 200, wrapper JWT carrying a never-expiring `scp=admin` service token in its `token` claim → full admin takeover re-demonstrated.
- **Variant B — `POST /access/api/v1/registry/join/router?override=true`** (override branch skipping node-id/IP validation). **Confirmed on 7.146.25**.
- **Variant C — `POST /access/api/v1/registry/join` with explicit `kid=e3b0c442…`** (kid-selected branch of `JoinKeyAccess.getRelevantJoinKeys`, vs the no-kid try-all branch used by the parent exploit). **Confirmed on 7.146.25**: HTTP 201.

All three variants were then re-run against the **fixed** build 7.146.38: **all rejected with HTTP 400**. Therefore this stage confirms **a distinct alternate trigger (variant), but NOT a bypass** — the shipped fix (reject blank join keys in `JoinKeyHashPair` + filter blank entries in `JoinKeyAccess.tryResolveJoinKeys`) covers every entry point and data path found, because all of them funnel through the single shared sink that was patched.

## Fix Coverage / Assumptions

The fix (Access 7.176.27 → 7.176.28, shipped in Artifactory 7.146.38 et al.) consists of exactly two changes:

1. `JoinKeyAccess.tryResolveJoinKeys()`: `.map(String::trim).filter(Strings::isNotBlank)` when parsing `shared.security.additionalJoinKeys`.
2. `JoinKeyHashPair.<init>`: `throw new IllegalArgumentException` when the join key is null or blank.

**Invariant the fix relies on**: *all* consumers of additional join keys go through `JoinKeyHashPair` / the `additionalJoinKeys` cache, i.e. blank keys can never enter the verification key set regardless of entry point. This stage verified the invariant holds:

- Byte-level/decompiled comparison shows `JoinServiceImpl`, `RegistryNoAuthResource`, `RegistryResource`, and `SecuritySubResource` are **identical** between the vulnerable and fixed builds — only `JoinKeyAccess` (and `JoinKeyHashPair` in `access-common-api`) changed.
- Constant-pool scan of all Access server jars shows the join-key verification sink (`JoinKeyAccess.getTokenSignatureVerifiers`, `JoinKeyUtils.getSigningKey`, `JoinKeyHashPair`) is consumed **only** by `JoinServiceImpl`; `JoinService` is referenced only by `RegistryNoAuthResource` (network-facing) and `TopologyServiceImpl` (internal). No gRPC or other REST resource reaches the sink.

**What the fix covers**: both no-auth endpoints (`join`, `join/router`), both key-selection branches (no-kid try-all, explicit-kid lookup), and even a hypothetical admin misconfiguration (`additionalJoinKeys` containing blank/whitespace entries would now throw at parse time).

**What the fix does NOT cover** (out of scope, requires admin-level config control): an administrator who deliberately configures a *short/guessable non-blank* hex join key (e.g. `00`) would still create a weak key, since `JoinKeyUtils.getSigningKey` pkcs7-pads short keys deterministically. This crosses no attacker trust boundary by itself (setting join keys is an admin operation), so it is not a variant of this CVE — but enforcing a minimum join-key entropy would be worthwhile hardening.

## Variant / Alternate Trigger

Same root cause (blank additional join key accepted by default), same sink, different entry point / data path:

| ID | Entry point | Code path | Vuln 7.146.25 | Fixed 7.146.38 |
|----|-------------|-----------|---------------|----------------|
| A | `POST /access/api/v1/registry/join/router` | `RegistryNoAuthResource.joinRouter` → `JoinServiceImpl.joinRouter` → `getValidatedJwtToken` → `getJoinKey` (no-kid try-all incl. blank key) → `registerWith` → `combineTokenAndCertificate` (wrapper JWT with admin token in `token` claim) | **HTTP 200**, admin takeover | HTTP 400 |
| B | `POST /access/api/v1/registry/join/router?override=true` | same as A, `override` branch skips `validateNodeIdAndIP` | **HTTP 200**, admin takeover | HTTP 400 |
| C | `POST /access/api/v1/registry/join` (explicit `kid` claim) | `RegistryNoAuthResource.join` → `join` → `getValidatedJwtToken` → `getJoinKey` → `getRelevantJoinKeys(kid)` kid-selected branch (direct blank-key hit) | **HTTP 201**, admin takeover | HTTP 400 |

Variant-specific notes:

- `joinRouter`'s `validateCheckUrl` is a **no-op when the `check_url` claim is omitted** (`StringUtils.isEmpty` guard), so no attacker-hosted callback server is needed.
- `validateNodeIdAndIP` (non-override path, access-topology mode) is satisfied with fresh random `node_id`/`node_ip` claims; `?override=true` skips it entirely.
- The router-join response is `text/plain`: a JWT signed with the (blank) join key whose `token` custom claim carries the inner never-expiring `scp=admin` service token — trivially decoded by the attacker.
- The wrapper JWT additionally embeds the platform **root CA certificate** (`root_cert` claim), a minor extra information disclosure of the variant endpoint.

Rule-out (no further real candidates exist): the sink has exactly one consumer (`JoinServiceImpl`) reachable from exactly one no-auth resource (`RegistryNoAuthResource`) exposing exactly two endpoints; both endpoints and both key-selection branches were tested. A whitespace-only `additionalJoinKeys` entry would collapse to the same blank key (trim → blank) and requires admin config access anyway (no trust-boundary crossing). Testing more payload permutations would only relabel the same trigger.

## Impact

- **Component**: JFrog Access service bundled with self-hosted JFrog Artifactory (tested on `artifactory-jcr` 7.146.25 / Access 7.176.15 vs 7.146.38 / Access 7.176.28).
- **Affected versions** (vendor advisory): 7.111.x <7.111.21, 7.117.x <7.117.28, 7.125.x <7.125.20, 7.133.x <7.133.29, 7.146.x <7.146.38, 7.161.x <7.161.20.
- **Risk**: CVSS 9.8 — unauthenticated remote administrative takeover. The `join/router` variant additionally returns the platform root CA certificate to the unauthenticated attacker.

## Impact Parity

- **Disclosed/claimed maximum impact (parent)**: unauthenticated auth bypass → administrative takeover (`authz_bypass`, C:H/I:H/A:H).
- **Reproduced via variants**: identical. For each variant on 7.146.25, with zero credentials: service admin token obtained (`sub=jfrt@cve202682329var…, scp=admin`) → `GET /access/api/v1/users` 200 → `POST /access/api/v1/tokens` mints admin user token (`sub=jfac@…/users/admin, scp=applied-permissions/admin, aud=*@*`) → `GET /artifactory/api/system/info` 200 (admin-only).
- **Parity: full.** Nothing claimed was left undemonstrated; the fixed build blocks all variants at the join step.

## Root Cause

Identical to the parent RCA: default configuration registers a blank join key in the additional-join-keys cache because `Try.isEmpty()` does not test string emptiness (`JoinKeyAccess.tryResolveJoinKeys`), and `JoinKeyHashPair` accepted blank keys; `JoinKeyUtils.getSigningKey("")` pkcs7-pads to the attacker-known constant `32 × 0x20`. This stage adds the sink-coverage proof that `join/router` (and the explicit-kid branch) hit the same unpatched code — and that the two-class fix closes all of them. Fix reference: JFrog security advisories (CVE-2026-82329, published 2026-08-28); binary diff 7.146.36 → 7.146.38 (Access 7.176.27 → 7.176.28), see `bundle/artifacts/diff/`.

## Reproduction Steps

1. `bundle/vuln_variant/reproduction_steps.sh` (self-contained; Docker, curl, python3). Idempotent; executed three times with identical results (final two runs after a logging fix: exit 1).
2. The script boots PostgreSQL + vulnerable `artifactory-jcr:7.146.25` + fixed `artifactory-jcr:7.146.38` (default config, no join keys configured), then runs `bundle/vuln_variant/exploit_join_router_variant.py` in modes `router`, `router-override`, `join-kid` against **both** instances.
3. Expected evidence: vulnerable → each variant returns a service admin token and full takeover steps succeed (`"exploited": true`); fixed → each variant rejected at the join step with HTTP 400. Exit 0 would mean a variant reproduces on the fixed build (true bypass); actual exit 1 = variants confirmed on vulnerable only, no bypass.

## Evidence

- `bundle/artifacts/variant_http/vuln_{router,router-override,join-kid}.json` — full request/response transcripts of the three successful variant exploits against 7.146.25 (200/200/201 at the join step, `scp=admin` token claims, admin takeover steps all 200).
- `bundle/artifacts/variant_http/fixed_{router,router-override,join-kid}.json` — identical attacks against 7.146.38, all HTTP 400, `"exploited": false`.
- `bundle/logs/vuln_variant/reproduction_steps.log` — orchestration log (three runs); `art-var-{vuln,fixed}-docker.log` — service logs; `docker_pull.log`; `vuln_version.txt` / `fixed_version.txt` — tested target identity (`artifactory.version=7.146.25` / `7.146.38`).
- `bundle/vuln_variant/decomp/` — extracted Access jars (7.176.15 / 7.176.28), decompiled sources proving: (a) `RegistryNoAuthResource` exposes two no-auth endpoints, (b) `JoinServiceImpl` is the sole sink consumer and is identical across versions, (c) `validateCheckUrl` is skippable by omitting `check_url`, (d) fix is confined to `JoinKeyAccess`/`JoinKeyHashPair`.
- Environment: rootless Docker, postgres:16-alpine sidecar, linux x86_64.

## Recommendations / Next Steps

- **No fix extension required**: the shipped patch covers all discovered variants; the coding stage can rely on the two-class fix as complete for this root cause. Regression tests should cover **both** no-auth endpoints (`join` and `join/router`) and **both** key-selection branches (no-kid, explicit `kid=e3b0c442…`) — the router endpoint is the one most likely to be forgotten by a test suite.
- Hardening ideas: enforce minimum join-key entropy/length in `JoinKeyHashPair` (pkcs7-padding silently upgrades short hex keys to deterministic keys); require a `kid` claim on join requests; bind join tokens to topology/node registration; rate-limit and audit `/access/api/v1/registry/*`; alert on `scp=admin` tokens issued to unknown `jfrt@…` subjects.
- Post-upgrade: rotate join key, master key, and admin password; audit `access_nodes` for rogue registrations (including `REGISTERING` router nodes, which variant A/B inserts).

## Additional Notes

- **Idempotency**: the script recreates network/containers/databases/keys each run; three consecutive executions produced identical verdicts (vuln: all 3 variants exploited; fixed: all 3 blocked). Runs 2 and 3 used the final script and exited 1 (no bypass).
- **First-run correction**: the initial script version had a verdict-parsing bug (command substitution captured `tee` output whose timestamps contained `:0`, producing a false "BYPASS" conclusion). This was fixed by routing exploit output to stderr/log only; the corrected verdict is definitive and was reproduced twice.
- **Limitations**: JCR image used (Pro repository APIs gated); the vulnerable code lives in the shared Access service, so the variants apply equally to Pro/ProX. No git commit SHA exists for the proprietary fixed build; exact identity is pinned by image tag + image ID + embedded `artifactory.product.version.properties`.
