A “top ten commands” script is about the most harmless thing you can write. In zsh, one common way to write it executes your history instead of counting it.
The problem
You want a count of your most-used commands. The obvious tool is an associative array keyed by the command line:
typeset -A counts
while read -r cmd; do
(( counts[$cmd]++ ))
done < <(fc -ln 1)
This runs every history entry that contains a command substitution.
Not the commands themselves - the shell does not re-run rm -rf /. It runs
whatever is inside $(...) or backticks in the entry. So a history line like:
echo "deploying to $(cat /etc/hostname)"
runs cat /etc/hostname again, silently, at counting time. A line once typed to
test a payload runs that payload again.
Reproducing it
The whole thing fits in one command. MARK does not exist beforehand:
$ cd "$(mktemp -d)"
$ zsh -c 'typeset -A a; c='"'"'x $(touch MARK)'"'"'; (( a[$c]++ ))'
$ ls MARK
MARK
The payload is assigned in single quotes, so the assignment itself never
substitutes anything. $c holds the literal 12 characters x $(touch MARK).
The file appears anyway. The only thing that ran it is (( a[$c]++ )).
Measured on zsh 5.9.2 (x86_64-pc-linux-gnu).
Why it happens
Inside (( )), zsh evaluates the whole expression as arithmetic. An array
subscript is part of that expression, so zsh evaluates the subscript as
arithmetic too. Arithmetic evaluation in zsh performs command substitution.
The subscript is therefore not a string key. It is code.
This is documented behaviour, in the sense that each step is documented
separately. The composition is what surprises people. zshexpn(1) notes that
subscripts of associative arrays are subject to arithmetic evaluation in
arithmetic context; nothing warns that this reaches command substitution.
Quoting does not fix it
This is the part worth remembering. Every instinct says to quote the subscript. Quoting changes nothing, because the quotes are consumed by the shell before arithmetic evaluation begins - the arithmetic evaluator never sees them.
Each row below was run the same way, with the payload assigned literally in single quotes:
| Form | Result |
|---|---|
(( a[$c]++ )) | executes |
(( a["$c"]++ )) | executes |
(( x = a[$c] )) | executes |
(( ${a[$c]:-0} )) | safe |
a[$c]=1 | safe |
a[$c]=$(( ${a[$c]:-0} + 1 )) | safe |
Read-only access is not safe either. (( x = a[$c] )) only reads the array and
still runs the payload. There is no “just looking” version.
Plain expansion is fine. echo $c, [[ $c == x* ]] and case $c in ... all
leave the string alone. The hazard is arithmetic context, not the variable.
The fix
Keep the subscript out of arithmetic context. Do the arithmetic on the value, and use ordinary assignment for the store:
typeset -A counts
while IFS= read -r cmd; do
counts[$cmd]=$(( ${counts[$cmd]:-0} + 1 ))
done < <(fc -ln 1)
${counts[$cmd]:-0} is a parameter expansion. It resolves to a number before
the arithmetic starts, so the arithmetic evaluator never sees the key.
If you want the increment to look like an increment, wrap it:
bump() { local k=$1; counts[$k]=$(( ${counts[$k]:-0} + 1 )); }
Scope
bash is not affected. The same construct with declare -A does not execute the
payload; bash treats an associative-array subscript as a plain string.
$ bash -c 'declare -A a; c='"'"'x $(touch MARK)'"'"'; (( a[$c]++ ))'
$ ls MARK
ls: cannot access 'MARK': No such file or directory
That difference is why the bug survives review. The pattern is correct in the shell most people learned first.
Where this actually bites
History analysis is the obvious case, and the input is your own. That is bad enough - old history contains command substitutions you have forgotten, run now in whatever directory you happen to be in.
The worse case is any zsh script that tallies strings it did not author:
- log line frequency counts
- HTTP paths, user agents or hostnames from a capture
- filenames from a directory walk
- fields from a CSV a colleague sent you
If an attacker controls a string that reaches an arithmetic subscript, they control command execution. A filename is enough:
$ touch -- '$(touch PWNED_BY_FILENAME)'
$ zsh -c 'typeset -A seen; for f in *; do (( seen[$f]++ )); done'
$ ls PWNED_BY_FILENAME
PWNED_BY_FILENAME
The attack needs no privilege beyond write access to the directory the script
walks. One constraint is worth knowing: the payload cannot contain /, because
it has to survive as a filename. That rules out absolute paths, so the payload
writes into whatever directory the script runs in. It does not rule out much
else - $(curl ...|sh) has no slash problem once you use a variable for the
URL, and the same script above executes it.
Swap the loop body for the safe form and the file is never created:
$ zsh -c 'typeset -A seen; for f in *; do seen[$f]=$(( ${seen[$f]:-0} + 1 )); done'
$ ls PWNED_BY_FILENAME
ls: cannot access 'PWNED_BY_FILENAME': No such file or directory
What this does not solve
This is not a zsh vulnerability to report. It is the specified behaviour of arithmetic evaluation, and changing it would break scripts that rely on computed subscripts. The fix is at the call site.
It also does not generalise to indexed arrays used with integer subscripts. If
your key is genuinely a number you computed, (( a[i]++ )) is fine. The hazard
starts when the key is text from outside the script.
Check your own scripts
grep -rn '((.*\[\$' --include='*.zsh' --include='*.sh' ~/.zshrc ~/.config ~/.scripts
Review each hit. If the subscript is a string that came from anywhere but your own arithmetic, rewrite it as an assignment.
