AnsibleXIX · Privilege EscalationPrivilege escalation
Unprivileged-to-unprivileged escalation
What you'll learn
- Explain why an unprivileged-to-unprivileged transition needs a file permission fix at all
- Recite the documented fallback chain and what each step attempts
- Identify the point at which module parameters become world-readable
- Choose between pipelining, a common group, and avoiding the transition
- Recognise the error message that names this problem
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Connect as deploy, become_user: appsvc. Neither account is root.
This is a perfectly reasonable thing to want — it is least privilege
applied to escalation, exactly what the previous lesson argued for — and
it is the configuration with the sharpest edge in the whole of become.
The problem is a file, and the transition makes it unsolvable by ordinary means.
Why there is a problem at all
Ansible executes a module by writing a payload — the AnsiballZ wrapper
containing the module code and its arguments — into a temporary
directory on the managed node, then running it with the target
interpreter.
The temporary directory belongs to the connection user, under
~/.ansible/tmp. The process that must read it runs as the become user.
Two different unprivileged accounts.
deploy cannot chown the file to appsvc, because changing a file’s
owner to another user requires privilege. appsvc cannot read a file
mode 0600 owned by deploy. Neither account can grant the other
access by the obvious route, and there is no root in the picture to do
it for them.
The documented fallback chain
Ansible tries a sequence of increasingly unpleasant ways to make the
file readable. The upstream documentation lists it, and the order is
visible in plugins/action/__init__.py in ansible-core 2.21.3:
| Step | Attempt | Why it usually fails |
|---|---|---|
| 1 | POSIX ACLs via setfacl | acl package absent, or the filesystem is mounted without ACL support |
| 2 | chown to the become user | Requires privilege the connection user does not have |
| 3 | chmod +a (macOS ACL syntax) | Not applicable on Linux |
| 4 | chgrp to ansible_common_remote_group | Only if you configured it, and only if both accounts share that group |
| 5 | World-readable temporary directory | Only if you enabled it — and it is the security problem |
| — | Fail | The default outcome when none of the above worked |
Step 1 is the one that succeeds on a well-configured Linux host, and it
is the one to aim for. It needs setfacl present — the acl package —
and a filesystem mounted with ACL support, which is the default for
ext4 and xfs and is not universal for network filesystems.
The fix upstream recommends: pipelining
Pipelining removes the temporary file from the picture entirely. Instead of writing the payload to disk and executing it, Ansible pipes it to the interpreter’s standard input over the existing SSH session.
No file, no permissions to fix, no fallback chain, no world-readable anything. It is also faster, because it removes a round trip per task.
[ssh_connection]
pipelining = true$ ansible-config dump --only-changed | grep -i pipeliningANSIBLE_PIPELINING(/home/deploy/infra/ansible.cfg) = TrueIllustrative output
The historical caveat is requiretty: pipelining requires that sudoers
does not demand a tty for the automation account. Modern sudo does not
enable requiretty by default and most current distributions ship it
off, so this is usually a non-issue — but on an older estate it is the
reason pipelining appears not to work. Lesson 6 covers the diagnosis and
the correctly scoped remedy.
There are also module-level exceptions: some plugins do not work with
pipelining, community.general.machinectl among them, and its
documentation says so. Those are individually documented rather than
guessable.
The other fix: do not make the transition
The upstream advice is two options, and the second one deserves equal weight: do not become an unprivileged user from an unprivileged user if you can restructure the work instead.
Three routes, in order of how often they are the right answer.
Connect as the account that does the work. If a play’s entire job is
to deploy an application as appsvc, adding appsvc to the inventory’s
ansible_user for that play removes the transition and the escalation
together. Whether that is acceptable depends on your key management —
you are now distributing a key for the service account — but it is often
cleaner than the alternative and it makes the identity obvious in the
inventory rather than buried in a task keyword.
Use a privileged connection user and become down. Connecting as an
account that can chown — root, or an account in admin_users — makes
step 2 succeed. This is the common estate shape, and it is the reason
most people never encounter this problem. It is also a weaker
least-privilege position than the one you were trying to build, which is
the trade being made.
Use a common group deliberately. If deploy and appsvc genuinely
share a group for other reasons, ansible_common_remote_group makes
step 4 work. Treat it as a configuration you asserted rather than
assumed: the group membership is now load-bearing, and someone removing
it during unrelated account cleanup breaks the automation.
# inventories/prod/group_vars/appservers.yml
ansible_user: deploy
ansible_become: true
ansible_become_user: appsvc
# deploy and appsvc are both members of appshared on these hosts.
# See roles/base-accounts. Removing that membership breaks every run.
ansible_common_remote_group: appsharedRecognising it in the wild
The error names itself, which is unusually helpful:
$ ansible-playbook -i inventories/prod deploy.yml --limit app01.example.comfatal: [app01.example.com]: FAILED! => {
"msg": "Failed to set permissions on the temporary files Ansible needs to create when becoming an unprivileged user (rc: 1, err: chown: changing ownership of '/home/deploy/.ansible/tmp/ansible-tmp-1754923847.2-9931-274858361952/': Operation not permitted}). For information on working around this, see https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_privilege_escalation.html#risks-of-becoming-an-unprivileged-user"
}Illustrative output
The chown in the error is step 2 failing, which tells you step 1 —
setfacl — did not succeed either. On a Linux host that usually means
the acl package is not installed, and installing it fixes the whole
chain at the first step. Check before assuming anything more exotic:
ansible -i inventories/prod appservers -m ansible.builtin.stat \
-a "path=/usr/bin/setfacl" | grep -E 'SUCCESS|"exists"'Knowledge check
Knowledge check · 4 questions
Q1. Why does an unprivileged-to-unprivileged become need a permission fix at all, when root-to-anything does not?
Q2. Enabling pipelining removes this problem entirely, because the module payload is piped to the interpreter over the existing connection rather than written to a file.
Q3. A play fails with "Failed to set permissions on the temporary files Ansible needs to create when becoming an unprivileged user", and the embedded error is a chown Operation not permitted. What should you check first?
Q4. Which are accurate about ansible_common_remote_group and allow_world_readable_tmpfiles? Select all that apply.
Passing score: 75%. Answers are checked in this browser.