# RCA Report — CVE-2026-60004 / GHSA-rcr6-4jqh-j84m

## Summary

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.

## Impact

- **Package/component affected:** `services/repository/files/patch.go`
  (`ApplyDiffPatch`), reached via the public REST endpoint
  `POST /api/v1/repos/{owner}/{repo}/diffpatch` and the web editor's
  "apply patch" / cherry-pick fallback paths. Also affects
  `services/repository/files/cherry_pick.go`.
- **Affected versions:** Gitea `>= 1.17` and `< 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-change` Git hook executed as the Gitea OS user (`vscode` in the
  test runtime) and wrote a marker file (`RCE_v1.27.0_CONFIRMED`) to disk,
  reached through the real `POST /api/v1/repos/{owner}/{repo}/diffpatch`
  endpoint after an unauthenticated self-registration (`/user/sign_up` → HTTP
  303) 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:

```go
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:

1. **Call #1** — a clean patch that adds `hooks/post-index-change` (mode
   `100755`). With `--cached` the file only enters the index; no working-tree
   file is written. The resulting commit (tree containing
   `hooks/post-index-change`) is pushed to the repository, so `HEAD` now tracks
   that path.
2. **Call #2** — the *same* patch is submitted again. `SetDefaultIndex`
   (`git read-tree HEAD`) loads the index from the new `HEAD`, which already
   contains `hooks/post-index-change`. Re-applying the "add" patch produces an
   **add/add collision**. The `-3` three-way fallback then checks the indexed
   path out to the working tree even though `--cached` was 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.
3. While git writes the index during the apply/merge, it invokes the
   `post-index-change` hook, 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

1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; downloads
   the official Gitea linux-amd64 binaries for the vulnerable `1.27.0` and the
   fixed `1.27.1` builds).
2. 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/version` healthcheck.
   - **Unauthenticated step:** `GET /user/sign_up` (verifies the open-registration
     form is served) then `POST /user/sign_up` to self-register an account
     (HTTP 303 = success); confirms the new account authenticates via the API
     (`GET /api/v1/user` with basic auth → HTTP 200).
   - Creates a repository `exploit-repo` with `auto_init` (establishes the
     `main` branch).
   - Builds a malicious patch that adds an executable file
     `hooks/post-index-change` whose body writes a version-specific marker file
     and records `id -un`.
   - **Call #1:** `POST /api/v1/repos/{owner}/exploit-repo/diffpatch` with 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).
3. Expected evidence: on the vulnerable build the marker file
   `RCE_v1.27.0_CONFIRMED` is created and `vuln_rce_hook.log` records
   `hook_ran_as_user=vscode`; on the fixed build Call #2 returns HTTP 500 with
   `git apply error: ... hooks/post-index-change: patch does not apply` and 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_kind
  `endpoint`, 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 --index` against a bare repository
  at all; avoid combining `--cached` with `--index`/`--3way` semantics on a
  bare clone; consider `core.hooksPath` isolation / disabling
  `post-index-change` for 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.go`
  `TestGitPatchPrepare` checking for `basePath/.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-change` hook was introduced in Git 2.31/2.32; the `-3`
  three-way fallback requires Git ≥ 2.32 (gated by
  `git.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_user` log
  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.
