「バインド変数を使いましょう」と言われて、素直に従ってはいるものの、それが具体的に何を防いでいるのか説明できるかというと、少し怪しい。そういう人は案外多いのではないかと思います。この記事では、SQLインジェクションが成立する仕組みを、実際のクエリの組み立て方に沿って追いかけます。読み終える頃には、「なぜ文字列連結が危険で、なぜバインド変数が安全なのか」を、対策の名前ではなく仕組みのレベルで説明できるようになっているはずです。
SQLは「文字列」として組み立てられる
Webアプリケーションがデータベースに問い合わせるとき、最終的にデータベースへ渡されるのはSQL文という一本のテキストです。たとえばユーザー名でユーザーを検索する処理を、素朴に書くとこうなります。
username = request.get("username")
query = "SELECT * FROM users WHERE name = '" + username + "'"
cursor.execute(query)
username に alice が入れば、実行されるSQLは次のようになります。
SELECT * FROM users WHERE name = 'alice'
問題なさそうに見えます。ここで、username に次の文字列が入ってきたらどうなるでしょうか。
' OR '1'='1
先ほどのコードは、これを疑いもせずそのまま連結します。組み立てられるSQLはこうなります。
SELECT * FROM users WHERE name = '' OR '1'='1'
name = '' は偽になりますが、'1'='1' は常に真です。OR でつながっているので条件全体が真になり、users テーブルの全行が返ってきます。ログイン画面であれば、パスワードを一切知らなくても認証を突破できてしまう、というわけです。
攻撃者はSQLの「文法」を書き足している
ここで起きていることを正確に言うと、攻撃者はデータを送り込んだのではありません。SQLの構文の一部を送り込んだのです。アプリケーション側は username を「値」として扱っているつもりでも、データベース側から見れば、渡されたテキストはあくまでSQL文の一部でしかありません。値のつもりで書いた文字列の中に、たまたま ' や OR といったSQLにとって意味のある記号やキーワードが混ざっていれば、データベースはそれをそのまま構文として解釈します。
この「値のはずが構文として解釈される」というズレこそが、SQLインジェクションの正体です。攻撃者はこのズレを狙って、'; DROP TABLE users; -- のような入力を送り込むこともあります。セミコロンで文を終端し、続けて別の文を書き足し、-- で以降のコメントアウトまで行う。こうなると単なる情報漏洩ではなく、テーブルの破壊にまで発展します。
バインド変数はなぜ安全なのか
対策として真っ先に挙がるのがバインド変数(プレースホルダ)です。先ほどのコードを書き直すとこうなります。
query = "SELECT * FROM users WHERE name = %s"
cursor.execute(query, (username,))
見た目としては、%s の部分に username が「埋め込まれる」だけのように見えるかもしれません。ですが、内部の処理は文字列連結とは根本的に異なります。データベースドライバは、まずクエリの「型」を SELECT * FROM users WHERE name = ? という構文だけの状態でデータベースへ送り、構文として確定させます。そのあとで、username の値を純粋なデータとして別チャンネルで渡します。
つまり、構文の確定とデータの受け渡しが、時間的にも経路的にも分離されているのです。この段階まで来ると、username の中に ' や OR がいくつ混ざっていようと、それらは「文字列の中身」以上の意味を持ちません。データベース側はすでに構文の解釈を終えているので、あとから来た値をSQLとして再解釈しようがない。文字列連結との違いは、エスケープの有無ではなく、構文とデータが交わる場所を最初から作らない、という設計の違いにあります。
それでも文字列連結が消えない理由
ここまで読むと、バインド変数を使わない理由などないように思えます。実際、値の埋め込みに関してはその通りです。ただ、テーブル名やカラム名、ORDER BY の対象列のように、バインド変数の仕組みでは差し込めない部分も存在します。プレースホルダはあくまで「値」の位置に使うものであり、SQLの構文そのものを動的に切り替える用途には使えません。
こうした箇所では、許可された値のリストをアプリケーション側で用意し、ユーザー入力をそのリストと照合してから文字列として組み立てる、という一段階を挟む必要があります。ここを雑にやると、バインド変数を正しく使っていても、ORDER BY の部分だけインジェクションが成立する、という事故が起きます。バインド変数は万能の護符ではなく、値の受け渡し口を安全にする道具だと捉えたほうが実態に近いと思います。
筆者の考え・所感
個人的にSQLインジェクションが面白いと感じるのは、これが「入力チェックの甘さ」という表面的な話ではなく、構文とデータという二つのレイヤーの境界がどこにあるかという、もう少し根の深い設計の問題だという点です。エスケープ処理で個別の記号を潰していくアプローチが根本的に脆いのも、結局は「構文とデータを同じ文字列の中に混ぜたまま、記号だけで区別しようとしている」からで、境界そのものを分離するバインド変数の考え方のほうが筋が良い、というのはコードを書いていて実感するところです。
同じ構造の問題は、SQL以外の場所にも顔を出します。シェルコマンドを文字列連結で組み立てるとコマンドインジェクションになりますし、HTMLに文字列をそのまま差し込めばXSSになる。どれも「本来は値であるはずのものが、たまたま構文として解釈されてしまう」という同じ形をしています。SQLインジェクションの仕組みを一度きちんと理解しておくと、初めて見る種類の脆弱性に出会ったときも、「これは構文とデータの境界がどこかで崩れているのではないか」という視点で当たりをつけられるようになる。これは実務でも何度か役に立った感覚です。
まとめ
- SQLインジェクションは、値のつもりで渡した文字列がSQLの構文として解釈されてしまうことで成立する
- バインド変数が安全なのは、エスケープが賢いからではなく、構文の確定とデータの受け渡しを分離しているから
- テーブル名やカラム名などバインド変数が使えない箇所は、許可リストとの照合で別途守る必要がある