GitHub Advisoryadvisory · Go
GHSA-rh79-75qm-gwjr Gitea LFS Deploy-Key Privilege Escalation
MEDIUM
Official page ↗
- Aliases
- CVE-2026-58435
- Ecosystem
- Go
- Packages
- code.gitea.io/gitea
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
- Weaknesses
- CWE-266, CWE-639
- Reviewed
- 2026-07-21
Affected packages
• Go code.gitea.io/gitea: < 1.27.0 → fixed in 1.27.0
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
...
}
}
```
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…
Related
Alias · CVE (1)
- CVE-2026-58435 Gitea LFS Deploy-Key Privilege Escalation vulnerability