Deserialization mengubah byte menjadi objek dalam memori. Operasi ini terdengar teknis, namun menjadi salah satu vektor serangan paling berbahaya: sebuah payload yang tampak seperti data biasa dapat memicu eksekusi kode acak (remote code execution) saat di-deserialize oleh library yang rentan. Java Serializable adalah contoh paling terkenal—payload seperti ysoserial membangun rantai objek yang memanfaatkan method readObject pada library umum untuk menjalankan perintah arbitrer. Bahkan bila tidak mencapai RCE, deserialization dapat menyebabkan denial of service melalui objek yang sangat besar atau dalam. Pada chapter ini kita akan mempraktikkan roundtrip Serializable untuk memahami mekanismenya, lalu membangun parser JSON aman yang memvalidasi setiap field sebelum membentuk objek domain.

Mengapa Java Serializable Berbahaya #

ObjectInputStream.readObject() membangkitkan instance class apa pun yang ditemukan dalam stream. Method readObject pada class tertentu (mis. HashMap, PriorityQueue, atau library seperti Commons Collections) menjalankan kode pada proses deserialization—sering kali memanggil equals, hashCode, atau compare pada objek yang berasal dari stream. Penyerang yang tahu class mana yang ada di classpath dapat menyusun rantai objek yang memicu eksekusi perintah shell.

kotlin Anti-pattern: deserialize dari sumber tidak terpercaya
import java.io.ObjectInputStream
import java.io.FileInputStream

// BAHAYA: input berasal dari sumber eksternal; readObject dapat menjalankan kode
// val obj = ObjectInputStream(FileInputStream("/tmp/data.bin")).use { it.readObject() }

Pola yang aman: jangan pernah memanggil readObject pada data dari sumber tidak terpercaya. Bila terpaksa memproses data serial, batasi class yang diizinkan via ObjectInputFilter (JDK 9+) dan gunakan resolveClass untuk menolak class yang tidak diharapkan.

Roundtrip Serializable Sederhana #

Untuk memahami mekanismenya, mari kita lakukan roundtrip pada class PenggunaRentan yang mengimplementasikan Serializable. Operasi ini aman karena data berasal dari objek yang kita buat sendiri, bukan dari sumber eksternal.

kotlin
// deserialization_security.kt
// Java Serializable is dangerous. Use safe formats (JSON).
// Validate before use. Demonstrates safe parsing of JSON-like input.

import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.Serializable

// --- Anti-pattern: Java Serializable dengan class Arbitrary ---
// Class ini sengaja dibuat sederhana untuk demonstrasi.
// Pada produksi, menerima ObjectInputStream dari sumber tidak terpercaya
// memungkinkan gadget chain exploit (mis. ysoserial).
class PenggunaRentan(val nama: String = "", val peran: String = "viewer") : Serializable {
    companion object { private const val serialVersionUID: Long = 1L }
}

// --- Demonstrasi Java Serialization roundtrip ---
fun javaSerialize(obj: Any): ByteArray = ByteArrayOutputStream().use { baos ->
    ObjectOutputStream(baos).use { it.writeObject(obj) }
    baos.toByteArray()
}

fun javaDeserialize(bytes: ByteArray): Any = ByteArrayInputStream(bytes).use { bais ->
    ObjectInputStream(bais).use { it.readObject() }
}

// --- Pola aman: parse JSON manual ke data class dengan validasi eksplisit ---
data class PenggunaAman(val nama: String, val peran: String)

// Pola aman: parser JSON sederhana yang memvalidasi setiap field.
// Dalam praktik nyata, gunakan kotlinx.serialization yang menghasilkan
// serializer compile-time dan menolak input tak terduga.
fun parsePengguna(json: String): Result<PenggunaAman> {
    // Pola regex sederhana untuk demo; jangan gunakan pada produksi.
    val regex = Regex("""\{\s*"nama"\s*:\s*"([^"]+)"\s*,\s*"peran"\s*:\s*"([^"]+)"\s*}""")
    val match = regex.matchEntire(json.trim())
        ?: return Result.failure(IllegalArgumentException("format JSON tidak valid"))
    val nama = match.groupValues[1]
    val peran = match.groupValues[2]

    // Validasi nilai: whitelist peran yang diizinkan
    if (nama.length !in 3..32) return Result.failure(IllegalArgumentException("nama di luar rentang 3..32"))
    val peranDikenali = setOf("admin", "editor", "viewer")
    val peranFinal = peranDikenali.firstOrNull { it.equals(peran, ignoreCase = true) }
        ?: return Result.failure(IllegalArgumentException("peran tidak dikenali: $peran"))

    return Result.success(PenggunaAman(nama, peranFinal))
}

// --- Anti-pattern: cast langsung tanpa validasi ---
fun unsafeCast(input: Any): String {
    // Bila input berasal dari sumber tidak terpercaya, ini berbahaya.
    // ClassCastException dapat dimanfaatkan untuk fingerprinting class loader.
    return (input as PenggunaRentan).nama
}

fun main() {
    // Java Serialization: roundtrip untuk class Serializable sederhana
    val asli = PenggunaRentan("budi", "admin")
    val bytes = javaSerialize(asli)
    val kembali = javaDeserialize(bytes) as PenggunaRentan
    println("Java Serializable: nama=${kembali.nama}, peran=${kembali.peran}")
    println("Ukuran serialisasi: ${bytes.size} bytes")
    println()

    // Pola aman: JSON parse + validasi
    val jsonBenar = """{"nama":"budi","peran":"admin"}"""
    val jsonSalahFormat = """{"nama":"budi","peran":"admin"}"""
    val jsonPeranAneh = """{"nama":"budi","peran":"superuser"}"""
    val jsonNamaPendek = """{"nama":"bu","peran":"admin"}"""

    println("parsePengguna(jsonBenar) = ${parsePengguna(jsonBenar).getOrElse { it.message ?: "error" }}")
    println("parsePengguna(jsonSalahFormat) = ${parsePengguna(jsonSalahFormat).getOrElse { it.message ?: "error" }}")
    println("parsePengguna(jsonPeranAneh) = ${parsePengguna(jsonPeranAneh).getOrElse { it.message ?: "error" }}")
    println("parsePengguna(jsonNamaPendek) = ${parsePengguna(jsonNamaPendek).getOrElse { it.message ?: "error" }}")
    println()

    // Anti-pattern: cast langsung
    println("unsafeCast(asli) = ${unsafeCast(asli)}")
    // unsafeCast("bukan pengguna") akan melempar ClassCastException
}

Output yang Dihasilkan:

text
Java Serializable: nama=budi, peran=admin
Ukuran serialisasi: 91 bytes

parsePengguna(jsonBenar) = PenggunaAman(nama=budi, peran=admin)
parsePengguna(jsonSalahFormat) = PenggunaAman(nama=budi, peran=admin)
parsePengguna(jsonPeranAneh) = peran tidak dikenali: superuser
parsePengguna(jsonNamaPendek) = nama di luar rentang 3..32

unsafeCast(asli) = budi

Mari kita bedah bagian kunci dari kode di atas:

  1. PenggunaRentan: class sederhana dengan field nama dan peran. serialVersionUID eksplisit mencegah incompatibility saat class berubah. Pada produksi, class dengan field Serializable adalah kandidat serangan—batasi pada class yang memang harus bisa di-serial.
  2. javaSerialize/javaDeserialize: menggunakan ObjectOutputStream dan ObjectInputStream dengan pattern .use {} yang menutup stream otomatis. Output 91 byte untuk objek sederhana menunjukkan overhead format Java—jauh lebih besar daripada JSON untuk data yang sama.
  3. parsePengguna: parser JSON manual dengan regex sederhana. Implementasi produksi sebaiknya memakai kotlinx.serialization atau Jackson dengan strict mode. Fungsi memvalidasi: format JSON cocok pola, panjang nama dalam rentang, peran ada di whitelist.
  4. parsePengguna menolak superuser: walaupun format JSON benar, nilai superuser tidak ada di whitelist peran. Inilah perbedaan kunci antara validasi format (sintaks) dan validasi nilai (semantik).
  5. unsafeCast: anti-pattern. Cast as PenggunaRentan tanpa pemeriksaan is dapat memicu ClassCastException. Pada input dari sumber tidak terpercaya, cast yang gagal dapat membocorkan informasi struktur class via pesan error.

Pola Aman: Parser JSON dengan Validasi #

Format JSON lebih aman daripada Serializable karena parser JSON tidak membangkitkan instance class arbitrer. Parser modern seperti kotlinx.serialization menghasilkan serializer pada compile time—tidak ada reflection pada runtime, tidak ada class acak yang dimuat. Walaupun begitu, validasi nilai tetap diperlukan setelah parsing selesai.

kotlin Pola aman dengan kotlinx.serialization (ilustrasi)
// import kotlinx.serialization.*
// import kotlinx.serialization.json.*
//
// @Serializable
// data class PenggunaAman(val nama: String, val peran: String)
//
// fun parsePenggunaSafe(json: String): PenggunaAman {
//     val parsed = Json.decodeFromString<PenggunaAman>(json)
//     require(parsed.nama.length in 3..32) { "nama di luar rentang" }
//     require(parsed.peran in setOf("admin", "editor", "viewer")) { "peran tidak dikenali" }
//     return parsed
// }

Kode di atas memerlukan dependency kotlinx-serialization-json. Pola yang sama berlaku: deserialize ke tipe data yang spesifik, lalu validasi nilai. Hindari JsonElement generik yang mempertahankan struktur pohon tak terduga.

Validasi Format vs Validasi Nilai #

Validasi format memastikan input sesuai dengan struktur yang diharapkan (sintaks JSON, tipe field). Validasi nilai memastikan nilai field berada dalam rentang yang diizinkan oleh aturan bisnis. Keduanya wajib dilakukan secara berurutan.

Lapis validasiPertanyaanContoh
FormatApakah JSON terparse dengan benar?{"nama":"budi"} valid; {nama:budi} tidak
TipeApakah tipe field sesuai?umur: 25 valid; umur: "dua puluh lima" tidak
RentangApakah nilai dalam batas wajar?umur: 25 valid; umur: 9999 tidak
DomainApakah nilai dikenali bisnis?peran: "admin" valid; peran: "superuser" tidak

Tabel di atas menunjukkan empat lapis yang harus dilewati data sebelum dianggap aman untuk diproses. Melewatkan satu lapis membuka peluang serangan—misalnya umur: -1 lolos validasi rentang bila hanya format dan tipe yang diperiksa.

Batasan Ukuran dan Kedalaman #

Serangan denial of service via deserialization umumnya memanfaatkan struktur yang sangat dalam atau besar. JSON dengan array berjuta elemen, atau objek bersarang 1000 tingkat, dapat menghabiskan memori atau stack. Mitigasi: batasi ukuran input sebelum parsing, batasi kedalaman bersarang, batasi jumlah elemen per array.

kotlin Pembatasan ukuran input
fun parseDenganBatas(json: String): Result<PenggunaAman> {
    if (json.length > 4_096) return Result.failure(IllegalArgumentException("payload terlalu besar"))
    return parsePengguna(json)
}

Referensi Lanjutan #

Penutup #

Deserialization adalah operasi yang aman hanya bila format dan parser dipilih dengan hati-hati, serta data divalidasi sebelum dipakai. Format JSON dengan kotlinx.serialization jauh lebih aman daripada Serializable; validasi nilai tetap diperlukan setelah parsing. Chapter berikutnya akan membahas path traversal—serangan klasik yang memanfaatkan input file untuk membaca di luar direktori yang diizinkan.