
At the end of last year I wrote about jailexec, my Ansible connection plugin that manages FreeBSD jails by SSHing to the jail host and running everything through jexec. That article has a section called Security Design. It proudly explains the two-stage file transfer: upload to a temporary location on the host, then move it into the jail with privilege escalation.
That two-stage file transfer was a jail escape.
Every release before 2.0.0 let a process with root inside a managed jail turn a routine Ansible file transfer into a root-owned write to an arbitrary directory on the jail host. It is now tracked as CVE-2026-55074 (GHSA-cxgv-hp74-jj7r). It was fixed in 2.0.0 on 2026-06-10.
This article explains what went wrong, why the input validation I was quite pleased with did not help at all, and what the release and disclosure looked like for a small one-person project.
To be clear about scope: this was a bug in my third-party plugin, not in ansible-core, the built-in ssh connection plugin, or FreeBSD jails. Both did exactly what they were told. I told them the wrong thing.
TL;DR
| CVE | CVE-2026-55074 |
| Advisory | GHSA-cxgv-hp74-jj7r |
| Package | ansible-jailexec (PyPI), or jailexec.py copied by hand |
| Affected | all versions < 2.0.0 (1.0.0 through 1.3.0) |
| Fixed | 2.0.0 (current: 2.0.2) |
| Weakness | CWE-59, link following |
| Severity | High. My own CVSS 3.1 estimate: AV:N/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H (8.0) |
If you use jailexec in any form, upgrade:
pip install --upgrade 'ansible-jailexec>=2.0.0'
If you copied jailexec.py into a connection_plugins/ directory as described in my original article, replace it with the current version from Codeberg or GitHub. There is no configuration workaround for older versions.
How transfers used to work
Commands were never the problem. exec_command always ran as doas jexec <jail> /bin/sh -c '...', so the shell and everything it touched lived inside the jail.
File transfers were different. Here is put_file from 1.3.0, lightly trimmed:
def _jail_path(self, path):
"""Map a path inside the jail to its absolute path on the host."""
ensure_no_traversal(path)
root = self._resolve_jail_root()
return posixpath.normpath(posixpath.join(root, path.lstrip("/")))
def put_file(self, in_path, out_path):
dest = self._jail_path(out_path)
dest_dir = posixpath.dirname(dest)
staged = posixpath.join(STAGING_DIR, f"{STAGING_PREFIX}{os.urandom(12).hex()}")
super().put_file(in_path, staged)
prefix = self._privesc_argv()
move = (
f"{_shelljoin(*prefix, 'mkdir', '-p', dest_dir)} && "
f"{_shelljoin(*prefix, 'mv', staged, dest)}"
)
rc, _, stderr = super().exec_command(move)
The plugin asked jls for the jail’s root directory (say /usr/local/bastille/jails/web/root), glued the in-jail destination onto it, uploaded the file to /tmp on the host, and then ran, on the host, as root:
doas mkdir -p /usr/local/bastille/jails/web/root/usr/local/etc/nginx
doas mv /tmp/ansible-jailexec-3f9a... /usr/local/bastille/jails/web/root/usr/local/etc/nginx/nginx.conf
It looks reasonable. The path starts with the jail root, .. components are rejected, every argument is shlex.quoted. What could go wrong?
Symlinks do not care where you meant to be
A symbolic link is resolved by whoever walks the path, relative to that process’s root directory.
Inside a jail, the root directory is the jail’s root. A symlink /usr/local/etc -> /etc created inside the jail points to the jail’s own /etc when the jail looks at it.
The host has a different root directory. When root on the host walks /usr/local/bastille/jails/web/root/usr/local/etc/... and hits that same symlink, the absolute target /etc means the host’s /etc.
So the attack needed nothing clever:
- Get root inside a jail managed by jailexec (a vulnerable web application, a compromised package, a tenant you do not fully trust).
- Replace a directory that Ansible will later write into with an absolute symlink to a host directory.
- Wait for the next playbook run.
The host-side mkdir -p follows the symlink. The host-side mv follows it too. The file lands in a host directory of the attacker’s choosing, owned by root. The filename and content come from the playbook, the location comes from the attacker. From there, getting to a full host compromise is a matter of patience and picking the right directory.
That is exactly the guarantee a jail is supposed to provide, and exactly the guarantee my plugin was supposed to preserve: whatever happens in the jail, stays in the jail.
Why the validation did not help
I had input validation. Jail names were matched against a strict regex. Paths containing .. were rejected. Everything crossing the SSH wire was quoted.
All of that checks the string. None of it checks what the filesystem does with the string. /usr/local/etc/nginx/nginx.conf is a perfectly clean path. It just happened to contain a directory that the jail had replaced with a trapdoor.
You cannot fix this with a better regex. Checking for symlinks before the mv is a race condition (the jail can swap the directory between your check and your write). realpath plus a prefix check has the same problem. The only robust fix is to make sure the path is resolved by a process that cannot see the host in the first place.
Which tasks were affected
The advisory lists copy, template, and fetch style tasks, anything that calls put_file. It is worth knowing that Ansible also uses put_file to upload the module payload itself into the remote temporary directory unless pipelining is enabled. “I never copy files into my jails” was therefore not a reliable reason to relax.
fetch_file had a related, milder flaw: it pulled the host-side path over SFTP as the unprivileged SSH user. A symlink inside the jail could point that read at a host file readable by the SSH user. The read was never privileged, but it was still resolved on the wrong side of the boundary, and 2.0.0 fixes it the same way.
The fix: resolve paths inside the jail
Version 2.0.0 stops touching the jail’s filesystem from the host entirely. The staged file is streamed into the jail over stdin, and a shell running inside the jail does the writing:
def put_file(self, in_path, out_path):
dest = self._in_jail_path(out_path)
staged = self._staging_path()
super().put_file(in_path, staged)
# The write happens *inside* the jail: jexec confines mkdir/cat to
# the jail's root, so a symlink planted inside the jail cannot
# redirect a privileged write onto a host path.
inner = (
f"mkdir -p {shlex.quote(posixpath.dirname(dest))} && "
f"cat > {shlex.quote(dest)}"
)
transfer = (
f"{_shelljoin(*self._jexec_argv(), '/bin/sh', '-c', inner)}"
f" < {shlex.quote(staged)} && rm -f {shlex.quote(staged)}"
)
rc, _, stderr = super().exec_command(transfer, sudoable=False)
On the host, this becomes roughly:
doas jexec web /bin/sh -c 'mkdir -p /usr/local/etc/nginx && cat > /usr/local/etc/nginx/nginx.conf' \
< /tmp/ansible-jailexec-3f9a...
The staged file is opened on the host by the unprivileged SSH login shell and handed over as stdin. Everything after that, including every symlink lookup, happens inside the jail. A malicious symlink can now redirect the write to another place inside the same jail, which the jail’s root could already write to anyway. Nothing gained.
fetch_file is the mirror image: jexec <jail> /bin/sh -c 'cat < src' writes into a staging file on the host (created with umask 077), which is then pulled over SFTP and removed.
What changed for users
Moving the transfer into the jail had consequences, which is why this became a major release:
- Transfers run as
ansible_jail_userinside the jail, not as root on the host. For the defaultansible_jail_user=rootnothing changes. If you use a non-root jail user and copy into root-owned paths, addbecome: trueto those tasks. - The host-side privilege footprint shrank to
jexec. In my original article I recommended adoas.confthat allowed the SSH user to runjls,jexec,mkdir,mv, andrmas root. An unrestricted rootmvis, in hindsight, a root shell with extra steps. You can delete everything except thejexecrule:
permit nopass ansible as root cmd jexec
ansible_jail_rootis deprecated and ignored. The plugin no longer needs to know where the jail lives on the host, because it never goes there. Thejlsprobe is gone as well.
While I was in there, 2.0.0 also fixed a handful of correctness bugs: the default jail name is now really the inventory hostname (not ansible_host), ansible_jail_user is passed as jexec -U so it is looked up in the jail’s password database instead of the host’s, and plugin-internal commands no longer wait for a become prompt that never comes. The changelog has the details.
The release was tested with the unit suite and end-to-end against a real FreeBSD 15.0 jail host. The integration smoke test now round-trips a file through put_file, slurp, and fetch_file, so the transfer path is exercised against a real jail on every run.
Release and disclosure
For a project with one maintainer, “coordinated disclosure” mostly means coordinating with yourself. The timeline on 2026-06-10 (UTC):
| Time | Event |
|---|---|
| before 08:50 | Issue identified while reviewing the transfer code, fix developed and tested |
| 08:50 | GitHub security advisory drafted |
| 08:59 | Fix, SECURITY.md, and advisory document committed and merged |
| 09:01 | Advisory published |
| 09:06 | ansible-jailexec 2.0.0 uploaded to PyPI |
Alongside that, 2.0.0 was pushed and tagged on both Codeberg and GitHub, with the advisory text also committed to the repository as docs/security/advisory-2.0.0-jail-escape.md and a new SECURITY.md explaining how to report issues privately. GitHub, acting as the CVE Numbering Authority, assigned CVE-2026-55074.
The part that does not show up in any feed was contacting people directly. I knew of exactly two people who had re-packaged the plugin into their own Ansible collections. I notified both of them and asked them to pull in 2.0.0.
That is the uncomfortable part: two is the number I knew about, not the number that exists. A plugin like this does not have a mailing list, and many installations are a single Python file copied into someone’s connection_plugins/ directory or vendored into a collection I have never heard of. Dependency scanners match package metadata, so a copied or vendored file never triggers an alert, and it does not change when PyPI does. For those users, the advisory and this article are the only channels I have.
If you maintain a collection or repository that bundles a copy of jailexec.py, please check its version and update it.
Since I had no evidence of anyone exploiting it and the fix was ready, I saw no value in an embargo. Publishing the advisory and the fixed release within minutes of each other kept the window between “public” and “fixed” as small as I could make it.
A small epilogue about signatures
In August, issue #7 (thanks @geekobiloba) turned up an unrelated bug: Ansible rebuilds a connection plugin’s options before every loop item, which reset the SSH target back to the jail name, so the second loop item went to the wrong host. That was 2.0.1.
Then GitHub and Codeberg both flagged the 2.0.1 commit as having an invalid signature, because the committer email was not a UID on my signing key. Fixing that meant rewriting the commit, and GitHub’s immutable releases had already reserved the v2.0.1 tag permanently. So there is now a 2.0.2 that is byte-identical except for the version string and a changelog entry explaining why it exists. After a security release, I would rather ship a boring extra version number than a release whose provenance cannot be verified.
Lessons learned
Resolve paths on the correct side of the boundary. If a privileged process on the outside touches a path the inside controls, the inside controls the privileged process. Do the work inside, with the inside’s privileges, and pass data across the boundary as a stream rather than as a path.
String validation is not filesystem validation. Rejecting .. felt like security. It protected against the attack I had imagined and did nothing against the one that existed.
Least privilege on the host matters. The old doas.conf handed out mv and mkdir as root without restriction. Even without this bug, that was more than the plugin needed. Now it is one line.
Write the security section last. Or at least reread it after every refactor. Mine described the vulnerable mechanism as a security feature.
Small projects still deserve a real advisory. The whole process, from GHSA draft to CVE, is free and took far less time than fixing the bug. People who depend on your code, even through a copied Python file, deserve a proper identifier they can search for, scan for, and reference in their own change tickets.
If you run jailexec, please upgrade. If you find something else, SECURITY.md explains how to reach me privately. I will take the report seriously, and apparently I also write articles about it.
Comments
You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.
Search for the copied link on your Mastodon instance to reply.
Loading comments...