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:

FormResult
(( a[$c]++ ))executes
(( a["$c"]++ ))executes
(( x = a[$c] ))executes
(( ${a[$c]:-0} ))safe
a[$c]=1safe
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.