{"repro_id":"REPRO-2026-00341","version":12,"title":"JFrog Artifactory critical unauthenticated authentication bypass leading to administrative takeover","repro_type":"security","status":"published","severity":"critical","description":"Critical (CVSS 9.8, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) unauthenticated authentication bypass in self-hosted JFrog Artifactory, default configuration, enabling full administrative takeover. Affected versions: 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. JFrog deliberately did NOT disclose the vulnerable endpoint or mechanism; no public PoC exists.","root_cause":"# Root Cause Analysis — CVE-2026-82329: JFrog Artifactory Unauthenticated Authentication Bypass (Blank Join Key → Cluster-Join Service Admin Token)\n\n## Summary\n\nJFrog 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.\n\n## Impact\n\n- **Component**: JFrog Access service bundled with self-hosted JFrog Artifactory (verified on `artifactory-jcr` 7.146.25; internal Access 7.176.x).\n- **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.\n- **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.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact**: unauthenticated authentication bypass → administrative takeover (`authz_bypass`, CVSS C:H/I:H/A:H).\n- **Reproduced impact in this run**: identical — zero-credential admin takeover demonstrated end-to-end against the real product:\n  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`).\n  2. `GET /access/api/v1/users` with that token → **HTTP 200**, full user list (including admin record).\n  3. `PUT /access/api/v1/users/admin` → **HTTP 200**, built-in admin password reset to an attacker-chosen value (account takeover).\n  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=*@*`).\n  5. `GET /artifactory/api/system/info` (admin-only) with that token → **HTTP 200** with full system internals.\n- **Parity: full.** No step used any pre-existing credential, account, or token; the chain starts from a raw unauthenticated HTTP request.\n\n## Root Cause\n\nThe 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):\n\n1. `org/jfrog/access/server/startup/JoinKeyAccess.class` — `tryResolveJoinKeys()`:\n   ```diff\n   - Arrays.stream(joinKey.get().split(\",\")).map(String::trim).forEach(jKey -> {\n   + Arrays.stream(joinKey.get().split(\",\")).map(String::trim).filter(Strings::isNotBlank).forEach(jKey -> {\n   ```\n2. `org/jfrog/access/token/JoinKeyHashPair.class` — constructor:\n   ```diff\n   + if (joinKey == null || joinKey.isBlank()) {\n   +     throw new IllegalArgumentException(\"Join key must not be null or blank\");\n   + }\n   ```\n\nWhy the bug fires in **default configuration**:\n\n- `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`.\n- `JoinKeyUtils.getSigningKey(\"\")` → `hexDecodeAndPad(\"\", 32)` → pkcs7-pads to the constant 32-byte key `0x20 0x20 … 0x20` — **publicly derivable, identical on every default installation**.\n- 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.\n- 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.\n\nSo 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.\n\n- 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.\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker, curl, python3).\n2. The script:\n   - 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).\n   - 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).\n   - 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).\n   - Writes per-run evidence JSON, image IDs, version files, and `runtime_manifest.json`.\n3. 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`.\n\n## Evidence\n\n- `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).\n- `bundle/artifacts/http/fixed_exploit.json` — identical attack against 7.146.38 rejected at the join step (HTTP 400, `\"exploited\": false`).\n- `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.\n- `bundle/logs/reproduction_steps.log` — orchestration log; `bundle/logs/art-{vuln,fixed}-docker.log`, `art-{vuln,fixed}-access-join.log` — service-side logs.\n- `bundle/artifacts/vuln_image_id.txt` / `fixed_image_id.txt`, `vuln_version.txt` / `fixed_version.txt` — tested target identity.\n- 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.\n\n## Recommendations / Next Steps\n\n- **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).\n- 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.\n- 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.\n- 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.\n\n## Additional Notes\n\n- 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.\n- 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.\n- The 30-second `iat` freshness check on join tokens (`MAX_REQUEST_AGE_IN_SECONDS`) is honored by minting the JWT at exploit time.\n","cve_id":"CVE-2026-82329","cwe_id":"CWE-287 Improper Authentication","source_url":"https://jfrog.com/help/r/jfrog-release-information/artifactory-security-fixes","reproduced_at":"2026-09-01T13:05:52.523565+00:00","duration_secs":8714.954515,"tool_calls":255,"handoffs":2,"total_cost_usd":8.716579,"agent_costs":{"claim_matcher":0.015503,"judge":0.494706,"repro":6.928935,"support":0.081734,"vuln_variant":1.195701},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.015503},"judge":{"gpt-5.6-sol":0.494706},"repro":{"accounts/fireworks/models/kimi-k3":6.928935},"support":{"accounts/fireworks/models/kimi-k3":0.081734},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.195701}},"vulnerable_version_variant_outcome":"found","fix_bypass_outcome":"not_found","variant_disclosure_state":"published","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-01T13:05:53.382150+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":9837,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":10186,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":11976,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":7449,"category":"reproduction_script"},{"path":"bundle/artifacts/diff/JoinKeyAccess.diff","filename":"JoinKeyAccess.diff","size":792,"category":"other"},{"path":"bundle/artifacts/diff/JoinKeyHashPair.diff","filename":"JoinKeyHashPair.diff","size":482,"category":"other"},{"path":"bundle/artifacts/fixed_image_id.txt","filename":"fixed_image_id.txt","size":130,"category":"other"},{"path":"bundle/artifacts/fixed_version.txt","filename":"fixed_version.txt","size":178,"category":"other"},{"path":"bundle/artifacts/http/fixed_exploit.json","filename":"fixed_exploit.json","size":1043,"category":"other"},{"path":"bundle/artifacts/http/vuln_exploit.json","filename":"vuln_exploit.json","size":5592,"category":"other"},{"path":"bundle/artifacts/vuln_image_id.txt","filename":"vuln_image_id.txt","size":130,"category":"other"},{"path":"bundle/artifacts/vuln_version.txt","filename":"vuln_version.txt","size":178,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":3450,"category":"log"},{"path":"bundle/repro/exploit_join_bypass.py","filename":"exploit_join_bypass.py","size":7144,"category":"script"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1951,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1554,"category":"other"},{"path":"bundle/artifacts/variant_http/fixed_join-kid.json","filename":"fixed_join-kid.json","size":1194,"category":"other"},{"path":"bundle/artifacts/variant_http/fixed_router-override.json","filename":"fixed_router-override.json","size":1123,"category":"other"},{"path":"bundle/artifacts/variant_http/fixed_router.json","filename":"fixed_router.json","size":1114,"category":"other"},{"path":"bundle/artifacts/variant_http/vuln_join-kid.json","filename":"vuln_join-kid.json","size":4890,"category":"other"},{"path":"bundle/artifacts/variant_http/vuln_router-override.json","filename":"vuln_router-override.json","size":6246,"category":"other"},{"path":"bundle/artifacts/variant_http/vuln_router.json","filename":"vuln_router.json","size":6237,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":178,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":10117,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_version.txt","filename":"vuln_version.txt","size":178,"category":"other"},{"path":"bundle/vuln_variant/decomp/src-vuln/org/jfrog/access/server/rest/resource/registry/RegistryNoAuthResource.java","filename":"RegistryNoAuthResource.java","size":2558,"category":"other"},{"path":"bundle/vuln_variant/exploit_join_router_variant.py","filename":"exploit_join_router_variant.py","size":7871,"category":"script"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4562,"category":"documentation"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":3149,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":2660,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1596,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3761,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":5294,"category":"other"}]}