CVE-2026-60004: Verified Reproduction
CVE-2026-60004: Gitea diffpatch Git hook installation leads to remote code execution
CVE-2026-60004 is verified against go-gitea/gitea · github. Affected versions: >=1.17, <1.27.1. Fixed in 1.27.1. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00312.
What Is CVE-2026-60004?
CVE-2026-60004 is a critical-severity RCE vulnerability affecting go-gitea/gitea >=1.17, <1.27.1. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00312).
CVE-2026-60004 Severity
CVE-2026-60004 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected go-gitea/gitea Versions
go-gitea/gitea · github versions >=1.17, <1.27.1 are affected.
How to Reproduce CVE-2026-60004
pruva-verify REPRO-2026-00312 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00312/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-60004
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
diffpatch patch content (Content field) submitted twice to POST /api/v1/repos/{owner}/{repo}/diffpatch, adding an executable hooks/post-index-change file
- Unauthenticated /user/sign_up registration
- create repo
- POST /api/v1/repos/{owner}/{repo}/diffpatch (call #1 clean apply, call #2 add/add collision + --3way three-way fallback checkout in bare temp clone)
- post-index-change Git hook executes as the Gitea OS user
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for CVE-2026-60004
Gitea's POST /api/v1/repos/{owner}/{repo}/diffpatch endpoint applies
attacker-controlled patches inside a shared bare temporary clone
(services/repository/files/patch.go → TemporaryUploadRepository.Clone(..., bare=true)). Because the clone is bare, its repository root is $GIT_DIR.
Submitting the same patch twice creates an add/add collision; git's -3
three-way fallback (enabled for Git ≥ 2.32) then checks the indexed path out
to the working tree even though the operation uses --cached. An executable
file placed at hooks/post-index-change therefore lands in the live Git hooks
directory and becomes an active hook. Git executes it while writing the index,
so repository-controlled content runs arbitrary shell commands as the Gitea OS
user. With default open registration an unauthenticated visitor can obtain the
required write access by registering an account and creating a repository.
- Package/component affected:
services/repository/files/patch.go(ApplyDiffPatch), reached via the public REST endpointPOST /api/v1/repos/{owner}/{repo}/diffpatchand the web editor's "apply patch" / cherry-pick fallback paths. Also affectsservices/repository/files/cherry_pick.go. - Affected versions: Gitea
>= 1.17and< 1.27.1. - Risk level: Critical — remote code execution as the Gitea service account. With open registration (default) the endpoint is reachable by an unauthenticated attacker who self-registers.
Impact Parity
- Disclosed/claimed maximum impact: Remote code execution (arbitrary shell command execution as the Gitea OS user), reachable by an unauthenticated attacker via open registration.
- Reproduced impact from this run: Full remote code execution. The planted
post-index-changeGit hook executed as the Gitea OS user (vscodein the test runtime) and wrote a marker file (RCE_v1.27.0_CONFIRMED) to disk, reached through the realPOST /api/v1/repos/{owner}/{repo}/diffpatchendpoint after an unauthenticated self-registration (/user/sign_up→ HTTP- and an ordinary repo-creation flow.
- Parity:
full— the claimed unauthenticated→account→repo→RCE chain was exercised end-to-end against the real product binary, with a fixed-version (v1.27.1) negative control that does not execute the hook. - Not demonstrated: Nothing; the code-execution outcome itself was demonstrated (not merely a crash).
Root Cause
ApplyDiffPatch prepares the patch in a temporary clone via
TemporaryUploadRepository.Clone(ctx, opts.OldBranch, /*bare=*/true) with
Shared: true. The apply command is:
cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached",
"--ignore-whitespace", "--whitespace=fix", "--binary")
if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
cmdApply.AddArguments("-3") // --3way fallback
}
Step-by-step:
- Call #1 — a clean patch that adds
hooks/post-index-change(mode100755). With--cachedthe file only enters the index; no working-tree file is written. The resulting commit (tree containinghooks/post-index-change) is pushed to the repository, soHEADnow tracks that path. - Call #2 — the same patch is submitted again.
SetDefaultIndex(git read-tree HEAD) loads the index from the newHEAD, which already containshooks/post-index-change. Re-applying the "add" patch produces an add/add collision. The-3three-way fallback then checks the indexed path out to the working tree even though--cachedwas requested. In a bare clone the working tree is$GIT_DIR, so the executable file is written to$GIT_DIR/hooks/post-index-change— a live Git hook. - While git writes the index during the apply/merge, it invokes the
post-index-changehook, which executes the attacker's shell commands as the Gitea OS user. The hook's exit value is not propagated to the diffpatch HTTP response.
Fix (v1.27.1, PR #38637/#38638 "refactor: git patch apply"): the temporary
clone is no longer bare — Clone(ctx, opts.OldBranch, /*bare=*/false). With a
real working tree, the three-way fallback writes the checked-out path into the
worktree (not $GIT_DIR/hooks), so no hook is installed and the apply fails
closed (git apply error: ... hooks/post-index-change: patch does not apply,
HTTP 500) instead of executing attacker code.
Reproduction Steps
- Reference:
bundle/repro/reproduction_steps.sh(self-contained; downloads the official Gitea linux-amd64 binaries for the vulnerable1.27.0and the fixed1.27.1builds). - What the script does, for each build:
- Starts a fresh Gitea instance (SQLite, open registration) on localhost as
the current OS user and waits for the
/api/v1/versionhealthcheck. - Unauthenticated step:
GET /user/sign_up(verifies the open-registration form is served) thenPOST /user/sign_upto self-register an account (HTTP 303 = success); confirms the new account authenticates via the API (GET /api/v1/userwith basic auth → HTTP 200). - Creates a repository
exploit-repowithauto_init(establishes themainbranch). - Builds a malicious patch that adds an executable file
hooks/post-index-changewhose body writes a version-specific marker file and recordsid -un. - Call #1:
POST /api/v1/repos/{owner}/exploit-repo/diffpatchwith the patch (clean apply, HTTP 201). - Call #2: the same patch again (add/add collision → three-way fallback → hook planted and triggered).
- Checks for the RCE marker file (written by the hook as the Gitea OS user).
- Starts a fresh Gitea instance (SQLite, open registration) on localhost as
the current OS user and waits for the
- Expected evidence: on the vulnerable build the marker file
RCE_v1.27.0_CONFIRMEDis created andvuln_rce_hook.logrecordshook_ran_as_user=vscode; on the fixed build Call #2 returns HTTP 500 withgit apply error: ... hooks/post-index-change: patch does not applyand no marker is created.
Evidence
All artifacts under bundle/ (relative to the bundle root):
bundle/repro/reproduction_steps.sh— the reproducer.bundle/repro/runtime_manifest.json— runtime manifest (entrypoint_kindendpoint, service_started/healthcheck_passed/target_path_reached all true).bundle/logs/repro/gitea_vuln_stdout.log/gitea_fixed_stdout.log— Gitea server logs for each build.bundle/logs/repro/vuln_registration.txt—registration_http=303,api_auth_http=200(unauthenticated→account chain).bundle/logs/repro/vuln_signup_page.html— the served open-registration form.bundle/logs/repro/vuln_patch.txt— the malicious patch payload.bundle/logs/repro/vuln_call1_response.json/vuln_call2_response.json— diffpatch API responses (both HTTP 201 on the vulnerable build).bundle/logs/repro/vuln_diffpatch_calls.txt:call1_http=201 call2_http=201 marker_found=yes gitea_run_user=vscode.bundle/logs/repro/vuln_rce_marker.txt—RCE_v1.27.0_CONFIRMED(written by the executed hook).bundle/logs/repro/vuln_rce_hook.log—hook_ran_as_user=vscode(twice, once per index write).bundle/logs/repro/fixed_call2_response.json—{"message":"git apply error: exit status 1 - Performing three-way merge... error: hooks/post-index-change: does not match index ... patch does not apply"}(HTTP 500, hook NOT installed).bundle/logs/repro/fixed_diffpatch_calls.txt:call1_http=201 call2_http=500 marker_found=no.
Environment: official Gitea 1.27.0 / 1.27.1 linux-amd64 binaries,
SQLite backend, Git 2.55.0 (≥ 2.32, so -3 three-way fallback active),
x86_64 Linux, gitea running as the vscode OS user.
Key excerpts:
[vuln] Registration POST HTTP=303 (303 redirect = success)
[vuln] API basic-auth as intruder_vuln: HTTP=200
[vuln] Call #1 HTTP=201
[vuln] Call #2 HTTP=201
[vuln] *** RCE MARKER FILE CREATED BY GIT HOOK ***
[vuln] marker content: RCE_v1.27.0_CONFIRMED
vuln_rce_hook.log: hook_ran_as_user=vscode
[fixed] Call #2 HTTP=500
[fixed] No RCE marker file present at /tmp/gitea_rce_marker_v1.27.1
fixed_call2_response.json: "git apply error: exit status 1 - Performing
three-way merge... error: hooks/post-index-change: does not match index
... patch does not apply"
Recommendations / Next Steps
- Upgrade to Gitea 1.27.1 (or later), which makes the temporary patch
clone non-bare so checked-out paths cannot land in
$GIT_DIR/hooks. - Defense-in-depth: do not run
git apply --indexagainst a bare repository at all; avoid combining--cachedwith--index/--3waysemantics on a bare clone; considercore.hooksPathisolation / disablingpost-index-changefor internal temporary clones. - Restrict
service.DISABLE_REGISTRATION/ require admin approval on internet-facing instances to remove the unauthenticated reachability path. - Add a regression test asserting that the temporary patch repository is
non-bare (the upstream fix added
services/repository/files/patch_test.goTestGitPatchPreparechecking forbasePath/.git).
Additional Notes
- Idempotency: confirmed — the script was run twice consecutively; both runs confirmed the vulnerable build (marker created, hook ran as the gitea OS user) and cleared the fixed build (no marker). The script removes prior per-run state and markers at start, so it is safe to re-run.
- The
post-index-changehook was introduced in Git 2.31/2.32; the-3three-way fallback requires Git ≥ 2.32 (gated bygit.DefaultFeatures().CheckVersionAtLeast("2.32")). The test runtime uses Git 2.55.0, satisfying both. - The hook's exit value is not reflected in the diffpatch HTTP response, so
the proof relies on the on-disk marker file and the
hook_ran_as_userlog rather than the API status code (Call #2 returns 201 on the vulnerable build). - The temporary upload repository is cleaned up by Gitea after the operation, so the planted hook file is transient; the durable proof is the marker the hook wrote while it was live.
CVE-2026-60004 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
Unknown error
Artifacts and Evidence for CVE-2026-60004
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-60004
Upgrade go-gitea/gitea · github to 1.27.1 or later.
FAQ: CVE-2026-60004
Is CVE-2026-60004 exploitable?
How severe is CVE-2026-60004?
What type of vulnerability is CVE-2026-60004?
Which versions of go-gitea/gitea are affected by CVE-2026-60004?
Is there a fix for CVE-2026-60004?
How can I reproduce CVE-2026-60004?
Is the CVE-2026-60004 reproduction verified?
References for CVE-2026-60004
Authoritative sources for CVE-2026-60004 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.