SQLインジェクションは、利用者の入力をSQL文へ文字列としてつなげているWebアプリに、細工した文字列を送り込んでデータベースを不正に操作する攻撃です(CWE-89)。この記事では、脆弱なコードと対策後のコードをPHP 8.5.8・MySQL 8.4.8・SQLite・Python・Javaで実際に動かした結果を示し、攻撃が成立する条件、プレースホルダで防げる理由と防げない箇所、sqlmapによるテストの手順までを扱います。情報処理技術者試験の「SQLインジェクション攻撃を防ぐ方法はどれか」の正答も解説します。
まとめ:SQLインジェクションの原因と対策の結論
- 原因:入力値をSQL文に連結していること。入力の一部がSQLの構文として解釈され、WHERE句やコメント、UNIONで文の意味が書き換わる。
- 根本対策はプレースホルダ(プリペアドステートメント)。IPAの「安全なウェブサイトの作り方」も根本的解決に位置づけている。
- PHPのPDO_MYSQLは既定で動的プレースホルダ(エミュレーション)になる。
PDO::ATTR_EMULATE_PREPARES => falseで静的プレースホルダに切り替える。 - 列名・テーブル名・ORDER BYの並び順指定は値としてバインドできない。許可リストで固定値に置き換える。LIKEのワイルドカードは別途エスケープが要る。
- WAF・最小権限・エラー非表示は保険的対策。根本対策の代わりにはならない。エスケープ(DBのAPIでリテラルを正しく組み立てる方法)はIPAも根本的解決に含めるが、書き漏れが起きやすいのでプレースホルダを優先する。
- テストは自分が管理する環境で、手動の入力確認とsqlmapなどのツールを組み合わせる。
SQLインジェクションの仕組みと発生状況
入力がSQL文の構造に変わる仕組み
次のPHPコードは、URLパラメータの値をそのままSQL文に埋め込んでいます。
// 脆弱なコード:入力を文字列連結している
$name = $_GET['name'];
$sql = "SELECT id, name FROM users WHERE name = '$name'";
$rows = $pdo->query($sql)->fetchAll();
name に ' OR '1'='1 を送ると、組み立てられるSQLは WHERE name = '' OR '1'='1' になります。'1'='1' は常に真なので、条件が無効化されて全件が返ります。PHP 8.5.8+SQLite 3.54.0で3件の users テーブルに対して実行した結果は次のとおりです。
[連結・条件1つ] 入力=' OR '1'='1 => 3件
ただし、この文字列がいつでも効くわけではありません。後ろに AND pass = '…' が続くログイン処理に同じ入力を入れると、ANDがORより先に評価されて name = '' OR ('1'='1' AND pass = 'wrong') となり、結果は0件でした。攻撃者はこうした場合、後続の条件をコメントで消す入力に切り替えます(次章の認証回避)。「’ OR ‘1’=’1 を入れて何も起きなかったから安全」とは判断できないのはこのためです。
[連結] 入力=' OR '1'='1 => 0件 []
実行SQL: SELECT id, name FROM users WHERE name = '' OR '1'='1' AND pass = 'wrong'
[連結] 入力=admin'-- => 1件 ["admin"]
実行SQL: SELECT id, name FROM users WHERE name = 'admin'--' AND pass = 'wrong'
CWE Top 25・OWASP Top 10での位置づけ
米MITREが公開するCWE Top 25の2025年版で、CWE-89(SQLインジェクション)は2位です(2024年版は3位)。OWASP Top 10:2025では、SQLインジェクションを含む「Injection」がA05に置かれ、2021年版の3位から5位に下がりました。順位は下がっても、上位から外れてはいません。
国内の数字では、IPAとJPCERT/CCの2026年第3四半期の届出状況で、2004年7月8日から2026年9月末までのウェブサイト脆弱性の累計届出のうち、不受理を除く13,360件ではSQLインジェクションが11%を占め、クロスサイト・スクリプティング(56%)に次ぐ2番目です。IPAが通知してから90日以上修正の報告が無い1,067件でも、約15%がSQLインジェクションでした。
SQLインジェクションの攻撃例と種類
認証回避:SQLコメントによるパスワード条件の除去
上の実行結果のとおり、ユーザー名に admin'-- を入れると -- 以降がコメントになり、パスワードの照合が消えます。パスワードを知らないまま管理者として1件が返り、ログインが成立します。MySQLでは -- の直後に空白か制御文字が必要なので、攻撃者は admin'-- (末尾に空白)や admin'# を使います。
UNIONベース:別テーブルの情報取得と画面への出力
商品詳細を SELECT name, price FROM items WHERE id = $id で表示するページに、id=0 UNION SELECT email,pass_hash FROM members を送ると、商品名と価格の欄に会員テーブルの値が表示されます。手元のPHPの組み込みサーバーで脆弱版と、プレースホルダを使う安全版に同じ値を送った応答です。
vuln.php?id=1 → pen : 120円
vuln.php?id=0 UNION SELECT email,pass_hash FROM members → [email protected] : $2y$10$abc円
safe.php?id=0 UNION SELECT email,pass_hash FROM members → 該当なし
元のクエリと列数・型を合わせる必要があるため、攻撃者は ORDER BY 1、ORDER BY 2… と増やしてエラーになる位置から列数を割り出します。
エラーベース・ブラインド・アウトオブバンドの情報取得経路
- エラーベース:DBのエラーメッセージに値を含めさせて読み出す。本番でSQLエラーを画面に出していると成立する。
- ブラインド:結果もエラーも画面に出ない場合に、「真のときと偽のときで応答が変わるか」(ブール型)や「
SLEEP()で応答が遅れるか」(時間型)を手がかりに1文字ずつ値を推定する。ツールで自動化されるため、エラーを隠しても防げない。 - アウトオブバンド:DBサーバーにDNSやHTTPの通信を発生させ、その通信で値を外へ持ち出す。DBからの外向き通信を許している環境で成立する。
スタッククエリ:PDO_MYSQLの複数文実行と無効化設定
入力に ; で区切った別の文を続けると、SELECTの後にINSERTやDELETEを実行させられることがあります。成立するかはドライバで違います。PDO_MYSQLの query() に連結したSQLを渡すと、MySQL 8.4.8に2文が順に届き、INSERTが実行されました。Pythonの sqlite3 は1回の execute() で1文しか受け付けず、例外になりました。
// PDO_MYSQL(PHP 8.5.8 / MySQL 8.4.8)の一般ログ
Query SELECT name FROM users WHERE id = 1;
Query INSERT INTO users(name,pass) VALUES('evil','x')
# Python 3.15 / sqlite3
複数文: ProgrammingError You can only execute one statement at a time.
PDOで複数文を止めるには、PHP 8.4以降は Pdo\Mysql::ATTR_MULTI_STATEMENTS => false を接続時に渡します(PHP 8.5では旧名の PDO::MYSQL_ATTR_MULTI_STATEMENTS が非推奨の警告を出しました)。ただしこれは被害を1文に限る保険で、連結そのものをやめない限りデータの読み出しは防げません。
SQLインジェクションによる情報流出事例とWordPressの脆弱性
2026年10月6日、業務用冷凍食品メーカーのアクティブは、10月2日に通販サイトの特定のページがSQLインジェクション攻撃を受け、顧客情報が最大54,916件流出した可能性を公表しました。調査では、不正に作成された管理者アカウントを経由して顧客データと受注データが出力された形跡が見つかっています。2025年6月30日には学悠出版が、愛知全県模試のサーバーへのSQLインジェクションによる不正アクセスを公表し、模試受験者に関する約28万2千件(重複を含む)などが流出した可能性があるとしました。公表によると、氏名は暗号化されており、復号鍵は不正取得されていません。
2026年10月には、アンケートサイトQzooを運営するモニタスが、SQLインジェクションによる不正アクセスで会員約40万件の情報が漏えいした可能性を公表しました。この事案ではデータの閲覧に加えて更新まで起きており、その構造と止め方はQzoo不正アクセス:40万件の対象項目と会員の対処、SQLインジェクション対策で再現しています。
独自開発のサイトに限った話ではありません。WordPressではコアのWP_Queryに起因するSQLインジェクション(CVE-2026-60137)が見つかり、6.8.6・6.9.5・7.0.2で修正されました。別の脆弱性と連鎖させた攻撃の流れはwp2shellとは?WordPress未認証RCE(CVE-2026-63030・CVE-2026-60137)の仕組みと影響バージョンで解説しています。
SQLインジェクションとXSSの違い
どちらも入力値が原因の脆弱性ですが、入力が解釈される場所が違います。SQLインジェクションはサーバー側のDBでSQLとして、XSS(クロスサイトスクリプティング)は閲覧者のブラウザでHTML・JavaScriptとして解釈されます。
| 観点 | SQLインジェクション | XSS |
|---|---|---|
| 入力が解釈される場所 | サーバーのDB | 閲覧者のブラウザ |
| 解釈される言語 | SQL | HTML・JavaScript |
| 主な被害 | 情報窃取・改ざん・認証回避 | セッション奪取・なりすまし |
| 根本対策 | プレースホルダ | 出力先に応じたエンコード等 |
XSSとCSRFの違いはXSSとCSRFの違いとは?仕組み・被害・対策を実装レベルで徹底比較で、入力がシェルに解釈される同系統の攻撃はOSコマンドインジェクションとは?仕組みと引数配列で防ぐ実装で扱っています。
SQLインジェクション対策の実装コードと適用条件
プレースホルダによる値のバインドと3言語の実装例
プレースホルダは、SQL文の値を入れる場所を ? などの記号にしておき、値は後から別に渡す方式です。値はSQLの構文として解釈されないので、' OR '1'='1 も「そういう名前の文字列」として比較されるだけです。IPAの安全なウェブサイトの作り方は、根本的解決の最初に「SQL文の組み立ては全てプレースホルダで実装する」(1-(i)-a)を挙げ、エラーメッセージを表示しないこと(1-(iii))とDBアカウントに適切な権限を与えること(1-(iv))は保険的対策に分けています。
// PHP(PDO)
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE name = ?');
$stmt->execute([$name]);
# Python(sqlite3は ?、psycopg・PyMySQLは %s)
cur.execute("SELECT id, name FROM users WHERE name = ?", (name,))
// Java(JDBC)
PreparedStatement ps = con.prepareStatement(
"SELECT COUNT(*) FROM users WHERE name = ?");
ps.setString(1, name);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
System.out.println(rs.getInt(1));
}
3言語とも、' OR '1'='1 を渡したときの件数を連結版と比べました。
| 実行環境 | 文字列連結 | プレースホルダ |
|---|---|---|
| PHP 8.5.8 + SQLite 3.54.0 | 3件 | 0件 |
| Python 3.15.0 + SQLite 3.54.0 | 3件 | 0件 |
| Java 25 + H2 2.4.240 | 3件 | 0件 |
「バインド」という言葉はプログラミング全般で別の意味でも使われます。用語の整理はバインド(binding)とは?プログラミングでの意味・代入との違いを参照してください。
静的プレースホルダと動的プレースホルダの違い
IPAの安全なSQLの呼び出し方は、プレースホルダを2種類に分けています。静的プレースホルダは、? のままのSQL文を先にDBエンジンへ送って構文解析を済ませ、値はDBエンジン側でバインドする方式です。動的プレースホルダは、バインドをアプリ側のライブラリの中で行う方式です。
PHPのPDO_MYSQLは、何も指定しないと動的プレースホルダ(エミュレーション)で動きます。PHPマニュアルにも「PDO_MYSQL uses emulated prepares by default.」とあり、手元で getAttribute(PDO::ATTR_EMULATE_PREPARES) を読むと true でした。同じ prepare() と execute() を、設定だけ変えて実行したときのMySQL一般ログです。
// ATTR_EMULATE_PREPARES = true(既定):PHP側で値を埋めた1本のSQLが届く
Query SELECT name FROM users WHERE name = 'x\' OR \'1\'=\'1'
// ATTR_EMULATE_PREPARES = false:先に ? 付きの文、後から値
Prepare SELECT name FROM users WHERE name = ?
Execute SELECT name FROM users WHERE name = 'x\' OR \'1\'=\'1'
どちらも結果は0件で、この入力では差は出ません。差が出るのは、エスケープの処理が文字コードの扱いを誤った場合などです。動的プレースホルダはPHP側のエスケープに安全性を預けるので、MySQLを使うなら静的プレースホルダに切り替えておきます。
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
DSNの charset は必ず指定します。エミュレーションのまま接続後に SET NAMES で文字コードを変えると、PHP側のエスケープは接続時の文字コードのまま動き、DBとの解釈がずれます。
プレースホルダの制約:識別子の選択とLIKEのワイルドカード
プレースホルダが置けるのは「値」の位置だけです。並び替えの列名を利用者に選ばせる画面で ORDER BY ? と書くと、エラーにはならず、定数で並べたのと同じになります(意図した並び替えにならない)。
ORDER BY ? に 'name' をバインド => ["bob","admin","alice"] ← 登録順のまま
ORDER BY name(列名を直書き) => ["admin","alice","bob"]
列名・テーブル名・ASC/DESCは、受け取った値をそのまま使わず、許可リストで固定の文字列に置き換えます。リストに無い値は既定値に倒します。
$allowed = ['name' => 'name', 'id' => 'id'];
$col = $allowed[$_GET['sort'] ?? ''] ?? 'id'; // リストに無ければ id
$rows = $pdo->query("SELECT name FROM users ORDER BY $col DESC")->fetchAll();
実行すると、sort=name; DROP TABLE users を送っても ORDER BY id DESC で実行されました。入力値の許可リスト設計は入力バリデーションとは?防げる攻撃の範囲と許可リスト設計で詳しく扱っています。
LIKE検索はプレースホルダで書けますが、入力の % と _ はワイルドカードとして効いたままです。「100%」で前方一致を探したつもりが 1000 まで一致しました。SQLインジェクションではありませんが、検索結果が意図より広がるので、ワイルドカードをエスケープしてから渡します。
$esc = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $kw);
$st = $pdo->prepare("SELECT name FROM users WHERE name LIKE ? ESCAPE '!'");
$st->execute([$esc . '%']);
// エスケープ前: ["100%_off","1000","100!x"] エスケープ後: ["100%_off"]
エスケープ文字に \ ではなく ! を使っているのは、MySQLは文字列リテラル中の \ 自体をエスケープ文字として扱うため ESCAPE '\\' と二重に書く必要があり、DBごとに書き方が変わるためです。! ならSQLite 3.54.0とMySQL 8.4.8の両方で同じ結果になりました。
ORM・ストアドプロシージャでも残る穴
ORMやクエリビルダでは、値をバインドするAPIを使います。ただし、列名や並び順などは値としてバインドできないため、通常のAPIでも入力を許可リストで制限します。もう1つの穴は、生SQLを書けるメソッドに文字列を連結した場合で、ORMを使っていても素のSQLと同じく注入できます。
// Laravel:危険(連結)
DB::select("SELECT * FROM users WHERE name = '$name'");
// Laravel:安全(バインド)
DB::select('SELECT * FROM users WHERE name = ?', [$name]);
User::whereRaw('name = ?', [$name])->get();
# Django:危険(f文字列)
User.objects.raw(f"SELECT * FROM app_user WHERE name = '{name}'")
# Django:安全(params)
User.objects.raw("SELECT * FROM app_user WHERE name = %s", [name])
ストアドプロシージャも、内部で文字列を連結して動的SQLを実行していれば同じ穴が開きます。使いどころはストアドプロシージャとは?MySQL・SQL Server・PostgreSQLでの書き方と使うべき場面を参照してください。
SQLリテラルのエスケープとプレースホルダを優先する理由
エスケープは ' などの特殊文字を無害な表記に置き換える処理です。IPAは、文字列連結でSQLを組み立てる場合には、エスケープ処理等を行うデータベースエンジンのAPIを使ってリテラルを正しく構成すること(1-(i)-b)を求めています。ただ、書き忘れが1か所でもあれば破られ、DBや文字コードごとに置き換える文字も違います。プレースホルダを使えない理由が無い限り、エスケープを主対策にはしません。使う場合も自作せず、DBドライバが用意する関数を使います。
保険的対策:最小権限・エラー非表示・WAF
- DBアカウントの最小権限:アプリ用ユーザーには、必要なテーブルと操作に限って権限を与える。参照専用ならSELECTのみとし、INSERT・UPDATE・DELETE等は機能上必要な場合だけ許可する。破られたときに読める範囲・壊せる範囲が狭まる。
- SQLエラーを画面に出さない:SQLエラー経由の情報漏えいを抑える。ただし、応答内容や時間差による列数・DB製品の推定は残る。エラーはアクセスを制限したログに記録する。
- WAF:攻撃らしい文字列の通信を遮断する。すぐに直せない既存システムを守る手段にはなるが、表記を変えた攻撃は通り抜ける。仕組みと選び方はWAFとは?仕組み・ファイアウォール/IPS・IDSとの違いと企業の選び方を参照。
SQLインジェクションのテスト方法
テストは自分が管理する環境、または書面で許可を得た対象にだけ行います。許可の無いサイトへの攻撃的な入力は、不正アクセス禁止法などに触れるおそれがあります。
手動で確かめる入力と見るべき反応
| 入力 | 脆弱な場合の反応 |
|---|---|
' |
SQLエラー・500エラー |
1 AND 1=1 と 1 AND 1=2 |
2つの応答内容が変わる |
' OR '1'='1 |
件数が増える |
admin'-- |
パスワード無しでログイン |
1 AND SLEEP(5)(MySQL) |
応答が5秒以上遅れる |
数値のパラメータには 1 AND 1=1 型、文字列のパラメータには ' を含む型が効きます。前章のとおり、1つの入力で反応が無いだけでは安全と言えません。
sqlmapによる自動検査の手順と検出結果
sqlmapはSQLインジェクションの検出と、データの読み出しまでを自動で試すオープンソースのツールです。PyPIの最新版は1.10.10(2026年10月5日公開)で、ここでの検証もこの版で行いました。検証には、商品テーブル(items)と会員テーブル(members)を持つSQLiteのファイルと、IDで商品を1件表示する2つのPHPファイルを用意しました。
# 検証用DBの作成(items 2件・members 1件のダミーデータ)
sqlite3 shop.db "CREATE TABLE items(id INTEGER PRIMARY KEY, name TEXT, price INT);
INSERT INTO items(name, price) VALUES ('pen',120),('note',300);
CREATE TABLE members(id INTEGER PRIMARY KEY, email TEXT, pass_hash TEXT);
INSERT INTO members(email, pass_hash) VALUES ('[email protected]','\$2y\$10\$abc');"
<?php // app/vuln.php(脆弱版:連結)
$pdo = new PDO('sqlite:' . __DIR__ . '/../shop.db');
$id = $_GET['id'] ?? '1';
$row = $pdo->query("SELECT name, price FROM items WHERE id = $id")->fetch(PDO::FETCH_ASSOC);
echo $row ? "{$row['name']} : {$row['price']}円" : "該当なし";
<?php // app/safe.php(安全版:静的プレースホルダ)
$pdo = new PDO('sqlite:' . __DIR__ . '/../shop.db', null, null, [PDO::ATTR_EMULATE_PREPARES => false]);
$st = $pdo->prepare('SELECT name, price FROM items WHERE id = ?');
$st->execute([$_GET['id'] ?? '1']);
$row = $st->fetch(PDO::FETCH_ASSOC);
echo $row ? "{$row['name']} : {$row['price']}円" : "該当なし";
PHPの組み込みサーバーをローカルだけで待ち受けるように起動し、2つのURLに同じオプションでsqlmapを実行します。
php -S 127.0.0.1:8731 -t app
pip install sqlmap==1.10.10
sqlmap -u "http://127.0.0.1:8731/vuln.php?id=1" --batch --dbms=SQLite --tables
sqlmap -u "http://127.0.0.1:8731/safe.php?id=1" --batch --dbms=SQLite
脆弱版では19回のリクエストで注入点を特定し、ブール型ブラインドとUNIONの2手法でテーブル一覧まで読み出しました(出力の一部)。
sqlmap identified the following injection point(s) with a total of 19 HTTP(s) requests:
Type: boolean-based blind
Payload: id=1 AND 6309=6309
Type: UNION query
Title: Generic UNION query (NULL) - 2 columns
back-end DBMS: SQLite
[2 tables]
| items |
| members |
プレースホルダを使う安全版では、次の結果で終わりました。
[WARNING] GET parameter 'id' does not seem to be injectable
[CRITICAL] all tested parameters do not appear to be injectable.
既定の --level 1 --risk 1 は検査の範囲が狭く、Cookieやヘッダーの値は対象外です。ログイン後の画面はブラウザやプロキシで記録したリクエストを -r request.txt で渡します。検査は本番データを書き換えるおそれがあるので、検証環境に向けて実行します。
練習環境・総合スキャナ・外部診断
攻撃と対策を手で試すなら、意図的に脆弱に作られたDVWAが使えます。導入手順はDVWAとは?脆弱性を安全に学べる環境構築とセキュリティレベルの使い方にまとめています。画面全体をSQLインジェクション以外の脆弱性も含めて検査するなら、ZAP(旧称OWASP ZAP・最新は2.17.0)やBurp Suiteのような総合スキャナが向いています。ZAPの検査項目はOWASP ZAPの診断項目とは?検出できる脆弱性一覧とOWASP Top 10 2025対応・使い方、Burp SuiteはBurp Suiteとは?通信傍受の仕組みとエディションの選び方・初期設定で解説しています。ただしツールは画面遷移や権限の組み合わせまでは読み切れません。リリース前や大きな改修の後は、第三者の診断で、画面遷移や権限ごとのアクセス制御を含む範囲を確認することを推奨します。費用と進め方は脆弱性診断とは?種類・費用相場・進め方と外注時の判断基準で整理しています。自社での実施が難しい場合は脆弱性診断・セキュリティ診断にご相談ください。
情報処理試験「SQLインジェクション攻撃を防ぐ方法はどれか」の正答
この検索で探されている問題は、IPAが公開している平成25年度春期 基本情報技術者試験 午前 問40です。選択肢と、それぞれが何への対策かを並べます。
| 選択肢 | 内容(要約) | 何への対策か |
|---|---|---|
| ア | 入力中の文字を特別な意味の文字として解釈させない | SQLインジェクション(正答) |
| イ | HTMLタグを別の文字列に置き換える | クロスサイト・スクリプティング |
| ウ | 上位ディレクトリ指定(../)を受け付けない | ディレクトリ・トラバーサル |
| エ | 長さの上限を超える入力を受け付けない | SQLインジェクションは防げない |
正答はアです(IPAの解答例)。アの文言はエスケープの考え方ですが、プレースホルダも「入力をSQLの構文として解釈させない」仕組みなので、同じ考え方に入ります。エの長さ制限は、admin'-- のような短い入力で攻撃が成立するため対策になりません。別の回で言い回しが変わっても、「入力がSQLの構文として解釈されないようにする」趣旨の選択肢を選べば正答にたどり着けます。
よくある質問
SQLインジェクションの目的は何ですか?
個人情報や認証情報の窃取が中心です。ほかにデータの改ざん・削除、Webページの改ざん、管理者権限の取得があり、侵入の足がかりにも使われます。
「’ OR ‘1’=’1」は何をしているのですか?
WHERE句に常に真になる条件を加えて、本来の絞り込みを無効にしています。条件が1つの検索なら全件が返ります。後ろにANDの条件が続くと効かないことがあり、その場合はコメント(--)で後続を消す入力が使われます。
エスケープ処理だけで対策として十分ですか?
DBの仕様に従ってすべてのリテラルを正しく組み立てられれば対策になります。ただ、1か所の処理漏れや文字コードの扱いの誤りで破られるため、プレースホルダを主対策にし、エスケープはLIKEのワイルドカードなど、プレースホルダでは扱えない部分の補助に使います。
プレースホルダとは何ですか?
SQL文の中で、後から値を入れる場所を示す記号です。? や :name の形で書き、値はSQL文とは別に渡します。DBに値をSQLの構文として解釈させないので、SQLインジェクションを根本から防げます。
SQLインジェクションの攻撃を検知するには?
WAFやIDS/IPSの検知ログに加え、DBのクエリログとアプリのエラーログで、急に増えたSQLエラーや UNION・SLEEP を含む要求を監視します。検知は発生後の対応なので、プレースホルダで発生そのものを止めるのが先です。