FlawOpen

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]

フレームワーク対応

ステップ・バイ・ステップの攻撃フロー

ステップ 1

リクエストの値がリポジトリに届く

Spring の @GetMapping ハンドラーが @RequestParam String id を受け取り、findEmail() に渡します。宣言型が String のため、Spring は値を別の型に変換しません。形式を信頼する前に明示的に検証してください。[9]

ステップ 2

連結が SQL を組み立てる

"... WHERE id = " + userId は 1 つの文字列を作ります。無害なテスト値 0 OR 1=1 なら WHERE id = 0 OR 1=1 になります。[1], [2], [6]

ステップ 3

ドライバーが攻撃者のテキストをコンパイルする

createStatement().executeQuery(sql) は文字列全体を解析に回します。パラメータがないため、データベースは OR 1=1 を WHERE 句の一部として扱います。[2], [1]

ステップ 4

全行が返る

条件はすべての行で真になり、検索はテーブル全体を返します。JPQL でも同じで、Hibernate は改変されたクエリを忠実に SQL へ変換します。[7], [1]

ソースコード比較:脆弱 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();
    }
}

エンジニアリング&システム堅牢化チェックリスト

参考資料