Skip to main content
RunBook Academy

AnsibleXIX · Privilege EscalationPrivilege escalation

Unprivileged-to-unprivileged escalation

Advanced⏱ ~20 minansible-playbookansible-config

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

Not yet marked complete on this device.

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:

StepAttemptWhy it usually fails
1POSIX ACLs via setfaclacl package absent, or the filesystem is mounted without ACL support
2chown to the become userRequires privilege the connection user does not have
3chmod +a (macOS ACL syntax)Not applicable on Linux
4chgrp to ansible_common_remote_groupOnly if you configured it, and only if both accounts share that group
5World-readable temporary directoryOnly if you enabled it — and it is the security problem
FailThe 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.

Configuration changeansible.cfg
[ssh_connection]
pipelining = true
Read-only / Safeconfirming pipelining is actually on
$ ansible-config dump --only-changed | grep -i pipelining
ANSIBLE_PIPELINING(/home/deploy/infra/ansible.cfg) = True

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

Configuration changedeclaring the common group where it applies
# 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: appshared

Recognising it in the wild

The error names itself, which is unusually helpful:

Read-only / Safethe failure, and the shape to recognise
$ ansible-playbook -i inventories/prod deploy.yml --limit app01.example.com
fatal: [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:

Read-only / Safeis setfacl even present?
ansible -i inventories/prod appservers -m ansible.builtin.stat \
-a "path=/usr/bin/setfacl" | grep -E 'SUCCESS|"exists"'

Knowledge check

Knowledge check · 4 questions

  1. Q1. Why does an unprivileged-to-unprivileged become need a permission fix at all, when root-to-anything does not?

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

  3. 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?

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