この章で分かること: SQLインジェクション、XSS、コマンドインジェクション。この三つは名前も舞台も違うけれど、実は「データとコードの境界が壊れる」というたった一つの構造の問題なんだ、ということ。そして、その根本をふさぐ実装がどこにあるのか。
聴く台本
今日の話は、たぶんあなたが名前だけは知ってる三つの攻撃の話なんだ。SQLインジェクション、XSS、コマンドインジェクション。資格試験の暗記カードだとこれ、バラバラの三項目として覚えさせられるんだよね。でも実務でこの三つを別々に暗記してると、たぶん永遠に脆弱性を作り続ける。だから今日はまず結論から言う。この三つは、全部「同じ一つの病気」なんだ。その病気の名前は「データとコードの境界が壊れる」。これさえ腹に落ちれば、対策も一本の筋で見えてくる。
じゃあ、その「境界が壊れる」ってどういうことか。まずSQLインジェクションで具体的に見てみよう。たとえばログイン処理で、ユーザーが入力したIDを使って、データベースに問い合わせる文を作るとするよね。よくある作り方は、文字列をくっつけて組み立てるやつ。「セレクト・なんとか・ワー・アイディーがイコール、ここにユーザーが入れた値」みたいに、文字列連結でクエリを組む。これがもう、出発点からして地雷なんだ。なぜか。ユーザーが普通に「tanaka」って入れてくれてる間は何も起きない。でも、もしユーザーが値の中にSQLの文法そのものを混ぜてきたらどうなる?たとえば、値を閉じるためのクォートと、その後ろに「オア・1イコール1」みたいな条件を足してくる。するとデータベースは、それを「ただのデータ」だと思わずに、「あ、これは命令の一部だな」と解釈して実行しちゃう。本来はデータとして扱うはずだった部分が、コードに昇格してしまった。これが境界の崩壊なんだ。
ここで一回立ち止まって考えてほしいんだけど、なんでこんなことが起きるのか。それは、文字列連結でクエリを組んだ瞬間、プログラムから見るとデータベースに渡すのは「ただの一本の文字列」になっちゃうからなんだ。データベースは、その一本の文字列を受け取ってから、どこが命令でどこがデータかを文法で読み解く。つまり「データ」と「コード」が同じ平面に溶けて、区別する手がかりが文法だけになる。だから攻撃者は文法の記号を紛れ込ませるだけで、データのフリをしてコードに化けられる。これが、文字列連結が根本的に危ない理由なんだ。
じゃあ、どう直すか。ここが今日いちばん大事なところ。根本対策は「パラメータ化クエリ」、別名プレースホルダって呼ばれるやつなんだ。これは何かというと、クエリの「骨組み」と「あとで埋める値」を、最初から別々の引数としてデータベースに渡す仕組みなんだ。骨組みのほうには、値が入る場所にハテナとか名前付きの目印だけ置いておく。「アイディーがイコール、ハテナ」みたいにね。そして実際の値は、それとは完全に別のルートで「この目印にはこの値ね」と渡す。こうすると何が起きるか。データベースは先に骨組みのほうを「これが命令の形だ」と確定させてしまう。命令の構造が決まったあとに、値をはめ込む。だから、あとから渡した値の中にどれだけSQLの記号が入っていようと、もう命令の構造を変える隙間がない。値はあくまで値の置き場所に収まるだけ。データがコードに昇格する経路そのものが、構造的に閉じられてるんだ。
ここがポイントなんだけど、よくある誤解として「入力をチェックして危ない記号を消せばいいんでしょ?」っていうのがある。入力時にクォートを削ったり、危ない文字を弾いたり。これね、補助としてはいいんだけど、本筋じゃないんだ。なぜなら「危ない記号のリスト」を人間が完全に作りきるのは無理だから。エンコーディングの違いとか、想定外の組み合わせで、いつか必ず漏れる。それに対してパラメータ化クエリは、記号を消そうとするんじゃなくて、そもそも値とコードを混ぜない。混ぜないものは、分離する必要もない。だから「危ない記号を探して消す」発想じゃなくて、「最初から混ぜない」発想が根本対策になるんだ。これ、僕らがやってる003のAI導入支援、つまり他社の社長の判断データや顧客情報をAIに預かるビジネスだと、もう絶対に妥協できないところでね。顧客データを引くクエリで文字列連結を一個でもやってたら、その一個から全顧客のデータが抜ける可能性があるわけ。だから「全クエリ、例外なくパラメータ化」が最低ラインになる。
さて、同じ目で次はXSSを見てみよう。クロスサイトスクリプティングって名前だけど、構造はまったく同じ病気だよ。今度の舞台はブラウザのHTMLなんだ。たとえば、ユーザーが投稿したコメントを、そのまま画面に表示するページがあるとする。ユーザーが「こんにちは」って書けば「こんにちは」と出る。でも、もしユーザーがコメント欄にHTMLのスクリプトタグごと書き込んだら?それをそのまま画面に流し込むと、ブラウザはそれを「ただの文字」じゃなくて「実行すべきプログラム」として読んじゃう。さっきのSQLとまったく同じだよね。データとして表示したかった文字列が、コードに昇格してる。境界が壊れてる。これが起きると、他人の画面で勝手にスクリプトが走って、ログイン情報を盗まれたりする。
じゃあXSSの根本対策は何かというと、「出力時のエスケープ」なんだ。エスケープって言葉、これも噛み砕くね。エスケープっていうのは、「特別な意味を持つ記号を、ただの文字に格下げする変換」のことなんだ。たとえばHTMLの世界では、小なり記号、つまり山括弧の開きが「ここからタグが始まるよ」っていう特別な意味を持ってる。だから、ユーザーの入力に含まれる小なり記号を、画面に出す直前に「これはタグの開始じゃなくて、ただの小なりって文字を表示したいだけだよ」っていう別の表記に置き換える。そうするとブラウザは、それをタグとして解釈しなくなる。コードに昇格する経路がふさがれて、ただのデータとして安全に表示される。これがエスケープの正体なんだ。
ここで、すごく大事な問いがある。さっきSQLでは「入力時にチェックするより、根本対策のほうがいい」って言ったよね。XSSでも似た話があって、「入力された時点で危ない記号を消しておけばいいんじゃないの?」って思うかもしれない。これを入力時サニタイズって言うんだけど、XSSではこれが特に本筋じゃないんだ。理由がね、すごく本質的で面白い。それは「安全な形は、出力する場所によって変わるから」なんだ。同じユーザー入力でも、HTMLの本文に出すときと、HTMLの属性の中に出すときと、JavaScriptのコードの中に出すときと、URLの一部に出すときで、「何を変換すれば安全か」が全部違う。HTMLの本文では山括弧が危ないけど、URLの中ではまた別の記号が危ない、みたいに、危ないものの定義が文脈ごとに変わるんだ。
だから入力の時点で「えいっ」と一回だけ変換しちゃうと、その変換はどこか一つの文脈にしか合ってない。別の場所で使われた瞬間に、守れてなかったり、逆に表示が壊れたりする。それに対して、出力する瞬間にエスケープすれば、「今この値をHTML本文に出すんだから、HTML本文用の変換をかける」って、その場の文脈に合った正しい変換を選べる。データは元のまま生で持っておいて、外に出す一歩手前で、出す先に応じて服を着替えさせる。これが「入力時サニタイズより出力時エスケープが本筋」っていうことの中身なんだ。一回の入力に対して、出力先は何種類もありうる。だから守る場所は入口じゃなくて、それぞれの出口なんだよ。
三つ目のコマンドインジェクションも、もう説明いらないくらい同じ構造だよ。これは、プログラムの中からOSのコマンドを呼ぶときに起きる。ユーザーが入れたファイル名を使って、シェルでコマンドを組み立てて実行する、みたいなケース。やっぱり文字列連結でコマンドを作ると、ユーザーがコマンドの区切り記号、たとえばセミコロンとかパイプを紛れ込ませて、別のコマンドを追加実行できちゃう。データのフリをしてコードに化ける。まったく同じ病気だ。だから対策も同じ発想で、シェルに一本の文字列として丸投げしないで、コマンド本体と引数を、最初から別々の配列として渡す。そうすればユーザーの値はあくまで一個の引数として扱われて、コマンドの構造を書き換えられない。SQLのプレースホルダと、思想が完全に同じだよね。
それと、忘れちゃいけない多層防御も一個だけ足しておく。インジェクションの根本対策はこの「混ぜない」なんだけど、それに加えて、データベースに繋ぐユーザーの権限を絞っておく。読み取りだけでいい処理なら読み取り専用にしておけば、万が一インジェクションが一箇所抜けても、データを消したり書き換えたりはされない。第1章でやった最小権限が、ここでも被害を局限する二枚目の壁になる。
で、実装ではどうするか。ここまで来たら一本にまとまる。覚えることは三つの攻撃名じゃなくて、一つの原則なんだ。「データとコードを、文字列連結で混ぜるな。最初から別々の引数として渡せ」。SQLならパラメータ化クエリ、OSコマンドなら引数を配列で渡す。そして、どうしてもデータをコードの文脈に出さなきゃいけない唯一の場所、つまり画面表示のときは、出力する瞬間に、出す先の文脈に合わせてエスケープする。入口で頑張って消すんじゃなくて、出口でそれぞれ着替えさせる。この二つだけ。これが、三つの脆弱性をまとめて根絶やしにする、たった一本の筋なんだ。
この章のまとめ(実装で守る一線)
クエリもOSコマンドも、文字列連結で組まない。値は必ずプレースホルダや引数配列で、データとコードを別ルートで渡す。これでデータがコードに昇格する経路を構造的に閉じる。画面表示だけはデータをコード文脈に出さざるを得ないので、入力時に消すのではなく、出力する瞬間に出力先の文脈(HTML本文・属性・JS・URL)に合ったエスケープをかける。守る場所は入口ではなく、それぞれの出口。顧客データを扱う003では、この一線を一箇所でも破ると全件流出に直結すると心得る。