My Ansible Plugin Had a Jail Escape: CVE-2026-55074

blog.hofstede.it · 2026-09-27 · by Long Nguyen ·generic
For almost a year, my jailexec connection plugin let a compromised FreeBSD jail redirect a root-owned write onto the jail host. The input validation was fine...

My Ansible Plugin Had a Jail Escape: CVE-2026-55074

---

A glowing file inside a steel jail cage travels through a pipe in the cage wall and lands in a folder sunk into the host server below

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.quote`d. 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:

1. Get root inside a jail managed by jailexec (a vulnerable web application, a compromised package, a tenant you do not fully trust). 2. Replace a directory that Ansible will later write into with an absolute symlink to a host directory. 3. 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_user` inside the jail, not as root on the host. For the default `ansible_jail_user=root` nothing changes. If you use a non-root jail user and copy into root-owned paths, add `become: true` to those tasks.
  • The host-side privilege footprint shrank to `jexec`. In my original article I recommended a `doas.conf` that allowed the SSH user to run `jls`, `jexec`, `mkdir`, `mv`, and `rm` as root. An unrestricted root `mv` is, in hindsight, a root shell with extra steps. You can delete everything except the `jexec` rule:
  • `permit nopass ansible as root cmd jexec `

  • `ansible_jail_root` is deprecated and ignored. The plugin no longer needs to know where the jail lives on the host, because it never goes there. The `jls` probe 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.

    ---

    References

  • GHSA\-cxgv-hp74-jj7r / CVE\-2026-55074
  • jailexec on Codeberg
  • jailexec on GitHub
  • ansible-jailexec on PyPI
  • Original article: Managing FreeBSD Jails with Ansible
  • CWE\-59: Improper Link Resolution Before File Access
  • jexec(8)
blog.hofstede.it · 2 images · 36 links

Comments (0)

No comments yet