AnsibleIX · command, shell and rawcommand, shell and raw
A variable inside a shell line is an injection point
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
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.
- 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 }}"$ ansible-playbook -i localhost, inject.ymlTASK [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.
| Value | What /bin/sh does |
|---|---|
/srv/app/cache | The intended thing. |
/srv/app data | Two arguments. Deletes /srv/app and data/ relative to the working directory. |
/srv/app/cache; touch /tmp/PROOF | Two 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_factsis data from the managed node, and a compromised node controls it. --extra-varson 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 previousshelltask’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.
- 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.
$ ansible-playbook -i localhost, inject.ymlTASK [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 }}/currentputs 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 atemplateis 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.
- 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.
# 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-hostsKnowledge check
Knowledge check · 4 questions
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?
Q2. Which of these does the ansible.builtin.quote filter actually protect against? Select all that apply.
Q3. Because ansible_facts comes from Ansible rather than from a user, values taken from it are safe to interpolate into a shell command.
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.