{"repro_id":"REPRO-2026-00312","version":6,"title":"Gitea diffpatch Git hook installation leads to remote code execution","repro_type":"security","status":"published","severity":"critical","description":"GHSA-rcr6-4jqh-j84m describes a critical remote code execution issue in Gitea's diffpatch Git hook installation path. In affected versions >=1.17 and <1.27.1, attacker-controlled patch application in services/repository/files/patch.go can create a repository path collision that causes Git's fallback checkout to install an executable hook file such as hooks/post-index-change inside a shared bare temporary clone. When Git later writes the index, the hook executes as the Gitea OS user. If open registration is enabled, this can be reached by an unauthenticated attacker who creates an account and then a repository. Fixed in 1.27.1.","root_cause":"# RCA Report — CVE-2026-60004 / GHSA-rcr6-4jqh-j84m\n\n## Summary\n\nGitea's `POST /api/v1/repos/{owner}/{repo}/diffpatch` endpoint applies\nattacker-controlled patches inside a **shared bare** temporary clone\n(`services/repository/files/patch.go` → `TemporaryUploadRepository.Clone(...,\nbare=true)`). Because the clone is bare, its repository root *is* `$GIT_DIR`.\nSubmitting the same patch twice creates an add/add collision; git's `-3`\nthree-way fallback (enabled for Git ≥ 2.32) then **checks the indexed path out\nto the working tree** even though the operation uses `--cached`. An executable\nfile placed at `hooks/post-index-change` therefore lands in the live Git hooks\ndirectory and becomes an active hook. Git executes it while writing the index,\nso repository-controlled content runs arbitrary shell commands as the Gitea OS\nuser. With default open registration an unauthenticated visitor can obtain the\nrequired write access by registering an account and creating a repository.\n\n## Impact\n\n- **Package/component affected:** `services/repository/files/patch.go`\n  (`ApplyDiffPatch`), reached via the public REST endpoint\n  `POST /api/v1/repos/{owner}/{repo}/diffpatch` and the web editor's\n  \"apply patch\" / cherry-pick fallback paths. Also affects\n  `services/repository/files/cherry_pick.go`.\n- **Affected versions:** Gitea `>= 1.17` and `< 1.27.1`.\n- **Risk level:** Critical — remote code execution as the Gitea service\n  account. With open registration (default) the endpoint is reachable by an\n  unauthenticated attacker who self-registers.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Remote code execution (arbitrary\n  shell command execution as the Gitea OS user), reachable by an\n  unauthenticated attacker via open registration.\n- **Reproduced impact from this run:** Full remote code execution. The planted\n  `post-index-change` Git hook executed as the Gitea OS user (`vscode` in the\n  test runtime) and wrote a marker file (`RCE_v1.27.0_CONFIRMED`) to disk,\n  reached through the real `POST /api/v1/repos/{owner}/{repo}/diffpatch`\n  endpoint after an unauthenticated self-registration (`/user/sign_up` → HTTP\n  303) and an ordinary repo-creation flow.\n- **Parity:** `full` — the claimed unauthenticated→account→repo→RCE chain was\n  exercised end-to-end against the real product binary, with a fixed-version\n  (v1.27.1) negative control that does **not** execute the hook.\n- **Not demonstrated:** Nothing; the code-execution outcome itself was\n  demonstrated (not merely a crash).\n\n## Root Cause\n\n`ApplyDiffPatch` prepares the patch in a temporary clone via\n`TemporaryUploadRepository.Clone(ctx, opts.OldBranch, /*bare=*/true)` with\n`Shared: true`. The apply command is:\n\n```go\ncmdApply := gitcmd.NewCommand(\"apply\", \"--index\", \"--recount\", \"--cached\",\n    \"--ignore-whitespace\", \"--whitespace=fix\", \"--binary\")\nif git.DefaultFeatures().CheckVersionAtLeast(\"2.32\") {\n    cmdApply.AddArguments(\"-3\")   // --3way fallback\n}\n```\n\nStep-by-step:\n\n1. **Call #1** — a clean patch that adds `hooks/post-index-change` (mode\n   `100755`). With `--cached` the file only enters the index; no working-tree\n   file is written. The resulting commit (tree containing\n   `hooks/post-index-change`) is pushed to the repository, so `HEAD` now tracks\n   that path.\n2. **Call #2** — the *same* patch is submitted again. `SetDefaultIndex`\n   (`git read-tree HEAD`) loads the index from the new `HEAD`, which already\n   contains `hooks/post-index-change`. Re-applying the \"add\" patch produces an\n   **add/add collision**. The `-3` three-way fallback then checks the indexed\n   path out to the working tree even though `--cached` was requested. In a\n   **bare** clone the working tree *is* `$GIT_DIR`, so the executable file is\n   written to `$GIT_DIR/hooks/post-index-change` — a live Git hook.\n3. While git writes the index during the apply/merge, it invokes the\n   `post-index-change` hook, which executes the attacker's shell commands as\n   the Gitea OS user. The hook's exit value is not propagated to the diffpatch\n   HTTP response.\n\n**Fix (v1.27.1, PR #38637/#38638 \"refactor: git patch apply\"):** the temporary\nclone is no longer bare — `Clone(ctx, opts.OldBranch, /*bare=*/false)`. With a\nreal working tree, the three-way fallback writes the checked-out path into the\nworktree (not `$GIT_DIR/hooks`), so no hook is installed and the apply fails\nclosed (`git apply error: ... hooks/post-index-change: patch does not apply`,\nHTTP 500) instead of executing attacker code.\n\n## Reproduction Steps\n\n1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; downloads\n   the official Gitea linux-amd64 binaries for the vulnerable `1.27.0` and the\n   fixed `1.27.1` builds).\n2. What the script does, for each build:\n   - Starts a fresh Gitea instance (SQLite, open registration) on localhost as\n     the current OS user and waits for the `/api/v1/version` healthcheck.\n   - **Unauthenticated step:** `GET /user/sign_up` (verifies the open-registration\n     form is served) then `POST /user/sign_up` to self-register an account\n     (HTTP 303 = success); confirms the new account authenticates via the API\n     (`GET /api/v1/user` with basic auth → HTTP 200).\n   - Creates a repository `exploit-repo` with `auto_init` (establishes the\n     `main` branch).\n   - Builds a malicious patch that adds an executable file\n     `hooks/post-index-change` whose body writes a version-specific marker file\n     and records `id -un`.\n   - **Call #1:** `POST /api/v1/repos/{owner}/exploit-repo/diffpatch` with the\n     patch (clean apply, HTTP 201).\n   - **Call #2:** the *same* patch again (add/add collision → three-way\n     fallback → hook planted and triggered).\n   - Checks for the RCE marker file (written by the hook as the Gitea OS user).\n3. Expected evidence: on the vulnerable build the marker file\n   `RCE_v1.27.0_CONFIRMED` is created and `vuln_rce_hook.log` records\n   `hook_ran_as_user=vscode`; on the fixed build Call #2 returns HTTP 500 with\n   `git apply error: ... hooks/post-index-change: patch does not apply` and no\n   marker is created.\n\n## Evidence\n\nAll artifacts under `bundle/` (relative to the bundle root):\n\n- `bundle/repro/reproduction_steps.sh` — the reproducer.\n- `bundle/repro/runtime_manifest.json` — runtime manifest (entrypoint_kind\n  `endpoint`, service_started/healthcheck_passed/target_path_reached all true).\n- `bundle/logs/repro/gitea_vuln_stdout.log` / `gitea_fixed_stdout.log` — Gitea\n  server logs for each build.\n- `bundle/logs/repro/vuln_registration.txt` — `registration_http=303`,\n  `api_auth_http=200` (unauthenticated→account chain).\n- `bundle/logs/repro/vuln_signup_page.html` — the served open-registration form.\n- `bundle/logs/repro/vuln_patch.txt` — the malicious patch payload.\n- `bundle/logs/repro/vuln_call1_response.json` / `vuln_call2_response.json` —\n  diffpatch API responses (both HTTP 201 on the vulnerable build).\n- `bundle/logs/repro/vuln_diffpatch_calls.txt`:\n  `call1_http=201 call2_http=201 marker_found=yes gitea_run_user=vscode`.\n- `bundle/logs/repro/vuln_rce_marker.txt` — `RCE_v1.27.0_CONFIRMED` (written by\n  the executed hook).\n- `bundle/logs/repro/vuln_rce_hook.log` — `hook_ran_as_user=vscode` (twice,\n  once per index write).\n- `bundle/logs/repro/fixed_call2_response.json` —\n  `{\"message\":\"git apply error: exit status 1 - Performing three-way merge...\n  error: hooks/post-index-change: does not match index ... patch does not\n  apply\"}` (HTTP 500, hook NOT installed).\n- `bundle/logs/repro/fixed_diffpatch_calls.txt`:\n  `call1_http=201 call2_http=500 marker_found=no`.\n\nEnvironment: official Gitea `1.27.0` / `1.27.1` linux-amd64 binaries,\nSQLite backend, Git 2.55.0 (≥ 2.32, so `-3` three-way fallback active),\nx86_64 Linux, gitea running as the `vscode` OS user.\n\nKey excerpts:\n\n```\n[vuln] Registration POST HTTP=303 (303 redirect = success)\n[vuln] API basic-auth as intruder_vuln: HTTP=200\n[vuln] Call #1 HTTP=201\n[vuln] Call #2 HTTP=201\n[vuln] *** RCE MARKER FILE CREATED BY GIT HOOK ***\n[vuln] marker content: RCE_v1.27.0_CONFIRMED\nvuln_rce_hook.log: hook_ran_as_user=vscode\n\n[fixed] Call #2 HTTP=500\n[fixed] No RCE marker file present at /tmp/gitea_rce_marker_v1.27.1\nfixed_call2_response.json: \"git apply error: exit status 1 - Performing\n  three-way merge... error: hooks/post-index-change: does not match index\n  ... patch does not apply\"\n```\n\n## Recommendations / Next Steps\n\n- **Upgrade to Gitea 1.27.1** (or later), which makes the temporary patch\n  clone non-bare so checked-out paths cannot land in `$GIT_DIR/hooks`.\n- Defense-in-depth: do not run `git apply --index` against a bare repository\n  at all; avoid combining `--cached` with `--index`/`--3way` semantics on a\n  bare clone; consider `core.hooksPath` isolation / disabling\n  `post-index-change` for internal temporary clones.\n- Restrict `service.DISABLE_REGISTRATION` / require admin approval on\n  internet-facing instances to remove the unauthenticated reachability path.\n- Add a regression test asserting that the temporary patch repository is\n  non-bare (the upstream fix added `services/repository/files/patch_test.go`\n  `TestGitPatchPrepare` checking for `basePath/.git`).\n\n## Additional Notes\n\n- **Idempotency:** confirmed — the script was run twice consecutively; both\n  runs confirmed the vulnerable build (marker created, hook ran as the gitea\n  OS user) and cleared the fixed build (no marker). The script removes prior\n  per-run state and markers at start, so it is safe to re-run.\n- The `post-index-change` hook was introduced in Git 2.31/2.32; the `-3`\n  three-way fallback requires Git ≥ 2.32 (gated by\n  `git.DefaultFeatures().CheckVersionAtLeast(\"2.32\")`). The test runtime uses\n  Git 2.55.0, satisfying both.\n- The hook's exit value is not reflected in the diffpatch HTTP response, so\n  the proof relies on the on-disk marker file and the `hook_ran_as_user` log\n  rather than the API status code (Call #2 returns 201 on the vulnerable\n  build).\n- The temporary upload repository is cleaned up by Gitea after the operation,\n  so the planted hook file is transient; the durable proof is the marker the\n  hook wrote while it was live.\n","cve_id":"CVE-2026-60004","cwe_id":"CWE-94 (Improper Control of Generation of Code - Code Injection)","source_url":"https://github.com/go-gitea/gitea/security/advisories/GHSA-rcr6-4jqh-j84m","package":{"name":"go-gitea/gitea","ecosystem":"github","affected_versions":">=1.17, <1.27.1","fixed_version":"1.27.1"},"reproduced_at":"2026-07-29T10:56:28.309374+00:00","duration_secs":1806.0,"tool_calls":243,"handoffs":2,"total_cost_usd":3.491145,"agent_costs":{"judge":0.042039,"learning_policy":0.012595,"repro":1.676025,"support":0.120483,"vuln_variant":1.640003},"cost_breakdown":{"judge":{"gpt-5.4-mini":0.042039},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.012595},"repro":{"accounts/fireworks/routers/glm-5p2-fast":1.676025},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.120483},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.640003}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-29T10:56:28.996996+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":16297,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10163,"category":"analysis"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1001,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1194,"category":"other"},{"path":"bundle/logs/repro/fixed_diffpatch_calls.txt","filename":"fixed_diffpatch_calls.txt","size":132,"category":"other"},{"path":"bundle/logs/repro/fixed_call2_response.json","filename":"fixed_call2_response.json","size":296,"category":"other"},{"path":"bundle/logs/repro/vuln_registration.txt","filename":"vuln_registration.txt","size":40,"category":"other"},{"path":"bundle/logs/repro/vuln_patch.txt","filename":"vuln_patch.txt","size":315,"category":"other"},{"path":"bundle/logs/repro/gitea_vuln_stdout.log","filename":"gitea_vuln_stdout.log","size":7459,"category":"log"},{"path":"bundle/logs/repro/gitea_fixed_stdout.log","filename":"gitea_fixed_stdout.log","size":7104,"category":"log"},{"path":"bundle/logs/repro/vuln_call1_response.json","filename":"vuln_call1_response.json","size":1112,"category":"other"},{"path":"bundle/logs/repro/fixed_call1_response.json","filename":"fixed_call1_response.json","size":1120,"category":"other"}]}