Chapter sebelumnya membuktikan betapa mudahnya menyimpan objek dan memulihkannya kembali. Kemudahan itu menyimpan jebakan: saat readObject() memproses byte dari pihak lain, JVM menuruti instruksi yang tertanam di stream — menginstantiasi class demi class, menjalankan constructor serta method readObject khusus milik class tersebut — sebelum kita sempat memeriksa apa isinya. Tahun 2015, dunia Java mendapati bahwa kombinasi sifat ini dengan pustaka umum di classpath memungkinkan remote code execution hanya dengan mengirim sebuah stream rakitan; kerentanan pada Apache Commons Collections menjadi kasus paling banyak dikutip dan memicu perombakan sikap industri terhadap serialization. Chapter ini membedah sumber risikonya, biaya tersembunyinya, dan pertahanan resmi berupa filter JEP 290.

Mengapa Deserialisasi Data Tak Terpercaya Berbahaya #

Stream serialization pada dasarnya adalah program dalam bentuk biner. Setiap class yang muncul di stream diinstantiasi oleh ObjectInputStream dengan nama class yang tertulis di stream itu sendiri — nama yang sepenuhnya dikendalikan pengirim. Bila class tersebut menyediakan method readObject kustom, method itu dijalankan; bila constructor-nya berdampak samping, dampak itu terjadi. Penyerang tidak perlu mengeksploitasi bug di program kita — cukup merangkai rantai objek dari pustaka populer di classpath (disebut gadget chain) sehingga pembacaan stream mengeksekusi perintah yang diinginkan penyerang, misalnya menjalankan proses sistem operasi.

Mari kita buktikan sendiri bahwa nama class memang tertanam mentah-mentah di stream. Program berikut menyerialisasi satu objek kecil ke memori lalu membedah byte-nya:

java BedahStream.java
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public class BedahStream {

    static class Pesan implements Serializable {
        private static final long serialVersionUID = 42L;
        private final String isi;

        Pesan(String isi) {
            this.isi = isi;
        }
    }

    public static void main(String[] args) throws IOException {
        var penampung = new ByteArrayOutputStream();
        try (var keluar = new ObjectOutputStream(penampung)) {
            keluar.writeObject(new Pesan("halo"));
        }
        byte[] data = penampung.toByteArray();
        System.out.println("panjang stream : " + data.length + " byte");

        var hex = new StringBuilder();
        for (int i = 0; i < data.length && i < 20; i++) {
            hex.append(String.format("%02X ", data[i]));
        }
        System.out.println("20 byte pertama: " + hex.toString().trim());

        // Tata letak: [0-3] header, [4] penanda objek, [5] penanda class,
        // [6-7] panjang nama, lalu nama class, lalu 8 byte serialVersionUID.
        int panjangNama = ((data[6] & 0xFF) << 8) | (data[7] & 0xFF);
        String namaClass = new String(data, 8, panjangNama, StandardCharsets.UTF_8);
        System.out.println("nama class     : " + namaClass);

        long uid = ByteBuffer.wrap(data, 8 + panjangNama, 8).getLong();
        System.out.println("serialVersionUID: " + uid);
    }
}

Output yang Dihasilkan:

text
panjang stream : 72 byte
20 byte pertama: AC ED 00 05 73 72 00 11 42 65 64 61 68 53 74 72 65 61 6D 24
nama class     : BedahStream$Pesan
serialVersionUID: 42

Bedah byte tersebut menyingkap tiga kenyataan. Header AC ED 00 05 menandai format serialization Java — magic number yang dikenali setiap pemindai keamanan. Nama class binary BedahStream$Pesan tertulis apa adanya sebagai teks di dalam stream; penyerang dapat menggantinya dengan nama class mana pun yang tersedia di classpath aplikasi target. Nilai 42 di ujung adalah serialVersionUID yang kita deklarasikan — delapan byte yang menentukan diterima atau tidaknya stream oleh versi class di JVM pembaca, sekaligus pengingat bahwa stream ini terikat erat pada bentuk class Java, bukan format data netral.

Biaya Selain Keamanan #

Keamanan bukan satu-satunya keberatan. Objek Pesan yang hanya memuat string pendek memakan 72 byte karena mayoritas stream berisi metadata class — bandingkan dengan representasi JSON {"isi":"halo"} yang jauh lebih ringkas dan bisa dibaca mata. Format ini juga rapuh terhadap evolusi class: nomor versi yang tidak cocok melempar InvalidClassException, dan refactoring yang mengubah nama field bisa membuat data lama salah pulih tanpa error. Selain itu, deserialisasi membangun object graph tanpa batas ukuran — stream besar bisa memaksa JVM mengalokasikan objek dalam jumlah besar sebelum satu pun nilai diperiksa — cara klasik membuat server kehabisan memori.

Karena beban ini, tim pengembangan Java sendiri mempertimbangkan masa depan serialization. JEP 154 (Remove Serialization) mencatat arah riset jangka panjang untuk melepaskan mekanisme ini dari platform, sementara rekayasa jangka pendek berjalan lewat JEP 290 yang memberi kita filter — topik berikutnya.

Filter Deserialisasi JEP 290 #

Sejak Java 9, setiap ObjectInputStream menerima ObjectInputFilter yang diberi kesempatan memeriksa setiap class sebelum class tersebut diinstantiasi. Filter mengembalikan keputusan ALLOWED, REJECTED, atau UNDECIDED per class, dan polanya ditulis dalam string ringkas: daftar nama yang diizinkan, module/* untuk seluruh package dalam satu modul, serta !* sebagai penolakan default di akhir daftar. Filter juga dapat dipasang menyeluruh proses lewat properti sistem jdk.serialFilter — berguna untuk aplikasi lama yang sulit diubah satu per satu.

Contoh berikut tetap memakai data lokal yang kita buat sendiri — satu-satunya skenario yang aman untuk latihan. Filter hanya mengizinkan class Pegawai, seluruh class inti dari modul java.base, dan menolak segalanya yang lain:

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class FilterDeserialisasi {

    static class Pegawai implements Serializable {
        private static final long serialVersionUID = 1L;
        private final String nama;

        Pegawai(String nama) {
            this.nama = nama;
        }

        @Override
        public String toString() {
            return "Pegawai(" + nama + ")";
        }
    }

    static class SkripLama implements Serializable {
        private static final long serialVersionUID = 1L;
        public final java.util.ArrayList<String> langkah = new java.util.ArrayList<>();

        @Override
        public String toString() {
            return "SkripLama(" + langkah + ")";
        }
    }

    static byte[] tulis(Object... objek) throws IOException {
        var penampung = new ByteArrayOutputStream();
        try (var keluar = new ObjectOutputStream(penampung)) {
            for (Object o : objek) {
                keluar.writeObject(o);
            }
        }
        return penampung.toByteArray();
    }

    public static void main(String[] args) throws Exception {
        ObjectInputFilter hanyaPegawai = ObjectInputFilter.Config.createFilter(
                "FilterDeserialisasi$Pegawai;java.base/*;!*");

        byte[] aman = tulis(new Pegawai("Siti"));
        try (var masuk = new ObjectInputStream(new ByteArrayInputStream(aman))) {
            masuk.setObjectInputFilter(hanyaPegawai);
            System.out.println("dibaca : " + masuk.readObject());
        }

        byte[] mencurigakan = tulis(new Pegawai("Budi"), new SkripLama());
        try (var masuk = new ObjectInputStream(new ByteArrayInputStream(mencurigakan))) {
            masuk.setObjectInputFilter(hanyaPegawai);
            System.out.println("dibaca : " + masuk.readObject());
            try {
                System.out.println("dibaca : " + masuk.readObject());
            } catch (InvalidClassException e) {
                System.out.println("ditolak: " + e.getMessage());
            }
        }
    }
}

Output yang Dihasilkan:

text
dibaca : Pegawai(Siti)
dibaca : Pegawai(Budi)
ditolak: filter status: REJECTED

Alur program tersebut menegaskan cara kerja filter:

  1. Baris 62-63 (createFilter): pola FilterDeserialisasi$Pegawai mengizinkan class pegawai kita; java.base/* meloloskan class inti seperti String dan ArrayList yang muncul sebagai isi field; !* menolak semua class lain.
  2. Baris 65-69 (stream pertama): stream yang hanya berisi Pegawai terbaca mulus — filter tidak mengganggu data yang memang diharapkan.
  3. Baris 71-81 (stream kedua): stream rakitan memuat dua objek. readObject() pertama berhasil karena Pegawai lolos filter; readObject() kedua menabrak SkripLama yang tidak ada di daftar, sehingga InvalidClassException dengan pesan filter status: REJECTED terbang sebelum class tersebut sempat diinstantiasi — tepat di titik yang kita inginkan.
  4. Method tulis(...): helper ini hanya menciptakan stream lokal untuk keperluan latihan; pada aplikasi nyata, byte seperti mencurigakan berasal dari jaringan atau berkas unggahan — dan filter harus terpasang sebelum byte itu disentuh.

Sebagai penutup babak serialization, bandingkan dua jalur penyimpanan objek: format biner Java yang cepat untuk data internal terpercaya namun menutup kontrak di balik nama class, versus format teks seperti JSON yang lebih besar tetapi transparan, mudah diaudit, dan bisa dibaca lintas bahasa. Untuk data yang melewati batas kepercayaan, jalur teks hampir selalu menjadi pilihan yang lebih sehat.

Kita sekarang berpindah keluarga API: dari java.io yang berorientasi stream menuju NIO yang berorientasi buffer dan channel. Chapter berikutnya memperkenalkan ByteBuffer — wadah data yang menjadi alfabet seluruh operasi NIO.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

BedahStream.java

Unduh berkas
java
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public class BedahStream {

    static class Pesan implements Serializable {
        private static final long serialVersionUID = 42L;
        private final String isi;

        Pesan(String isi) {
            this.isi = isi;
        }
    }

    public static void main(String[] args) throws IOException {
        var penampung = new ByteArrayOutputStream();
        try (var keluar = new ObjectOutputStream(penampung)) {
            keluar.writeObject(new Pesan("halo"));
        }
        byte[] data = penampung.toByteArray();
        System.out.println("panjang stream : " + data.length + " byte");

        var hex = new StringBuilder();
        for (int i = 0; i < data.length && i < 20; i++) {
            hex.append(String.format("%02X ", data[i]));
        }
        System.out.println("20 byte pertama: " + hex.toString().trim());

        // Tata letak: [0-3] header, [4] penanda objek, [5] penanda class,
        // [6-7] panjang nama, lalu nama class, lalu 8 byte serialVersionUID.
        int panjangNama = ((data[6] & 0xFF) << 8) | (data[7] & 0xFF);
        String namaClass = new String(data, 8, panjangNama, StandardCharsets.UTF_8);
        System.out.println("nama class     : " + namaClass);

        long uid = ByteBuffer.wrap(data, 8 + panjangNama, 8).getLong();
        System.out.println("serialVersionUID: " + uid);
    }
}

FilterDeserialisasi.java

Unduh berkas
java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class FilterDeserialisasi {

    static class Pegawai implements Serializable {
        private static final long serialVersionUID = 1L;
        private final String nama;

        Pegawai(String nama) {
            this.nama = nama;
        }

        @Override
        public String toString() {
            return "Pegawai(" + nama + ")";
        }
    }

    static class SkripLama implements Serializable {
        private static final long serialVersionUID = 1L;
        public final java.util.ArrayList<String> langkah = new java.util.ArrayList<>();

        @Override
        public String toString() {
            return "SkripLama(" + langkah + ")";
        }
    }

    static byte[] tulis(Object... objek) throws IOException {
        var penampung = new ByteArrayOutputStream();
        try (var keluar = new ObjectOutputStream(penampung)) {
            for (Object o : objek) {
                keluar.writeObject(o);
            }
        }
        return penampung.toByteArray();
    }

    public static void main(String[] args) throws Exception {
        ObjectInputFilter hanyaPegawai = ObjectInputFilter.Config.createFilter(
                "FilterDeserialisasi$Pegawai;java.base/*;!*");

        byte[] aman = tulis(new Pegawai("Siti"));
        try (var masuk = new ObjectInputStream(new ByteArrayInputStream(aman))) {
            masuk.setObjectInputFilter(hanyaPegawai);
            System.out.println("dibaca : " + masuk.readObject());
        }

        byte[] mencurigakan = tulis(new Pegawai("Budi"), new SkripLama());
        try (var masuk = new ObjectInputStream(new ByteArrayInputStream(mencurigakan))) {
            masuk.setObjectInputFilter(hanyaPegawai);
            System.out.println("dibaca : " + masuk.readObject());
            try {
                System.out.println("dibaca : " + masuk.readObject());
            } catch (InvalidClassException e) {
                System.out.println("ditolak: " + e.getMessage());
            }
        }
    }
}