SQL Suntikan dan Bagaimana Mencegahnya?

Ketekunan atas

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

>> SEMAK KURSUS

1. Pengenalan

Walaupun menjadi salah satu kelemahan yang terkenal, SQL Injection terus berada di kedudukan teratas dalam senarai 10 OWASP yang terkenal - kini menjadi sebahagian daripada kelas Suntikan yang lebih umum .

Dalam tutorial ini, kita akan meneroka kesalahan pengkodan umum di Java yang menyebabkan aplikasi rentan dan cara menghindarinya menggunakan API yang tersedia di perpustakaan runtime standard JVM. Kami juga akan melindungi perlindungan yang dapat kami peroleh daripada ORM seperti JPA, Hibernate dan lain-lain dan tempat buta mana yang masih perlu kita bimbangkan.

2. Bagaimana Aplikasi Menjadi Rentan terhadap SQL Suntikan?

Serangan suntikan berfungsi kerana, untuk banyak aplikasi, satu-satunya cara untuk melakukan pengiraan tertentu adalah dengan menghasilkan kod secara dinamik yang seterusnya dijalankan oleh sistem atau komponen lain . Sekiranya dalam proses menghasilkan kod ini, kami menggunakan data yang tidak dipercaya tanpa pembersihan yang betul, kami membiarkan pintu terbuka untuk dimanfaatkan oleh penggodam.

Pernyataan ini mungkin terdengar sedikit abstrak, jadi mari kita lihat bagaimana ini berlaku 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 kod ini jelas: kami telah meletakkan nilai customerId ke dalam pertanyaan tanpa pengesahan sama sekali . Tidak ada yang buruk akan berlaku sekiranya kita yakin bahawa nilai ini hanya akan datang dari sumber yang dipercayai, tetapi bolehkah kita?

Mari kita bayangkan bahawa fungsi ini digunakan dalam pelaksanaan REST API untuk sumber akaun . Mengeksploitasi kod ini adalah remeh: yang harus kita lakukan hanyalah mengirim nilai yang, apabila digabungkan dengan bahagian tetap dari pertanyaan, mengubah tingkah laku yang dimaksudkan:

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

Dengan mengandaikan nilai parameter customerId tidak dicentang sehingga mencapai fungsi kita, inilah yang akan kita terima:

abc' or '1' = '1

Apabila kita menggabungkan nilai ini dengan bahagian tetap, kita mendapat pernyataan SQL akhir yang akan dilaksanakan:

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

Mungkin bukan yang kita mahukan ...

Seorang pemaju pintar (bukankah kita semua?) Sekarang akan berfikir: "Itu bodoh! Saya tidak akan pernah menggunakan penggabungan rentetan untuk membina pertanyaan seperti ini ”.

Tidak begitu pantas ... Contoh kanonik ini memang bodoh tetapi ada situasi di mana kita masih perlu melakukannya :

  • Pertanyaan kompleks dengan kriteria carian dinamik: menambahkan klausa UNION bergantung kepada kriteria yang disediakan pengguna
  • Pengelompokan atau susunan dinamik: API REST yang digunakan sebagai backend ke jadual data GUI

2.1. Saya Menggunakan JPA. Saya Selamat, Betul?

Ini adalah salah tanggapan biasa . JPA dan ORM yang lain melegakan kami daripada membuat penyataan SQL yang dikodkan dengan tangan, tetapi mereka tidak akan menghalang kami daripada menulis kod yang rentan .

Mari lihat bagaimana versi JPA contoh sebelumnya:

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 kemukakan sebelumnya juga terdapat di sini: kami menggunakan input yang tidak sah untuk membuat pertanyaan JPA , jadi kami terdedah kepada eksploitasi yang sama di sini.

3. Teknik Pencegahan

Sekarang kita tahu apa itu suntikan SQL, mari kita lihat bagaimana kita dapat melindungi kod kita dari serangan semacam ini. Di sini kita memfokuskan kepada beberapa teknik yang sangat berkesan yang terdapat di Java dan bahasa JVM lain, tetapi konsep serupa tersedia untuk persekitaran lain, seperti PHP, .Net, Ruby dan sebagainya.

Bagi mereka yang mencari senarai lengkap teknik yang ada, termasuk yang khusus untuk pangkalan data, Projek OWASP mengekalkan Lembaran Menipu Pencegahan Suntikan SQL, yang merupakan tempat yang baik untuk mempelajari lebih lanjut mengenai subjek ini.

3.1. Pertanyaan Parameter

Teknik ini terdiri daripada menggunakan pernyataan yang disiapkan dengan ruang letak tanda tanya (“?”) Dalam pertanyaan kami setiap kali kami perlu memasukkan nilai yang disediakan pengguna. Ini sangat berkesan dan, kecuali ada bug dalam pelaksanaan pemacu JDBC, kebal terhadap eksploitasi.

Mari tulis semula fungsi contoh kami 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 kami telah menggunakan kaedah persiapanStatement () yang tersedia dalam contoh Sambungan untuk mendapatkan PreparedStatement . Antaramuka ini memperluaskan antara muka Pernyataan biasa dengan beberapa kaedah yang membolehkan kita memasukkan nilai yang dibekalkan pengguna dalam pertanyaan dengan selamat sebelum melaksanakannya.

Untuk JPA, kami mempunyai ciri yang 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)

Semasa menjalankan kod ini di Spring Boot, kita dapat menetapkan properti logging.level.sql ke DEBUG dan melihat pertanyaan apa yang sebenarnya dibina untuk melaksanakan 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 dijangkakan, lapisan ORM membuat penyataan siap menggunakan placeholder untuk parameter customerId . Ini adalah perkara yang sama yang telah kita lakukan dalam kes JDBC biasa - tetapi dengan beberapa pernyataan kurang, yang bagus.

Sebagai bonus, pendekatan ini biasanya menghasilkan pertanyaan yang berkinerja lebih baik, kerana kebanyakan pangkalan data dapat menyimpan rancangan pertanyaan yang berkaitan dengan penyataan yang telah disediakan.

Harap maklum bahawa pendekatan ini hanya berfungsi untuk pemegang tempat yang digunakan sebagai nilai . Sebagai contoh, kami tidak dapat menggunakan tempat letak untuk mengubah nama jadual secara dinamis:

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

Di sini, JPA 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-dua kes, kami akan mendapat ralat runtime.

Sebab utama di sebalik ini adalah hakikat pernyataan yang telah disediakan: pelayan pangkalan data menggunakannya untuk menyimpan rancangan pertanyaan yang diperlukan untuk menarik set hasil, yang biasanya sama untuk sebarang nilai yang mungkin. Ini tidak benar untuk nama jadual dan konstruk lain yang terdapat dalam bahasa SQL seperti lajur yang digunakan dalam urutan mengikut klausa.

3.2. API Kriteria JPA

Oleh kerana bangunan pertanyaan JQL eksplisit adalah sumber utama SQL Injections, kami harus memilih penggunaan API Pertanyaan JPA, jika mungkin.

Untuk maklumat ringkas mengenai API ini, rujuk artikel mengenai pertanyaan Kriteria Hibernate Juga patut dibaca adalah artikel kami mengenai JPA Metamodel, yang menunjukkan cara menghasilkan kelas metamodel yang akan membantu kami menyingkirkan pemalar rentetan yang digunakan untuk nama lajur - dan pepijat runtime yang timbul semasa mereka berubah.

Mari tulis semula kaedah pertanyaan JPA kami untuk menggunakan API Kriteria:

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)

Di sini, kami telah menggunakan lebih banyak baris kod untuk mendapatkan hasil yang sama, tetapi yang terbalik adalah sekarang kita tidak perlu bimbang tentang sintaks JQL .

Titik penting lain: walaupun dengan ketajamannya, API Kriteria menjadikan perkhidmatan pertanyaan kompleks lebih mudah dan selamat. Untuk contoh lengkap yang menunjukkan cara melakukannya dalam praktik, sila lihat pendekatan yang digunakan oleh aplikasi yang dihasilkan oleh JHipster.

3.3. Pembersihan Data Pengguna

Pembersihan Data adalah teknik menerapkan penapis ke data yang dibekalkan pengguna sehingga dapat digunakan dengan selamat oleh bahagian lain dari aplikasi kita . Pelaksanaan penapis mungkin banyak berbeza, tetapi kita secara amnya dapat mengklasifikasikannya dalam dua jenis: senarai putih dan senarai hitam.

Senarai hitam , yang terdiri daripada penapis yang cuba mengenal pasti corak yang tidak betul, biasanya tidak bernilai dalam konteks pencegahan SQL Injection - tetapi tidak untuk pengesanan! Lebih lanjut mengenai ini kemudian.

Senarai putih , sebaliknya, berfungsi dengan baik apabila kita dapat menentukan dengan tepat apakah input yang sah.

Mari tingkatkan kaedah safeFindAccountsByCustomerId kami sehingga sekarang pemanggil juga dapat menentukan lajur yang digunakan untuk menyusun set hasil. Oleh kerana kami mengetahui kumpulan lajur yang mungkin, kami dapat menerapkan daftar putih menggunakan set sederhana dan menggunakannya untuk membersihkan parameter yang diterima:

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 }

Di sini, kami menggabungkan pendekatan penyataan yang telah disediakan dan senarai putih yang digunakan untuk membersihkan argumen orderBy . Hasil akhir adalah rentetan selamat dengan pernyataan SQL akhir. Dalam contoh sederhana ini, kami menggunakan set statik, tetapi kami juga dapat menggunakan fungsi metadata pangkalan data untuk membuatnya.

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 penyelesaian pengesanan pencerobohan yang serupa: itu adalah contoh senarai hitam khas - biasanya, ia dilengkapi dengan pangkalan data yang cukup besar mengenai tanda tangan serangan yang diketahui dan akan mencetuskan tindakan yang dapat diprogramkan semasa pengesanan. Sebilangannya juga merangkumi agen dalam-JVM yang dapat mengesan gangguan dengan menerapkan beberapa instrumen - Kelebihan utama pendekatan ini ialah kelemahan akhirnya menjadi lebih mudah untuk diperbaiki kerana kita akan mempunyai jejak timbunan penuh.

5. Kesimpulan

Dalam artikel ini, kami telah membahas kerentanan SQL Injection dalam aplikasi Java - ancaman yang sangat serius bagi organisasi mana pun yang bergantung pada data untuk perniagaan mereka - dan bagaimana menghalangnya menggunakan teknik sederhana.

Seperti biasa, kod penuh untuk artikel ini terdapat di Github.

Ketekunan bawah

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

>> SEMAK KURSUS