なぜSQLインジェクションは20年以上現役の攻撃なのか
セキュリティの世界に「古典的」と呼ばれながらも根絶できない脆弱性がいくつかあります。SQLインジェクション(SQLi)はその筆頭格です。1998年頃に広く認識されるようになってから四半世紀が経つ今も、OWASP Top 10の「Injection」カテゴリはトップクラスの常連であり続けています。IPA(情報処理推進機構)が毎年発表する「情報セキュリティ10大脅威」でも、Webアプリケーションの脆弱性として繰り返し取り上げられています。
なぜなくならないのか。理由は単純です。SQLを動的に組み立てるコードは今も書かれ続けており、フレームワークを使っていても「便利な抜け道」を使った瞬間に無防備になるからです。本記事では仕組みの理解から、実装レベルの対策、そしてよくある誤解まで、体系的に整理します。
SQLインジェクションの仕組み
SQLインジェクションは、アプリケーションがユーザー入力をSQL文の一部としてそのまま組み立てるときに発生します。典型的な脆弱なコードを見てみましょう。
# 脆弱なコード例(Python)
username = request.form['username']
password = request.form['password']
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
db.execute(query)
ユーザーが username に ' OR '1'='1 を入力するとどうなるでしょうか。組み立てられるSQL文は次のようになります。
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = ''
'1'='1' は常に真なので、WHERE句全体が真になり、すべてのユーザーレコードが返ってしまいます。パスワードを知らなくてもログインできてしまうわけです。
さらに悪意ある攻撃者は '; DROP TABLE users; -- のような入力でテーブルを削除したり、UNION SELECT 句を使って別テーブルのデータを引き出したりすることもできます。
攻撃の種類と影響
SQLインジェクションにはいくつかのバリエーションがあり、それぞれ影響の出方が異なります。
| 種類 | 概要 | 攻撃者が得るもの |
|---|---|---|
| クラシックSQLi | エラーメッセージや結果がレスポンスに出る | DB構造・データの直接取得 |
| ブラインドSQLi(Boolean型) | 真偽によってレスポンスが変わる | 条件分岐でデータを1ビットずつ推測 |
| ブラインドSQLi(Time-based) | SLEEP()等の時間差でTrue/Falseを判断 | エラーもレスポンス差もない環境でも悪用可能 |
| エラーベースSQLi | 意図的にエラーを起こしてDB情報を取得 | DBMSのバージョン・テーブル名などのメタ情報 |
| UNION型SQLi | UNION句で別テーブルの結果を合流 | 任意テーブルのデータ取得 |
ブラインドSQLiは「レスポンスを見れば安全」という誤解を突いてきます。エラーメッセージを非表示にしても、レスポンスの内容や応答時間の差から情報を盗み出せるのです。
根本的な対策:プリペアドステートメント
SQLインジェクションへの最も根本的な対策はプリペアドステートメント(パラメータ化クエリ)の使用です。SQL文の構造をあらかじめDBに送り、後からパラメータだけを渡すことで、入力値がSQL構文として解釈されなくなります。
Pythonでの実装例
# 安全なコード(プリペアドステートメント)
cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password)
)
%s のプレースホルダーにユーザー入力を渡すことで、どんな文字列を入力されてもSQL構文として扱われません。' OR '1'='1 を渡しても、それはただの文字列として比較されるだけです。
JavaでのPreparedStatement
// 安全なコード(Java)
String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, username);
stmt.setString(2, password);
ResultSet rs = stmt.executeQuery();
PHPでのPDO
// 安全なコード(PHP PDO)
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->execute([$username, $password]);
どの言語・フレームワークでも、「SQL文字列にユーザー入力を直接連結しない」というルールを徹底することが最重要です。
ORMを使っていれば安全か?
「ORMを使っているから大丈夫」という考え方は、半分正解で半分誤りです。DjangoのORMやLaravelのEloquentなどは、デフォルトではパラメータ化クエリを使用するため安全です。しかし、「生のSQLを書ける機能」を使うと途端に危険になります。
# Django - 安全
User.objects.filter(username=username)
# Django - 危険(raw()に直接文字列を連結している)
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'")
// Laravel - 安全
User::where('username', $username)->get();
// Laravel - 危険(whereRaw に直接連結)
User::whereRaw("username = '$username'")->get();
ORMが提供する「生SQL実行機能」を使う際は、必ずバインディング(パラメータ束縛)を使うように徹底しましょう。
多層防御:プリペアドステートメントだけに頼らない
プリペアドステートメントは非常に強力ですが、それだけで完結と思うのは危険です。多層防御の考え方で補完的な対策を重ねましょう。
入力バリデーション
数値のみが期待されるフィールドには整数型チェックを行い、文字列長の上限を設定します。バリデーションはSQLiの直接的な防止策ではありませんが、不正な入力をアプリケーション層で早期排除できます。
最小権限の原則(DBユーザーの権限制御)
アプリケーションが使用するDBユーザーには、必要最小限の権限のみを付与します。SELECTしか必要ないユーザーにDROPやDELETEを許可してはいけません。万が一SQLiが成立しても、被害を最小化できます。
-- アプリ用ユーザーに必要なSELECT/INSERT/UPDATE権限のみ付与
GRANT SELECT, INSERT, UPDATE ON mydb.* TO 'appuser'@'localhost';
-- DROP、DELETE、CREATE等の権限は与えない
エラーメッセージの非表示
本番環境では詳細なエラーメッセージをユーザーに表示しないようにします。DBMSのエラーメッセージにはテーブル名やカラム名などの情報が含まれることがあり、攻撃者に有用な手がかりを与えます。
WAF(Webアプリケーションファイアウォール)
AWS WAFやCloudflareのWAFは、既知のSQLiパターンをリクエスト段階でブロックします。完全な防御にはなりませんが、自動スキャンツールによる攻撃を減らす効果があります。
脆弱性の検出方法
実装後は、実際に脆弱性が残っていないかテストする習慣をつけましょう。
手動テストでは、入力フィールドに '(シングルクォート)を入力してエラーが出るか確認するだけでも有効です。エラーが返ってきたり、レスポンスの挙動が変わったりする場合は要注意です。
自動スキャンツールとしては、sqlmapがよく使われます。URLやパラメータを指定するだけで、さまざまなSQLiパターンを試してくれます(自社の開発・検証環境でのみ使用してください)。
静的解析(SAST)ツールをCI/CDパイプラインに組み込み、危険な文字列連結パターンをコードレビュー段階で検出する体制を整えることも有効です。
まとめ
SQLインジェクションは「古くからある脆弱性」ですが、今も現役の脅威であり続けています。対策の基本はシンプルです。ユーザー入力をSQL文字列に直接連結せず、必ずプリペアドステートメントを使うこと。ORMを使う場合でも生SQL実行機能には注意を払うこと。DBユーザーの権限を最小限に絞ること。この3点を守るだけで、SQLインジェクションのリスクを大幅に低減できます。
「ORMを使っているから安全」「フレームワークが守ってくれる」という思い込みを捨て、データベースアクセスのコードを書くたびに「これは安全なクエリの組み立て方か?」と問いかける習慣こそが、長期的なセキュリティ品質を支えます。