Gitea LFS Deploy-Key Privilege Escalation (CVE-2026-58435)
## 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
Gitea LFS Deploy-Key Privilege Escalation (CVE-2026-58435)
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` | ```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
CVSS v3.1
Affected software
Run on your own infrastructure? Check whether these packages are installed with threat-finder — our free open-source scanner.
Technical Details
- Gcve Source
- db.gcve.eu
- Osv Id
- GHSA-rh79-75qm-gwjr
- Osv Schema Version
- 1.4.0
- Aliases
- ["CVE-2026-58435"]
- Ecosystems
- ["Go"]
- Database Specific Severity
- MODERATE
- Cvss Version
- 3.1
Threat ID: 6a600ab19c2644c7f8fe1c92
Added to database: 07/22/2026, 00:11:29 UTC
Last updated: 07/22/2026, 00:11:29 UTC
Views: 1
Community Reviews
0 reviewsCrowdsource mitigation strategies, share intel context, and vote on the most helpful responses. Sign in to add your voice and help keep defenders ahead.
Want to contribute mitigation steps or threat intel context? Sign in or create an account to join the community discussion.
Actions
Need more coverage?
Upgrade to Pro Console for AI refresh and higher limits.
For incident response and remediation, OffSeq services can help resolve threats faster.
Latest Threats
Check if your credentials are on the dark web
Instant breach scanning across billions of leaked records. Free tier available.