SQL Injection dan Bagaimana Mencegahnya?

Ketekunan teratas

Saya baru saja mengumumkan kursus Learn Spring baru , yang berfokus pada dasar-dasar Spring 5 dan Spring Boot 2:

>> LIHAT KURSUSnya

1. Perkenalan

Meskipun menjadi salah satu kerentanan yang paling terkenal, SQL Injection terus menempati peringkat teratas dari daftar 10 Teratas OWASP yang terkenal - sekarang bagian dari kelas Injeksi yang lebih umum .

Dalam tutorial ini, kita akan menjelajahi kesalahan pengkodean umum di Java yang mengarah ke aplikasi yang rentan dan cara menghindarinya menggunakan API yang tersedia di pustaka runtime standar JVM. Kami juga akan membahas perlindungan apa yang dapat kami peroleh dari ORM seperti JPA, Hibernate, dan lainnya dan titik buta mana yang masih harus kami khawatirkan.

2. Bagaimana Aplikasi Menjadi Rentan terhadap SQL Injection?

Serangan injeksi bekerja karena, untuk banyak aplikasi, satu-satunya cara untuk menjalankan komputasi yang diberikan adalah dengan menghasilkan kode secara dinamis yang pada gilirannya dijalankan oleh sistem atau komponen lain . Jika dalam proses menghasilkan kode ini kami menggunakan data yang tidak tepercaya tanpa sanitasi yang tepat, kami meninggalkan pintu terbuka bagi peretas untuk mengeksploitasi.

Pernyataan ini mungkin terdengar agak abstrak, jadi mari kita lihat bagaimana hal ini terjadi dalam praktik dengan contoh buku teks:

public List unsafeFindAccountsByCustomerId(String customerId) throws SQLException { // UNSAFE !!! DON'T DO THIS !!! String sql = "select " + "customer_id,acc_number,branch_id,balance " + "from Accounts where customer_id = '" + customerId + "'"; Connection c = dataSource.getConnection(); ResultSet rs = c.createStatement().executeQuery(sql); // ... }

Masalah dengan kode ini jelas: kami telah memasukkan nilai customerId ke dalam kueri tanpa validasi sama sekali . Tidak ada hal buruk yang akan terjadi jika kita yakin bahwa nilai ini hanya akan datang dari sumber terpercaya, tetapi bisakah kita?

Mari kita bayangkan bahwa fungsi ini digunakan dalam implementasi REST API untuk sumber daya akun . Memanfaatkan kode ini sepele: yang harus kita lakukan adalah mengirim nilai yang, ketika digabungkan dengan bagian tetap dari kueri, mengubah perilaku yang diinginkan:

curl -X GET \ '//localhost:8080/accounts?customerId=abc%27%20or%20%271%27=%271' \

Dengan asumsi nilai parameter customerId tidak dicentang hingga mencapai fungsi kami, inilah yang akan kami terima:

abc' or '1' = '1

Saat kita menggabungkan nilai ini dengan bagian tetap, kita mendapatkan pernyataan SQL terakhir yang akan dieksekusi:

select customer_id, acc_number,branch_id, balance from Accounts where customerId = 'abc' or '1' = '1'

Mungkin bukan yang kita inginkan…

Seorang pengembang yang cerdas (bukankah kita semua?) Sekarang akan berpikir: “Itu konyol! Saya tidak pernah menggunakan penggabungan string untuk membuat kueri seperti ini ”.

Tidak terlalu cepat… Contoh kanonik ini memang konyol, tetapi ada situasi di mana kita mungkin masih perlu melakukannya :

  • Kueri kompleks dengan kriteria penelusuran dinamis: menambahkan klausa UNION bergantung pada kriteria yang disediakan pengguna
  • Pengelompokan atau pengurutan dinamis: REST API digunakan sebagai backend ke tabel data GUI

2.1. Saya Menggunakan JPA. Saya Aman, bukan?

Ini adalah kesalahpahaman yang umum . JPA dan ORM lainnya membebaskan kita dari membuat pernyataan SQL dengan kode tangan, tetapi mereka tidak akan mencegah kita menulis kode yang rentan .

Mari kita lihat bagaimana versi JPA dari contoh sebelumnya terlihat:

public List unsafeJpaFindAccountsByCustomerId(String customerId) { String jql = "from Account where customerId = '" + customerId + "'"; TypedQuery q = em.createQuery(jql, Account.class); return q.getResultList() .stream() .map(this::toAccountDTO) .collect(Collectors.toList()); } 

Masalah yang sama yang telah kami tunjukkan sebelumnya juga ada di sini: kami menggunakan input yang tidak divalidasi untuk membuat kueri JPA , jadi kami dihadapkan pada jenis eksploitasi yang sama di sini.

3. Teknik Pencegahan

Sekarang kita tahu apa itu injeksi SQL, mari kita lihat bagaimana kita dapat melindungi kode kita dari serangan semacam ini. Di sini kami berfokus pada beberapa teknik yang sangat efektif yang tersedia di Java dan bahasa JVM lainnya, tetapi konsep serupa tersedia untuk lingkungan lain, seperti PHP, .Net, Ruby, dan sebagainya.

Bagi mereka yang mencari daftar lengkap dari teknik yang tersedia, termasuk yang spesifik database, Proyek OWASP menyimpan Lembar Curang Pencegahan Injeksi SQL, yang merupakan tempat yang baik untuk mempelajari lebih lanjut tentang subjek.

3.1. Kueri Parameter

Teknik ini terdiri dari penggunaan pernyataan yang disiapkan dengan placeholder tanda tanya ("?") Dalam kueri kami setiap kali kami perlu memasukkan nilai yang diberikan pengguna. Ini sangat efektif dan, kecuali ada bug dalam implementasi driver JDBC, kebal terhadap eksploitasi.

Mari tulis ulang fungsi contoh kita untuk menggunakan teknik ini:

public List safeFindAccountsByCustomerId(String customerId) throws Exception { String sql = "select " + "customer_id, acc_number, branch_id, balance from Accounts" + "where customer_id = ?"; Connection c = dataSource.getConnection(); PreparedStatement p = c.prepareStatement(sql); p.setString(1, customerId); ResultSet rs = p.executeQuery(sql)); // omitted - process rows and return an account list }

Di sini kita telah menggunakan metode preparedStatement () yang tersedia di instance Connection untuk mendapatkan PreparedStatement . Antarmuka ini memperluas antarmuka Pernyataan biasa dengan beberapa metode yang memungkinkan kita memasukkan nilai yang disediakan pengguna dengan aman dalam kueri sebelum menjalankannya.

Untuk JPA, kami memiliki fitur serupa:

String jql = "from Account where customerId = :customerId"; TypedQuery q = em.createQuery(jql, Account.class) .setParameter("customerId", customerId); // Execute query and return mapped results (omitted)

Saat menjalankan kode ini di bawah Spring Boot, kita dapat menyetel properti logging.level.sql ke DEBUG dan melihat kueri apa yang sebenarnya dibuat untuk menjalankan operasi ini:

// Note: Output formatted to fit screen [DEBUG][SQL] select account0_.id as id1_0_, account0_.acc_number as acc_numb2_0_, account0_.balance as balance3_0_, account0_.branch_id as branch_i4_0_, account0_.customer_id as customer5_0_ from accounts account0_ where account0_.customer_id=?

Seperti yang diharapkan, lapisan ORM membuat pernyataan yang disiapkan menggunakan placeholder untuk parameter customerId . Ini adalah hal yang sama yang telah kami lakukan pada kasus JDBC biasa - tetapi dengan beberapa pernyataan lebih sedikit, itu bagus.

Sebagai bonus, pendekatan ini biasanya menghasilkan kueri yang berkinerja lebih baik, karena sebagian besar database dapat menyimpan cache rencana kueri yang terkait dengan pernyataan yang disiapkan.

Harap perhatikan bahwa pendekatan ini hanya berfungsi untuk placeholder yang digunakan sebagai nilai . Misalnya, kita tidak dapat menggunakan placeholder untuk mengubah nama tabel secara dinamis:

// This WILL NOT WORK !!! PreparedStatement p = c.prepareStatement("select count(*) from ?"); p.setString(1, tableName);

Di sini, JPA juga tidak akan membantu:

// This WILL NOT WORK EITHER !!! String jql = "select count(*) from :tableName"; TypedQuery q = em.createQuery(jql,Long.class) .setParameter("tableName", tableName); return q.getSingleResult(); 

Dalam kedua kasus tersebut, kita akan mendapatkan error runtime.

Alasan utama di balik ini adalah sifat dari pernyataan yang disiapkan: server database menggunakannya untuk menyimpan rencana kueri yang diperlukan untuk menarik kumpulan hasil, yang biasanya sama untuk nilai yang mungkin. Ini tidak benar untuk nama tabel dan konstruksi lain yang tersedia dalam bahasa SQL seperti kolom yang digunakan dalam urutan menurut klausa.

3.2. API Kriteria JPA

Since explicit JQL query building is the main source of SQL Injections, we should favor the use of the JPA's Query API, when possible.

For a quick primer on this API, please refer to the article on Hibernate Criteria queries. Also worth reading is our article about JPA Metamodel, which shows how to generate metamodel classes that will help us to get rid of string constants used for column names – and the runtime bugs that arise when they change.

Let's rewrite our JPA query method to use the Criteria API:

CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Account.class); Root root = cq.from(Account.class); cq.select(root).where(cb.equal(root.get(Account_.customerId), customerId)); TypedQuery q = em.createQuery(cq); // Execute query and return mapped results (omitted)

Here, we've used more code lines to get the same result, but the upside is that now we don't have to worry about JQL syntax.

Another important point: despite its verbosity, the Criteria API makes creating complex query services more straightforward and safer. For a complete example that shows how to do it in practice, please take a look at the approach used by JHipster-generated applications.

3.3. User Data Sanitization

Data Sanitization is a technique of applying a filter to user supplied-data so it can be safely used by other parts of our application. A filter's implementation may vary a lot, but we can generally classify them in two types: whitelists and blacklists.

Blacklists, which consist of filters that try to identify an invalid pattern, are usually of little value in the context of SQL Injection prevention – but not for the detection! More on this later.

Whitelists, on the other hand, work particularly well when we can define exactly what is a valid input.

Let's enhance our safeFindAccountsByCustomerId method so now the caller can also specify the column used to sort the result set. Since we know the set of possible columns, we can implement a whitelist using a simple set and use it to sanitize the received parameter:

private static final Set VALID_COLUMNS_FOR_ORDER_BY = Collections.unmodifiableSet(Stream .of("acc_number","branch_id","balance") .collect(Collectors.toCollection(HashSet::new))); public List safeFindAccountsByCustomerId( String customerId, String orderBy) throws Exception { String sql = "select " + "customer_id,acc_number,branch_id,balance from Accounts" + "where customer_id = ? "; if (VALID_COLUMNS_FOR_ORDER_BY.contains(orderBy)) { sql = sql + " order by " + orderBy; } else { throw new IllegalArgumentException("Nice try!"); } Connection c = dataSource.getConnection(); PreparedStatement p = c.prepareStatement(sql); p.setString(1,customerId); // ... result set processing omitted }

Here, we're combining the prepared statement approach and a whitelist used to sanitize the orderBy argument. The final result is a safe string with the final SQL statement. In this simple example, we're using a static set, but we could also have used database metadata functions to create it.

We can use the same approach for JPA, also taking advantage of the Criteria API and Metadata to avoid using String constants in our code:

// Map of valid JPA columns for sorting final Map
    
      VALID_JPA_COLUMNS_FOR_ORDER_BY = Stream.of( new AbstractMap.SimpleEntry(Account_.ACC_NUMBER, Account_.accNumber), new AbstractMap.SimpleEntry(Account_.BRANCH_ID, Account_.branchId), new AbstractMap.SimpleEntry(Account_.BALANCE, Account_.balance)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); SingularAttribute orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy); if (orderByAttribute == null) { throw new IllegalArgumentException("Nice try!"); } CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Account.class); Root root = cq.from(Account.class); cq.select(root) .where(cb.equal(root.get(Account_.customerId), customerId)) .orderBy(cb.asc(root.get(orderByAttribute))); TypedQuery q = em.createQuery(cq); // Execute query and return mapped results (omitted)
    

This code has the same basic structure as in the plain JDBC. First, we use a whitelist to sanitize the column name, then we proceed to create a CriteriaQuery to fetch the records from the database.

3.4. Are We Safe Now?

Let's assume that we've used parameterized queries and/or whitelists everywhere. Can we now go to our manager and guarantee we're safe?

Well… not so fast. Without even considering Turing's halting problem, there are other aspects we must consider:

  1. Stored Procedures: These are also prone to SQL Injection issues; whenever possible please apply sanitation even to values that will be sent to the database via prepared statements
  2. Triggers: Same issue as with procedure calls, but even more insidious because sometimes we have no idea they're there…
  3. Insecure Direct Object References: Even if our application is SQL-Injection free, there's still a risk that associated with this vulnerability category – the main point here is related to different ways an attacker can trick the application, so it returns records he or she was not supposed to have access to – there's a good cheat sheet on this topic available at OWASP's GitHub repository

In short, our best option here is caution. Many organizations nowadays use a “red team” exactly for this. Let them do their job, which is exactly to find any remaining vulnerabilities.

4. Damage Control Techniques

As a good security practice, we should always implement multiple defense layers – a concept known as defense in depth. The main idea is that even if we're unable to find all possible vulnerabilities in our code – a common scenario when dealing with legacy systems – we should at least try to limit the damage an attack would inflict.

Of course, this would be a topic for a whole article or even a book but let's name a few measures:

  1. Apply the principle of least privilege: Restrict as much as possible the privileges of the account used to access the database
  2. Use database-specific methods available in order to add an additional protection layer; for example, the H2 Database has a session-level option that disables all literal values on SQL Queries
  3. Use short-lived credentials: Make the application rotate database credentials often; a good way to implement this is by using Spring Cloud Vault
  4. Log everything: If the application stores customer data, this is a must; there are many solutions available that integrate directly to the database or work as a proxy, so in case of an attack we can at least assess the damage
  5. Gunakan WAF atau solusi deteksi intrusi serupa: itu adalah contoh daftar hitam yang khas - biasanya, mereka datang dengan database yang cukup besar dari tanda tangan serangan yang diketahui dan akan memicu tindakan yang dapat diprogram setelah deteksi. Beberapa juga menyertakan agen dalam JVM yang dapat mendeteksi intrusi dengan menerapkan beberapa instrumentasi - keuntungan utama dari pendekatan ini adalah bahwa kerentanan akhirnya menjadi lebih mudah diperbaiki karena kami akan memiliki pelacakan tumpukan lengkap.

5. Kesimpulan

Dalam artikel ini, kami telah membahas kerentanan Injeksi SQL dalam aplikasi Java - ancaman yang sangat serius bagi organisasi mana pun yang bergantung pada data untuk bisnis mereka - dan cara mencegahnya menggunakan teknik sederhana.

Seperti biasa, kode lengkap untuk artikel ini tersedia di Github.

Ketekunan bawah

Saya baru saja mengumumkan kursus Learn Spring baru , yang berfokus pada dasar-dasar Spring 5 dan Spring Boot 2:

>> LIHAT KURSUSnya