# Root Cause Analysis — CVE-2026-82329: JFrog Artifactory Unauthenticated Authentication Bypass (Blank Join Key → Cluster-Join Service Admin Token)

## Summary

JFrog Artifactory's Access service, in default configuration, registered a **blank (empty-string) join key** in its "additional join keys" verification cache. Join keys are the HMAC secrets used to authenticate cluster-join requests at the **unauthenticated** endpoint `POST /access/api/v1/registry/join`. Because `JoinKeyUtils.getSigningKey("")` pkcs7-pads the empty key to the constant `32 × 0x20`, every vulnerable instance accepts a join JWT signed with an attacker-known key. A successful join returns a **never-expiring service admin token** (`scope: "admin"`) for an attacker-chosen service id, which is then usable to dump users, reset the built-in administrator's password, and mint platform-wide admin user tokens — full administrative takeover starting from **zero valid credentials**. Fixed in 7.146.38 (and corresponding branches) by rejecting blank join keys.

## Impact

- **Component**: JFrog Access service bundled with self-hosted JFrog Artifactory (verified on `artifactory-jcr` 7.146.25; internal Access 7.176.x).
- **Affected versions** (vendor advisory): 7.111.4–7.111.20, 7.117.0–7.117.27, 7.125.0–7.125.19, 7.133.0–7.133.28, 7.146.0–7.146.36, 7.161.0–7.161.19. Fixed: 7.111.21 / 7.117.28 / 7.125.20 / 7.133.29 / 7.146.38 / 7.161.20.
- **Risk**: CVSS 9.8 Critical (AV:N/AC:L/PR:N/UI:N). Any unauthenticated network attacker can obtain administrative control of the platform (user management, admin credential reset, admin token issuance), leading to full compromise of hosted artifacts and CI/CD supply chain.

## Impact Parity

- **Disclosed/claimed maximum impact**: unauthenticated authentication bypass → administrative takeover (`authz_bypass`, CVSS C:H/I:H/A:H).
- **Reproduced impact in this run**: identical — zero-credential admin takeover demonstrated end-to-end against the real product:
  1. `POST /access/api/v1/registry/join` with a JWT signed with HMAC-SHA256 key `20*32` → **HTTP 201**, service admin token (`sub=jfrt@cve202682329poc…, scp=admin`).
  2. `GET /access/api/v1/users` with that token → **HTTP 200**, full user list (including admin record).
  3. `PUT /access/api/v1/users/admin` → **HTTP 200**, built-in admin password reset to an attacker-chosen value (account takeover).
  4. `POST /access/api/v1/tokens {"username":"admin","scope":"applied-permissions/admin"}` → **HTTP 200**, admin user token (`sub=jfac@…/users/admin, scp=applied-permissions/admin, aud=*@*`).
  5. `GET /artifactory/api/system/info` (admin-only) with that token → **HTTP 200** with full system internals.
- **Parity: full.** No step used any pre-existing credential, account, or token; the chain starts from a raw unauthenticated HTTP request.

## Root Cause

The fix was isolated by binary-diffing `artifactory-jcr:7.146.36` (last vulnerable) against `artifactory-jcr:7.146.38` (fixed). The **entire** payload difference is the Access service (7.176.27 → 7.176.28), and within it exactly two security-relevant classes changed (the rest are manifests/UI bundles):

1. `org/jfrog/access/server/startup/JoinKeyAccess.class` — `tryResolveJoinKeys()`:
   ```diff
   - Arrays.stream(joinKey.get().split(",")).map(String::trim).forEach(jKey -> {
   + Arrays.stream(joinKey.get().split(",")).map(String::trim).filter(Strings::isNotBlank).forEach(jKey -> {
   ```
2. `org/jfrog/access/token/JoinKeyHashPair.class` — constructor:
   ```diff
   + if (joinKey == null || joinKey.isBlank()) {
   +     throw new IllegalArgumentException("Join key must not be null or blank");
   + }
   ```

Why the bug fires in **default configuration**:

- `JoinKeyAccess.tryResolveJoinKeys()` resolves `shared.security.additionalJoinKeys`. When unset (default), `resolveJoinKeys()` returns `""` wrapped in a vavr `Try`. The guard `if (!joinKey.isEmpty())` calls **`Try.isEmpty()`**, which tests for failure/null — **not** string emptiness — so the empty default proceeds to `"".split(",")` → `[""]`, and a `JoinKeyHashPair("")` (blank join key) is registered in the additional-join-keys map under `kid = sha256("") = e3b0c442…b855`.
- `JoinKeyUtils.getSigningKey("")` → `hexDecodeAndPad("", 32)` → pkcs7-pads to the constant 32-byte key `0x20 0x20 … 0x20` — **publicly derivable, identical on every default installation**.
- The unauthenticated `RegistryNoAuthResource.join` (`POST /access/api/v1/registry/join`) → `JoinServiceImpl.getValidatedJwtToken()` → `getJoinKey(jwt)` → `joinKeyAccess.getTokenSignatureVerifiers(kid)`: with no `kid` claim it tries the main join key **plus every additional join key** (including the blank one); with `kid=e3b0c442…` it selects the blank key directly. An HS256 JWT signed with `32 × 0x20` therefore verifies.
- On verification, `ServiceTokenProviderImpl.getToken(serviceId)` issues `TokenSpec … .scope("admin") .expiresIn(0)` via `createInternalTokenWithoutAuthAndNotify` — a platform-trusted, never-expiring **service admin token** for the attacker-chosen `service_id` claim.

So one unauthenticated POST yields admin-level identity; trivial follow-ups (`PUT /access/api/v1/users/admin`, `POST /access/api/v1/tokens`) convert it into full administrative takeover of Artifactory.

- Fix: JFrog advisory <https://docs.jfrog.com/releases/docs/jfrog-security-advisories> (CVE-2026-82329, published 2026-08-28). Patch is the two-class change above (blank-key rejection), present in Access 7.176.28 / Artifactory 7.146.38.

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker, curl, python3).
2. The script:
   - Pulls `releases-docker.jfrog.io/jfrog/artifactory-jcr:7.146.25` (vulnerable), `:7.146.38` (fixed), and `postgres:16-alpine` (7.146.x refuses to start on the legacy embedded Derby DB).
   - Boots each Artifactory with a default-config `system.yaml` (external PostgreSQL only; **no** join key / additional join keys configured) plus a generated `master.key`, using `docker create` + `docker cp` + `docker start` (single-file bind mounts break JFrog's atomic `system.yaml` rewrite).
   - Runs `bundle/repro/exploit_join_bypass.py` against each instance: blank-key JWT join, Access admin operations, admin password reset, admin token mint, admin-only Artifactory API call, plus built-in controls (anonymous token mint must 401; wrong-signature join must 400).
   - Writes per-run evidence JSON, image IDs, version files, and `runtime_manifest.json`.
3. Expected evidence: vulnerable instance → join HTTP 201 with `scp=admin` token, admin takeover steps all 200, exploit JSON `"exploited": true`, script exit 0; fixed instance → join HTTP 400 (`JWT's signature does not match the server's join key`), `"exploited": false`.

## Evidence

- `bundle/artifacts/http/vuln_exploit.json` — full request/response transcript of the successful exploit against 7.146.25 (join 201 + `scp=admin` token claims; users dump 200; admin password reset 200; admin user token claims `sub=jfac@…/users/admin, scp=applied-permissions/admin`; `/artifactory/api/system/info` 200; anonymous controls 401; wrong-signature join 400).
- `bundle/artifacts/http/fixed_exploit.json` — identical attack against 7.146.38 rejected at the join step (HTTP 400, `"exploited": false`).
- `bundle/artifacts/diff/JoinKeyAccess.diff`, `bundle/artifacts/diff/JoinKeyHashPair.diff` (+ full decompiled classes) — the two-class security patch between 7.146.36 and 7.146.38.
- `bundle/logs/reproduction_steps.log` — orchestration log; `bundle/logs/art-{vuln,fixed}-docker.log`, `art-{vuln,fixed}-access-join.log` — service-side logs.
- `bundle/artifacts/vuln_image_id.txt` / `fixed_image_id.txt`, `vuln_version.txt` / `fixed_version.txt` — tested target identity.
- Environment: Docker (rootless), postgres:16-alpine sidecar, `artifactory-jcr:7.146.25` (Access 7.176.15) vs `artifactory-jcr:7.146.38` (Access 7.176.28), linux x86_64.

## Recommendations / Next Steps

- **Upgrade** self-hosted Artifactory to 7.111.21 / 7.117.28 / 7.125.20 / 7.133.29 / 7.146.38 / 7.161.20 or later (per branch).
- Interim mitigation: restrict network access to the Access/router endpoints (`/access/api/v1/registry/*`) to trusted networks; audit `access_nodes`/`access_audit` for unexpected service registrations and tokens (`scp=admin` with unknown `jfrt@…` subjects), and rotate the join key, master key, and the admin password after upgrading.
- Fix approach (already shipped): reject null/blank join keys in `JoinKeyHashPair` and filter blank entries when parsing `additionalJoinKeys`. Additionally consider requiring a `kid` and binding join tokens to `node_id`/topology registration, and rate-limiting/auditing the no-auth join endpoint.
- Testing: regression test that a default install has **no** additional join keys (`/access/api/v1/system/security/join_key` children) and that `registry/join` rejects empty-key HMAC JWTs.

## Additional Notes

- Idempotency: the script tears down and recreates all containers/network each run and was executed twice consecutively with identical results (vulnerable exploited, fixed blocked). Each run generates fresh master keys, databases, node ids, and attacker service ids.
- Limitations: verification used the JCR (Container Registry) image; repository-management REST (`/api/repositories`) is Pro-gated in JCR, so admin takeover was demonstrated via Access admin APIs + admin token mint + the admin-only `/artifactory/api/system/info` endpoint instead of repository creation. The vulnerable code lives in the shared Access service, so Pro/ProX distributions are equally affected.
- The 30-second `iat` freshness check on join tokens (`MAX_REQUEST_AGE_IN_SECONDS`) is honored by minting the JWT at exploit time.
