CVE-2026-82329: Verified Reproduction
CVE-2026-82329: JFrog Artifactory critical unauthenticated authentication bypass leading to administrative takeover
CVE-2026-82329 is verified against the affected target. Vulnerability class: Auth Bypass. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00341.
What Is CVE-2026-82329?
CVE-2026-82329 is a critical-severity Auth Bypass vulnerability. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00341).
CVE-2026-82329 Severity
CVE-2026-82329 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
How to Reproduce CVE-2026-82329
pruva-verify REPRO-2026-00341 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00341/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-82329
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
Unauthenticated HS256 join JWT (service_id/node_id claims chosen by attacker) signed with the publicly derivable blank-join-key HMAC secret (32 bytes of 0x20), sent to POST /access/api/v1/registry/join
- JFrog Access RegistryNoAuthResource POST /access/api/v1/registry/join
- JoinServiceImpl.getValidatedJwtToken
- JoinKeyAccess.getTokenSignatureVerifiers (blank additional join key, kid=sha256(''))
- ServiceTokenProviderImpl issues scope=admin service token; then /access/api/v1/users + /access/api/v1/tokens + /artifactory/api/system/info for admin takeover
Alternate unauthenticated entry points and key-selection branches reaching the same blank-join-key sink as the parent exploit: (A) POST /access/api/v1/registry/join/router (RegistryNoAuthResource.joinRouter, second no-auth endpoint), (B) same with ?override=true, (C) POST /access/api/v1/registry/join with explicit kid…
How the agent worked
Root Cause and Exploit Chain for CVE-2026-82329
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.
- Component: JFrog Access service bundled with self-hosted JFrog Artifactory (verified on
artifactory-jcr7.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:
POST /access/api/v1/registry/joinwith a JWT signed with HMAC-SHA256 key20*32→ HTTP 201, service admin token (sub=jfrt@cve202682329poc…, scp=admin).GET /access/api/v1/userswith that token → HTTP 200, full user list (including admin record).PUT /access/api/v1/users/admin→ HTTP 200, built-in admin password reset to an attacker-chosen value (account takeover).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=*@*).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):
org/jfrog/access/server/startup/JoinKeyAccess.class—tryResolveJoinKeys():- Arrays.stream(joinKey.get().split(",")).map(String::trim).forEach(jKey -> { + Arrays.stream(joinKey.get().split(",")).map(String::trim).filter(Strings::isNotBlank).forEach(jKey -> {org/jfrog/access/token/JoinKeyHashPair.class— constructor:+ 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()resolvesshared.security.additionalJoinKeys. When unset (default),resolveJoinKeys()returns""wrapped in a vavrTry. The guardif (!joinKey.isEmpty())callsTry.isEmpty(), which tests for failure/null — not string emptiness — so the empty default proceeds to"".split(",")→[""], and aJoinKeyHashPair("")(blank join key) is registered in the additional-join-keys map underkid = sha256("") = e3b0c442…b855.JoinKeyUtils.getSigningKey("")→hexDecodeAndPad("", 32)→ pkcs7-pads to the constant 32-byte key0x20 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 nokidclaim it tries the main join key plus every additional join key (including the blank one); withkid=e3b0c442…it selects the blank key directly. An HS256 JWT signed with32 × 0x20therefore verifies. - On verification,
ServiceTokenProviderImpl.getToken(serviceId)issuesTokenSpec … .scope("admin") .expiresIn(0)viacreateInternalTokenWithoutAuthAndNotify— a platform-trusted, never-expiring service admin token for the attacker-chosenservice_idclaim.
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
bundle/repro/reproduction_steps.sh(self-contained; requires Docker, curl, python3).- The script:
- Pulls
releases-docker.jfrog.io/jfrog/artifactory-jcr:7.146.25(vulnerable),:7.146.38(fixed), andpostgres: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 generatedmaster.key, usingdocker create+docker cp+docker start(single-file bind mounts break JFrog's atomicsystem.yamlrewrite). - Runs
bundle/repro/exploit_join_bypass.pyagainst 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.
- Pulls
- Expected evidence: vulnerable instance → join HTTP 201 with
scp=admintoken, 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=admintoken claims; users dump 200; admin password reset 200; admin user token claimssub=jfac@…/users/admin, scp=applied-permissions/admin;/artifactory/api/system/info200; 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) vsartifactory-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; auditaccess_nodes/access_auditfor unexpected service registrations and tokens (scp=adminwith unknownjfrt@…subjects), and rotate the join key, master key, and the admin password after upgrading. - Fix approach (already shipped): reject null/blank join keys in
JoinKeyHashPairand filter blank entries when parsingadditionalJoinKeys. Additionally consider requiring akidand binding join tokens tonode_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_keychildren) and thatregistry/joinrejects 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/infoendpoint instead of repository creation. The vulnerable code lives in the shared Access service, so Pro/ProX distributions are equally affected. - The 30-second
iatfreshness check on join tokens (MAX_REQUEST_AGE_IN_SECONDS) is honored by minting the JWT at exploit time.
Variant Analysis & Alternative Triggers for CVE-2026-82329
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 inRegistryNoAuthResource,joinRouter()). Confirmed on 7.146.25: HTTP 200, wrapper JWT carrying a never-expiringscp=adminservice token in itstokenclaim → 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/joinwith explicitkid=e3b0c442…(kid-selected branch ofJoinKeyAccess.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:
JoinKeyAccess.tryResolveJoinKeys():.map(String::trim).filter(Strings::isNotBlank)when parsingshared.security.additionalJoinKeys.JoinKeyHashPair.<init>:throw new IllegalArgumentExceptionwhen 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, andSecuritySubResourceare identical between the vulnerable and fixed builds — onlyJoinKeyAccess(andJoinKeyHashPairinaccess-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 byJoinServiceImpl;JoinServiceis referenced only byRegistryNoAuthResource(network-facing) andTopologyServiceImpl(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'svalidateCheckUrlis a no-op when thecheck_urlclaim is omitted (StringUtils.isEmptyguard), so no attacker-hosted callback server is needed.validateNodeIdAndIP(non-override path, access-topology mode) is satisfied with fresh randomnode_id/node_ipclaims;?override=trueskips it entirely.- The router-join response is
text/plain: a JWT signed with the (blank) join key whosetokencustom claim carries the inner never-expiringscp=adminservice token — trivially decoded by the attacker. - The wrapper JWT additionally embeds the platform root CA certificate (
root_certclaim), 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.
- Component: JFrog Access service bundled with self-hosted JFrog Artifactory (tested on
artifactory-jcr7.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/routervariant 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/users200 →POST /access/api/v1/tokensmints admin user token (sub=jfac@…/users/admin, scp=applied-permissions/admin, aud=*@*) →GET /artifactory/api/system/info200 (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
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).- The script boots PostgreSQL + vulnerable
artifactory-jcr:7.146.25+ fixedartifactory-jcr:7.146.38(default config, no join keys configured), then runsbundle/vuln_variant/exploit_join_router_variant.pyin modesrouter,router-override,join-kidagainst both instances. - 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=admintoken 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)RegistryNoAuthResourceexposes two no-auth endpoints, (b)JoinServiceImplis the sole sink consumer and is identical across versions, (c)validateCheckUrlis skippable by omittingcheck_url, (d) fix is confined toJoinKeyAccess/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 (
joinandjoin/router) and both key-selection branches (no-kid, explicitkid=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 akidclaim on join requests; bind join tokens to topology/node registration; rate-limit and audit/access/api/v1/registry/*; alert onscp=admintokens issued to unknownjfrt@…subjects. - Post-upgrade: rotate join key, master key, and admin password; audit
access_nodesfor rogue registrations (includingREGISTERINGrouter 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
teeoutput 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.
CVE-2026-82329 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls -la /pruva/project-cache/ 2>/dev/null; echo ---; docker info 2>&1 | head -5; echo ---; which docker dockerd; echo ---; free -g | head -2; df -h / /pruva 2>/dev/null | head -5total 4 drwx------ 4 vscode vscode 80 Sep 1 05:40 . drwxr-xr-x 3 root root 4096 Sep 1 05:40 .. drwx------ 2 vscode vscode 60 Sep 1 05:40 .pruva drwx------ 2 vscode vscode 40 Sep 1 05:40 repo-mirrors --- Client: Version: 29.1.3
docker info 2>&1 | grep -E "Server|ERROR|error" | head -10; echo ---; ps aux | grep -i dockerd | grep -v grep; echo ---; cat /pruva/project-cache/.pruva/* 2>/dev/null | head -50; ls /pruva/project-cache/repo-mirrors/Server:
Server Version: 27.5.1
---
---
{
"schema_version": 1,
"entries": [
{Artifacts and Evidence for CVE-2026-82329
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-82329
FAQ: CVE-2026-82329
Is CVE-2026-82329 exploitable?
How severe is CVE-2026-82329?
What type of vulnerability is CVE-2026-82329?
How can I reproduce CVE-2026-82329?
Is the CVE-2026-82329 reproduction verified?
References for CVE-2026-82329
Authoritative sources for CVE-2026-82329 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.