flawopen.com/sql-injection/Java
●
CWE-89 · 緊急
セキュリティ研究 · FlawOpen
Java SQLインジェクション:JDBC・JPA・MyBatis
JavaのSQLインジェクションは、入力をクエリ文字列に連結すると起きます。値はバインドし、ORDER BYなどの識別子は許可リストで制限します。[1], [2], [3], [7], [4], [6]
💡 わかりやすい解説 (ELI5)
顧客ラベルが届く前に金属の型を固定する工場を想像してください。JDBC PreparedStatement は最初に ? の差し込み口を持つ型を作り、setLong(1, userId) は指示を変えずに値だけを差し込みます。準備前に文字列を連結すると、その文字が型に刻み込まれます。JdbcTemplate と JPQL setParameter も値を分離します。MyBatis では違いが明確です。#{status} はバインド値、${status} は挿入される SQL 文字列です。ORDER BY のフィールドは値ではなく識別子なので、allowlist または許可済みの Sort.by プロパティへ対応付けます。[2], [3], [7], [4], [8]
主要な概念と専門用語
PreparedStatement- 値が渡される前に、
?マーカー付きの SQL からコンパイルされる JDBC ステートメントです。値はsetLong(1, id)のような型付きセッターで渡され、クエリを変えることはできません。prepareStatement("..." + id)は攻撃者のテキストをコンパイルするだけで、何も守りません。[2], [1] JdbcTemplate query(sql, args)- Spring の
queryForList(sql, Long.class, status)とupdate(sql, args...)は、末尾の引数を PreparedStatement 経由で?マーカーにバインドします。NamedParameterJdbcTemplateは:nameマーカーで同じことを行います。sql引数に連結すれば、そのすべてが素通りになります。[3], [2] JPQL / HQL injection- JPA や Hibernate のクエリも文字列から解析されます。
createQuery("... = '" + email + "'")では入力が JPQL を変え、Hibernate がそれを SQL に変換します。:emailとsetParameter、または Criteria API を使います。[7], [1] MyBatis #{} vs ${}- MyBatis のマッパーでは、
#{status}はバインドされた?パラメータになります。${status}はテキストとして SQL に貼り付けられます。${}が安全なのは、許可リストのカラム名のように自分で選んだ値だけです。[4] Spring Data Sort- Spring Data は
Sort.by("email")をマッピング済みプロパティに対する ORDER BY に変換し、未知の名前を拒否するので、手書きの ORDER BY より優先します。それでも、公開するつもりのないフィールドで並べ替えられないよう、リクエストのキーは許可リストで対応付けます。[8]
フレームワーク対応
- このガイドで説明する弱点の CWE 参照: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
ステップ・バイ・ステップの攻撃フロー
ステップ 1
リクエストの値がリポジトリに届く
Spring の @GetMapping ハンドラーが @RequestParam String id を受け取り、findEmail() に渡します。宣言型が String のため、Spring は値を別の型に変換しません。形式を信頼する前に明示的に検証してください。[9]
ステップ 2
ステップ 3
ソースコード比較:脆弱 vs 堅牢化
脆弱な実装
// UserRepository.java: 以下のクエリはどれもリクエストのデータをクエリ文に連結している
public class UserRepository {
private final DataSource dataSource;
private final JdbcTemplate jdbc;
private final EntityManager em;
public UserRepository(DataSource dataSource, JdbcTemplate jdbc, EntityManager em) {
this.dataSource = dataSource;
this.jdbc = jdbc;
this.em = em;
}
// JDBC: Statement は値を含めた連結後の文字列を解析する
public String findEmail(String userId) throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT email FROM users WHERE id = " + userId)) {
return rs.next() ? rs.getString(1) : null;
}
}
// JdbcTemplate: Spring が SQL を見る前に連結が終わっている
public List<Long> findOrderIds(String status) {
return jdbc.queryForList("SELECT id FROM orders WHERE status = '" + status + "'", Long.class);
}
// JPA: JPQL 自体が入力から組み立てられ、SQL に変換される
public List<User> findByEmail(String email) {
return em.createQuery("SELECT u FROM User u WHERE u.email = '" + email + "'", User.class)
.getResultList();
}
// ORDER BY のフィールドをリクエストからそのまま取っている
public List<User> listUsers(String sortField) {
return em.createQuery("SELECT u FROM User u ORDER BY u." + sortField, User.class)
.getResultList();
}
}
堅牢化されたセキュアパッチ
// UserRepository.java: 値はバインドパラメータ、ソート項目は許可リストから取る
public class UserRepository {
private static final Map<String, String> SORTED_QUERIES = Map.of(
"id", "SELECT u FROM User u ORDER BY u.id",
"email", "SELECT u FROM User u ORDER BY u.email",
"created", "SELECT u FROM User u ORDER BY u.createdAt");
private final DataSource dataSource;
private final JdbcTemplate jdbc;
private final EntityManager em;
public UserRepository(DataSource dataSource, JdbcTemplate jdbc, EntityManager em) {
this.dataSource = dataSource;
this.jdbc = jdbc;
this.em = em;
}
// JDBC: 先に ? マーカーをコンパイルし、型付きセッターで値を設定
public String findEmail(long userId) throws SQLException {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT email FROM users WHERE id = ?")) {
ps.setLong(1, userId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}
}
// JdbcTemplate: 末尾の引数が ? マーカーにバインドされる
public List<Long> findOrderIds(String status) {
return jdbc.queryForList("SELECT id FROM orders WHERE status = ?", Long.class, status);
}
// JPA: 名前付きパラメータ。値は setParameter でバインド
public List<User> findByEmail(String email) {
return em.createQuery("SELECT u FROM User u WHERE u.email = :email", User.class)
.setParameter("email", email)
.getResultList();
}
// 許可リストで照合。クエリに入るのは定数の JPQL 断片だけ
public List<User> listUsers(String sortKey) {
String jpql = SORTED_QUERIES.getOrDefault(sortKey, SORTED_QUERIES.get("id"));
return em.createQuery(jpql, User.class)
.getResultList();
}
}
エンジニアリング&システム堅牢化チェックリスト
- ✓SpotBugs の
SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTEと、Find Security Bugs のSQL_INJECTION_JDBC・SQL_INJECTION_SPRING_JDBC・SQL_INJECTION_JPAでビルドを失敗させる。[10], [5] - ✓SQL 引数を
+、String.format、StringBuilderで組み立てているStatement.execute*、createQuery、createNativeQuery、JdbcTemplate の呼び出しはすべて却下する。[2], [3], [7] - ✓CI で MyBatis マッパー内の
${を検索する。該当箇所はすべて、コードに固定されマッパーに記載された許可リストの値でなければならない。[4] - ✓ORDER BY は
Sort.by()か、プロパティ名のMap許可リストから組み立てる。@RequestParamを直接使わない。[8], [7] - ✓リクエストパラメーターは実際のドメイン型(数値 ID は
long、UUID ID はUUID)で宣言し、Spring がリポジトリー処理の前に変換失敗を拒否できるようにします。SQL Injection 対策は引き続き SQL パラメーターのバインドで行います。[9], [2]
参考資料
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- Oracle — Using Prepared Statements (JDBC tutorial)
- Spring Framework — JdbcTemplate
- MyBatis — Mapper XML: String Substitution
- Find Security Bugs — SQL_INJECTION rules
- OWASP SQL Injection Prevention Cheat Sheet
- Jakarta EE Tutorial — The Jakarta Persistence Query Language
- Spring Data JPA — Query Methods and Sorting
- Spring Framework — @RequestParam
- SpotBugs — SQL Injection Detectors