Privilege escalation, scoped

become per task, not per playbook.

Intermediate14 min · lesson 5 of 12

A hotel that hands every cleaner a master key has no security story. One lost key opens every room, and the front desk can never say who went where. A better-run hotel keeps a key cabinet. You sign out one key, for one room, and the book remembers. Privilege escalation in Ansible is that cabinet. Used lazily, it hands root, the all-powerful administrator account on Linux, to every step of your automation, including the step that only wanted to read a version number. Used well, it signs root out for the two or three tasks that genuinely need it, and leaves a trail somebody can read on Monday morning.

Ansible runs from a control node: your laptop, a bastion host, or a CI runner (CI is continuous integration, the pipeline that runs your automation with nobody watching). It reaches managed nodes over SSH (Secure Shell, the encrypted remote-login protocol) as an ordinary connection user, usually a dedicated unprivileged account called something like deploy. Almost everything worth automating needs more power than that account has. Installing packages. Writing into /etc. Restarting a system service. become is the switch that gets it. Set become: true and Ansible wraps that unit of work in an escalation command before running it. become_user names who you land as (root unless you say otherwise) and become_method picks the tool that gets you there, with sudo as the default. Ansible ships exactly three become methods in the box, written in full as ansible.builtin.sudo, ansible.builtin.su and ansible.builtin.runas (that last one is for Windows). Everything else lives in collections you install yourself: community.general.doas, community.general.dzdo, community.general.machinectl and friends. Name one of those with an ansible.builtin. prefix and the run dies on the control node with a plugin-not-found error, before it reaches a host at all.

The failure this whole design exists to prevent is direct root login over SSH. If your control node authenticates as root, one leaked private key is a whole-fleet compromise, and every line in every authentication log on every host says nothing more useful than "root". With an unprivileged connection user plus scoped escalation, a stolen key still has to get past each host's own sudo policy, and every escalation becomes a separate, attributable event you can alert on.

terminal
$ ansible web01 -i inventory.ini -m ansible.builtin.command -a 'id -un'
$ ansible web01 -i inventory.ini -m ansible.builtin.command -a 'id -un' --become
output
web01 | CHANGED | rc=0 >>
deploy
web01 | CHANGED | rc=0 >>
root

What sudo is actually asked to run

Escalation is not a second login. A courier does not carry your shopping list to the shop. The parcel is packed before they leave, and they hand over exactly what is in it. Ansible works the same way. For each task the control node bundles the module's code and its arguments into one self-contained Python program, copies that to the managed node, drops it in a freshly created temporary directory, and runs it there. Ansible calls that packing format AnsiballZ. The whole thing is then wrapped in a become command line, and the default sudo flags are -H -S -n. -H sets HOME to the target user's home directory. -S reads any password from standard input rather than from a terminal. -n says never prompt. That last flag is why a missing sudo rule fails in about a second with Missing sudo password instead of hanging your play forever on a prompt nobody can see.

You can watch this from either end. Add -vvv on the control node and Ansible prints the exact string it hands to SSH, on a line starting SSH: EXEC ssh. Or read the log on the managed host, which is what your detection team will be reading anyway.

terminal
# on web01, the managed host
$ sudo journalctl -t sudo -n 3 --no-pager
output
Jul 21 09:14:02 web01 sudo[4711]: deploy : TTY=unknown ; PWD=/home/deploy ; USER=root ;
COMMAND=/bin/sh -c echo BECOME-SUCCESS-kkqvxnzudwvmbhlqrqrbxxjmwvkcuhmk ;
/usr/bin/python3 /home/deploy/.ansible/tmp/ansible-tmp-1784625242.4419291-8842-219484077115362/AnsiballZ_apt.py
Jul 21 09:14:02 web01 sudo[4711]: pam_unix(sudo:session): session opened for user root(uid=0) by deploy(uid=1001)
Jul 21 09:14:07 web01 sudo[4711]: pam_unix(sudo:session): session closed for user root

Read that COMMAND= field again, because it is the most important thing to understand about locking Ansible down. TTY=unknown means no terminal was attached, which is normal for automation. PWD= is the directory sudo was called from. uid=1001 is the numeric user ID that deploy holds on this host, and pam_unix is the authentication module that opened the session. Then comes the payload: /bin/sh -c, an echo of a one-time success token, and a Python interpreter aimed at a randomly named file in a directory that did not exist a second earlier. The classic sudoers move of allowlisting exact commands, so an account may run /usr/bin/apt and nothing else, cannot constrain Ansible at all. The permission Ansible needs is "python3, against any path it likes", and granting that is the same as granting everything. Real control lives in three other places, and none of them stands in for the others.

Three independent places escalation is really controlled
In the playbook
become on tasks, not plays
escalation is opt-in, one task at a time
become_user: appsvc
least privilege inside escalation, not around it
visible in code review
a diff shows exactly what gained root
In sudoers on the host
runas list (root, appsvc)
the only identities deploy may become
env_reset
nothing inherited from the SSH session leaks in
visudo -cf before it ships
a broken policy locks everyone out of escalation
In the session record
log_output + iolog_dir
what each escalated command printed
shipped off-host
root on the box can rewrite local logs
sudoreplay for forensics
replay a run task by task, months later
Keep all three, because each one fails differently: the playbook can be edited by anyone with merge rights, sudoers by anyone who already has root on that host, and a session recording is worthless if it stays on the box the attacker owns.

Scope become to tasks, not plays

The shape you find in most real repositories is become: true on the play, one line under hosts:. Everything below it then runs as root. Fact gathering, a version check, a debug task, and every task in every role you imported, including the community role you skimmed rather than read. Two things get worse at the same time. A file task with state: absent whose path is built from a variable that turns out to be empty now deletes as root instead of failing with a permission error. And your authentication log fills with several hundred routine root invocations per run, which buries the one line you would actually have wanted to notice.

patch-web.yml
---
- name: Patch web tier
hosts: web
become: false # explicit default: nothing escalates unless a task asks
tasks:
- name: Read the running nginx version
ansible.builtin.command: /usr/sbin/nginx -v
register: nginx_ver
changed_when: false
- name: Apply security updates
ansible.builtin.apt:
upgrade: safe
update_cache: true
become: true
- name: Reload the app as its own service account
ansible.builtin.command: /opt/app/bin/reload
become: true
become_user: appsvc
changed_when: true

Three deliberate choices sit in that file. The play pins become: false, so nothing inherits escalation by accident. Only the package upgrade becomes root. The reload becomes appsvc, the service account that owns the application, which is least privilege applied inside escalation rather than around it. If a bug in /opt/app/bin/reload writes somewhere it should not, it writes as an account that owns one directory tree, not as an account that owns the machine.

Run it with -K (the long form is --ask-become-pass) when the sudo rule on those hosts wants a password. Ansible prompts once and reuses the answer for the whole run.

terminal
$ ansible-playbook -i inventory.ini patch-web.yml -K
output
BECOME password:
PLAY [Patch web tier] **********************************************************
TASK [Gathering Facts] *********************************************************
ok: [web01]
TASK [Read the running nginx version] ******************************************
ok: [web01]
TASK [Apply security updates] **************************************************
changed: [web01]
TASK [Reload the app as its own service account] *******************************
changed: [web01]
PLAY RECAP *********************************************************************
web01 : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Nobody types a password into a pipeline, so unattended runs supply it through the ansible_become_password variable (the older spelling ansible_become_pass still works, and both are accepted). Keep it encrypted with ansible-vault, the tool that ships with Ansible for encrypting variable files at rest, which the vault lesson in this course covers end to end. Never leave it in a plaintext group_vars file. Never pass it with -e on a command line either, because a process listing or a CI job log will happily capture that.

become_user on its own escalates nothing
Write become_user: appsvc and forget become: true, and the task runs quietly as your SSH connection user. No error, no warning. If deploy happens to have write access to the files involved, the task even succeeds, leaving artifacts owned by the wrong account and a control you believe in that was never switched on. ansible-lint catches exactly this with its partial-become rule, which is a good reason to run it over every playbook you own before the playbook runs over your fleet.

A default is not a lock

Be honest about what that play-level become: false buys you. It sets a default, and defaults get overridden. Think of the four sources as a stack of memos on a desk, where whoever drops theirs on top wins the argument. Bottom of the pile is ansible.cfg. Then command-line flags. Then playbook keywords, where the more specific keyword wins, so a task beats the play it sits in. Sitting on top of all of it: variables. That last layer is what bites people. One ansible_become: true in group_vars/all.yml quietly switches escalation back on for every play that touches those hosts, no matter what any playbook says.

So treat the playbook layer as hygiene rather than as a boundary. It shrinks the damage your own mistakes can do, keeps the audit trail readable, and puts escalation in front of a reviewer. The boundary that an attacker holding commit rights cannot move is the one on the managed host.

Write the sudoers rule, then break it on purpose

sudoers is the guest list at the door of each host. The playbook can ask. The list decides. (sudo is short for "superuser do", the standard Linux tool for running a command as another account, and sudoers is the policy file that says who may become whom.) Give the automation account its own drop-in file instead of editing the main /etc/sudoers, reset the environment so nothing inherited from the SSH session influences the escalated command, restrict the runas list to exactly the identities your playbooks become, and record what those sessions did.

/etc/sudoers.d/ansible-deploy
# owned root:root, mode 0440
Defaults:deploy env_reset
Defaults:deploy log_output
Defaults:deploy iolog_dir=/var/log/sudo-io/%{user}
Defaults:deploy !log_input # see the warning below
# A runas list, not a command list: the only identities deploy may become.
deploy ALL=(root,appsvc) NOPASSWD: ALL

Validate before that file goes anywhere near a fleet. A syntax error in sudoers takes the entire policy out of service: sudo refuses to parse it, and nobody on that host can escalate any more, including you, including the automation that would have fixed it. Then read the policy back the way sudo sees it, which is the only reading that counts.

terminal
$ sudo visudo -cf /etc/sudoers.d/ansible-deploy
$ sudo -l -U deploy
output
/etc/sudoers.d/ansible-deploy: parsed OK
Matching Defaults entries for deploy on web01:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
log_output, iolog_dir=/var/log/sudo-io/%{user}, !log_input
User deploy may run the following commands on web01:
(root, appsvc) NOPASSWD: ALL

A control you have never tested is a belief. Take appsvc back out of the runas list, ship the file to one host, rerun the playbook against that host, and watch the third task die.

terminal
$ ansible-playbook -i inventory.ini patch-web.yml --limit web01
output
TASK [Reload the app as its own service account] *******************************
fatal: [web01]: FAILED! => {"changed": false, "module_stderr": "Sorry, user deploy is not
allowed to execute '/bin/sh -c echo BECOME-SUCCESS-nvdmzhtqfkbaxxqjrpwlhvbtdjqgkylr ;
/usr/bin/python3 /var/tmp/ansible-tmp-1784625249.8830712-9013-88413056772910/AnsiballZ_command.py'
as appsvc on web01.\n", "module_stdout": "", "msg": "MODULE FAILURE\nSee stdout/stderr for the
exact error", "rc": 1}
PLAY RECAP *********************************************************************
web01 : ok=3 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0

There is the proof you wanted. The host decides what your automation may become, and the playbook only gets to ask. Two details in that failure repay a second look. The wording is sudo's, verbatim, so rc (the return code, 1 here) and the whole message come straight from the machine that refused, with Ansible relaying rather than inventing. And the temp path is /var/tmp, where the root task used /home/deploy/.ansible/tmp. When you become an account that is neither root nor your connection user, Ansible moves the payload into a shared system directory, because two unprivileged accounts have no private ground in common. That relocation sets up the next trap. Notice changed=0 on this rerun too: the packages were already current from the first run, so the upgrade reported ok and only the reload failed.

NOPASSWD in that rule is a real trade-off, so say it out loud rather than pretending. A pipeline cannot answer a prompt, and a become password sitting on the control node next to the SSH key adds close to nothing once that node is compromised. Most fleets therefore grant the automation account NOPASSWD and pay for it elsewhere: the private key exists only on the control node or inside a credential store such as AWX (the upstream open-source project behind Red Hat's Ansible Automation Platform, which keeps credentials encrypted at rest and injects them into jobs without ever displaying them to operators), the authorized_keys entry carries a from= restriction so the key only works from the control node's address, and the session logs ship off the host so an attacker who reaches root cannot quietly edit them.

The world-readable temp file trap
When the connection user and the become user are both unprivileged (deploy becoming appsvc), Ansible has to hand the module payload from one non-root account to another, and ordinary owner/group/other permission bits cannot express that. It reaches for POSIX ACLs first (POSIX is the standard that keeps Unix-like systems behaving alike; an ACL, or access control list, is a finer-grained permission attached to a single file, set with the setfacl command from the acl package). With no acl on the managed host the task fails with Failed to set permissions on the temporary files Ansible needs to create when becoming an unprivileged user. The tempting fix is allow_world_readable_tmpfiles = true under [defaults] in ansible.cfg. Watch the naming here, because it trips people: that is the ini key, while the underlying option is called world_readable_temp and the inventory variable is ansible_shell_allow_world_readable_temp. It works by making the payload readable to every local account on the box, module arguments included, which is exactly where tokens and connection strings live. Install acl on the managed hosts instead, or set common_remote_group to a group both accounts share, or turn on pipelining (pipelining = True under [ssh_connection]) so the payload streams over standard input and never lands on disk. Pipelining does not cover every case: modules that push files, such as ansible.builtin.copy and ansible.builtin.template, still write into the remote temp directory, so acl stays the general answer.

Keep the receipts

log_output is the till roll behind the counter. It tells sudo to record everything the escalated command printed, in a per-session directory under iolog_dir. Because the escalated command is a generated Python program rather than a person typing at a shell, the recording is module output rather than a screen capture of somebody being clever. Less cinematic, and still exactly what you want at three in the morning: which module ran, at what second, as which identity, with what result. sudoreplay lists those sessions and plays any one of them back.

terminal
$ sudo sudoreplay -d /var/log/sudo-io/deploy -l
output
Jul 21 09:14:02 2026 : deploy : TTY=unknown ; CWD=/home/deploy ; USER=root ;
TSID=000003 ; COMMAND=/bin/sh -c echo BECOME-SUCCESS-kkqvxnzudwvmbhlqrqrbxxjmwvkcuhmk ;
/usr/bin/python3 /home/deploy/.ansible/tmp/ansible-tmp-1784625242.4419291-8842-219484077115362/AnsiballZ_apt.py
Jul 21 09:14:09 2026 : deploy : TTY=unknown ; CWD=/home/deploy ; USER=appsvc ;
TSID=000004 ; COMMAND=/bin/sh -c echo BECOME-SUCCESS-nvdmzhtqfkbaxxqjrpwlhvbtdjqgkylr ;
/usr/bin/python3 /var/tmp/ansible-tmp-1784625249.8830712-9013-88413056772910/AnsiballZ_command.py

TSID is sudo's label for one recorded session, short for time-stamp ID, and it doubles as the folder the recording lives in. sudo sudoreplay -d /var/log/sudo-io/deploy 000004 plays that second session back. Now look at the USER= fields. One escalation to root for the patch, one to appsvc for the reload. That distinction only exists in the log because you scoped it in the playbook, and it is what turns "the automation account did something as root" into "the automation account restarted the app as the app's own user at 09:14:09". The -l listing also takes a search expression, so user deploy or a fromdate range trims a busy directory down to the window you care about.

Do not switch on log_input with pipelining
log_output records what the escalated command printed. Its sibling log_input records what was fed into it, and with pipelining enabled what gets fed into sudo is the AnsiballZ payload itself, which carries the task's arguments as plain JSON (JavaScript Object Notation, the plain-text data format Ansible uses to pass module arguments around). Turn log_input on and the API token in an ansible.builtin.uri header (API is application programming interface, the machine-to-machine door into a service), the database password in a community.postgresql.postgresql_user task, the connection string in a template, all of it gets written to disk on every managed host, in a file nobody treats as a secret store. no_log: true will not save you either. It hides values from Ansible's own display and from its callback logs, and it has no effect at all on what sudo sees or on what lands in the remote temp file. Log the output, keep !log_input in the drop-in so a later global default cannot flip it on behind your back, and set the I/O log directory to mode 0700 owned by root.

What become is not

become is not a sandbox. Once a task runs as root it can do anything root can do, and as that log line showed, sudo cannot narrow it down to a list of approved commands. Mixed fleets add friction on top. Old RHEL (Red Hat Enterprise Linux) images ship requiretty in sudoers, which insists on a real terminal and breaks pipelining until you clear it for the automation account. Per-host become passwords multiply the vault-managed variables you have to keep in step. Network gear does not use sudo at all: Cisco IOS-style platforms (IOS is the Internetwork Operating System, the software running on those switches and routers) escalate into enable mode with become_method: ansible.netcommon.enable, which needs the ansible.netcommon collection installed on the control node. Scoping still pays for itself everywhere, because fewer escalated tasks means quieter logs, faster runs, and less reach when a playbook does something you did not intend.

The cheapest useful thing you can do today is count how many of your plays escalate wholesale. In the standard layout a play-level key sits at two spaces of indentation and a task-level key at six, so indentation alone separates them.

terminal
$ grep -rEn --include='*.y*ml' '^ become:[[:space:]]*(true|yes|True|Yes)' playbooks/
output
playbooks/deploy-api.yml:4: become: true
playbooks/bootstrap-host.yml:5: become: yes
playbooks/rotate-certs.yml:6: become: true

That pattern catches the common spellings, though a repo with unusual indentation, or a play built by an include, will slip past it. Read the hits as a starting list rather than a census. Then work through them one file at a time. Delete the line, run the playbook against a single host with --limit, and let it fail. Every task that comes back with a permission error or Missing sudo password has earned its own become: true. Everything that still passes never needed root in the first place. A patching playbook of fifteen tasks usually comes out the other side with two or three of them keeping become, and the rest no longer showing up in anybody's authentication log as root.

Quick check
01A teammate proposes tightening the automation account with a sudoers rule that permits only deploy ALL=(root) NOPASSWD: /usr/bin/apt. Why does that fail to constrain what Ansible can do?
Incorrect — sudo enforces the same policy however the shell was started, which is exactly why a rule that does not match shows up as an immediate task failure.
Incorrect — sudo is already the default become method, so the rule is being consulted; it fails to constrain anything for a different reason.
Correct — The COMMAND field in the sudo log reads /bin/sh -c ... python3 .../AnsiballZ_apt.py, and allowing python3 against any path is the same as allowing everything.
Incorrect — become is a wrapper around the host's own sudo, su or doas; there is no private channel that skips it.
02A task carries become_user: appsvc but no become: line, and the play above it sets become: false. What happens when that task runs?
Correct — become_user only names a target; with no become: true nothing is escalated, and ansible-lint's partial-become rule exists precisely to catch this.
Incorrect — No escalation is attempted at all, so sudo is never invoked and never asks for anything.
Incorrect — The two keywords are independent, and there is no such inference.
Incorrect — The task set no become keyword to override anything with, and the identity it names is appsvc rather than root.
03A play has deploy becoming appsvc to restart an application. On one host out of twenty the task fails with Failed to set permissions on the temporary files Ansible needs to create when becoming an unprivileged user. What is the right move?
Incorrect — A policy refusal reads Sorry, user deploy is not allowed to execute ... as appsvc; this message appears after sudo already said yes, while Ansible is handing a file between two unprivileged accounts.
Incorrect — For the life of the task the module arguments, tokens and connection strings included, are readable by every local account on the host, and Ansible logs a warning saying so.
Incorrect — That removes the failure by removing the control, handing the application restart full root on every host.
Correct — POSIX ACLs are how Ansible passes the payload between unprivileged accounts, and pipelining sidesteps the temp file entirely for modules that do not push files.

Related