# Gitea: Cached Per-Branch Permission Check in Pre-Receive Hook Allows Full Repository Write

Gitea's pre-receive hook cached the per-branch CanMaintainerWriteToBranch result across a multi-ref push, letting a single-branch grant overwrite protected branches like main.

- Payout: Research
- Severity: high
- Program: Gitea
- Found by (vendor credit): adrian-doyensec
- Found by: adrian-doyensec
- Disclosed: 2026-07-21
- CVE: CVE-2026-27775
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-649p-mmhf-85c7
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-649p-mmhf-85c7
- Tags: area:web domain:oss domain:web impact:integrity kind:research platform:ghsa sev:high tech:go technique:source-review vuln:access-control vuln:auth-bypass vuln:privilege-escalation

**Target:** Git smart-HTTP receive-pack pre-receive hook (routers/private/hook_pre_receive.go)
**Root cause:** CanWriteCode() evaluates the branch-specific CanMaintainerWriteToBranch check only on the first ref of a push batch and caches the boolean via checkedCanWriteCode, reusing it for all subsequent refs regardless of ctx.branchName. The AGit-flow relaxation downgrading the outer receive-pack gate from Write to Read defers all authorization to this buggy hook.
**Steps:**
1. Attacker owns repo `attacker/project`; victim forks it and creates `feature-branch` with a commit.
2. Victim opens PR from `victim:feature-branch` to `attacker/project:main` with `allow_maintainer_edit: true`, granting attacker per-branch write on `feature-branch` only.
3. Attacker clones own repo, fetches `victim/project` `feature-branch`, commits a legitimate change to it and a malicious commit to `main`.
4. Attacker pushes both refs in one operation: `git push http://attacker:pw@<gitea>/victim/project.git victim-feature-branch:feature-branch main:main`.
5. First ref evaluates CanMaintainerWriteToBranch('feature-branch')=true and caches it; second ref (`main`) reuses cached true without re-evaluation.
6. Hook returns 200, both refs accepted; `victim/project:main` now contains attacker's commit (verified via `GET /api/v1/repos/victim/project/contents/PWNED?ref=main`).
**How found:** Code review of the pre-receive hook loop: noticed CanWriteCode() caches a branch-specific check in a session-scoped boolean while ctx.branchName mutates per ref — a check-vs-use divergence; combined with the AGit-flow Read-downgrade of the outer gate that defers authz to the hook.
**Exploitation:** Attacker needs only a legitimate per-branch maintainer-edit grant from an open PR. By batching the granted branch with any target ref in a single `git push`, the cached true from the first ref authorizes overwriting protected branches, creating arbitrary branches, and pushing tags — full repository write. Deterministic, not a race: attacker controls ref order so the granted branch sorts first.
**Escalation:** Single-branch maintainer-edit grant escalates to full repo write (protected branches, new branches, tags).
**Insight:** When a permission check takes a per-object parameter but stores its result in a per-session/per-request cache, look for batch endpoints (multi-ref push, bulk APIs) that mutate the parameter between calls — the cache turns a scoped grant into a global one. Also check whether outer access gates were relaxed to defer authz to inner hooks.

## Proof of concept (copied exactly from the original report)

**Single push of granted branch plus main**

```
git push http://attacker:pw@<gitea>/victim/project.git victim-feature-branch:feature-branch main:main
```

**Verify main overwritten via API**

```
curl "http://victim:pw@<gitea>/api/v1/repos/victim/project/contents/PWNED?ref=main"
```

