The constructor stored both fields verbatim
# src/prefect/runner/storage.py, tag 3.6.23, lines 174-191
if branch and commit_sha:
raise ValueError(
"Cannot provide both a branch and a commit SHA. Please provide only one."
)
self._url = url
self._branch = branch
self._commit_sha = commit_sha
self._credentials = credentials
self._include_submodules = include_submodules
repo_name = urlparse(url).path.split("/")[-1].replace(".git", "")
safe_branch = branch.replace("/", "-") if branch else None
default_name = f"{repo_name}-{safe_branch}" if safe_branch else repo_name
self._name = name or default_name
self._logger = get_logger(f"runner.storage.git-repository.{self._name}")
self._storage_base_path = Path.cwd()
self._pull_interval = pull_interval
self._directories = directories
The constructor's only check on commit_sha is the mutual-exclusion guard against branch. The string lands on the instance attribute and stays there. The same is true of directories, a list[str] the caller controls element-for-element. No regex, no character class, no length cap, no rejection of values that begin with -. The two fields hit storage. The next method that reads them runs git.
A flow author or deployment spec that sets commit_sha is, in the running Prefect worker, the caller of GitRepository. Prefect Cloud and self-hosted Prefect both expose deployment-source configuration to users who hold the role permitted to write deployments. The role's CVSS expression in the NVD record is PR:L: any user with that role authors the value the constructor stores. The worker, whose process holds whatever filesystem and network reach the operator gave it, runs the git commands.
The argument list reaches git fetch origin and git checkout
pull_code is the lifecycle method workers call to materialize a flow's source. When the destination directory does not contain a .git, it delegates to _clone_repo, which performs:
# src/prefect/runner/storage.py, tag 3.6.23
if self._commit_sha:
# Fetch the commit
await run_process(
["git", "fetch", "origin", self._commit_sha],
cwd=self.destination,
)
# Checkout the specific commit
await run_process(
["git", "checkout", self._commit_sha],
cwd=self.destination,
)
When the destination already has a .git, the update branch in pull_code runs the same fetch through a piecewise-built cmd list:
cmd = ["git"]
cmd += self._git_config
# ...
cmd += ["fetch", "origin", self._commit_sha]
await run_process(cmd, cwd=self.destination)
await run_process(
["git", "checkout", self._commit_sha],
cwd=self.destination,
)
Each call passes the attacker-controlled value as the last positional argument. run_process invokes asyncio.create_subprocess_exec, which forwards the list to the kernel's execvp without ever constructing a shell command line. The shell, the metacharacter interpreter the developer chose argv form to avoid, is not in the picture.
Git is. git is a long-lived command-line program whose option parser was written against POSIX conventions and extended by twenty years of subcommands. Anything in its argument vector that begins with - is an option until the parser sees --. There is no -- in any of the four argument lists above.
--upload-pack= is git's documented mechanism for running a program
--upload-pack=<program> is one of git's older transport options. It exists because git pack negotiation between two endpoints requires a server-side helper, and the original design did not assume that the helper would always be /usr/lib/git-core/git-upload-pack. The option lets the client specify the absolute path to a binary to invoke as the upload-pack helper. The manual entry is in git-fetch(1), git-clone(1), and git-ls-remote(1). It has been there since the early git transport refactor.
The option's invocation rule is specific to transports that spawn a local process. On HTTPS, git negotiates pack data over the smart HTTP protocol with the remote server, so --upload-pack is silently ignored. On local (file://) and SSH transports, git literally launches the program. git fetch origin --upload-pack=/bin/sh against a file:// remote forks /bin/sh as the pack helper.
The PoC's run_offline.sh builds a file:// repo at ${TMPDIR}/prefect-poc-offline.XXXXXX/repo.git and sets POC_TARGET_REPO=file://.... With the local transport selected, the payload
--upload-pack=/bin/sh -c 'echo "EXPLOITED via commit_sha $(date)" > /tmp/prefect_rce_COMMIT_${ns}.txt 2>&1 || true'
is the program git launches. /bin/sh runs the inner echo, writes the marker, and exits with no understanding of git's pack protocol. Git logs a protocol error and returns nonzero. The marker is on disk.
The same behavior reaches Prefect through any remote git URL whose transport spawns a local helper. file:// works. ssh:// and user@host:path work. The remote does not have to be attacker-controlled; the option overrides whatever helper the URL would have selected. The PoC defaults to a public HTTPS GitHub repo only because the network-clean reproduction needs a controlled transport; the underlying primitive does not depend on the URL once the transport is local or SSH.
A Prefect worker that pulls flow source from a remote the operator trusts can be turned into a remote shell by a user who controls the commit_sha field of any deployment that worker pulls. The worker runs as whatever account the operator gave it. The flow has not started.
The patch is two different fixes for two different commands
PR #21384 landed on April 2, 2026, authored by devin-ai-integration[bot] and co-authored by Prefect engineer Alex Streed. The diff is twenty-six lines on storage.py plus sixty-six on test_storage.py. The relevant constructor addition:
+ if commit_sha and not re.match(r"^[0-9a-fA-F]{4,64}$", commit_sha):
+ raise ValueError(
+ f"Invalid commit SHA: {commit_sha!r}."
+ " Expected a hexadecimal Git commit SHA ..."
+ " If you are trying to specify a branch or tag name,"
+ " use the 'branch' parameter instead."
+ )
+
+ if directories:
+ for d in directories:
+ if d.startswith("--"):
+ warnings.warn(
+ f"Directory {d!r} starts with '--' and will be"
+ " interpreted as a path by git sparse-checkout."
+ " ...",
+ UserWarning,
+ stacklevel=2,
+ )
And the two call-site changes, identical:
- ["git", "sparse-checkout", "set", *self._directories],
+ ["git", "sparse-checkout", "set", "--", *self._directories],
The commit_sha path is closed by value-shape rejection: any string that is not 4 to 64 hexadecimal characters fails the constructor. The directories path is closed by the documented end-of-options separator inserted into the argument list of git sparse-checkout set, plus a UserWarning for callers whose values begin with --. The two closures are not equivalent. The regex on commit_sha is a content predicate that rejects option-shaped inputs at the door. The "--" token on sparse-checkout is a positional contract with the receiver that says everything after this point is a positional argument no matter what bytes it contains.
The regex itself is the Convention Is The Allowlist move. Every git porcelain command treats a commit SHA as a hexadecimal string of 4 to 64 characters, and every codepath that produces a SHA value in any git workflow honors that constraint. The convention lives in gitrevisions(7) and in every git user's mental model. Prefect's constructor never installed it. The 3.6.25 patch installs it explicitly; the set of accepted values for commit_sha is now whatever the hexadecimal predicate happens to admit, and every other field the constructor stores remains at the broad syntactic type str.
The git fetch origin and git checkout invocations on the commit_sha path are not modified. The patch does not insert -- between origin and self._commit_sha. It does not insert -- between checkout and self._commit_sha. Both commands honor the separator. git fetch origin -- <refspec> is valid. git checkout -- <ref> is valid. The patch chose the regex.
The choice is defensible. Validation rejects the bad input across every call site at once, and a fix that lives in the constructor is auditable from a single line. The end-of-options separator on the sparse-checkout invocation is the same fix at a different location and was apparently easier to argue at the call site than at the constructor. Both shapes are in the same diff. Both close the bug. The reader can see the patch knew both fixes and applied each one to the path it was easiest to apply to, not to the path where the RCE was.
The argv list stopped the shell. The shell was not the parser.
Prefect's argv-list construction is what Python's subprocess documentation, the OWASP Command Injection cheat sheet, and every internal security review checklist recommend over shell=True. The recommendation is correct. The argv form is not the problem here. The problem is that argv form addresses one threat and the developer treated it as if it addressed the threat. Argv list is not sanitization.
The shape: a subprocess invocation is built as a list to bypass shell metacharacter interpretation. The list elements then enter the receiver binary's own argument parser. The receiver, git in this case, is a program whose argument grammar distinguishes options from positionals by leading character. List elements that begin with - present to the receiver as options regardless of what the calling code intended them to be. The shell defense at one boundary did nothing at the next.
Every receiver with a getopt-style parser has the same shape. find . -name "$user_input" accepts attacker-controlled -delete. curl <URL> accepts attacker-controlled --upload-file. tar -xf "$user_input" accepts attacker-controlled --checkpoint-action=exec=.... ssh <user>@<host> accepts attacker-controlled options that route the connection. The argv list around any of these does nothing to the receiver's parser. The defense lives in two places: the receiver's -- separator inserted before attacker-controlled positions, and value-shape predicates that reject option-shaped inputs.
The first defense is structural. The second is content. Prefect's patch used both, on different paths, in the same diff. The path that did not get the structural fix is the path the RCE was on.
PoC: renat0z3r0/prefect-cve-2026-5366