Skip to main content
RunBook Academy

AnsibleIX · command, shell and rawcommand, shell and raw

A variable inside a shell line is an injection point

Advanced⏱ ~20 minansible-playbook

What you'll learn

  • Explain why templating happens before the string reaches the target shell
  • Predict the rendered command for a value containing a space, a semicolon, or nothing
  • Apply the quote filter correctly, and state what it does not protect against
  • Choose argv over quoting where the task allows it
  • Validate a value before it reaches any execution module

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.

This is the security core of the part, and the mechanism is simpler than people expect.

Jinja templating happens on the controller, before anything is sent anywhere. By the time a shell task reaches the managed node, the variable is gone: what travels is a finished string. The target’s /bin/sh receives that string with no idea which characters you wrote and which came from a variable, and it applies its own grammar to all of them equally.

That is the whole vulnerability. Everything below is a consequence.

Watch it happen

You do not have to take this on trust, and you should not run the experiment with a real shell task. ansible.builtin.debug renders the same template through the same engine, so it shows you the exact string the shell would have received — without executing anything.

Read-only / Safea safe reproduction
- name: What the shell line actually becomes
hosts: localhost
gather_facts: false
connection: local
vars:
  clean_dir: /srv/app/cache
  spaced_dir: /srv/app data
  hostile_dir: /srv/app/cache; touch /tmp/PROOF
  empty_dir: ''
tasks:
  - name: Render the command string the way ansible.builtin.shell would receive it
    ansible.builtin.debug:
      msg: "rm -rf {{ item }}/"
    loop:
      - "{{ clean_dir }}"
      - "{{ spaced_dir }}"
      - "{{ hostile_dir }}"
      - "{{ empty_dir }}"
Read-only / Saferendered, not executed
$ ansible-playbook -i localhost, inject.yml
TASK [Render the command string the way ansible.builtin.shell would receive it] ***
ok: [localhost] => (item=/srv/app/cache) => {
  "msg": "rm -rf /srv/app/cache/"
}
ok: [localhost] => (item=/srv/app data) => {
  "msg": "rm -rf /srv/app data/"
}
ok: [localhost] => (item=/srv/app/cache; touch /tmp/PROOF) => {
  "msg": "rm -rf /srv/app/cache; touch /tmp/PROOF/"
}
ok: [localhost] => (item=) => {
  "msg": "rm -rf /"
}

Read those four results as four different incidents.

ValueWhat /bin/sh does
/srv/app/cacheThe intended thing.
/srv/app dataTwo arguments. Deletes /srv/app and data/ relative to the working directory.
/srv/app/cache; touch /tmp/PROOFTwo commands. The second is whatever the value’s author chose.
'' (defined, empty)rm -rf /.

The last row is the one that ends careers. A variable that is defined and empty renders to nothing, the trailing slash you wrote is still there, and the command is a fleet-wide root filesystem deletion that Ansible will happily run on every targeted host in parallel.

Where hostile values come from

“Nobody would put a semicolon in an inventory variable” is true right up until you enumerate the sources. In a real estate, a value reaching a shell line can come from:

  • Inventory, including a dynamic inventory plugin populated from a cloud API, where tags are edited by people outside your team.
  • Facts — a hostname, a mount point, a network interface name, a package version string. ansible_facts is data from the managed node, and a compromised node controls it.
  • --extra-vars on a CI job, where the value may come from a branch name, a pull-request title, or a form field in an automation platform.
  • A lookup from a file, a database, or a secrets store.
  • register, where a previous shell task’s stdout becomes the next task’s input.

The fact case deserves emphasis because it inverts the trust direction most people assume. A play that builds a shell command from ansible_facts is taking input from the machine it is administering. If that machine is the one you are running the play to remediate, you are accepting input from an attacker.

Mitigation 1: argv with command

The best fix is the one that removes the shell.

Destructiveno shell, no grammar, no injection
- name: Remove the cache directory
ansible.builtin.command:
  argv:
    - /bin/rm
    - -rf
    - "{{ cache_dir }}"

With cache_dir set to /srv/app/cache; touch /tmp/PROOF, rm receives one argument containing a semicolon and reports that no such directory exists. There is no second command because nothing in the path knows how to make one.

This is the first thing to try, always. It is only unavailable when the task genuinely needs a pipe, a redirect or a builtin — which, per the previous lesson, is rarer than the codebase suggests.

Mitigation 2: the quote filter

When the task really does need a shell, ansible.builtin.quote is the tool. It is a passthrough to Python’s shlex.quote, and it wraps the value so the target shell treats it as a single literal word.

Read-only / Safethe same four values, quoted
$ ansible-playbook -i localhost, inject.yml
TASK [The same values through the quote filter] ********************************
ok: [localhost] => (item=/srv/app/cache) => {
  "msg": "rm -rf /srv/app/cache/"
}
ok: [localhost] => (item=/srv/app data) => {
  "msg": "rm -rf '/srv/app data'/"
}
ok: [localhost] => (item=/srv/app/cache; touch /tmp/PROOF) => {
  "msg": "rm -rf '/srv/app/cache; touch /tmp/PROOF'/"
}
ok: [localhost] => (item=) => {
  "msg": "rm -rf ''/"
}

Two more things about quote worth knowing before you rely on it:

  • It quotes the whole value, so {{ path | quote }}/current puts the quotes around the variable only, exactly as shown above. That is what you want.
  • It is a POSIX-shell quoter. It has nothing to say about a value travelling onward into an SQL statement, a URL, a systemd unit or a regular expression — a quoted value pasted into a template is not thereby safe for whatever reads that template.

Mitigation 3: validate before you execute

Neither of the first two mitigations answers “is this value the right value”. Only an explicit precondition does.

Destructiverefuse to run rather than run wrong
- name: The cache path must be a real, absolute path under /srv
ansible.builtin.assert:
  that:
    - cache_dir is defined
    - cache_dir | length > 5
    - cache_dir is match('^/srv/[A-Za-z0-9._/-]+$')
    - not cache_dir.endswith('/')
  fail_msg: >-
    cache_dir is {{ cache_dir | default('UNDEFINED') | to_json }},
    which is not an acceptable target for a recursive delete.
  quiet: true

- name: Remove the cache directory
ansible.builtin.command:
  argv:
    - /bin/rm
    - -rf
    - "{{ cache_dir }}"

The allow-list regex is doing the real work. It rejects the empty string, the semicolon, the space, .., and anything outside /srv — in one expression that a reviewer can read. A deny-list of “characters we think are dangerous” is the wrong shape here and always has been; you will not think of all of them.

Blast radius

An injected command runs on every host the play targets, with whatever privileges the task has, in parallel. If the task carries become: true, it runs as root on all of them before the first PLAY RECAP line is printed.

There is no partial exposure here and no gradual failure to notice. The control is upstream: validate the value, remove the shell, and confirm the host list before the run.

Read-only / Safepre-flight for a play that deletes things
# See the value the play will use, for a single host, without running the play.
ansible -i inventories/prod web01.example.com -m ansible.builtin.debug \
-a "msg={{ cache_dir | default('UNDEFINED') | to_json }}"

# Confirm the blast radius separately.
ansible-playbook -i inventories/prod purge-cache.yml --limit webservers --list-hosts

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task reads ansible.builtin.shell: "rm -rf {{ target }}/" and target is defined in an INI inventory as target= with nothing after the equals sign. What runs on the managed node?

  2. Q2. Which of these does the ansible.builtin.quote filter actually protect against? Select all that apply.

  3. Q3. Because ansible_facts comes from Ansible rather than from a user, values taken from it are safe to interpolate into a shell command.

  4. Q4. A task must pipe output through grep, so command with argv is not available. What is the correct handling for an interpolated path?

Passing score: 75%. Answers are checked in this browser.