Local privilege escalation in the ansible.posix.authorized_key Ansible module via symlink-following chown (and symlink-following file creation) on a user's ~/.ssh directory and authorized_keys file.
This is a writeup of a vulnerability I reported and which was assigned CVE-2026-11837 by Red Hat Product Security (the CNA). It is published here as a technical record, not as a claim of novelty: the issue is the sibling of CVE-2024-9902 in a module the earlier fix did not cover.
- Red Hat CVE record: https://access.redhat.com/security/cve/CVE-2026-11837
- Red Hat Bugzilla flaw: https://bugzilla.redhat.com/show_bug.cgi?id=2487424
- Affected module source: https://github.com/ansible-collections/ansible.posix/blob/main/plugins/modules/authorized_key.py
Red Hat's published acknowledgement: "Red Hat would like to thank Valentino Paulon for reporting this issue."
| CVE | CVE-2026-11837 |
| Component | ansible.posix collection, file plugins/modules/authorized_key.py, function keyfile() |
| Type | Elevation of Privilege (local). CWE-59 / CWE-61 (link following) with CWE-282 (improper ownership management) |
| CVSS | 7.3 (High) |
| Public | 2026-06-10 |
| Reported by | Valentino Paulon |
When a playbook running as root uses ansible.posix.authorized_key to manage a local user's keys, the module's keyfile() helper:
- changes ownership of the user's
~/.sshdirectory and of~/.ssh/authorized_keyswith plainos.chown(notos.lchown), and - creates the key file with a plain
open(..., "w")(noO_NOFOLLOW).
With the default follow=False, the module neither follows-and-replaces nor refuses symlinks; it operates on the path directly, so the kernel follows any symlink the unprivileged target user has pre-staged inside their own ~/.ssh. This yields two primitives that transfer ownership of a root-controlled path to the unprivileged user, who then escalates to root. Both were reproduced end to end against the collection at HEAD.
keyfile() (plugins/modules/authorized_key.py), with the default manage_dir=True, follow=False:
if manage_dir:
if not os.path.exists(sshdir): # os.path.exists FOLLOWS symlinks
try:
os.mkdir(sshdir, int('0700', 8))
...
os.chown(sshdir, uid, gid) # UNCONDITIONAL, plain chown, follows symlink
os.chmod(sshdir, int('0700', 8)) # follows symlink
if not os.path.exists(keysfile): # follows symlink
...
f = open(keysfile, "w") # plain open(), no O_NOFOLLOW, follows symlink
...
try:
os.chown(keysfile, uid, gid) # UNCONDITIONAL, plain chown, follows symlink
os.chmod(keysfile, int('0600', 8)) # follows symlink
except OSError:
passsshdir and keysfile are derived from the target user's passwd home (pwd.getpwnam(user).pw_dir), a directory the unprivileged target user owns and controls. The follow parameter (default False) only chooses whether to os.path.realpath() the path; in neither case is there an os.path.islink check, an O_NOFOLLOW open, an os.lchown, or an unlink-and-replace of a pre-existing symlink. No privilege drop (seteuid/setfsuid) is performed; everything runs as root.
The two os.chown calls run unconditionally, outside the if not os.path.exists(...) guards, so they fire whether or not the directory/file already existed. That is what makes both primitives reliable.
Both confirmed end to end on a throwaway Linux VM, with ansible.posix at HEAD. victim is an unprivileged local user (uid 1000). The module is run as root, exactly as an operator's playbook would. Canaries stand in for sensitive root targets.
Vector 1: directory symlink (chown of an existing root-owned directory)
- As
victim:ln -s /root/AD_CANARY ~/.ssh(with/root/AD_CANARYan existing root-owned directory). - Operator runs the
authorized_keytask forvictim. - Observed:
/root/AD_CANARYownership changed fromroot:roottovictim:victim. Theos.chown(sshdir, ...)call followed the~/.sshsymlink and handed a root-owned directory to the unprivileged user.
Vector 2: dangling file symlink (create + chown of a new root-path file)
- As
victim(with~/.ssha normal directory):ln -s /root/AF_CANARY ~/.ssh/authorized_keys(target does not exist). - Operator runs the
authorized_keytask forvictim. - Observed:
/root/AF_CANARYwas created, ownedvictim:victim, mode 0600.open(keysfile, "w")created the target through the symlink andos.chown(keysfile, ...)handed it over.
In both cases the unprivileged user ends up owning a root-controlled path. Pointing the link at a directory under /etc (Vector 1) or at a new file under /etc/cron.d/ or /etc/sudoers.d/ (Vector 2) yields root by the standard follow-on, where the user, now the owner, rewrites the target.
This is a sibling of CVE-2024-9902 (the user module's generate_ssh_key, in ansible-core), which addressed the same symlink-following class. authorized_key lives in the separate ansible.posix collection and was not changed by that fix; the symlink-following chown/create pattern was still present and unguarded. It is reported as the same accepted class in a module the prior fix did not reach, not as a novel root cause.
- Elevation of privilege from an unprivileged local user to root on a host managed by Ansible.
- Conditional on an operator running an
ansible.posix.authorized_keytask that targets the victim's account (the common use of this module). The local user pre-stages the symlink in their own~/.ssh; they do not run the playbook. This is operator-triggered: the same trigger model as CVE-2024-9902, which Red Hat accepted with the mitigation "do not run against untrusted accounts". The precondition is stated explicitly rather than rated as self-triggered.
- Create the key file without following symlinks and exclusively:
os.open(keysfile, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)and operate on the descriptor; refuse (or unlink-and-replace, per thefollow=Falsedocstring contract) ifsshdirorkeysfileis a symlink. - Replace
os.chown(...)withos.lchown, or withos.fchownon a safely-opened descriptor, and likewiseos.fchmodinstead ofos.chmod, so ownership and permissions cannot be redirected through a link. - Gate the unconditional
chown/chmodso they only apply to a path the module itself just created safely, not to a pre-existing (possibly symlinked) path.
| Date | Event |
|---|---|
| 2026-06-08 | Reported to security@ansible.com (Red Hat Product Security CC as CNA), framed as the sibling of CVE-2024-9902 |
| 2026-06-10 | CVE-2026-11837 assigned and published; credit accepted |
MIT. See LICENSE.