TL;DR: JFrog Artifactory is the server your builds pull packages from and publish to, and servers in a cluster prove membership with a shared secret called a join key. On a default install that key was never set, and the code read "never set" as the empty string. Three checks each asked a slightly different question than the one that mattered, so the empty string registered as a real key. Its secret works out to 32 space characters. Sign a join request with those and the server hands back an administrator token that never expires. CISA gave federal agencies three days.

Three unrelated stories this week. In each one a check existed and worked exactly as written, while the decision it was meant to govern happened somewhere the check never ran.

Attack of the Week: three checks let an empty string through

Nate Robb at Bishop Fox, "CVE-2026-82329: Unauthenticated Administrative Access in JFrog Artifactory via an Empty Cluster Join Key" [2026-09-11]. CVSS 9.8, advisory 2026-08-28. Bishop Fox decompiled the two classes that changed and reproduced the chain against a default 7.111.20.

Every Artifactory bundles an authentication service called JFrog Access, and Access owns join keys. A joining node has no identity yet, so the endpoint taking the request cannot require one. Its class name says exactly that: RegistryNoAuthResource. It accepts any token whose signature matches a key the server holds.

The three guards. Access reads its join keys from a comma-separated config list called additional-join-keys. On a default install it is never set, and the lookup returns an empty string rather than reporting it absent.

The first guard reads if (!joinKey.isEmpty()). But joinKey is the lookup's result object, not the string inside it, so that asks whether the lookup failed. The second splits on a comma, and splitting an empty string returns one element: the empty string. The third checks the value is well-formed hex rather than that it exists, and an empty string is valid hex.

What that produces. Access registers each key under the SHA-256 of its own value, so the id is the hash of nothing:

kid = SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

The secret comes from that same empty value. Access decodes it from hex and pads the result to 32 bytes, and the padding scheme writes the count of added bytes into every one of them. So all 32 bytes hold the number 32, and byte 32 is a space. Anyone can work both halves out offline.

Send one POST /access/api/v1/registry/join signed with those 32 spaces. Back comes HTTP 201 and a token scoped admin with no expiry field, because the builder calls .scope("admin").expiresIn(0). In Robb's words, that request "buys a permanent Access administrator credential."

It is good for Access only. Reaching Artifactory took one further call, minting a second token scoped applied-permissions/admin that returned the full system configuration. Robb's root cause: "check that a secret exists, not just that it is well formed."

It is being used. Shahar Dorfman, Sean Johnstone, Zohar Kaplan and Kurt Giacchino at Wiz [2026-09-10] watched several actors exploit it between September 1 and 8: persistent admin accounts, the real join key pulled from the API, their own SSH keys attached. Some accounts were named to survive a glance at a user list, like jfrog-distribution. Among organisations running Artifactory, 67% had a vulnerable instance when this CVE published, falling to 49% within two weeks.

Ship This Week. Patch to 7.111.21, 7.117.28, 7.125.20, 7.133.29, 7.146.38 or 7.161.20. JFrog patched hosted Cloud directly, so this is self-managed only. CISA listed it on 2026-09-02, due 2026-09-05, with a forensic triage requirement, so patching alone does not close it out. Treat any pre-patch internet-facing instance as potentially compromised and hunt 201s from /access/api/v1/registry/join, admin tokens with no expiry, and accounts carrying artifactory_admin in custom data. Then rotate the join key, Access tokens and every upstream registry credential. One catch on revocation: Access reports the deletion immediately while Artifactory keeps honouring that account's basic-auth login for a cache window. Confirm the login fails.

Rule of the Week: ask the server without exploiting it

So how do we check our own instance? Proving it outright means minting an admin token on the host, which leaves a record indistinguishable from an attacker's. Bishop Fox built a check that stops short: two requests, both signed wrong.

The first names the empty kid, the key id from the hash above. A vulnerable server finds that key, fails the signature check and says so. A patched server reports the key as unknown. The second is a control carrying a decoy kid registered nowhere, which matters because some Access builds return a signature mismatch to everything.

probe   -> kid = SHA-256("") = e3b0c442...
control -> kid = a decoy registered nowhere

answers differ     -> the empty key is registered, vulnerable
answers identical  -> not vulnerable
both "signature does not match" -> inconclusive (Access 7.128.x)

Neither request can make the server mint anything. Access answers on port 8082, not the 8081 UI port, and carries its own version numbers. The logs give the same answer without a request: vulnerable builds write Adding join key with kid: e3b0c442... at WARN on every Access startup, so the history shows how far back the exposure goes.

Defender's Corner: read .git/config before you open that folder

Audit the repositories that reached us as files rather than as clones. Francisco Rosales at Manifold Security, "GitSpawn: A Single Flaw Lets Untrusted Repos Run Code in Claude Code, Codex, Cursor, and Grok" [2026-09-01] found the same placement problem in seven coding agents.

core.fsmonitor is a git performance setting. Instead of checking every file, git asks a helper program what changed and runs it during an index refresh. Git reads that setting from the repository's own .git/config, so the repository picks the command, and any git call refreshing the index runs it. git status and git diff both do.

Agents run exactly those at startup, as a subprocess of their own code, to work out where they are. The command lands on the host with our privileges, outside the sandbox, no prompt and nothing on screen. On Claude Code it fired while the workspace-trust prompt was still waiting.

This only reaches us one way. Cloning does nothing, and neither does fetch or pull. The repository has to arrive as files with its .git directory inside: a zip, a sync folder, a USB stick, a consultant handing over a project. Rosales filed eight findings across seven agents, four unpatched when he published.

Do this Monday. Read .git/config before pointing an agent at any directory that arrived as files. That is Rosales's own advice and the only one that holds, because git prefers the repository's config to your global one, so setting core.fsmonitor false globally does nothing against a repository that sets it. If you ship an agent, strip the config on every context-gathering call: git -c core.fsmonitor=false status.

Agent Bench: find where an unset value becomes a usable one

An agent finds this class of bug faster than we do. Point one at our config-resolution code and ask one question: where does a missing value come back usable instead of as an error?

Give it the config loader, the secret-resolution path and the callers consuming what those return. Ask for four things, each at file:line:

  • Getters that return an empty string or a zero when a key is absent, where the caller cannot tell that from a real value.

  • Guards testing a wrapper rather than the value inside it. The !joinKey.isEmpty() shape.

  • Validators that check format without checking presence. Hex, base64 and UUID all accept the empty string.

  • Any secret reaching a cryptographic operation with no length assertion.

Require a trace for each: the config key, the resolution path, the consuming call site. Findings without one are guesses.

What you walk away with: a list of config keys that silently become usable when unset, each with its call site.

Agents over-report here, and the failure mode is confident wrong control flow. Expect one to insist a guard is missing three lines above where that guard already sits.

Try it this week: pick the one service where an unset value could reach a signing or auth path, and check only that.

Also on the Radar

We publish SBOMs, sign releases and attach build provenance so something downstream will check them. Shan measured whether assistants do, with the protocol deposited under a DOI before any trial ran and behaviour scored from container logs rather than from what the assistant claimed.

Across 1,920 trials, an assistant opened a provenance signal before installing 9 times. No test group ran a verification command at all, so all 9 were a file opened and never checked.

Two details matter more than the rate. All 9 came from one model's group, and counting the three extra models Shan ran outside the registered trials, five of the six produced one positive or none, so he says plainly they cannot be ranked. The second is the approval step: the harness that paused to ask permission granted 4,498 requests across 951 of 960 trials and did not move the rate.

It is a preprint on six research-software projects, so the rate belongs to that setting. The conclusion travels further: verification has to be built into the program that runs the assistant, because neither the model nor the approval step supplied it.

In all three the check was real and ran exactly as written. A guard tested the wrapper instead of the value. A trust prompt rendered after the subprocess had already gone. An approval step fired 4,498 times and changed nothing. Each was checking a true thing, at a point where the decision had already been made somewhere else.

Full issue with sources: join.defensive.works

Until next Tuesday,
R.K.

// end of issue 022

Sponsored content may appear below. Not part of Weekly Recon editorial.

Stop rewriting prompts. Start engineering loops. The Code built The Ultimate Guide to Loop Engineering, giving you the exact system Silicon Valley engineers use. Get it free. Claim your Loop Engineering guide