PR 本記事には広告(Amazonアソシエイト・もしもアフィリエイト・A8.net等)が含まれます。掲載情報の正確性には努めていますが、商品の詳細は必ずリンク先で最新情報をご確認ください。
CTF・セキュリティ学習

【OSコマンドインジェクション編】入力をシェルへ渡さない設計と守り方|CTF思考フレームワーク #30

かも次郎とアンペンが「OSコマンドインジェクショ」を解説するマスコットイラスト
安全に生きたい編集部

こんにちは、アンペンです!

前回は、サーバ側フレームワークでのデシリアライズRCEを扱いました。

今回は、サーバの裏で動くOSコマンド呼び出しに潜むOSコマンドインジェクションを、サーバ視点で改めて深掘りしていきます。シェルに入力が繋がる経路と、その守り方を一緒に見ていきましょう。

コマンドインジェクションも#16でやったよね。何が違うの?

#16は概要中心。今回はサーバ側の言語別の危険関数と『シェル経由の有無』を、もう少し細かく見ていくよ。

コマンドインジェクションは#16でも学びました。今回はその“実装編”——『コードをどう書くか』で、防げるか防げないかがハッキリ分かれる、という話をします。実はこの攻撃、エスケープを頑張るより、たった一つの“書き方の選択”でほぼ封じられます。その分かれ目が『シェルを経由するかどうか』。今日はそこを、受付係への伝言のたとえで、コードレベルまで踏み込んで整理します。

まず結論

OSコマンドインジェクションは、利用者入力をシェルに渡る文字列に連結することで発生します。守りは『シェル非経由』『引数配列で渡す』『絶対パス指定』『最小権限プロセス』の組み合わせ。エスケープよりも構造で防ぐのが王道です。

この記事で分かること

  • シェル経由と非経由の違い(なぜ非経由が安全か)
  • 言語別の危険関数(Python/Node/PHP/Java)
  • 配列引数・絶対パス・最小権限の守り方
難易度:中級向け 所要時間:10分 体験:コマンド呼び出しを点検 おすすめ:#29の後

📖 はじめてのWebセキュリティ #30|OSコマンドインジェクション編
『シェルに入力を渡す瞬間』を、サーバ側のコードレベルで整理します。 シリーズ一覧を見る →

⚠️ 大事なお約束
この記事の確認は、CTF・自分の検証環境のみで行ってください。本番サービスへのメタ文字付き入力は不正アクセスに該当する可能性があります。

『シェル経由』が分かれ目

サーバから外部コマンドを呼ぶときの選択肢は、大きく2つあります。

  • シェル経由:OSのシェル(bash/cmd)に文字列を渡す。; | ` 等の区切り文字が解釈される
  • シェル非経由:コマンドと引数を配列でOSに直接渡す。シェルが介入せず、区切り文字は『ただの文字』に

シェル経由を選んだ瞬間、利用者入力が混ざると『次のコマンド』が成立する可能性が常に生まれます。だから、コードレベルで非経由を選ぶ設計が最強の防御になります。

ここがこの回の核心です。シェルを経由しない(非経由)と決めるだけで、「この記号は危ない?許していい?」という悩みが、まるごと消えてなくなります。なぜなら、危険なのは“シェルが記号を解釈すること”であって、シェルがいなければ、 ; rm -rf / と書かれても、ただの“長い文字列”として安全に扱われるから。エスケープで一個ずつ記号と戦うのではなく、戦う土俵そのものをなくす——これが一番ラクで、一番確実な守りです。

もう少しかみくだきましょう。サーバが外部コマンドを呼ぶとき、道は2つあります。一つは『コマンドを一本の文章にして、通訳さん(シェル)に読んでもらう』方法。もう一つは『コマンド名と引数を、最初からバラバラの部品として、OSに直接手渡す』方法。前者では、文章の中の ; などをシェルが“区切り”と解釈してしまう。後者では、シェルが登場しないので、何が書いてあっても“ただの部品の中身”として扱われます。この違いが、運命を分けます。

図解:文字列連結 vs 引数配列

文字列連結でシェル経由で実行する場合と、引数を配列で渡してシェル非経由で実行する場合の比較図
構造で防ぐ。シェルが介在しなければ『次のコマンド』は成立しない。

同じ画像変換処理でも、引数の渡し方によって、入力に混ざる悪意の効き方がまったく違います。

言いかえると、攻撃を防げるかどうかは“どんな入力チェックをしたか”ではなく、“どう呼び出したか”でほぼ決まる、ということ。入口で頑張るより、出口(コマンドの呼び方)を正すほうが、ずっと効きます。

紙の伝言には追記が混ざるが、構造化フォームは混ざらないというたとえ図
OSコマンドも『非経由+配列』で混入経路を構造的に断つ。
📝 たとえるなら、受付係への伝言

『山田太郎さんの書類を出してください』と受付係に頼むとします。これを紙に書いて渡すと、紙の余白に追加の指示を書き足されるかもしれません。逆に、『お名前』『依頼内容』を別々のフォームに書いてもらえば、追加指示は混入しにくくなります。シェル経由は紙の伝言、非経由はフォーム入力に近いイメージです。

ここで覚える用語:シェルメタ文字
シェルが特別な意味で扱う ; & | ` $() < > 等の記号です。シェル経由でコマンドを実行する限り、入力でこれらを許す/拒むの判断が必要になります。非経由なら判断ごと不要になります。

メタ文字をエスケープで防ぐ道も一応ありますが、おすすめしません。理由は#16でも触れたとおり、記号の種類が多く、OSやシェルで挙動も違って、“漏れ”が必ず生まれるから。一個でも見落とすと、そこから抜けられます。非経由なら、そもそもエスケープという作業自体が要らない。「頑張って正しくエスケープする」より「エスケープしなくていい構造にする」。守りは、頑張りに頼らない形が一番強いんです。

言語別 危険関数の代表

次に、自分の言語の“危ない関数”を知っておきましょう。どの言語にも、たいてい“シェルを呼んでしまう便利関数”が用意されています。Pythonの os.system、Node.jsの exec、PHPの shell_exec……名前は違っても、共通点は「文字列を渡すと、裏でシェルに流す」こと。まずは自分の使う言語のこのリストを頭に入れて、コードの中で見かけたら“立ち止まる”クセをつけるのが第一歩です。

注意すべき関数

Python(os.system / subprocess shell=True)・Node.js(child_process.exec)・PHP(shell_exec)・Java(Runtime.exec文字列形式)の危険関数を示したカード型インフォグラフィック
守りは『非経由+配列引数+絶対パス+最小権限プロセス』。
  • Python: os.system / subprocess.run(..., shell=True) / os.popen
  • Node.js: child_process.exec / execSync
  • PHP: shell_exec / system / exec / passthru
  • Java: Runtime.exec(String) 文字列形式・ ProcessBuilder 文字列連結
  • Ruby: バッククォート `cmd` / system(str) 文字列形式

これらに利用者入力を文字列として渡している箇所は、最優先で見直す対象です。画像変換・PDF生成・ping・grep等の便利機能は、内部でこれらを使うことが多いため、ライブラリ仕様も合わせてチェックしましょう。

見落としがちなのが、“自分では呼んでいないつもりの”コマンド実行です。画像変換、PDF生成、ping確認、圧縮・解凍——こうした便利機能の中には、裏でこっそり外部コマンドをシェル経由で呼んでいるライブラリが少なくありません。あなたのコードに os.system が無くても、使っているライブラリが内部で呼んでいれば、同じ穴が空きます。だから自分のコードだけでなく、“依存ライブラリが何をしているか”まで目を向けることが大切です。

練習は、コードを検索して『外部コマンドを呼んでいる場所』と『シェル経由かどうか』を一覧にするだけ。攻撃ではなく棚卸しです。あわせて、そのプロセスがどんな権限で動いているかも見てみましょう。もし全部rootで動いていたら、それは別の意味で危険信号。本物のサービスにメタ文字を送るのは厳禁、確認は自分のラボの中だけで。

CTFでやってみよう:コマンド呼び出しを棚卸し

やってみよう / 自分の環境・CTFのみ

自分のラボのコードで、外部コマンド呼び出しを整理しよう

目的は実際の攻撃ではなく、『シェル経由になっている箇所』をリスト化することです。

  1. 上記の言語別危険関数をコードベースで文字列検索する
  2. 各箇所で shell=True や文字列連結が使われていないか確認する
  3. 非経由API(subprocess.run([...], shell=False)等)に置き換え可能か検討する
  4. コマンドのパスは絶対パスか、許可リストで限定されているか確認する
  5. そのプロセスが必要最小限の権限で動いているか(rootで動いていないか)確認する
本物のサービスにメタ文字付き入力を送る試行は絶対にやめてください。確認は自分のラボの範囲だけです。

守りの4点セットのうち、最後の『最小権限』について補足しておきましょう。

非経由にしたなら、もうプロセスの権限まで気にしなくていいんじゃない?

そう思いたくなるけど、これは“もしもの保険”として効くんだ。万一どこかでコマンドインジェクションが成立しても、そのプロセスが“ほとんど何の権限も持たない専用ユーザ”で動いていれば、攻撃者にできることはごくわずか。逆にrootで動かしていたら、一発でサーバ全部を握られる。入口を塞ぐ(非経由)と、破られた後の被害を小さくする(最小権限)。デシリアライズのときと同じ“二段構え”が、ここでも効くんだよ。

守る側なら、「非経由+配列+絶対パス+最小権限」

コマンドインジェクションの守りは、「シェルを経由しない」「引数は配列」「絶対パスでコマンド指定」「プロセスは最小権限」の4点が中心です。

守るための基本チェック
  • OS呼び出しはshell=False+配列引数を基本にする(Python subprocess.run 等)
  • そもそも外部コマンドを呼ばずに済むライブラリAPIに置き換えられないか優先検討
  • 必要なコマンドは絶対パスで指定し、PATH依存を排除する
  • 入力を含める場合は、許可リストと厳格な文字種チェックを行う
  • プロセスは最小権限の専用ユーザで動かし、被害範囲を狭める
  • 監査ログでコマンド実行履歴と異常パターンを継続監視する

『非経由+配列』にするだけで、ほぼ防げるんだね。

そう。エスケープに頼るより、シェルを介在させない構造が一番強いよ。

ここまでをひと言で言うと、コマンドインジェクションの守りは『シェルという土俵を、最初から用意しない』。記号と戦うのではなく、戦いが起きない構造にする。コマンドと引数を配列でOSに直接渡し、絶対パスで指定し、最小権限で動かす。この4点は、暗記というより“クセ”にしてしまうのがおすすめです。

まとめ:『シェルを介在させない』が王道

今回のポイント
  • コマンドインジェクションはシェル経由の文字列連結で発生
  • 守りの王道は非経由+配列引数+絶対パス+最小権限
  • 言語別の危険関数を棚卸しし、置換計画を持つ
  • ライブラリAPIで完結できるなら、そもそも外部コマンドを呼ばない

今日の持ち帰りは『エスケープより、シェル非経由』です。多くの人はつい“危ない記号をどう弾くか”を考えますが、本当に強いのは“そもそもシェルを呼ばない”という一手。shell=True を避け、引数は配列で。この書き方を標準にするだけで、OSコマンドインジェクションはほとんど起きなくなります。

次回は、ファイルパスに紛れるパストラバーサルを扱います。 ../ でディレクトリ階層を越える攻撃と、その守り方を見ていきましょう。

次に読みたい記事

参考資料

記事URLをコピーしました