FlawOpen

flawopen.com/sql-injection/Java

● CWE-89 · Kritis
Riset Keamanan · FlawOpen

SQL Injection Java: JDBC, JPA, MyBatis

SQL Injection Java terjadi saat input digabung ke teks kueri. Ikat nilai dengan parameter dan gunakan allowlist untuk identifier seperti ORDER BY.[1], [2], [3], [7], [4], [6]

💡 Penjelasan Sederhana (ELI5)

Bayangkan pabrik yang mengunci cetakan logam sebelum menerima label pelanggan. JDBC PreparedStatement membuat cetakan dengan slot ? terlebih dahulu; setLong(1, userId) kemudian menaruh nilai ke slot tanpa mengubah instruksi. Teks yang digabungkan sebelum persiapan akan terukir pada cetakan. JdbcTemplate dan JPQL setParameter juga memisahkan nilai. MyBatis memperlihatkan perbedaannya: #{status} adalah nilai terikat, sedangkan ${status} adalah teks SQL yang disisipkan. Kolom ORDER BY adalah identifier, bukan nilai; petakan melalui allowlist atau properti Sort.by yang disetujui.[2], [3], [7], [4], [8]

Konsep Kunci & Istilah

PreparedStatement
Perintah JDBC yang dikompilasi dari SQL bertanda ? sebelum nilai apa pun diberikan. Nilai masuk lewat setter bertipe seperti setLong(1, id) dan tidak pernah bisa mengubah kueri. prepareStatement("..." + id) justru mengompilasi teks penyerang dan tidak melindungi apa pun.[2], [1]
JdbcTemplate query(sql, args)
queryForList(sql, Long.class, status) dan update(sql, args...) milik Spring mem-bind argumen di belakang ke penanda ? lewat PreparedStatement. NamedParameterJdbcTemplate melakukan hal yang sama dengan penanda :name. Menggabungkan string ke argumen sql melewati semua itu.[3], [2]
JPQL / HQL injection
Kueri JPA dan Hibernate juga diurai dari string. createQuery("... = '" + email + "'") membiarkan input mengubah JPQL, yang kemudian diterjemahkan Hibernate menjadi SQL. Gunakan :email dengan setParameter, atau Criteria API.[7], [1]
MyBatis #{} vs ${}
Di mapper MyBatis, #{status} menjadi parameter ? yang di-bind. ${status} ditempelkan ke SQL sebagai teks. ${} hanya aman untuk nilai yang Anda pilih sendiri, misalnya kolom dari allowlist.[4]
Spring Data Sort
Spring Data mengubah Sort.by("email") menjadi ORDER BY atas properti yang dipetakan dan menolak nama yang tidak dikenal, jadi utamakan ini daripada menyusun ORDER BY secara manual. Tetap petakan kunci request lewat allowlist agar pengguna tidak bisa mengurutkan berdasarkan field yang tidak ingin Anda tampilkan.[8]

Pemetaan kerangka kerja

Alur Serangan Langkah demi Langkah

Langkah 1

Nilai request sampai ke repository

Handler Spring @GetMapping menerima @RequestParam String id dan meneruskannya ke findEmail(). Karena tipe yang dideklarasikan adalah String, Spring tidak mengonversi nilai ke tipe lain; validasi formatnya secara eksplisit sebelum mengandalkannya.[9]

Langkah 2

Penggabungan string menyusun SQL

"... WHERE id = " + userId menghasilkan satu string. Dengan nilai uji yang tidak berbahaya 0 OR 1=1, isinya menjadi WHERE id = 0 OR 1=1.[1], [2], [6]

Langkah 3

Driver mengompilasi teks penyerang

createStatement().executeQuery(sql) mengirim seluruh string untuk diurai. Tidak ada parameter, sehingga database memperlakukan OR 1=1 sebagai bagian klausa WHERE.[2], [1]

Langkah 4

Semua baris kembali

Kondisinya benar untuk setiap baris, sehingga pencarian mengembalikan tabel. Hal yang sama terjadi lewat JPQL: Hibernate menerjemahkan kueri yang sudah diubah itu ke SQL apa adanya.[7], [1]

Kode Sumber: Rentan vs Aman

IMPLEMENTASI RENTAN
// UserRepository.java: setiap kueri di bawah menggabungkan data request ke teks kueri
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 mengurai string gabungan beserta nilainya
    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: penggabungan terjadi sebelum Spring melihat SQL-nya
    public List<Long> findOrderIds(String status) {
        return jdbc.queryForList("SELECT id FROM orders WHERE status = '" + status + "'", Long.class);
    }

    // JPA: JPQL-nya sendiri disusun dari input, lalu diterjemahkan ke SQL
    public List<User> findByEmail(String email) {
        return em.createQuery("SELECT u FROM User u WHERE u.email = '" + email + "'", User.class)
                 .getResultList();
    }

    // field ORDER BY diambil langsung dari request
    public List<User> listUsers(String sortField) {
        return em.createQuery("SELECT u FROM User u ORDER BY u." + sortField, User.class)
                 .getResultList();
    }
}
PERBAIKAN AMAN & KUAT
// UserRepository.java: nilai berupa parameter bind, field pengurutan berasal dari allowlist
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: penanda ? dikompilasi dulu, nilai diisi lewat setter bertipe
    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: argumen di belakang di-bind ke penanda ?
    public List<Long> findOrderIds(String status) {
        return jdbc.queryForList("SELECT id FROM orders WHERE status = ?", Long.class, status);
    }

    // JPA: parameter bernama, nilai di-bind dengan 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();
    }

    // cek allowlist; hanya potongan JPQL konstan yang masuk ke kueri
    public List<User> listUsers(String sortKey) {
        String jpql = SORTED_QUERIES.getOrDefault(sortKey, SORTED_QUERIES.get("id"));
        return em.createQuery(jpql, User.class)
                 .getResultList();
    }
}

Daftar Periksa Penguatan Sistem Rekayasa

Sumber