Skip to main content
RunBook Academy

← All break/fix scenarios in Ansible

intermediateprivilege~30 min

Break/Fix: privilege escalation stopped working overnight and sudo -l says the account is fine

Reported symptoms

  • Every task with `become: true` fails on about 60 of 200 hosts, starting at 06:00
  • Tasks without `become` succeed on those same hosts, so the connection itself is healthy
  • The error is `Missing sudo password`, and no password has ever been configured or needed
  • The service account key has not changed and `ssh` as that account works by hand
  • A colleague ran `sudo -l -U svc_ansible` on a failing host and it printed a NOPASSWD rule
  • The affected hosts are the ones a hardening baseline reached first

Evidence

  • · `ansible failing_host -m ansible.builtin.ping` succeeds; the same command with `-b` fails
  • · The failure message is `Missing sudo password`, raised by the connection plugin, not by a module
  • · `sudo -l -U svc_ansible` on a failing host lists `NOPASSWD: /usr/bin/dnf, /usr/bin/systemctl`
  • · `sudo -l -U svc_ansible` on a healthy host lists `NOPASSWD: ALL`
  • · `ansible-playbook -vvv` shows the become command wrapping `/usr/bin/python3 /var/tmp/ansible-tmp-.../AnsiballZ_dnf.py`, not `/usr/bin/dnf`
  • · `journalctl -t sudo --since 06:00` on a failing host shows entries with `command not allowed` for a python3 invocation
  • · The configuration-management change log shows a hardening baseline rolled out from 05:40, alphabetically by hostname
Diagnosis and resolutionclick to reveal

Root cause

A hardening baseline replaced the automation account's blanket `NOPASSWD: ALL` rule with an allow-list of the specific binaries the playbooks were believed to run. That belief is wrong about how Ansible works. Ansible does not execute `dnf` or `systemctl` on the target. It writes a self-contained Python payload named `AnsiballZ_<module>.py` into a temporary directory and escalates to run the Python interpreter against that file, so the command sudo is asked to authorise is always the interpreter with a path argument, never the tool the module wraps. No entry in the allow-list can ever match it. sudo therefore falls through to its default behaviour and demands a password; Ansible, which has none, reports `Missing sudo password`. The message is accurate and misleading in equal measure - it describes what sudo asked for rather than why, and it sends the investigation towards credentials and vaults. `sudo -l -U svc_ansible` reinforces the illusion, because it prints a NOPASSWD rule that is real, correct, and irrelevant to the command that is actually being run.

Remediation

The allow-list cannot be made to work by adding binaries, because the binary is never the tool. Either restore an unrestricted rule for the automation account, or - if the security requirement is real, which it usually is - change the shape of the control rather than its contents: run the automation as a dedicated account whose sudo rule is unrestricted but whose SSH access is tightly constrained by source address, key, and a forced-command or session-recording wrapper, and audit what it did from the controller run logs and the target audit log rather than from the sudoers policy. Coordinate the change with the security team using this incident as the evidence, and roll it to a canary host before the fleet. Deploy the corrected rule through the configuration-management system that deployed the broken one, so the next converge does not reinstate it.

Verification

`ansible <host> -m ansible.builtin.ping -b` must succeed on a repaired host - this is the smallest command that exercises escalation and it either works or it does not. Then run the real playbook with `--check` against one repaired host and confirm no become failures. Prove the check can fail by running it against a host that has not yet been repaired and confirming it still reports `Missing sudo password`; a verification that passes everywhere proves nothing. Confirm from the target side with `journalctl -t sudo` that the python3 invocation is now authorised rather than merely absent from the log. Finally re-run the configuration-management agent and repeat the checks, because a rule fixed by hand is a rule that will be replaced.

Prevention

Write down, in the repository, that Ansible escalates the Python interpreter and not the tool, so the next person proposing a command allow-list has the answer before they write the rule. Test authorisation changes against the automation account with an actual escalated task, not with `sudo -l` - rendering a policy and exercising it are different claims. Canary every sudoers change to one host and require a green `ping -b` before the fleet. Keep the automation account's escalation broad and its access narrow, and put the audit burden on the run log and the target audit daemon where it can record what was actually done. Alert on `become` failures as a distinct class rather than letting them merge into a general failure count, because a fleet-wide escalation failure is both an outage and a signal that somebody changed authorisation without telling you.

Reported symptoms

The 06:00 patch run fails on 61 of 200 hosts. Every failure is on a task with become: true; every task without it succeeds on the same hosts.

The message is the same everywhere:

fatal: [app017]: FAILED! => {"msg": "Missing sudo password"}

Nobody has ever configured a sudo password for this account, because it has always had NOPASSWD. So the investigation started, reasonably, on the controller:

  • Is the vault password file readable? Yes.
  • Did become_password get set somewhere by accident? No.
  • Did the service account’s SSH key change? No, and ssh as that account works by hand.
  • Is the account still in the right group? Yes.

Then somebody ran sudo -l -U svc_ansible on a failing host and it printed a NOPASSWD rule. At that point the team concluded the sudoers policy was fine and went back to looking at the controller, where they stayed for three hours.

Evidence provided

Read-only / Safethe connection is healthy
$ ansible app017 -i inventory -m ansible.builtin.ping
app017 | SUCCESS => {
"changed": false,
"ping": "pong"
}
Read-only / Safethe same command with escalation
$ ansible app017 -i inventory -m ansible.builtin.ping -b
app017 | FAILED! => {
"msg": "Missing sudo password"
}
Read-only / Safea real NOPASSWD rule - which is why sudoers was ruled out
$ ansible app017 -i inventory -m ansible.builtin.command -a 'sudo -l -U svc_ansible'
User svc_ansible may run the following commands on app017:
(root) NOPASSWD: /usr/bin/dnf, /usr/bin/systemctl
Read-only / Safea host that still works
$ ansible app093 -i inventory -m ansible.builtin.command -a 'sudo -l -U svc_ansible'
User svc_ansible may run the following commands on app093:
(root) NOPASSWD: ALL
Read-only / Safewhat sudo is actually being asked to authorise
$ ansible-playbook -i inventory patch.yml --limit app017 -vvv 2>&1 | grep -m1 'sudo -H -S -n'
sudo -H -S -n  -u root /bin/sh -c 'echo BECOME-SUCCESS-xkjqwe ; /usr/bin/python3 /var/tmp/ansible-tmp-1786550412.7-1841-193/AnsiballZ_dnf.py'
Read-only / Safethe target has been describing the fault precisely since 06:00
$ ansible app017 -i inventory -m ansible.builtin.command -a 'journalctl -t sudo --since 06:00 -n 3 --no-pager' -b --become-method su
Aug 11 06:04:11 app017 sudo[3312]: svc_ansible : command not allowed ; TTY=unknown ; PWD=/home/svc_ansible ; USER=root ; COMMAND=/bin/sh -c echo BECOME-SUCCESS-xkjqwe ; /usr/bin/python3 /var/tmp/ansible-tmp-.../AnsiballZ_dnf.py

Work the evidence before reading on

The controller is fine. The connection is fine. The account is fine. The sudoers rule is present, syntactically valid and grants NOPASSWD.

  1. Read the -vvv line and the sudo -l output side by side. Write down the command sudo is being asked to run, and then write down the list of commands it is allowed to run. Do they intersect?
  2. AnsiballZ_dnf.py is a file in a temporary directory. Its name changes every run. Could any allow-list entry match it?
  3. The failure began at 06:00 and spread alphabetically. What runs alphabetically across a fleet?

Before continuing: when a playbook uses ansible.builtin.dnf, what binary does the target actually execute?

Root cause

1. Ansible never runs the tool the module is named after

This is the fact the whole incident turns on, and it is not obvious from the outside.

When a task uses ansible.builtin.dnf, Ansible does not construct a dnf command line and run it. It assembles the module’s Python source and its dependencies into a single self-contained payload, transfers it to the target as AnsiballZ_dnf.py in a temporary directory, and executes it with the target’s Python interpreter. The module code then does whatever it does - which for dnf involves the package manager’s Python bindings, not a shell invocation of /usr/bin/dnf at all.

So the command presented to sudo is always of the form:

/bin/sh -c 'echo BECOME-SUCCESS-... ; /usr/bin/python3 /var/tmp/ansible-tmp-<timestamp>-<pid>-<n>/AnsiballZ_<module>.py'

The module name appears only in a filename, inside a directory whose name is different on every run.

2. An allow-list of tools can never match

The hardening baseline replaced NOPASSWD: ALL with NOPASSWD: /usr/bin/dnf, /usr/bin/systemctl. Those are the two tools the playbook appears to use, and if a human were running the patch by hand they would be exactly right.

They cannot match the interpreter invocation. Not with more entries, not with wildcards on the temporary directory - and an allow-list that permits /usr/bin/python3 with an arbitrary script argument grants unrestricted root anyway, which is the opposite of what the baseline was trying to achieve.

When no rule matches, sudo does not fail silently. It falls through to its default behaviour, which is to ask for a password. Ansible has none, so the connection plugin raises Missing sudo password.

3. The message describes the request, not the reason

Missing sudo password is literally true: sudo prompted, and no password was supplied. It says nothing about why sudo prompted, and the natural reading - a credential is missing - is wrong. Every hour spent on the vault, the become password file and the service account was spent chasing that reading.

The target knew. journalctl -t sudo on any failing host had been logging command not allowed since 06:00.

Resolution

  1. Establish the split precisely. Run ansible all -i inventory -m ansible.builtin.ping -b across the fleet and record which hosts fail. That list is the blast radius of the baseline rollout and you will need it to confirm the repair.
  2. Get root on one failing host by a path that does not depend on the broken rule - an existing session, the console, out-of-band management, or the configuration-management agent, which runs as root and does not consult sudoers.
  3. Confirm the diagnosis there with sudo -l -U svc_ansible /usr/bin/python3, which must report that a password is required. This is the check that distinguishes this fault from a genuine credential problem.
  4. Deploy the corrected rule through the configuration-management system that deployed the broken one. A hand-edit on 61 hosts is undone by the next converge and hides the fact that the source is still wrong.
  5. Restore escalation for the automation account. If the security requirement behind the baseline is real, change the shape of the control rather than its contents: unrestricted sudo for the account, combined with SSH access restricted by source address and key, and auditing from the run log and the target audit daemon.
  6. Canary to one host and verify with ansible <host> -m ansible.builtin.ping -b before touching the other 60.
  7. Roll out, then re-verify a random sample across roles and regions rather than assuming convergence implies correctness.
  8. Take the finding to the security team with the -vvv line as evidence. The baseline is wrong about how Ansible works, and it will be applied to the next fleet unless the source is corrected.

Verification

  1. Escalation works on a repaired host. ansible <host> -i inventory -m ansible.builtin.ping -b returns SUCCESS. This is the smallest command that exercises the whole become path.
  2. The check can fail. Run the same command against a host that has not yet been repaired and confirm it still reports Missing sudo password. A verification that passes on every host, including the broken ones, is not a verification.
  3. The specific command is authorised. sudo -l -U svc_ansible /usr/bin/python3 on a repaired host reports the command as permitted rather than demanding a password.
  4. The target agrees. journalctl -t sudo --since -10min on a repaired host shows the python3 invocation being authorised, with no command not allowed entries.
  5. A real run completes. ansible-playbook -i inventory patch.yml --check --limit <host> finishes with no become failures; a ping -b exercises escalation once, a playbook exercises it per task.
  6. Convergence does not undo it. Re-run the configuration-management agent and repeat the first two checks. This is where a hand-fix that skipped the source is exposed.
  7. The full fleet is clean. ansible all -i inventory -m ansible.builtin.ping -b reports zero failures, and the count of hosts matches the count from the original survey.

Prevention

  • Record in the repository that Ansible escalates the Python interpreter running a generated payload, never the tool a module is named after. This single sentence prevents the entire class of command-allow-list proposals.
  • Test authorisation changes by exercising them. sudo -l renders a policy; ansible ... -m ping -b evaluates it. Only the second one can catch a rule that does not match.
  • Canary sudoers changes to one host, with an automated escalation check, before any fleet rollout. Authorisation changes deserve the same care as firewall changes because both can remove the means of repair.
  • Keep the automation account’s escalation broad and its reachability narrow. Constrain who may become the account and from where; audit what it did from the controller run log and the target audit daemon, which record actions rather than permissions.
  • Alert on become failures as their own category. A fleet-wide escalation failure is simultaneously an outage and a signal that authorisation changed without coordination.
  • When a fault spreads alphabetically or by batch, look for a configuration-management rollout before looking anywhere else. That ordering is not a property of any fault; it is a property of a converge.