September 11, 2026
CVE-2026–60004 — RCE in Gitea via diffpatch Git Hook Injection
Gitea’s diffpatch API applies a user-supplied patch to a temporary internal repository. Send the exact same patch twice, and Git's own…
By Guidancewhite
4 min read
Gitea's diffpatch API applies a user-supplied patch to a temporary internal repository. Send the exact same patch twice, and Git's own 3-way merge conflict-resolution logic quietly defeats the --cached flag, writing the patched file straight to disk. Point that file at hooks/post-index-change, and you get arbitrary command execution as the Gitea service account.
All you need is write access to one repository. If public sign-up is enabled — which is common on self-hosted instances — that means an unauthenticated attacker can register, create a repo, and go straight to RCE.
1. Three ordinary behaviors, one dangerous combination
This isn't a single bug — it's three individually reasonable behaviors that happen to line up badly.
- Bare clones: the temporary clone
diffpatchcreates to apply the patch is--bare. A bare repo has no working tree, so the repo root is$GIT_DIR—hooks/,objects/,refs/all sit directly at the root. git apply's 3-way fallback: since Git 2.32,git applycan retry with-3when a plain apply fails. That fallback is designed to check out the merge result into the working tree to resolve conflicts.- Automatic hook execution:
post-index-changeis a hook Git invokes on its own, with zero human interaction, any time the index changes. If an executable file exists at that path, Git just runs it.
Gitea's implementation didn't account for what happens when (1) and (2) collide.
2. The vulnerable code
The relevant logic lives in services/repository/files/patch.go:
cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--binary")
if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
cmdApply.AddArguments("-3")
}cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--binary")
if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
cmdApply.AddArguments("-3")
}Breaking this down:
--cachedmeans "update the index only — never touch the working directory." This is the flag that makes the whole approach look safe.-3tells Git to fall back to a 3-way merge when a patch doesn't apply cleanly (i.e., context mismatch). On its own, this is a legitimate usability improvement added in Git 2.32.
The problem: when the -3 fallback actually triggers, it doesn't honor the "nothing touches disk" guarantee that --cached is supposed to provide. The 3-way merge path resolves conflicts by checking the merged blob out to the real filesystem.
The trick to reliably trigger that fallback is embarrassingly simple — send the same patch twice:
- 1st request: a patch adding a new file at
hooks/post-index-changeapplies cleanly and lands in the index (thanks to--cached, nothing hits disk yet). - 2nd request: resubmitting the identical patch means a file is now being "added" at a path that's already been added — an add/add conflict.
- On that conflict, the
-3fallback kicks in and checks out the merge result to real disk. - Because the clone is bare, the file that just landed on disk at
hooks/post-index-changeis sitting inside the actual Git hooks directory. - If the patch's diff header specifies
new file mode 100755, the file even keeps its executable bit.
The next time git apply --index touches the index in that same clone, Git follows its normal hook-invocation path and runs post-index-change — as a child process of the Gitea service, with the Gitea service account's privileges. That's arbitrary command execution.
One more detail worth calling out: the hook's exit code is never surfaced in the diffpatch API response. A successful attack returns the same response as an ordinary, successful patch application, so there's no obvious signal in the HTTP layer that anything happened.
3. Full attack flow
- Attacker registers an account (no prior credentials needed if public sign-up is on).
- Attacker creates a repository with
auto_init— write access to that one repo is the only precondition. - Attacker sends a
diffpatchrequest addinghooks/post-index-change. - Attacker resends the exact same patch, forcing an add/add conflict.
- Git's 3-way fallback bypasses
--cachedand writes the hook file to the bare clone's realhooks/directory. - Git auto-executes
post-index-changeon the next index update → arbitrary command execution. - If the hook script stores command output as a Git object or a branch, the attacker retrieves it with a plain, authenticated
fetch— no outbound connection from the server required.
Every one of these steps rides on standard Git protocol and the standard Gitea REST API, which is exactly why this is hard to catch with a WAF or network IDS: from the outside, it just looks like "an API client uploaded the same patch twice."
4. Impact
- Anything the Gitea process can reach is in scope:
app.inisecrets, process environment variables, every mounted repository, the database connection and its contents, OAuth/integration credentials, and any internal services reachable from that host. - On instances with public registration enabled, this is effectively a pre-auth RCE — no stolen credentials required.
- The CVE has been added to CISA's Known Exploited Vulnerabilities (KEV) catalog, with observed exploitation delivering cryptominer payloads.
5. Mitigation
- Upgrade to Gitea 1.27.1 or later immediately. The fix shipped quietly, described in release notes as "refactor: git patch apply" rather than flagged as a security fix — check the version number itself, not just the changelog wording.
- Disable public registration (
DISABLE_REGISTRATION = trueunder[service]inapp.ini) to remove the unauthenticated attack path entirely. - Run the Gitea process under a least-privilege dedicated account so a successful hook execution has limited blast radius.
- Watch for repeated calls to
diffpatchagainst the same repo with the same patch body in a short window — that's the fingerprint of this attack. - Periodically audit repositories'
hooks/directories for recently created or modified executables (post-receive,pre-receive,update,post-index-change, etc.).
6. Wrap-up
What stands out about this CVE is that every individual piece was added for a good reason. --cached was a deliberate safety flag. The -3 fallback was a usability improvement. Automatic hook execution is core Git design, not a bug. It's only when all three meet inside a temporary bare clone that the safety guarantee --cached was supposed to provide quietly breaks. Any feature that manipulates a temporary Git repository from untrusted input needs to account for the repo's shape (bare vs. non-bare) and the less-common fallback paths of the Git subcommands it invokes — not just their documented happy-path behavior.