はじめに
今回は、JavaのPreparedStatementでSQLインジェクションを防ぐ方法について、コードの動作を比較しながらログとして記載していきたいと思います。
以前のMySQLで登録・更新・削除する方法では、SQLの?に値を設定する書き方を扱いました。今回は「なぜ入力値をSQLに直接つなげてはいけないのか」「PreparedStatementを使えば、どの部分まで対策できるのか」を確認します。
対象は、Javaの変数やメソッドを学び、DBへの検索処理を書き始めた方です。まず普通の名前を検索し、次に引用符を含む名前、最後にSQLの条件を変えてしまう入力を試します。通常の入力だけでは見えない違いを順番に確かめます。
掲載コードは、この記事のために用意した小さな検証例です。接続先には手元で動く一時的なDBを使い、実際の顧客データや外部のシステムは使用しません。
最初に知っておきたい言葉
最初は、JavaがSQLという命令文をDBへ渡し、DBが検索結果を返す流れをイメージしてください。検索する名前を、画面やAPIから受け取った入力に置き換えると、実際のアプリに近い構成になります。
| 用語 | この記事での意味 |
|---|---|
| データベース(DB) | 名前などのデータを整理して保存する仕組み |
| テーブル | データを行と列で管理する表 |
| SQL | データの検索や更新をDBへ指示する言葉 |
| JDBC | JavaからDBを操作するための共通の仕組み |
| JDBCドライバー | 接続先のDBに対応したライブラリ |
| パラメータ | SQLの命令とは分けて渡す値 |
今回のテーブル名はmembers、名前を保存する列はuser_nameです。次の3件を用意します。O'Reillyは引用符を含む名前を確認するための例です。
| id | user_name |
|---|---|
| 1 | admin |
| 2 | mori |
| 3 | O’Reilly |
adminという文字列を保存していますが、この例にログイン機能や管理者権限はありません。比較するのは、名前による検索条件だけです。
おすすめ本
Amazonで『スッキリわかるJava入門 実践編 第5版』(Kindle版)を見る
文字列を連結すると何が起きるのか
SQLで名前を検索する場合、次のように書けます。SELECTは取得する列、FROMは対象テーブル、WHEREは絞り込む条件です。
SELECT user_name FROM members WHERE user_name = 'mori' ORDER BY id
このSQLは、「membersテーブルから、名前がmoriの行を探し、id順に名前を取得する」という意味です。SQLの文字列はシングルクォート'で囲みます。
検索する名前をJavaのinputという変数に入れ、前後の文字列とつなげると、次のようなコードになります。これは問題を比較するための例です。
String sql = "SELECT user_name FROM members WHERE user_name = '"
+ input + "' ORDER BY id";
Javaの+でSQLを組み立てています。inputがmoriなら意図したSQLになるため、普通の入力だけを試すと、問題がないように見えます。
引用符を含む名前を入れてみる
では、inputがO'Reillyだったらどうなるでしょうか。文字列をそのままつなぐと、次のSQLになります。
SELECT user_name FROM members WHERE user_name = 'O'Reilly' ORDER BY id
名前の中の'がSQLの文字列を囲む記号として解釈され、文字列の区切りが崩れます。今回のH2で実行すると、SQLの構文エラーになりました。
ここで分かるのは、特別な攻撃を考える前に、普通のデータに含まれる記号だけでも文字列連結は壊れるということです。「名前に引用符があったら削除する」という対応では、本来保存・検索できるべき名前を変更してしまいます。
入力がSQLの条件として解釈される
今度は、比較用の入力として' OR '1'='1を渡します。連結後のSQLは次のようになります。
SELECT user_name FROM members WHERE user_name = '' OR '1'='1' ORDER BY id
ORは「どちらかの条件が成り立つ」という意味です。'1'='1'は常に成り立つため、名前を絞り込むはずの検索で、今回のテーブルの全3件が返ります。
このように、入力値が単なるデータではなくSQLの命令や条件の一部として解釈され、意図した動作を変えてしまう問題がSQLインジェクションです。対策の中心は、SQLの形と入力値を分けることです。
PreparedStatementでSQLと値を分ける
PreparedStatementでは、値を入れる位置に?を書き、SQLの形を決めてから値を渡します。次のコードは、この後に載せる完全なサンプルの検索処理から抜き出したものです。connectionは接続中のDB、inputは検索する名前を表します。
String sql = "SELECT user_name FROM members WHERE user_name = ? ORDER BY id";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, input);
try (ResultSet results = statement.executeQuery()) {
while (results.next()) {
System.out.println(results.getString("user_name"));
}
}
}
?をシングルクォートで囲まず、そのまま書くのがポイントです。'?'にすると、値を渡す場所ではなく、疑問符という文字列になってしまいます。
どの行で何をしているか
まずprepareStatement(sql)でSQLを準備します。次にsetString(1, input)で、最初の?へ文字列を設定します。これを「バインドする」と呼びます。文字列を手作業でSQLへ置換するという意味ではなく、値をパラメータとして渡しています。
| コード | 役割 |
|---|---|
| connection.prepareStatement(sql) | 値を受け取る位置を含んだSQLを準備する |
| statement.setString(1, input) | 1番目のパラメータに文字列を設定する |
| statement.executeQuery() | 準備した検索を実行する |
| results.next() | 検索結果を1行ずつ読み進める |
| results.getString(“user_name”) | 現在の行から名前を取り出す |
パラメータ番号は1から始まります。Javaの配列の添字は0から始まるため、混同しやすい箇所です。数値の列にはsetIntなど、対象に合う型の設定方法を選びます。PreparedStatement公式資料
try (...)は、使用したDB操作用のオブジェクトを処理後に閉じる書き方です。この例では検索結果とPreparedStatementをそれぞれ使い終わったところで閉じています。
同じ入力がどう扱われるか
O'Reillyは名前の値として渡されるため、引用符が入っていてもその名前を検索できます。' OR '1'='1も、入力全体が名前として扱われます。今回のDBにはその名前の行がないので、検索結果は0件です。
「危険そうな文字を見つけて消す」のではなく、「入力をSQLの構造として扱わせない」という違いを押さえておくと、対策の理由を説明しやすくなります。
手元のDBで2つの書き方を比較する
ここからは、同じ入力を文字列連結とパラメータ設定の両方へ渡し、件数を比較します。JDK 21.0.2とH2 2.5.252で動作を確認しました。
H2はJavaで利用できるDBです。今回はメモリ上にDBを作り、プログラムの終了時に消える構成にします。MySQLサーバーの起動や接続用アカウントの作成は不要です。H2のメモリDBの説明
ファイルとライブラリを準備する
VSCodeで作業用フォルダを開き、次の構成を作ります。H2のJARは、JavaからH2を利用するためのファイルです。H2の公式ダウンロードページから、この記事と同じバージョンのJARを用意してください。現在の配布版と違う場合は、同ページの過去版も確認します。
preparedstatement-sample/
SqlInjectionDemo.java
lib/
h2-2.5.252.jar
H2 2.5.252のJARは、Maven Centralの配布ファイルからも取得できます。libフォルダに保存してください。
JDKが未設定の場合は、先にJavaの環境構築を確認します。以下のコマンドはVSCodeの「ターミナル」→「新しいターミナル」で開いた画面に、1行ずつ入力します。
実行できるサンプルコード
次の内容をSqlInjectionDemo.javaへ保存します。完全なコードを載せているため、先ほどの抜粋を別途追加する必要はありません。コードが長く感じる場合は、まず保存・実行し、出力の比較から読み進めてください。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public class SqlInjectionDemo {
public static void main(String[] args) throws SQLException {
try (Connection connection = DriverManager.getConnection(
"jdbc:h2:mem:demo", "sa", "")) {
initialize(connection);
System.out.println("Database: " + connection.getMetaData()
.getDatabaseProductVersion());
compare(connection, "normal", "mori", 1, 1);
compare(connection, "unknown", "nobody", 0, 0);
compare(connection, "quote", "O'Reilly", -1, 1);
compare(connection, "condition", "' OR '1'='1", 3, 0);
System.out.println("All 8 checks passed.");
}
}
private static void initialize(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.executeUpdate(
"CREATE TABLE members (id INT PRIMARY KEY, user_name VARCHAR(100))");
}
String sql = "INSERT INTO members (id, user_name) VALUES (?, ?)";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
String[] names = {"admin", "mori", "O'Reilly"};
for (int i = 0; i < names.length; i++) {
statement.setInt(1, i + 1);
statement.setString(2, names[i]);
statement.executeUpdate();
}
}
}
// Comparison only: this method intentionally includes a vulnerability.
private static List<String> findByConcatenation(
Connection connection, String input) throws SQLException {
String sql = "SELECT user_name FROM members WHERE user_name = '"
+ input + "' ORDER BY id";
try (Statement statement = connection.createStatement();
ResultSet results = statement.executeQuery(sql)) {
return readNames(results);
}
}
private static List<String> findByParameter(
Connection connection, String input) throws SQLException {
String sql = "SELECT user_name FROM members WHERE user_name = ? ORDER BY id";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, input);
try (ResultSet results = statement.executeQuery()) {
return readNames(results);
}
}
}
private static List<String> readNames(ResultSet results) throws SQLException {
List<String> names = new ArrayList<>();
while (results.next()) {
names.add(results.getString("user_name"));
}
return names;
}
private static void compare(Connection connection, String label, String input,
int expectedUnsafe, int expectedSafe)
throws SQLException {
System.out.println("case=" + label + ", input=" + input);
try {
List<String> names = findByConcatenation(connection, input);
System.out.println(" concatenation=" + names.size() + " " + names);
require(names.size() == expectedUnsafe, label + ": concatenation");
} catch (SQLException exception) {
if (expectedUnsafe != -1) {
throw exception;
}
require(exception.getSQLState().startsWith("42"), label + ": SQL state");
System.out.println(" concatenation=SQL_ERROR");
}
List<String> names = findByParameter(connection, input);
System.out.println(" parameter=" + names.size() + " " + names);
require(names.size() == expectedSafe, label + ": parameter");
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
}
最初のinitializeでテーブルと3件の名前を用意します。テーブル作成には入力を含まない固定SQLを使い、名前の登録にはPreparedStatementを使っています。
findByConcatenationが比較用の問題を含む検索、findByParameterがパラメータを使った検索です。readNamesは結果を名前のリストへまとめます。compareが両方の結果を表示し、期待する件数かどうかも確認しています。
コード中の-1は、この比較用プログラムで「SQLエラーを期待する」という意味を付けた値です。JDBCの検索結果を表す値ではありません。また、SQL_ERRORはこの記事の表示用ラベルで、H2の実際のエラーメッセージそのものではありません。
実行結果から分かったこと
サンプルの実行では、次の出力を確認しました。concatenationが文字列連結、parameterがPreparedStatementの結果です。数字が件数、角括弧内が検索で見つかった名前です。
Database: 2.5.252 (2026-09-23)
case=normal, input=mori
concatenation=1 [mori]
parameter=1 [mori]
case=unknown, input=nobody
concatenation=0 []
parameter=0 []
case=quote, input=O'Reilly
concatenation=SQL_ERROR
parameter=1 [O'Reilly]
case=condition, input=' OR '1'='1
concatenation=3 [admin, mori, O'Reilly]
parameter=0 []
All 8 checks passed.
普通の名前moriと、存在しない名前nobodyでは、どちらも同じ結果です。ここだけを見れば、2つの書き方に違いはないように見えます。
違いが出るのは、引用符を含む名前と、SQL条件を書き換える入力です。O'Reillyでは、文字列連結はエラーになり、パラメータ設定は1件を取得できました。条件を書き換える入力では、文字列連結は3件、パラメータ設定は0件でした。
検索結果が0件でも、確認は成功している
今回の条件を書き換える入力は、DBに保存していない名前です。そのため、PreparedStatementで0件になるのが期待した動作です。単にエラーが出なかったかではなく、どのデータを取得できるべきかを基準に判断します。
最後のAll 8 checks passed.は、4種類の入力を2通りで比較した8個の確認が、想定どおりだったことを表します。文字列連結が安全だったという意味ではなく、問題の再現と対策後の動作を確認できたという意味です。
ここで残したいログは、「PreparedStatementなら成功した」という結論だけではありません。「通常の入力では同じだったが、引用符と条件式を含む入力では、SQLの組み立て方によって結果が変わった」という比較の過程です。
まとめ
今回は、文字列連結とPreparedStatementの違いを、同じ入力で比較しました。
- 普通の入力だけでは、問題のあるSQLでも動いて見える
- 引用符を含む名前では、文字列連結がSQLの区切りを壊す場合がある
- PreparedStatementでは、SQLの形と入力値を分ける
?に値を設定し、SQLへ直接入力をつなげない- 列名・並び順・入力チェック・権限は、それぞれ別に考える
理解のポイントは、PreparedStatementという名前を覚えることよりも、「入力した文字列が、データとして扱われるか、SQLの条件として扱われてしまうか」の違いです。今回の比較を残しておくと、SQLを組み立てるコードをレビューするときの判断基準になります。
まずは自分の検索コードで、入力を直接つなげている場所がないか、?と値の設定が対応しているかを確認してみてください。
ドキュメント
Javaのメソッドの使い方はJava公式資料、対策の考え方や対応できないSQLの部分はOWASPの資料で確認できます。
最後に
JDBCでの登録・更新の基本は、MySQLで登録・更新・削除する方法も参考にしてみてください。今回の値の渡し方は、検索だけでなく登録や更新のコードを読むときにもつながります。
以上、ログになります。
これからも継続していきましょう!!


コメント