「動くコード」と「安全なコード」は別物である
Webアプリケーションの開発を始めたばかりの頃、筆者が最初に書いたログイン機能は「動く」という意味では完璧でした。ユーザーIDとパスワードを入力すれば認証できましたし、実際に本番環境にデプロイもしました。しかしそのコードは、ほんの数行の悪意ある入力によって、データベース全体を丸裸にできる致命的な欠陥を抱えていました。
SQLインジェクションは、OWASP(Open Web Application Security Project)が毎年発表するWebアプリケーション脆弱性ランキングの常連です。2021年版では「インジェクション」カテゴリとして3位にランクインしており、歴史が長く対策方法も広く知られているにもかかわらず、いまだに被害が絶えない脆弱性です。「知らなかった」では済まされない時代になっています。
この記事では、SQLインジェクションがどのような仕組みで発生するのかを具体的なコードで示したうえで、実務で必ず使うべき防御手法を丁寧に解説します。攻撃の仕組みを理解することが、最も確実な防御への第一歩です。
SQLインジェクションの仕組み
脆弱なコードとは何か
SQLインジェクションが発生する根本的な原因は、「ユーザーからの入力をそのままSQL文に埋め込む」ことです。百聞は一見にしかず、まず典型的な脆弱なコードを見てみましょう。
// 脆弱なPHPコード(絶対に使ってはいけない)
$username = $_POST['username'];
$password = $_POST['password'];
$sql = "SELECT * FROM users WHERE username = '" . $username . "'
AND password = '" . $password . "'";
$result = $pdo->query($sql);
if ($result->rowCount() > 0) {
// ログイン成功
}
このコードは、ユーザーが正直に yamada や mypassword123 を入力してくれる前提で書かれています。では、username の入力欄に次の文字列を入力されたらどうなるでしょうか。
' OR '1'='1
このとき組み立てられるSQL文は以下のようになります。
SELECT * FROM users
WHERE username = '' OR '1'='1'
AND password = ''
'1'='1' は常に真であるため、この WHERE 句全体が真になります。つまり、正しいパスワードを知らなくても、データベース上のすべてのユーザーレコードが返却され、最初のユーザー(多くの場合は管理者アカウント)としてログインできてしまいます。
攻撃パターンの種類
SQLインジェクションは一種類ではなく、目的や手法によっていくつかに分類されます。
| 種類 | 概要 | 攻撃者の目的 |
|---|---|---|
| クラシックSQLインジェクション | エラーメッセージや結果を直接確認できる | データの直接取得・改ざん・削除 |
| ブラインドSQLインジェクション | 応答の差異(真偽・遅延)から情報を推測する | データの間接的な窃取 |
| UNIONベース攻撃 | UNION句で別テーブルのデータを取得する | 他テーブルの全件取得 |
特にブラインドSQLインジェクションは、エラーメッセージを非表示にしただけでは防げないため厄介です。攻撃者は SLEEP(5) のようなSQL関数を使ってレスポンスの遅延を引き起こし、それをもとにデータベースの構造や値を少しずつ推測していきます。「エラーを消せば安全」という誤解は非常に危険です。
被害の深刻さを知る
SQLインジェクションが成功した場合、何が起こりうるのかを理解しておくことも重要です。単純なデータ漏洩だけにとどまらず、以下のような被害が報告されています。
- 全データの窃取:ユーザーテーブルからメールアドレス・パスワードハッシュ・個人情報が一括で取得される
- データの改ざん・削除:UPDATE文やDELETE文を注入することで、データの破壊や改ざんが行われる
- 管理者権限の奪取:管理者アカウントのパスワードをリセットして不正ログインされる
- サーバーコマンドの実行:MySQLの
LOAD_FILE()や SQL Server のxp_cmdshellを悪用してOSコマンドを実行される
実際に過去には、数百万件の個人情報が漏洩した大規模事件がSQLインジェクションによって引き起こされています。ECサイトのクレジットカード情報が盗まれた事例も国内外に多数存在します。
防御手法:5つの対策を組み合わせる
SQLインジェクションへの対策は「これだけやれば完璧」という単一の方法は存在せず、複数の手法を組み合わせて多層防御することが基本です。
プリペアドステートメント(最重要)
最も効果的かつ必須の対策が「プリペアドステートメント(prepared statement)」です。SQL文の構造をあらかじめデータベースに送信しておき、パラメータ(ユーザー入力)は後から別途バインドする仕組みです。これにより、ユーザー入力はあくまで「データ」として扱われ、SQL文の一部として解釈されることがなくなります。
先ほどの脆弱なコードをプリペアドステートメントで書き直してみましょう。
// 安全なPHPコード(プリペアドステートメント)
$username = $_POST['username'];
$password = $_POST['password'];
$stmt = $pdo->prepare(
"SELECT * FROM users WHERE username = ? AND password = ?"
);
$stmt->execute([$username, $password]);
if ($stmt->rowCount() > 0) {
// ログイン成功
}
? プレースホルダーの位置にユーザー入力が「データとして」バインドされるため、どのような文字列を入力されても SQL 文の構造が変化することはありません。' OR '1'='1 を入力しても、それはただの文字列として username 列と比較されるだけです。
PDO(PHP Data Objects)を使う場合でも、MySQLi を使う場合でも、プリペアドステートメントは利用できます。ORMを使っている場合(LaravelのEloquentやDjangoのORMなど)は、内部的にプリペアドステートメントが使われているケースがほとんどですが、生のSQL文を書く whereRaw() や raw() を使う際は注意が必要です。
入力値のバリデーション
プリペアドステートメントが主防衛線だとすれば、入力値のバリデーションは第二防衛線です。ユーザーからの入力が「期待するデータ型・形式・範囲」に収まっているかを確認します。
たとえばユーザーIDが整数であることが確実ならば、intval() などで整数型に変換してしまうだけで、SQL注入可能な文字列は入り込めなくなります。メールアドレスならフォーマットチェック、電話番号なら数字のみを許可するホワイトリスト方式のバリデーションが有効です。
# Pythonでの例:IDは整数に変換
user_id = int(request.args.get('id', 0)) # 文字列が来ても整数になる
# メールアドレスの形式チェック
import re
email = request.form.get('email', '')
if not re.match(r'^[^@]+@[^@]+.[^@]+$', email):
abort(400)
エラーメッセージの非表示
データベースのエラーメッセージをそのままユーザーに表示することは避けましょう。エラーメッセージには、テーブル名・カラム名・データベースの種類といった攻撃者にとって有用な情報が含まれていることがあります。
本番環境では必ずエラーメッセージを汎用的な文言に置き換え、詳細はサーバーサイドのログにのみ記録するようにします。
WAF(Webアプリケーションファイアウォール)の導入
WAFは、HTTPリクエストをアプリケーションが受け取る前に検査し、既知の攻撃パターンをブロックするセキュリティ機器・サービスです。AWS WAF、Cloudflare WAF、ModSecurityなどが代表的です。
WAFはあくまで補助的な手段です。WAFを導入したからといってプリペアドステートメントが不要になるわけではありませんが、既知攻撃のブロックや攻撃の可視化に役立ちます。
最小権限の原則
アプリケーションがデータベースに接続する際に使用するアカウントには、必要最小限の権限だけを付与します。たとえば、読み取り専用の操作しかしないレポート機能用のDBユーザーには SELECT 権限だけを与え、DROP や DELETE は付与しません。
万が一SQLインジェクションが成功してしまった場合でも、被害の範囲を最小限に抑えるための「保険」として機能します。
対策チェックリスト
実装を始める前・コードレビュー時に確認すべきポイントをまとめます。
| チェック項目 | 確認内容 |
|---|---|
| プリペアドステートメント | すべてのSQL実行でプレースホルダーを使っているか |
| 生SQL | ORM の raw() / whereRaw() など生SQL混在箇所を確認したか |
| 入力バリデーション | 型・形式・最大長のチェックを実装しているか |
| エラーハンドリング | 本番でDBエラーの詳細が表示されないようになっているか |
| DB権限 | アプリ用DBユーザーに不要な権限を与えていないか |
| WAF | 重要度の高いエンドポイントにWAFが適用されているか |
まとめ
SQLインジェクションは古くからある攻撃手法ですが、今もWebセキュリティ上の重大な脅威であり続けています。その根本原因は「ユーザー入力を信用してSQL文に直接埋め込む」という実装上の誤りであり、プリペアドステートメントを徹底するだけで大多数のケースは防ぐことができます。
セキュリティ対策は「難しくて自分には関係ない」ものではありません。むしろ、今日書いているコードのなかに脆弱性が潜んでいるかもしれない、という感覚を持つことが出発点です。「動けばいい」から「安全に動く」へ。その一歩が、プロのエンジニアとしての信頼につながります。