Java serialization terasa seperti kemudahan yang menyenangkan: satu panggilan writeObject mengubah objek menjadi byte yang bisa disimpan atau dikirim, dan readObject membangunkannya kembali utuh — field, tipe, bahkan struktur grafik objeknya. Namun di balik kemudahan itu tersembunyi perbedaan fundamental dari sekadar mem-parse data: deserialisasi merekonstruksi objek hidup, dan merekonstruksi objek berarti menjalankan kode — constructor, block initializer, dan method khusus seperti readObject pada class-class di classpath aplikasi kita. Ketika data yang direkonstruksi berasal dari pihak yang tidak dipercaya, pihak itu pada dasarnya memilih class mana yang dieksekusi JVM dan dengan argumen apa. Bab ini menjelaskan mengapa risikonya begitu serius, memperkenalkan ObjectInputFilter (JEP 290) sebagai pagar resmi JDK, dan menutup dengan alternatif format data yang jauh lebih aman.
Bagaimana Deserialisasi Menjadi Pintu Eksekusi #
Ancaman deserialisasi bekerja lewat gadget chain: rangkaian class-library biasa — koleksi, proxy, atau class aplikasi yang menimpa readObject — yang disusun beruntun sehingga rekonstruksi grafiknya memicu efek berbahaya, dari menulis berkas hingga menjalankan perintah sistem. Penyerang tidak perlu mengirimkan kode Java mentah; penyerang cukup mengirimkan stream serialization yang dirangkai dari class-class yang sudah ada di classpath aplikasi yang diserang. Perbedaan dengan injeksi SQL atau XSS penting dipahami: pada injeksi, data disisipkan ke dalam kode; pada serangan deserialisasi, data menjadi kode karena formatnya memang format objek, bukan format data.
Yang membuat risikonya meluas di ekosistem Java adalah JSON library berlabel “Java serialization” — yang membangun objek berdasar nama class yang dikirim — di samping ObjectInputStream bawaan. Ciri umum keduanya sama: nama class dikendalikan pengirim, JVM memilih class itu dari classpath, lalu menghidupkannya.
Filter Deserialisasi JEP 290 #
JDK memiliki mekanisme pertahanan resmi sejak Java 9: serialization filtering (JEP 290). Filter diberi tahu setiap class yang akan direkonstruksi, kedalaman grafik, ukuran array, dan total referensi — lalu memutuskan izinkan, tolak, atau serahkan ke filter berikutnya. Program berikut memakai filter allowlist sederhana: hanya Tugas yang boleh hidup kembali; stream berisi class lain ditolak mentah:
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public class FilterDeserialisasi {
// Dua record serializable sederhana untuk demonstrasi.
record Tugas(String judul, int prioritas) implements Serializable { }
record Catatan(String isi) implements Serializable { }
public static void main(String[] args) throws Exception {
byte[] dataTugas;
try (var keluar = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(keluar)) {
oos.writeObject(new Tugas("Backup harian", 1));
dataTugas = keluar.toByteArray();
}
byte[] dataCatatan;
try (var keluar = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(keluar)) {
oos.writeObject(new Catatan("rapat jam 9"));
dataCatatan = keluar.toByteArray();
}
System.out.println("ukuran stream Tugas : " + dataTugas.length + " byte");
System.out.println("ukuran stream Catatan: " + dataCatatan.length + " byte");
// Filter JEP 290: hanya class Tugas yang boleh direkonstruksi;
// class lain apapun langsung ditolak (fail closed).
ObjectInputFilter hanyaTugas = ObjectInputFilter.allowFilter(
kelas -> Tugas.class.equals(kelas),
ObjectInputFilter.Status.REJECTED);
try (var ois = new ObjectInputStream(new ByteArrayInputStream(dataTugas))) {
ois.setObjectInputFilter(hanyaTugas);
Object hasil = ois.readObject();
System.out.println("Tugas diterima : " + hasil);
}
try (var ois = new ObjectInputStream(new ByteArrayInputStream(dataCatatan))) {
ois.setObjectInputFilter(hanyaTugas);
try {
Object hasil = ois.readObject();
System.out.println("Catatan diterima: " + hasil);
} catch (InvalidClassException e) {
System.out.println("Catatan DITOLAK : " + e.getMessage());
}
}
}
}Output yang Dihasilkan:
ukuran stream Tugas : 107 byte
ukuran stream Catatan: 89 byte
Tugas diterima : Tugas[judul=Backup harian, prioritas=1]
Catatan DITOLAK : filter status: REJECTEDStream Tugas lolos dan objeknya hidup kembali utuh; stream Catatan — class yang tidak ada dalam allowlist — ditolak sebelum satu byte field-nya pun dibaca, dan readObject melontarkan InvalidClassException. Di sinilah letak perbedaan mendasarnya: tanpa filter, JVM akan dengan senang hati merekonstruksi class apa pun yang disebut stream; dengan filter, keputusan berada di tangan kode kita, bukan di tangan pengirim.
Bedah baris-per-baris untuk bagian filter:
- Baris 36-38 (
ObjectInputFilter.allowFilter(...)): factory method membangun filter dariPredicate<Class<?>>dan status untuk class yang tidak cocok — di siniREJECTED, artinya allowlist ketat. StatusUNDECIDEDdipakai bila beberapa filter dirangkai berlapis (pattern filter global + filter per-stream). - Baris 41 (
ois.setObjectInputFilter(hanyaTugas)): filter dipasang pada instanceObjectInputStreamsebelumreadObjectdipanggil — setelahreadObjectberjalan, memasang filter terlambat. Setiap stream yang masuk dari sumber berbeda bisa (dan sebaiknya) membawa filternya sendiri. - Baris 49-51 (
catch (InvalidClassException e)): penolakan filter muncul sebagai exception bertipe jelas, sehingga pemanggil bisa memperlakukannya sebagai data tidak sah — dicatat, dijawab dengan error, dan tidak dicoba ulang.
Filter juga membatasi bentuk grafik, bukan hanya nama class: kedalaman, jumlah referensi, dan ukuran stream mencegah penyerang mengirim grafik raksasa yang menghabiskan memori (denial of service) meski class-nya terlihat polos. Filter bentuk ini bisa ditulis sebagai lambda yang memeriksa FilterInfo, atau dibangun dari string pola:
ObjectInputFilter batas = info -> {
if (info.serialClass() != null && !Tugas.class.equals(info.serialClass())) {
return ObjectInputFilter.Status.REJECTED; // class di luar allowlist
}
if (info.depth() > 4 || info.references() > 64) {
return ObjectInputFilter.Status.REJECTED; // grafik terlalu dalam/lebar
}
if (info.streamBytes() > 1_000_000) {
return ObjectInputFilter.Status.REJECTED; // stream terlalu besar
}
return ObjectInputFilter.Status.ALLOWED;
};
// alternatif ringkas lewat string pola:
// ObjectInputFilter.Config.createFilter("maxdepth=4;maxrefs=64;maxbytes=1000000");Alternatif yang Lebih Aman: Format Data #
Pertahanan terbaik terhadap deserialization berbahaya adalah tidak memakainya untuk data lintas batas. Format data — JSON, Protocol Buffers, Avro — di-parse, bukan direkonstruksi: parser membaca nilai bernama dan tipe tetap ke dalam struktur yang kita definisikan, bukan struktur yang dipilih pengirim. Pengirim JSON tidak bisa memerintahkan “hidupkan class Runtime”; yang bisa dikirim hanyalah teks, angka, dan struktur bersarang, dan kode kita yang memutuskan nilai itu menjadi apa.
// Parser hanya menghasilkan nilai generik; class dipilih kode kita, bukan pengirim.
var pembaca = com.fasterxml.jackson.databind.json.JsonMapper.builder().build();
Tugas tugas = pembaca.readValue(jsonMasukan, Tugas.class);
// Meski konsep - Jackson tidak dibundel JDK; pola berlaku untuk parser JSON apapun.Aturan mainnya satu kalimat: lintas trust boundary, kirim data yang diparse; Java serialization hanya untuk komunikasi antar komponen yang saling mempercayai penuh dan berbagi versi class — dan tetap dengan filter terpasang.
Ada satu kelas serangan lagi yang bekerja lewat jalur berkas dan nama path yang dikendalikan pengirim, tanpa menyentuh JVM object stream sama sekali. Bab berikutnya, Path Traversal, membahas bagaimana string ../ yang tampak polos bisa membawa akses berkas keluar dari direktori yang kita maksudkan — dan bagaimana Path.normalize() menutupnya.
Referensi #
- JEP 290: Filter Incoming Serialization Data
- Oracle: java.io ObjectInputFilter
- OWASP Deserialization Cheat Sheet
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
FilterDeserialisasi.java
Unduh berkasimport java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public class FilterDeserialisasi {
// Dua record serializable sederhana untuk demonstrasi.
record Tugas(String judul, int prioritas) implements Serializable { }
record Catatan(String isi) implements Serializable { }
public static void main(String[] args) throws Exception {
byte[] dataTugas;
try (var keluar = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(keluar)) {
oos.writeObject(new Tugas("Backup harian", 1));
dataTugas = keluar.toByteArray();
}
byte[] dataCatatan;
try (var keluar = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(keluar)) {
oos.writeObject(new Catatan("rapat jam 9"));
dataCatatan = keluar.toByteArray();
}
System.out.println("ukuran stream Tugas : " + dataTugas.length + " byte");
System.out.println("ukuran stream Catatan: " + dataCatatan.length + " byte");
// Filter JEP 290: hanya class Tugas yang boleh direkonstruksi;
// class lain apapun langsung ditolak (fail closed).
ObjectInputFilter hanyaTugas = ObjectInputFilter.allowFilter(
kelas -> Tugas.class.equals(kelas),
ObjectInputFilter.Status.REJECTED);
try (var ois = new ObjectInputStream(new ByteArrayInputStream(dataTugas))) {
ois.setObjectInputFilter(hanyaTugas);
Object hasil = ois.readObject();
System.out.println("Tugas diterima : " + hasil);
}
try (var ois = new ObjectInputStream(new ByteArrayInputStream(dataCatatan))) {
ois.setObjectInputFilter(hanyaTugas);
try {
Object hasil = ois.readObject();
System.out.println("Catatan diterima: " + hasil);
} catch (InvalidClassException e) {
System.out.println("Catatan DITOLAK : " + e.getMessage());
}
}
}
}