DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-60004 — RCE in Gitea via diffpatch Git Hook Injection

CVE ID CVE-2026-60004
CVSS 3.1 9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE CWE-94 (Improper Control of Generation of Code)
Affected Gitea 1.17 – 1.27.0
Fixed in 1.27.1 (released 2026-07-27)
Endpoint POST /api/v1/repos/{owner}/{repo}/diffpatch
Reporter Shai Rod (NightRang3r)

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.

  1. Bare clones: the temporary clone diffpatch creates to apply the patch is --bare. A bare repo has no working tree, so the repo root is $GIT_DIRhooks/, objects/, refs/ all sit directly at the root.
  2. git apply's 3-way fallback: since Git 2.32, git apply can retry with -3 when a plain apply fails. That fallback is designed to check out the merge result into the working tree to resolve conflicts.
  3. Automatic hook execution: post-index-change is 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")
}
Enter fullscreen mode Exit fullscreen mode

Breaking this down:

  • --cached means "update the index only — never touch the working directory." This is the flag that makes the whole approach look safe.
  • -3 tells 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-change applies 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 -3 fallback 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-change is 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

  1. Attacker registers an account (no prior credentials needed if public sign-up is on).
  2. Attacker creates a repository with auto_init — write access to that one repo is the only precondition.
  3. Attacker sends a diffpatch request adding hooks/post-index-change.
  4. Attacker resends the exact same patch, forcing an add/add conflict.
  5. Git's 3-way fallback bypasses --cached and writes the hook file to the bare clone's real hooks/ directory.
  6. Git auto-executes post-index-change on the next index update → arbitrary command execution.
  7. 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.ini secrets, 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

  1. 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.
  2. Disable public registration (DISABLE_REGISTRATION = true under [service] in app.ini) to remove the unauthenticated attack path entirely.
  3. Run the Gitea process under a least-privilege dedicated account so a successful hook execution has limited blast radius.
  4. Watch for repeated calls to diffpatch against the same repo with the same patch body in a short window — that's the fingerprint of this attack.
  5. 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.

Top comments (0)