- 外部入力をシェルに渡すことでサーバーを乗っ取りられる、単純ながら極めて強力な脆弱性
- `exec`ではなく引数を配列で渡す`spawn`を利用し、シェルの特殊文字解釈を物理的に回避
- 入力値のホワイトリスト検証と最小権限の徹底により、万が一の注入時も被害を最小化
OSコマンドインジェクションとは?
OSコマンドインジェクションは、攻撃者がサーバー上で任意のOSコマンドを実行させる脆弱性です。
アプリケーションが外部入力をシェルに渡して実行する際に、適切にエスケープやバリデーションを行わないことで発生します。この攻撃が成功すると、サーバー内のファイル奪取、バックドアの設置、他のサーバーへの踏み台利用など、致命的な被害につながります。
脆弱性が発生する仕組み
「ユーザー入力されたIPアドレスにpingを打つ」といった機能が典型的な例です。
// 脆弱な実装例
const target = req.query.target;
exec(`ping -c 3 ${target}`); // 入力をそのままシェルに渡す
攻撃者が target=127.0.0.1; rm -rf / と入力すると、 ping 実行後に続けて rm -rf / が実行されてしまいます。シェルにおける「セミコロン(;)」や「パイプ(|)」などの特殊文字が、攻撃の手がかりとなります。
根本的な対策:シェルを介さない実行
最も推奨される対策は、**「OSコマンドを直接実行せず、言語標準のライブラリやAPIを使用すること」**です。
対策のポイント(仕様まとめ)
| 対策手法 | 実装の方向性 | エンジニアとしての所感 |
|---|---|---|
| シェル呼び出しの回避 | exec ではなく spawn や execFile で引数を配列として渡す |
そもそも「シェル」を起動させないことで、メタ文字の解釈を物理的に防ぎます。 |
| ホワイトリスト検証 | 入力値を許容されるパターン(数値や英数字のみ)に厳格に限定する | IPアドレスなら正規表現で形式をチェックし、それ以外は即エラーにします。 |
| 最小権限で実行 | Webサーバープロセスに不必要な特権を与えない | 万が一コマンドが注入されても、 root 権限がないことで被害を限定できます。 |
2026年3月の現場感: シェル注入は「便利機能」から戻ってくる
2026年3月のコミュニティを追うと、OSコマンドインジェクションは昔ながらの CGI 的コードだけでなく、管理画面の診断機能、動画変換、Zip 展開、Git 操作ラッパー、AI ワークフローの補助スクリプトから再発している印象が強いです。つまり「バックエンドが少しだけ OS に触る便利機能」が危険源です。
特に最近は、AI 補助で「まず shell command で動かす」実装が生成されやすく、そこへユーザー入力や設定値が混ざって事故になるパターンが増えています。2026年時点で重要なのは、危険文字を全部弾くことではなく、文字列としてコマンドを組み立てない文化をチームで固定することです。
私ならレビュー時に次の点を優先します。
exec、system、subprocess(..., shell=True)が残っていないか- 設定ファイルの値や管理画面入力が、そのままコマンド引数へ入っていないか
- 本当に外部コマンドが必要か、標準ライブラリで代替できないか
トラブルシューティング:セキュアな外部コマンド実行手順
どうしても外部コマンドを呼び出す必要がある場合は、以下の手順を守ります。
1. 引数を分離した呼び出し
// NG: 文字列として結合してシェルで実行
exec(`ping -c 3 ${target}`);
// OK: 配列として引数を渡し、シェルを介さず実行
const { spawn } = require('child_process');
const ping = spawn('ping', ['-c', '3', target]);
2. 環境変数のクリア
外部コマンド実行時に、予期せぬ環境変数の影響を受けないように、環境変数を明示的にクリアまたは制限して渡します。
3. パスの絶対指定
実行ファイルを名前だけで指定せず(例: ls )、 /bin/ls のようにフルパスで指定することで、パス改ざん(PATHインジェクション)のリスクを低減します。
4. 設定値も「信頼しない入力」として扱う
本番事故では、フォーム入力よりも .env、管理画面設定、ジョブキューの payload など「内部値」のほうが盲点になりがちです。ユーザー入力と同じく、設定値も allowlist や型検証を通してから引数へ渡すべきです。
5. タイムアウトと作業ディレクトリを固定する
安全に実行できても、長時間ぶら下がる外部コマンドは障害の種です。timeout、作業ディレクトリ、環境変数、標準入力の有無を明示し、実行条件を固定したラッパー関数に寄せると保守しやすくなります。
実務で事故を減らす確認表
| 確認項目 | 合格ライン | ありがちな事故 |
|---|---|---|
| 実行API | spawn / execFile / 標準ライブラリ利用 |
exec で一発実装 |
| 引数生成 | 配列で固定、allowlistあり | テンプレート文字列で結合 |
| 実行ファイル | 絶対パス指定 | PATH 汚染で別バイナリ実行 |
| 権限 | 専用ユーザー、最小権限 | root でそのまま稼働 |
| 運用 | timeout、監査ログ、失敗時の扱い明確 | ハングしても気づけない |
よくある質問(FAQ)
Q: 全ての特殊文字をエスケープすれば安全ですか?
A: 理論上は可能ですが、OSやシェル(bash, zsh, cmd.exe)によって特殊文字の挙動が異なるため、漏れなく対応するのは極めて困難です。シェル回避が第一選択です。
Q: PHPの escapeshellarg は信頼できますか?
A: 一定の防御効果はありますが、歴史的には脆弱性が見つかったケースもあります。可能な限り exec 系の関数自体の使用を避ける設計にすべきです。
Q: 社内専用ツールなら shell 実行でも大丈夫ですか?
A: 安全とは言えません。社内限定でも、認証済みユーザー、設定ミス、CI 実行、ジョブキュー経由の値が攻撃面になります。内向きツールほどレビューが甘くなりやすいため、むしろ共通ラッパーで厳格に縛る方が安全です。
脆弱なコードと修正例
典型的な脆弱コードと、シェルを介さない安全な書き直し例を言語別に並べてみます。
Node.js の例
// NG: ユーザー入力をそのままシェル文字列に埋め込む
// target = "127.0.0.1; rm -rf /" のような入力で任意コマンドが実行される
const { exec } = require('child_process');
const target = req.query.target;
exec(`ping -c 3 ${target}`, (err, stdout) => {
res.send(stdout);
});
// OK: spawn で引数を配列として渡し、シェルを起動しない
const { spawn } = require('child_process');
const target = req.query.target;
// ホワイトリスト検証(IPv4 形式のみ許可)
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(target)) {
return res.status(400).send('Invalid IP address');
}
const ping = spawn('/bin/ping', ['-c', '3', target], {
shell: false, // シェルを介さない(デフォルトは false だが明示する)
timeout: 5000, // 5秒タイムアウト
});
ping.stdout.on('data', (data) => res.write(data));
ping.on('close', () => res.end());
Python の例
# NG: shell=True でユーザー入力を渡す
import subprocess
domain = request.args.get('domain') # "example.com; cat /etc/passwd" が来る可能性
result = subprocess.run(f"nslookup {domain}", shell=True, capture_output=True)
# OK: リストで引数を渡し shell=False(デフォルト)
import re
import subprocess
domain = request.args.get('domain', '')
# ドメイン名として有効な文字のみ許可(ホワイトリスト)
if not re.match(r'^[a-zA-Z0-9.-]{1,253}$', domain):
return 'Invalid domain', 400
result = subprocess.run(
['/usr/bin/nslookup', domain],
capture_output=True,
text=True,
timeout=10, # タイムアウト必須
env={}, # 環境変数をクリア
)
PHP の例
// NG: escapeshellarg を使っていても exec 自体がシェルを介するため原則禁止
$file = $_GET['file'];
exec("convert " . escapeshellarg($file) . " output.jpg", $output);
// OK: 許可リストで入力を絞り、ライブラリ経由で処理する
$allowed_files = ['photo1.png', 'photo2.png'];
$file = basename($_GET['file']);
if (!in_array($file, $allowed_files, true)) {
http_response_code(400);
die('File not allowed');
}
// ImageMagick を PHP ライブラリ(Imagick)経由で呼び出す(シェル不要)
$imagick = new Imagick('/uploads/' . $file);
$imagick->writeImage('/output/converted.jpg');
よくやらかす失敗パターンと対処法
コードレビューやインシデント対応で実際に遭遇したパターンです。
① 「管理画面だから安全」と思って検証を省いた
原因: 内部ツールや管理画面は「社内の人しか使わないから」と高をくくりがち。
対処法: 認証済みユーザーでも SSRF や誤操作で危険な入力が届くことがある。内部ツールでも同じバリデーションを適用するのが鉄則でした。
② exec を execFile に変えただけで安心した
原因: execFile でもファイル名部分にユーザー入力を混ぜると脆弱になる。
対処法: 実行するバイナリのパスは絶対パスのハードコードにし、引数だけを配列で渡してさらにホワイトリストで検証する。
③ 設定ファイルの値を「信頼できる入力」だと思い込んでいた
原因: .env や管理画面から保存された設定値を「ユーザー入力とは別物」と判断し、そのままコマンド引数に渡していた。
対処法: 設定値も allowlist や型・長さ検証を通してから引数に渡す。ジョブキューやスケジューラに動的な値が流れる設計は特に要注意です。
④ タイムアウトを設定していなかったためサーバーがぶら下がった
原因: 外部コマンドにタイムアウトを設定しておらず、意図的に長時間実行させられた。
対処法: 必ずタイムアウトを設定します。Python なら timeout=10、Node.js なら spawn の timeout オプションを使います。
# タイムアウト付きの安全な外部コマンド実行例
try:
result = subprocess.run(
['/usr/bin/dig', '+short', domain],
capture_output=True,
text=True,
timeout=5, # 5秒で強制終了
env={} # 環境変数をクリア
)
except subprocess.TimeoutExpired:
return 'Request timed out', 408
⑤ PATH 汚染に気づかなかった
原因: ls や curl のようにコマンド名だけで呼ぶと、PATH が改ざんされた環境では別のバイナリが実行される。
対処法: 外部コマンドは /bin/ls、/usr/bin/curl のように必ず絶対パスで指定する。さらに env={} で環境変数をクリアすると安全性がさらに上がると確認しました。
私の検証メモ
本記事は、自分が業務 / 自宅環境で実際にぶつかった事象に対し、検証用 VM やサブ機を使って再現・対処した一次記録です。一般論ではなく『私の環境では確かにこう挙動した』という観測をベースにしているため、構成が違えば挙動も変わります。再現できない場合は、本文中で挙げた前提条件(OS バージョン / ドライバ / BIOS / 関連サービスの状態)から差分を疑ってください。