おい、最近のコードレビューでまだ system() や exec() の臭いがするパッチを見かけたぞ。お前らが平然と書いているその「便利なシェル呼び出し」、レッドチームから見れば「どうぞ社内ネットワークを踏み台にしてください」と言っているようなものだ。
インシデント対応の現場で何度も見てきた。ちょっとしたパラメータの不備からOSコマンドインジェクションを突かれ、Webサーバーの権限でリバースシェルを取られ、内部セグメントへ横展開(ラテラルムーブメント)されて機密データをごっそり持って行かれる。あの絶望感を味わいたくないなら、今日ここで「シェルを呼ばないAPI設計」の極意を叩き込んでおけ。
今回は、攻撃者がどうやってその脆弱性をこじ開けるのかというリアルなリスクと、明日からお前のコードベースを鉄壁にするための具体的な実装方法を解説する。
—
1. 攻撃者の視点:なぜシェル経由の処理は簡単に抜かれるのか
開発現場でよくあるのが、「OSのコマンドラインツール(ImageMagickやdig、pingなど)のほうが確実だから」という理由で、ユーザーからの入力をそのまま文字列結合してシェルに投げているケースだ。
例えば、ユーザーから受け取ったホスト名に対して疎通確認を行う、次のような愚かなPHPコードを考えてみよう。
// 【絶対に真似してはいけない危険なコード例】
$host = $_GET['host'];
// ユーザー入力をそのままシェル経由で実行している
$output = system("ping -c 1 " . $host);
お前らは「ping コマンドなんだからIPアドレスしか入らないはず」と性善説にすがるが、攻撃者はそんなルールを守らない。入力値に ; や |、& といったメタ文字を仕込むのは基本中の基本だ。
もしリクエストパラメータの host に 8.8.8.8; cat /etc/passwd なんて渡されたらどうなるか。シェルはこれを2つのコマンドとして順に実行する。
1. ping -c 1 8.8.8.8 (正常な疎通確認)
2. cat /etc/passwd (システム上の機密ファイルの強奪)
これがOSコマンドインジェクションの脅威だ。WAF(Web Application Firewall)で | や ; を弾けばいいと考えているなら甘い。URLエンコード、ダブルエンコード、ヌルバイト、さらには改行文字(\n)を用いたコマンド区切りなど、バイパス手法はいくらでもある。WAFはあくまで気休めの防波堤であり、根本的な解決は「アプリケーション側でシェルを起動させないこと」に尽きる。
—
2. 防御の鉄則:シェルを排除した安全なAPI設計と実装
セキュアな設計の基本はシンプルだ。「ユーザー入力をシェルの解釈する文字列に含めない」こと。これに尽きる。
システムコマンドをどうしても実行しなければならない場合は、シェルを介さずに直接バイナリを呼び出し、引数を配列として安全に渡す exec系関数(シェルを伴わないプロセス起動) を使う。また、Webアプリケーションの文脈であれば、そもそも外部コマンドに頼らず、言語の標準ライブラリや公式SDKで完結させるのがベストプラクティスだ。
ここでは、主要な言語における「セキュアな実装サンプル」を提示する。そのままコピペして現場のコードに組み込んでくれ。
PHPでの安全なプロセス実行(proc_open または escapeshellcmd の限界と正しい代替)
PHPで外部コマンドを叩く場合、passthru() や system() は厳禁だ。どうしても引数を渡して外部バイナリを叩く場合は、配列形式で引数を安全に渡せる proc_open を用いるか、より上位のラッパーライブラリを使用する。だが、一番確実なのは「PHPの標準機能で代替すること」だ。
以下は、IPアドレスのフォーマットを厳格にホワイトリスト検証(正規表現)した上で、安全に処理を分離するPHPのサンプルコードだ。
<?php
/**
* ホスト名またはIPアドレスの疎通確認(セキュア実装)
* シェルを介さず、入力値を厳格に検証した上で処理を行う
*/
function securePingCheck(string $inputHost): array {
// 1. ホワイトリスト検証(IPv4アドレスまたは厳格なドメイン名のみを許可)
// 任意のシェル文字やスペース、オプション指定(-など)を完全に排除する
$ipv4Pattern = '/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/';
$domainPattern = '/^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$/';
if (!preg_match($ipv4Pattern, $inputHost) && !preg_match($domainPattern, $inputHost)) {
throw new InvalidArgumentException('不正なホスト名またはIPアドレス形式です。');
}
// 2. シェルを介さない安全なプロセス実行(proc_openの利用)
// シェル(/bin/sh等)を経由しないため、メタ文字(; や | 等)は単なる文字列の引数として扱われる
$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'] // stderr
];
// 引数を配列で完全に分離して渡す
$cmd = ['/bin/ping', '-c', '1', '-W', '2', $inputHost];
$process = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('プロセスの起動に失敗しました。');
}
// 標準出力を取得
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
return [
'success' => ($exitCode === 0),
'output' => $stdout,
'error' => $stderr
];
}
// 実行例
try {
$result = securePingCheck($_POST['host'] ?? '');
echo json_encode($result);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
}
Python (FastAPI / Flask) での安全なサブプロセス呼び出し
Pythonでも os.system() や os.popen() は論外だ。subprocess.run() を使い、かつ shell=False(デフォルト)を厳守すること。shell=True にした瞬間、PHPと同じ地獄への扉が開く。
import subprocess
import re
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
class PingRequest(BaseModel):
# Pydanticを用いた厳格な入力値バリデーション
host: str = Field(..., description="対象のIPアドレスまたはホスト名")
@app.post("/api/v1/ping")
def run_ping(req: PingRequest):
# 1. ホワイトリスト検証(正規表現による厳格なチェック)
ipv4_pattern = re.compile(r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$")
if not ipv4_pattern.match(req.host):
raise HTTPException(status_code=400, detail="無効なIPアドレス形式です。")
try:
# 2. shell=False(デフォルト)により、シェルインジェクションを完全に防止
# 引数はリスト形式で渡すため、OS側でコマンドと引数が厳密に分離される
result = subprocess.run(
["ping", "-c", "1", req.host],
capture_output=True,
text=True,
timeout=3,
check=False,
shell=False # ★絶対にTrueにしてはならない
)
return {
"success": result.returncode == 0,
"stdout": result.stdout,
"stderr": result.stderr
}
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="コマンドの実行がタイムアウトしました。")
except Exception as e:
raise HTTPException(status_code=500, detail="内部サーバーエラーが発生しました。")
—
3. 静採解析による「人間のポカミス」の完全封殺
どれだけ口頭で「exec 系関数を使うな」「shell=True は禁止だ」と叫んでも、疲弊したプログラマは納期に追われると禁忌を犯す。だからこそ、CI/CDパイプラインや静的解析ツール(SAST)で機械的に弾く仕組みを強制しなければならない。
チームの開発ルールとして、以下の設定をLintツールに組み込め。
SonarQube / Semgrep による静検知ルール(Semgrep用設定例)
オープンソースの静的解析ツールである Semgrep を使えば、危険な関数やパラメータの利用をビルド時に自動検知してブロックできる。以下の設定ファイルをリポジトリのルートに置いておけ。
rules:
- id: avoid-shell-execution-php
pattern-either:
- pattern: system(...)
- pattern: exec(...)
- pattern: passthru(...)
- pattern: shell_exec(...)
- pattern: proc_open(..., $descriptors, $pipes) # シェルを伴う設定や不安全な利用の検知
message: "【セキュリティ警告】シェルを伴う危険な関数(system/exec等)の利用が検出されました。シェルを介さない安全な代替手段または厳格なホワイトリスト検証を実装してください。"
severity: ERROR
languages: [php]
- id: avoid-shell-true-python
pattern: subprocess.run(..., shell=True, ...)
message: "【セキュリティ警告】subprocess.run で shell=True が指定されています。OSコマンドインジェクションの脆弱性に直結するため削除してください。"
severity: ERROR
languages: [python]
この設定を GitHub Actions などの CI パイプラインに組み込み、PR(プルリクエスト)の段階でマージを強制ブロックするように設定する。これがチーム運用の「最後の砦」だ。
—
4. チーフエンジニアからの総括
セキュリティは「意識が高い・低い」といった精神論で語るものではない。仕組みで縛り、間違ったコードが動かない環境を作るエンジニアリングそのものだ。
1. ユーザー入力をシェルの解釈する文脈に絶対に置かない。
2. 外部コマンドを叩く場合は、シェルを無効化(shell=False 等)し、引数を配列で完全分離する。
3. そもそもOSコマンドに頼らず、言語の標準ライブラリや安全なAPIで実装できないか設計を疑う。
4. 人間の善意に頼らず、静的解析ツールで機械的に不安全なコードを排除する。
このルールを今日からお前のチームの標準にしろ。次に同じ脆弱性をレビューで見つけたら、その時はコードではなくお前を問い詰めるから覚悟しておいてくれ。頼んだぞ。
コメント