OSコマンドインジェクション(コマンドインジェクション・CWE-78)は、サーバー自身に、用意していないコマンドを実行させる欠陥です。「アップロードされた画像を変換する」「指定されたホストに疎通確認をする」。こうした処理の裏で、サーバーが受け取った値を混ぜてコマンド文字列を組み立て、シェルに渡していると、区切り記号を使って別のコマンドを足されます。入口は「画像を変換するだけ」でも、出口はサーバーの乗っ取りまで届きます。
本記事は連載「事故から逆算するWebセキュリティ」の第25回です。外部コマンドを呼ぶ処理を書く開発者に向けて、何が危険で、どう防ぐかを手元で確かめられる形で整理します。セキュリティ全体の地図は「サプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説」にまとめています。
SHELL vs EXEC根っこは『シェルに文字列を渡すか』
文字列連結でシェルに渡すと危険/引数を分けて直接実行なら安全
危ない:シェル経由で文字列
- 受け取った値をコマンド文字列に連結
- シェルが ; | & $() ` を解釈して実行
- 区切り記号で別コマンドを足される
- エスケープ漏れ・多重デコードで破れる
安全:コマンドと引数を分離
- シェルを介さず実行(exec 系)
- コマンドは固定、ユーザー値は引数配列へ
- OS が記号を解釈しない=注入の口が無い
- 引数インジェクションには -- で線引き
30秒でわかる
- ・何が起きるか:外部コマンドを呼ぶ処理で、サーバー上で任意のコマンドを実行される(CWE-78)。乗っ取り・情報窃取・横移動へ
- ・どこに潜むか:
system/execのシェル呼び出し、画像・動画・PDF 変換、ping・DNS 確認、tar・git の呼び出し - ・なぜエスケープでは不十分か:シェルが解釈する記号・OS・ロケール・多重デコードの差を攻撃側に合わせ続ける不利な戦い
- ・効く設計:シェルを介さず、コマンドを固定して、ユーザー入力は引数の配列としてだけ渡す。加えて引数インジェクションに
--で線引き
同じ「サーバーに外を触らせる」でも、URLを渡してネットワークへ取りに行かせるのはSSRF(サーバーに内側を叩かせる攻撃)、ファイルパスを基底の外へ向けさせるのはパストラバーサルです。OSコマンドインジェクションは、その先のシェル(コマンド実行)を乗っ取ります。
OSコマンドインジェクションはどこで起きるか
影響を受けるのは、ユーザー入力の一部を混ぜて外部コマンドを組み立て、実行する処理です。言語やフレームワークを問わず、次のような外部プロセスの起動が候補になります。
WHERE IT HIDES外部プロセスを呼ぶ処理すべてが候補
『外部コマンドを呼ぶ』処理に潜む
-
1.system / exec をシェル付きで呼ぶ
os.system・child_process.exec・Runtime.exec(String)・shell=True。文字列を連結して渡す経路
-
2.画像・動画・PDF をツールで変換
ImageMagick・ffmpeg・ghostscript・wkhtmltopdf をシェル経由で呼ぶ。ファイル名やオプションにユーザー値が混ざる
-
3.ネットワーク・DNS の確認
ping・nslookup・curl・host をユーザー入力のホスト名で叩く
-
4.アーカイブ・git・バックアップ
tar・unzip・git・mysqldump をファイル名やブランチ名を連結して呼ぶ
・system / exec のシェル呼び出し:受け取った値を連結して os.system・child_process.exec・Runtime.exec(String) などに渡す
・画像・動画・PDFの変換:ImageMagick・ffmpeg・ghostscript・wkhtmltopdf をシェル経由で呼び、ファイル名や変換オプションにユーザー値が混ざる
・ネットワーク・DNSの確認:ping・nslookup・curl をユーザー入力のホスト名で叩く
・アーカイブ・git・バックアップ:tar・unzip・git・mysqldump をファイル名やブランチ名を連結して呼ぶ
画像変換は特に見落とされがちです。直近でも、Rails の Active Storage が ImageMagick 経由でサーバにファイル書き込み・読み取りを許す脆弱性が公表されました。あれは coder と delegate の問題でしたが、「画像ツールをシェルやデリゲート越しに呼ぶ」設計が面を広げる点は共通です。外部プロセスを呼ぶ処理は、入力が記号を含むと何が起きるかを常に想定しておく必要があります。
なぜエスケープやブラックリストでは防げないのか
いちばんありがちな守りが、シェルに渡す前に危ない記号をエスケープ・除去するブラックリスト方式です。これは次の理由で不利です。
・記号の種類が多い:コマンドを区切る・連結する・置換する記号(; | & $() ` 改行など)は多く、シェルやOSで挙動が違う
・表現が複数ある:同じ意味を別の綴りで表せ、エンコードや多重デコードの順番しだいで除去の後に復活する
・ロケール・環境依存:シェルの種類やロケールで解釈が変わり、「この環境では安全」が別環境で崩れる
・引数インジェクションが残る:記号を全部消しても、ユーザー値が - で始まればコマンドのオプションとして解釈されうる
要は、記号を消す発想は「消し方の順番」と「表現の種類」を攻撃側に合わせ続けなければならず、守り手が後手に回ります。守るべきは記号ではなく、「ユーザー入力がコマンドとして解釈される経路」そのものを断つことです。攻撃者が送れる入力と、守りが無いときに起こることを整理すると、問題が「記号」ではなく「どこまでがコマンドで、どこからがデータか」の区別にあると分かります。
| 攻撃者が送れる入力 | 守りが無いと起こること |
|---|---|
| コマンド区切り記号を含む値 | 元のコマンドの後に別コマンドが実行される |
| コマンド置換の記号を含む値 | 値の評価中に任意コマンドが走る |
| エンコードした記号 | エスケープ・除去の後でデコードされ復活する |
- で始まる値 |
コマンドのオプションとして解釈される(引数インジェクション) |
OSコマンドインジェクション対策——シェルを介さず引数を分ける
効くのは、記号を消すことではなく、シェルを介さずに実行し、コマンドを固定して、ユーザー入力は引数としてだけ渡すことです。OWASP の Command Injection Defense も、OSコマンドの呼び出しを避け、やむを得ないときは引数を構造化して渡す(シェルで解釈させない)ことを勧めています。順序はこうです。
SAFE DESIGN設計の順序
シェルを介さない → コマンド固定 → 引数検証 → 最後の手段だけエスケープ
-
1
シェルを介さず実行
引数を配列で渡す exec 系に寄せる(subprocess.run([...], shell=False)・execFile/spawn・ProcessBuilder・exec.Command)
-
2
コマンドは固定、引数だけ可変
実行するバイナリは定数。ユーザー値はデータ(引数)としてのみ渡し、コマンド名やオプションにはしない
-
3
引数インジェクションを塞ぐ
ユーザー値の前に -- を置きオプション解釈を止める。値の形式(数値・ID・許可リスト)を検証する
-
4
どうしてもシェルが要るなら
escapeshellarg 等で各引数を個別にクォート。ただし代替で、根本はシェルを使わない設計
ポイントは、OSがコマンド(実行するバイナリ)と引数(データ)を別々に受け取る仕組みを使うことです。シェルを経由しなければ、引数の中に ; や | があっても、それはただの文字列であり、区切りとしては解釈されません。これが「記号を消す」より確実な理由です。消し忘れても、そもそも解釈されないからです。
守り方ごとに、何を防ぎ、何を防がないかを並べます。
| 守り方 | 何を防ぐか | 何を防がないか |
|---|---|---|
| ブラックリスト除去(記号を消す) | 単純な区切り記号の一部 | 多重・エンコード・ロケール差・引数インジェクション |
| シェルを介さず引数配列で実行 | コマンド注入(別コマンドの実行) | 引数インジェクション・実行対象の取り違え |
+ -- と引数の形式検証 |
オプション解釈の悪用 | バイナリ自体の脆弱性・権限過多 |
| そもそも外部コマンドを呼ばない | 経路ごと(ライブラリで代替) | ライブラリ側の脆弱性(別途管理) |
いちばん固いのは、外部コマンドを呼ばずに言語のライブラリで置き換えることです。ファイルの圧縮やコピー、HTTPアクセス、画像の変換は、多くの場合ライブラリ関数で行えます。外部プロセスの起動自体を減らせば、注入の面はそこから消えます。
言語ごとの正しい書き方の要点
どの言語も「シェルを介さず引数配列で実行する方法」が用意されています。公式ドキュメントの関数名で対応づけると次のとおりです。
| 言語 | 安全:シェルを介さず引数配列 | 避ける:シェルに文字列 |
|---|---|---|
| Python | subprocess.run([cmd, arg], shell=False)(既定で shell=False) |
os.system() / subprocess.run(..., shell=True) |
| Node.js | child_process.execFile() / spawn(cmd, [args]) |
child_process.exec()(シェルで解釈) |
| Java | ProcessBuilder(List<String>) |
Runtime.getRuntime().exec(String) の文字列連結 |
| PHP | proc_open() に配列で/各引数 escapeshellarg() |
shell_exec() / system() への連結 |
| Go | exec.Command(name, args...)(シェルを介さない) |
exec.Command("sh","-c", 連結文字列) |
注意点が二つあります。一つ目は、引数を配列で渡しても「どのコマンドを実行するか」をユーザーに決めさせないことです。実行するバイナリは定数にし、ユーザー値は引数(データ)としてだけ渡します。二つ目は、引数インジェクションです。たとえばファイル名のつもりで受けた値が --output=... のようなオプションとして解釈されると、書き込み先を変えられることがあります。ユーザー由来の引数の前に --(オプションの終わり)を置き、値の形式(数値・ID・許可リスト)を検証します。Go のように exec.Command が既定でシェルを介さない言語でも、-c 付きの sh を自分で呼べば元の木阿弥になる点は共通です。
自分のコードを確認する
ここからは読み取りだけの確認コマンドです。いずれも判定ではなく構成の確認で、ヒットの有無だけでは安全とは言えません。該当箇所を目で追う起点として使います。. は自分のリポジトリのルートに読み替えてください。
まず、シェルを介してコマンドを実行していそうな呼び出しを洗い出します。
grep -rnE "(os\.system|subprocess\.(run|call|Popen)|shell_exec|child_process\.exec|Runtime\.getRuntime|ProcessBuilder|exec\.Command)" . --include="*.py" --include="*.js" --include="*.ts" --include="*.php" --include="*.java" --include="*.go"
次に、危険な「シェルで解釈させる」指定が残っていないかを確かめます。shell=True や sh -c、Node の exec( がヒットしたら、引数配列の execFile/spawn・shell=False への置き換えを検討します。
grep -rnE "shell=True|[\"']sh[\"'][, ]+[\"']-c[\"']|child_process\.exec\(" . --include="*.py" --include="*.js" --include="*.ts"
いずれも、小さなダミーのプロジェクトで陽性(シェル経由で連結している側)と陰性(引数配列に直した側)の両方を走らせ、拾うべきものを拾い、無いときに何も出ないことを確かめてから載せています。ヒットした箇所が本当に危険かは、その呼び出しにユーザー入力が混ざっているか、シェルを介しているか、引数がデータとして分離されているかを、コードを読んで確かめます。攻撃の再現手順・具体的なペイロードは、悪用を助けるため本記事では扱いません。
参照ソース
- OWASP Community — Command Injection: https://owasp.org/www-community/attacks/Command_Injection
- OWASP Cheat Sheet Series — OS Command Injection Defense: https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
- MITRE CWE-78 — Improper Neutralization of Special Elements used in an OS Command: https://cwe.mitre.org/data/definitions/78.html
- Python 公式ドキュメント — subprocess(Security Considerations): https://docs.python.org/3/library/subprocess.html
- Node.js 公式ドキュメント — child_process(execFile / spawn / exec): https://nodejs.org/api/child_process.html
- Go 公式ドキュメント — os/exec: https://pkg.go.dev/os/exec





