「ファイル名にスペースが入っていただけで、バックアップ処理が途中で壊れた」
シェルスクリプトの脆弱性は、難解な暗号理論や複雑なプロトコル知識とは無縁のところで生まれる。クォートの省略、外部入力の無検証使用、eval の安易な利用——いずれも「ちょっとした書き方の癖」が招く問題だ。
この記事では、シェルスクリプトのセキュリティ設計の基本を具体的に解説する。RHEL 9.4 / Ubuntu 24.04 LTS の bash 5.x 環境で動作確認した実例をもとに、クォートの徹底からコマンドインジェクション対策、eval を避ける書き方、set -u・mktemp・IFS リセットまでを体系的にカバーする。
この記事のポイント
・変数は必ずダブルクォートで囲む("$var" が安全な基本形)
・外部入力はバリデーション後にコマンドへ渡す(インジェクション対策の核心)
・eval は使わない。配列・declare・case 文で安全に代替できる
・set -u・IFSリセット・mktemp でスクリプト全体を堅牢化する
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
シェルスクリプトの脆弱性はなぜ起きるか
シェルは「ユーザーが入力したテキストをそのままコマンドとして解釈する」設計になっている。これはパイプや変数展開を自在に組み合わせられる強みであると同時に、外部からの入力を不用意に扱うと「意図しないコマンドが実行される」脆弱性に直結する。脆弱性の主な原因は4つに分類できる。
・クォート省略:変数を " で囲まないと、スペース・タブ・glob 文字が予期せぬ単語分割やファイル展開を引き起こす
・外部入力の無検証使用:ユーザー入力やファイル名をそのままコマンドに渡すと、インジェクション攻撃の足がかりになる
・eval の安易な使用:eval は渡した文字列をシェルコマンドとして再評価する。攻撃者が制御できる文字列を eval に渡すと任意コマンドを実行できる
・一時ファイルの安全でない作成:/tmp/tmpfile のような固定名で作ると、シンボリックリンク攻撃(TOCTOU)の対象になる
それぞれ具体的に対策を見ていこう。
クォートの徹底:ダブルクォートで変数を囲む
1. クォートなしで何が起きるか
以下のスクリプトで問題を確認してみる。# 悪い例:クォートなし filename="my document.txt" rm $filename # bash は "my" と "document.txt" の2引数として解釈する
rm my document.txt として解釈する——つまり my と document.txt の2ファイルを削除しようとする。ファイル名にスペースが含まれると、この挙動が深刻なバグを生む。glob 展開でも同様の問題が起きる。
# 悪い例:glob 展開の意図しない動作 search_term="*.conf" ls $search_term # カレントディレクトリの全 .conf ファイルが展開されてしまう
2. ダブルクォートで変数を守る
解決策は単純だ——変数参照は必ず " で囲む。# 良い例:ダブルクォートで囲む filename="my document.txt" rm "$filename" # スペースを含むファイル名を1つの引数として扱う search_term="*.conf" ls "$search_term" # glob 展開されずリテラル文字列として渡される
$() もダブルクォートで囲む。# サブシェル展開のクォート output="$(command)" # OK:結果を変数に格納 echo "$output" # 改行が保持される # シングルクォートは変数展開を行わない echo '$filename' # リテラル "$filename" と出力される echo "$filename" # 変数の値が展開される
コマンドインジェクション対策:外部入力を安全に扱う
1. 外部入力をそのまま渡す危険
Web アプリケーションが外部からの入力をシェルに渡す処理や、スクリプトが引数・環境変数・ファイルの内容を受け取る場面で、コマンドインジェクションのリスクが生まれる。# 危険な例:外部入力をそのままコマンドに渡す user_input="$1" ls $user_input # $1 に "; cat /etc/shadow" が渡された場合、セミコロン以降が実行される
"; cat /etc/shadow" など)が渡されると、予期しないコマンドが実行される可能性がある。2. バリデーションと安全な渡し方
最初のルールは「外部入力を信頼しない」こと。入力値を使う前に、必ず内容をチェックする。#!/bin/bash # 良い例:入力バリデーション user_input="$1" # 英数字・ハイフン・アンダースコアのみ許可 if [[ ! "$user_input" =~ ^[a-zA-Z0-9_-]+$ ]]; then echo "エラー: 不正な入力です" >&2 exit 1 fi # バリデーション済みの値を使用 ls /var/log/"$user_input"
#!/bin/bash # ディレクトリ限定の安全なファイル処理 BASE_DIR="/var/app/data" user_file="$1" # realpath で絶対パスに変換してから確認 real_path="$(realpath -m "${BASE_DIR}/${user_file}")" # BASE_DIR 配下かチェック(パストラバーサル対策) if [[ "$real_path" != "${BASE_DIR}/"* ]]; then echo "エラー: 許可されたディレクトリ外へのアクセスです" >&2 exit 1 fi cat "$real_path"
# 正常なアクセス $ ./safe_cat.sh config.json {"version": "1.0", "env": "production"} # パストラバーサル試行 $ ./safe_cat.sh ../../etc/passwd エラー: 許可されたディレクトリ外へのアクセスです
シェルスクリプトをゼロから体系的に学ぶなら
セキュリティ設計・エラー処理・自動化実装まで、現場で通用するシェルスクリプトの書き方を宮崎が解説するコース。独学でつまずいている方に特に好評です。
evalを避ける:なぜ危険でどう書き換えるか
1. evalが危険な理由
eval はシェルスクリプトの中でも特に危険なコマンドだ。渡した文字列をシェルが再度解釈するため、ユーザー入力やファイルの内容が含まれる文字列を eval に渡すと、任意コマンドの実行が可能になる。# 非常に危険な例 user_input="$1" eval "echo $user_input" # $1 に "$(cat /etc/shadow)" を渡すとそのまま実行される
$1 に $(cat /etc/shadow) のような文字列を渡すと、eval がシェルコマンドとして実行し、機密ファイルの内容が読み取られてしまう。2. 安全な書き換えパターン
eval を使いたくなる代表的なパターンと、安全な代替手段を確認しよう。パターン1:変数名を動的に作りたい
# eval を使いたくなるパターン(危険) prefix="user" eval "${prefix}_name=Tanaka" # 安全な代替:連想配列(bash 4.0 以降) declare -A user_info user_info[name]="Tanaka" echo "${user_info[name]}" # Tanaka
# eval を使いたくなるパターン(危険) action="start" eval "${action}_service" # 安全な代替:case 文でホワイトリスト制御 case "$action" in start) start_service ;; stop) stop_service ;; *) echo "不明なアクション: $action" >&2; exit 1 ;; esac
# 安全な代替:配列でコマンドを組み立てる cmd=(ls -la) if [[ "$show_hidden" == "true" ]]; then cmd+=(--all) fi cmd+=("$target_dir") # 配列要素がそれぞれ1つの引数として渡される "${cmd[@]}"
その他のセキュリティプラクティス(set -u・IFSリセット・一時ファイル安全化)
1. set -u で未定義変数を検出する
デフォルトでは、未定義の変数を参照しても bash は空文字列として扱う。これが意図しない動作を引き起こすことがある。#!/bin/bash set -eu # -e: コマンド失敗で即終了、-u: 未定義変数でエラー # $backup_dir が未定義のまま展開されると危険 # set -u なし → 空文字に展開されてシステム直下が対象になりかねない # set -u あり → "bash: backup_dir: unbound variable" で即停止 echo "バックアップ先: ${backup_dir}"
set -eu をスクリプト先頭に置くだけで、未定義変数起因のバグを早期に検出できる。2. IFSを明示的にリセットする
IFS(Internal Field Separator)は単語分割の区切り文字を制御する変数だ。外部環境から変更された IFS が引き継がれる可能性があるため、スクリプト冒頭で明示的にリセットするのが安全だ。#!/bin/bash # IFS を安全にリセット(スペース・タブ・改行) IFS=$' ' # ファイルを1行ずつ読む場合も IFS を制御する while IFS= read -r line; do # $line の内容が予期せず分割されない echo "$line" done < /etc/hosts
while IFS= read -r line のパターンは、ファイル読み込み時の標準イディオムだ。IFS=(空)にすることで行頭・行末の空白が削除されず、-r でバックスラッシュエスケープを無効化する。3. mktempで一時ファイルを安全に作る
一時ファイルを固定名で作ると、攻撃者に先にシンボリックリンクを置かれる可能性がある(TOCTOU 問題)。mktemp を使ってランダムな名前を生成することで、この問題を防ぐ。#!/bin/bash set -eu # 危険な例:固定名の一時ファイル # /tmp/myapp.tmp ← 攻撃者が事前にシンボリックリンクを作れる # 安全な例:mktemp でランダムな一時ファイルを生成 tmpfile="$(mktemp /tmp/myapp.XXXXXXXX)" # スクリプト終了時(正常・異常問わず)に確実に削除 trap 'rm -f "$tmpfile"' EXIT # 一時ファイルへの書き込みと利用 process_input > "$tmpfile" validate_output < "$tmpfile"
trap 'rm -f "$tmpfile"' EXIT を組み合わせることで、スクリプトが正常終了・異常終了のどちらでも確実に一時ファイルを削除できる。セキュアなシェルスクリプトのチェックリスト
スクリプトを書き終えたら、以下の項目を確認する習慣をつけてほしい。| チェック項目 | NG パターン | OK パターン |
|---|---|---|
| 変数のクォート | rm $filename |
rm "$filename" |
| 外部入力のバリデーション | ls $1(無検証) |
正規表現チェック後に ls "$1" |
| eval の使用 | eval "$user_cmd" |
配列・case 文・declare で代替 |
| 未定義変数対策 | デフォルト(未定義=空文字) | set -u をスクリプト冒頭に配置 |
| IFS の明示化 | 環境依存の IFS を引き継ぐ | IFS=$' \t\n' でリセット |
| 一時ファイルの作成 | /tmp/myapp.tmp(固定名) | mktemp /tmp/myapp.XXXXXXXX |
| 一時ファイルの削除 | スクリプト末尾のみで削除 | trap 'rm -f "$tmpfile"' EXIT |
| パストラバーサル対策 | ユーザー入力をパスに直接連結 | realpath + BASE_DIR プレフィックス検証 |
シェルスクリプトを体系的に習得したい方へ
今回解説したセキュリティ設計だけでなく、実用的な自動化スクリプトの書き方・エラーハンドリング・cron 連携まで、現場で使えるシェルスクリプトをゼロから学べるコースを提供しています。独学で行き詰まりを感じている方は、ぜひ一度ご覧ください。
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
登録10秒/合わなければ解除3秒 / 詳細はこちら
- 前のページへ:#!/bin/shと#!/bin/bashの違いとPOSIX互換スクリプトの書き方|dash環境で壊れないための移植性設計
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は1分、解除もいつでも可。
登録無料・いつでも解除できます