どさにっきキャッシュレス 〜2019年9月上旬〜

by やまや
<< = >>

2019年9月1日(日)

bash の危険な算術式

_ 使ってる人がいちばん多いだろうからタイトルでは bash としてるけど、ここで取り上げることは zsh および ksh 一族(本家 ksh、pdksh、mksh)にも該当する。ash、dash などでは該当しない。

_ 以下のシェルスクリプトには脆弱性がある。わかるだろうか。

#!/bin/bash
# "品目,単価,個数" の形式の CSV を読んで、"品目,合計金額" の形式で出力する
csv="foo.csv"
while IFS=, read item price num; do
    echo "$item,$((price*num))"
done < "$csv"
これ、細工された CSV ファイルを食わせることで、任意コードの実行ができてしまう。数ある脆弱性の中でもとくにヤバいやつだ。どこが穴なのかというと、タイトルにもあるとおり算術式なのだが、しかしこのスクリプトは $((price*num)) という単純な掛け算をしているにすぎない。いったいどこがマズいのだろうか。

_ 算術式の中では、変数の値は $ なしで参照することができる。

$ a=5
$ echo $((a))
5
これは POSIX で規定された動作。個人的にはこれだけでも十分以上に気持ち悪い動作なのだが、bash、zsh、ksh 一族では POSIX 仕様からの拡張で、さらに以下のようなことができる。
$ a=5
$ b=a
$ echo $((b))
5
算術式内にあらわれた $ なしの b は文字列ではなく変数名として扱われる。そして、$b の値が a であるから、それがまた変数名とみなされるのである。これは bash の独自拡張である間接展開とはまた異なる。

_ 間接展開(indirect expansion)とは以下のようなもの。

$ a=5
$ b=a
$ echo ${!b}
5
変数 $b の値が a なので、${!b} として $a の値すなわち 5 を返す、というもの。上の算術式とよく似ているが、もう1段増やすと違いが明瞭になる。
$ a=5
$ b=a
$ c=b
$ echo ${!c} $((c))
a 5
$c の値が b であるから、${!c} は $b の値である "a" を返す。しかし $((c)) は $b ⇒ $a ⇒ 5 のように参照される。算術式内に算術式として解釈される文字列が含まれている場合、再帰的に展開されるのだ。bash、zsh、ksh 一族の算術式は、再帰展開という拡張によって算術式だけでチューリング完全性を獲得している(再帰回数に制限があるので厳密にチューリング完全ではないが)。

_ ここまでの説明で、算術式が危険な罠だということがわかっただろうか。まだわからんか。

_ 以下のようなシェルスクリプト(hoge.sh)を考える。

#!/bin/bash
typeset -i n	# 変数 n を整数型に宣言(typeset は declare と同じ)
a=5
n="$1"
echo "$a"
スクリプトの第1引数を $n に代入してるだけ。$a は最初に代入された値をいじらずそのまま echo している、つまり 5 が出力されるはず。しかし、以下のように実行するとその予想と異なる結果になる。
$ ./hoge.sh a=10
10
変数 n は整数型に宣言されているため、n="$1" は「$1 を n に代入する」ではなく、「$1 を算術式として再帰評価した上でその結果を n に代入する」という動作になる。そして $1 の中身である "a=10" は算術式として正しいのでこの代入が実行されて、意図せず $a の値が破壊されてしまう。この例では $1 で値を受けてるけれど、read n のように標準入力から値を受けたり、n=$(hoge) のようにコマンドの実行結果を代入したりする場合でも、typeset -i (declare -i) で変数が整数型宣言されていればその値が算術式として再帰評価されることには変わりない。

_ あまり知られていないが、算術式内部から任意のコマンドを呼び出すことができる。上の hoge.sh を以下のように実行してみる。

$ ./hoge.sh 'x[$(whoami>&2)]'
yamaya
5
whoami が実行されてしまっている。このわけわかんないコマンド実行については こちらの解説を参照。この例では whoami はカレントシェルで実行されて、その標準出力(空文字列)が hoge.sh に渡されたわけ*ではない*ことに注意。これは sudo すれば明らか。
$ sudo ./hoge.sh 'x[$(whoami>&2)]'
root
5
whoami がカレントシェルで実行されてから sudo hoge.sh にその結果が渡されるのであれば、whoami の出力は root ではなく sudo する前のユーザ名(yamaya)になるはずである。sudo により hoge.sh が root 権限で実行され、その中で whoami が実行されるから root という結果になるのだ。仮に /etc/sudoers により sudo で hoge.sh 以外のコマンドを実行できないよう制限してあった場合でも、sudo で実行してるのはあくまで hoge.sh であって whoami を直接実行してるわけではないから制限をすり抜けてしまう。権限上昇の脆弱性ということだ(sudoers で制限してないならどうせどんなコマンドでも実行できちゃうので関係ない)。

_ 冒頭の CSV を処理するスクリプトでは、CSV ファイルに含まれる文字列を $((price*num)) のように算術式評価してしまっている。よって、foo.csv に

hoge,100,x[$(whoami>&2)]
のような行が含まれていると、そのコマンドが実行されてしまう。その他シェルスクリプトの用途としてログ処理に使うケースも多いかと思うが、外部からのログに含まれる文字列をうかつに算術式評価される文脈で扱うと脆弱性になりうるので十分に注意しなければならない。

_ $(( ... )) という算術式展開や typeset -i という整数型宣言だけではない。非常にわかりづらいが、以下も同じ脆弱性がある。

#!/bin/bash
if [[ $1 -eq 0 ]]; then
    a=0
else
    a=1
fi
[[ ... ]] という条件式構文で -eq や -le などの比較演算子が使われる場合、比較される値は暗黙に文字列ではなく算術式として評価される。よって、上のスクリプトの第1引数に算術式を食わせるとそれが評価されてコード実行につながる。bash や zsh では [[ ... ]] ではなく [ ... ] なら -eq や -le などを使っても算術式としては扱われず、数値以外の文字列ならエラーになるので、どうしても [[ でなければならない理由がなければ [ を使ったほうがよい。ksh 一族では [ ... ] や test を使う場合でも算術式展開されてしまうので注意。

_ そのほか、for(( expr1 ; expr2 ; expr3 )) というループ構文の各パラメータや(for i in ... という文なら算術式評価されない)、配列のインデックス(${a[x]} における x)、文字列の部分切り出しに使われる ${var:offset:length} の offset と length も暗黙的に算術式として評価される。

_ zsh の挙動が謎。

% declare -i n
% n='a=10'				# a=10 が実行される
% n=$(echo a=10)			# 実行される
% echo a=10 | read n			# 実行される
% n='x[$(whoami>&2)]'			# whoami は実行されない
% n=$(echo 'x[$(whoami>&2)]')		# 実行されない
% echo 'x[$(whoami >&2)]' | read n	# 実行されない
% n='n[$(whoami>&2)]'			# 整数型宣言した変数と配列変数名が一致した場合は実行される
算術式評価が実行される場合とされない場合があってよくわからん。bash はすべてのケースで実行される。

_ ash や dash などでは、算術式内に数値でなく文字列があらわれても再帰展開されずエラーになるので、外部からこういった文字列が入力されてコード実行されることはない。安全。

_ 外部から来た値をそのまま算術式評価してはならない。typeset -i していない変数にいったん文字列として代入し、その値が数字だけで構成されているかチェックし、問題ない場合だけ算術式評価するという手順を踏むように書かなければならない。

#!/bin/bash
typeset -i n
a="$1"
[[ $a =~ ^[0-9]*$ ]] && n=$a
念のため、ここで [[ ... ]] を使っているが、=~ (正規表現マッチ)は文字列として扱われ算術式評価はされない。

_ これ、極めて非直感的な挙動で、こういう問題があるということを知らなければ回避するコードを書くという発想にすら至らないのではないかと思うけど、いちおう bash などのシェルでは仕様どおりの動作。いくら仕様どおりの動作でも、仕様が脆弱なら脆弱性なんじゃねーの、ということで実は4月に IPA に報告してみた (*1)。が、「スクリプトを書く人が対処すべき問題であってシェルそのものの問題じゃないねー」ということで脆弱性扱いはしないという回答が先週になって返ってきた (*2)。うん、まあ、そう言われちゃうとそのとおりなんだよねぇ。しかたないねぇ。ということでスクリプトを書くみなさんが各自対処してください。

_ なお、IPA への報告後に気付いたが、この件は mksh では man でしっかり 警告されていた。警告するけど動作は修正しないってのも、IPA の回答と同じくスクリプトを書く側の問題であってシェルの問題ではないって立場だよね。

Warning: This also affects implicit conversion to integer, for example as done by the let command. Never use unchecked user input, e.g. from the environment, in an arithmetic context!
まあ、mksh なんて、使ってる人以前に知ってる人がそもそもいないよね。警告されても誰も気付かん。OpenBSD から folk した MirBSD という OS で /bin/sh として使われているシェル。MirBSD 以外でもコンパイルすれば動くけど、わざわざそれを使う気にはならんよなぁ。

_ 20190905追記: ksh 一族(ksh93, pdksh, mksh)では [[ ... ]] だけでなく [ ... ] や test を使う場合でも、-eq などの数値比較演算子では算術式展開がおこなわれることがわかったのでその旨修正した。また、zsh の謎の挙動について例を追加した。


(*1): 報告先に各シェル実装の開発元ではなく IPA を選んだのは、複数実装に同じ問題があって個別に連絡するのがクソめんどくさい(しかも pdksh のように public domain でどこに報告したらいいのかすら不明なものもある)から。
(*2): IPA に報告した時点では /etc/sudoers の制限を回避できる可能性があることに気づいておらず、IPA 側も権限上昇はできないという前提で判断している。が、「シェルが悪いんじゃなくてスクリプトが悪い」という判断に権限上昇は関係ないだろう。

<< = >>
やまや