Skip to content

M8seven/cve-2026-11837-ansible-posix-authorized-key

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 

Repository files navigation

CVE-2026-11837: ansible.posix authorized_key local privilege escalation

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.

Official references

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

Summary

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 ~/.ssh directory and of ~/.ssh/authorized_keys with plain os.chown (not os.lchown), and
  • creates the key file with a plain open(..., "w") (no O_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.

Affected code and root cause

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:
    pass

sshdir 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.

Two primitives

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)

  1. As victim: ln -s /root/AD_CANARY ~/.ssh (with /root/AD_CANARY an existing root-owned directory).
  2. Operator runs the authorized_key task for victim.
  3. Observed: /root/AD_CANARY ownership changed from root:root to victim:victim. The os.chown(sshdir, ...) call followed the ~/.ssh symlink and handed a root-owned directory to the unprivileged user.

Vector 2: dangling file symlink (create + chown of a new root-path file)

  1. As victim (with ~/.ssh a normal directory): ln -s /root/AF_CANARY ~/.ssh/authorized_keys (target does not exist).
  2. Operator runs the authorized_key task for victim.
  3. Observed: /root/AF_CANARY was created, owned victim:victim, mode 0600. open(keysfile, "w") created the target through the symlink and os.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.

Relationship to CVE-2024-9902

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.

Impact and honest preconditions

  • 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_key task 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.

Suggested fix

  • 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 the follow=False docstring contract) if sshdir or keysfile is a symlink.
  • Replace os.chown(...) with os.lchown, or with os.fchown on a safely-opened descriptor, and likewise os.fchmod instead of os.chmod, so ownership and permissions cannot be redirected through a link.
  • Gate the unconditional chown/chmod so they only apply to a path the module itself just created safely, not to a pre-existing (possibly symlinked) path.

Disclosure timeline

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

License

MIT. See LICENSE.

About

CVE-2026-11837: local privilege escalation in the ansible.posix authorized_key module via symlink-following chown. Technical writeup; sibling of CVE-2024-9902.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors