Serialisasi adalah pintu masuk data eksternal ke aplikasi. Setiap byte yang diterima dari REST API client, message queue, atau file yang diunggah adalah data yang tidak terpercaya—bisa datang dari pengguna sah, bisa juga dari penyerang. Tanpa pertahanan yang tepat, serialisasi dapat menjadi vektor serangan: Remote Code Execution via polymorphic deserialization, Denial of Service via payload besar, atau gadget chain via Java ObjectInputStream. Pada chapter ini kita akan memahami empat kategori ancaman utama, melihat decoder aman yang menolak payload berbahaya, dan tujuh lapisan pertahanan yang wajib diterapkan saat menerima data serialized.
Empat Kategori Ancaman #
Ancaman pertama: polymorphic RCE. Library seperti Jackson dengan enableDefaultTyping atau Gson dengan @class field mengizinkan JSON menyebut class mana yang harus di-instantiate. Penyerang dapat membangun JSON dengan class berbahaya yang mengeksekusi kode di constructor atau method readObject. Ancaman kedua: DoS via payload besar. JSON 10 GB dapat menghabiskan memori server; parser yang memuat seluruh string ke memori akan crash. Ancaman ketiga: billion laughs. XML kecil yang memakai entitas (<!ENTITY x100 "&x99;&x99;...">) dapat meng-ekspand ke miliaran byte di memori. Ancaman keempat: gadget chain. Java ObjectInputStream dapat mengeksekusi method arbitrary saat deserialize object—dikenal sebagai Java deserialization vulnerability.
| Ancaman | Format | Vektor | Mitigasi |
|---|---|---|---|
| Polymorphic RCE | JSON, binary | Class tidak di-whitelist | Disable default typing, whitelist class |
| DoS payload besar | JSON, XML, binary | Memori habis | Batasi ukuran payload |
| Billion laughs | XML | Entity expansion | Disable DTD, batasi ekspansi |
| Gadget chain | Java Serializable | Method arbitrary | Jangan pakai ObjectInputStream |
Decoder Aman untuk Sumber Tidak Terpercaya #
Demo berikut menunjukkan decoder JSON dengan tujuh lapisan pertahanan. Decoder menolak payload berbahaya pada berbagai tahap—sebelum parse, saat parse, dan setelah parse—dengan pesan error spesifik.
// serialization_security.kt
// Serialization security: validasi input, batasi ukuran, hindari
// polymorphic dari sumber tidak terpercaya (risiko RCE).
// Demo: validator + decoder aman untuk JSON dari sumber eksternal.
import java.time.LocalDate
// --- Bahaya 1: Polymorphic deserialization (RCE risk) ---
// Pada library seperti Jackson dengan enableDefaultTyping, JSON bisa
// menyebut class mana yang harus di-instantiate. Penyerang dapat
// membangun JSON dengan class berbahaya yang mengeksekusi kode saat
// constructor/deserialize dipanggil.
// --- Bahaya 2: DoS via payload besar ---
// JSON berukuran 10 GB dapat menghabiskan memori. Batasi ukuran maksimum.
// --- Bahaya 3: Billion laughs attack (XML) ---
// Payload XML kecil yang meng-ekspand ke miliaran byte di memory.
// Mitigasi: batasi kedalaman nested dan total ukuran setelah ekspansi.
// --- Bahaya 4: Deserialization gadget chain ---
// Java ObjectInputStream dapat mengeksekusi method arbitrary saat
// deserialize object. JANGAN pernah deserialize data dari sumber
// tidak terpercaya.
// --- Model aman: data class sederhana tanpa polimorfisme ---
data class TransaksiAman(
val id: Long,
val jumlah: Long,
val mataUang: String,
val tanggal: String,
val catatan: String? = null
)
// --- Konfigurasi decoder aman ---
class SafeDecoderConfig(
val maxPayloadBytes: Int = 1 * 1024 * 1024, // 1 MB
val maxFieldLength: Int = 10_000, // 10k char per field
val maxNestingDepth: Int = 10,
val allowedTypes: Set<String> = setOf(
"TransaksiAman", "PenggunaAman", "ProdukAman"
)
)
// --- Hasil decode ---
sealed class DecodeResult<T> {
data class Ok<T>(val value: T) : DecodeResult<T>()
data class Err<T>(val reason: String, val kind: DecodeError) : DecodeResult<T>()
}
enum class DecodeError {
PAYLOAD_TOO_LARGE,
FIELD_TOO_LONG,
NESTING_TOO_DEEP,
TYPE_NOT_ALLOWED,
INVALID_FORMAT,
REQUIRED_FIELD_MISSING,
INVALID_RANGE
}
// --- Decoder aman untuk TransaksiAman ---
class SafeTransaksiDecoder(private val config: SafeDecoderConfig) {
fun decode(json: String): DecodeResult<TransaksiAman> {
// Step 1: Batasi ukuran payload
if (json.length > config.maxPayloadBytes) {
return DecodeResult.Err(
"Payload ${json.length} bytes melebihi batas ${config.maxPayloadBytes}",
DecodeError.PAYLOAD_TOO_LARGE
)
}
// Step 2: Parse JSON (parser naif yang sudah divalidasi)
val parsed: Map<String, String?>
try {
parsed = parseSimple(json, depth = 0, config = config)
?: return DecodeResult.Err("Format JSON tidak valid", DecodeError.INVALID_FORMAT)
} catch (e: SecurityException) {
return DecodeResult.Err(e.message ?: "Decode error", DecodeError.NESTING_TOO_DEEP)
} catch (e: Exception) {
return DecodeResult.Err("Format JSON tidak valid: ${e.message}", DecodeError.INVALID_FORMAT)
}
// Step 3: Validasi field wajib
val idStr = parsed["id"] ?: return DecodeResult.Err(
"Field 'id' wajib", DecodeError.REQUIRED_FIELD_MISSING
)
val jumlahStr = parsed["jumlah"] ?: return DecodeResult.Err(
"Field 'jumlah' wajib", DecodeError.REQUIRED_FIELD_MISSING
)
val mataUang = parsed["mataUang"] ?: return DecodeResult.Err(
"Field 'mataUang' wajib", DecodeError.REQUIRED_FIELD_MISSING
)
val tanggal = parsed["tanggal"] ?: return DecodeResult.Err(
"Field 'tanggal' wajib", DecodeError.REQUIRED_FIELD_MISSING
)
val catatan = parsed["catatan"] // opsional
// Step 4: Validasi tipe dan range
val id = idStr.toLongOrNull() ?: return DecodeResult.Err(
"id harus integer", DecodeError.INVALID_FORMAT
)
if (id < 0) return DecodeResult.Err(
"id tidak boleh negatif", DecodeError.INVALID_RANGE
)
val jumlah = jumlahStr.toLongOrNull() ?: return DecodeResult.Err(
"jumlah harus integer", DecodeError.INVALID_FORMAT
)
if (jumlah < 0 || jumlah > 100_000_000L) return DecodeResult.Err(
"jumlah harus 0 - 100_000_000", DecodeError.INVALID_RANGE
)
// Step 5: Validasi format mata uang (ISO 4217)
val mataUangValid = setOf("IDR", "USD", "EUR", "GBP", "JPY", "SGD", "AUD")
if (mataUang !in mataUangValid) return DecodeResult.Err(
"mataUang tidak diizinkan: $mataUang", DecodeError.INVALID_FORMAT
)
// Step 6: Validasi format tanggal (ISO-8601)
try {
LocalDate.parse(tanggal)
} catch (e: Exception) {
return DecodeResult.Err(
"tanggal tidak valid (yyyy-MM-dd)", DecodeError.INVALID_FORMAT
)
}
// Step 7: Validasi panjang catatan
if (catatan != null && catatan.length > 500) {
return DecodeResult.Err(
"catatan maksimal 500 karakter", DecodeError.FIELD_TOO_LONG
)
}
// Step 8: Tolak field tidak dikenali (defense in depth)
val allowedKeys = setOf("id", "jumlah", "mataUang", "tanggal", "catatan")
val unknown = parsed.keys - allowedKeys
if (unknown.isNotEmpty()) {
return DecodeResult.Err(
"Field tidak dikenali: $unknown", DecodeError.TYPE_NOT_ALLOWED
)
}
return DecodeResult.Ok(TransaksiAman(id, jumlah, mataUang, tanggal, catatan))
}
private fun parseSimple(json: String, depth: Int, config: SafeDecoderConfig): Map<String, String?>? {
if (depth > config.maxNestingDepth) {
throw SecurityException("Kedalaman nested melebihi ${config.maxNestingDepth}")
}
val result = mutableMapOf<String, String?>()
val trimmed = json.trim()
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null
val body = trimmed.substring(1, trimmed.length - 1).trim()
if (body.isEmpty()) return result
// Parse key-value pairs sederhana (tanpa nested object untuk demo)
var i = 0
while (i < body.length) {
// Cari key
while (i < body.length && body[i].isWhitespace()) i++
if (i >= body.length) break
if (body[i] != '"') return null
i++
val keyStart = i
while (i < body.length && body[i] != '"') i++
val key = body.substring(keyStart, i)
if (i >= body.length) return null
i++ // skip closing quote
while (i < body.length && body[i].isWhitespace()) i++
if (i >= body.length || body[i] != ':') return null
i++
while (i < body.length && body[i].isWhitespace()) i++
// Value: string atau angka atau true/false/null
val value: String? = when {
body[i] == '"' -> {
i++
val start = i
while (i < body.length && body[i] != '"') {
if (body[i] == '\\' && i + 1 < body.length) i++
i++
}
val v = body.substring(start, i)
if (i < body.length) i++
v
}
body[i] == 'n' && body.startsWith("null", i) -> {
i += 4; null
}
body[i] == 't' && body.startsWith("true", i) -> {
i += 4; "true"
}
body[i] == 'f' && body.startsWith("false", i) -> {
i += 5; "false"
}
body[i].isDigit() || body[i] == '-' -> {
val start = i
while (i < body.length && (body[i].isDigit() || body[i] in ".-+eE")) i++
body.substring(start, i)
}
else -> return null
}
// Batasi panjang value
if (value != null && value.length > config.maxFieldLength) {
throw SecurityException("Field '$key' melebihi ${config.maxFieldLength} karakter")
}
result[key] = value
while (i < body.length && body[i].isWhitespace()) i++
if (i < body.length && body[i] == ',') i++
}
return result
}
}
// --- Demo ---
fun main() {
println("=== Serialization Security ===\n")
val decoder = SafeTransaksiDecoder(SafeDecoderConfig())
val testCases = listOf(
"valid lengkap" to """{"id":9001,"jumlah":150000,"mataUang":"IDR","tanggal":"2026-01-15","catatan":"pembayaran bulanan"}""",
"valid tanpa catatan" to """{"id":9002,"jumlah":250000,"mataUang":"USD","tanggal":"2026-01-15"}""",
"payload berbahaya: field tidak dikenali" to """{"id":9003,"jumlah":1000,"mataUang":"IDR","tanggal":"2026-01-15","class":"java.lang.Runtime"}""",
"payload berbahaya: jumlah negatif" to """{"id":9004,"jumlah":-5000,"mataUang":"IDR","tanggal":"2026-01-15"}""",
"payload berbahaya: jumlah ekstrim" to """{"id":9005,"jumlah":9999999999,"mataUang":"IDR","tanggal":"2026-01-15"}""",
"payload berbahaya: mata uang tidak valid" to """{"id":9006,"jumlah":1000,"mataUang":"XYZ","tanggal":"2026-01-15"}""",
"payload berbahaya: tanggal format salah" to """{"id":9007,"jumlah":1000,"mataUang":"IDR","tanggal":"15/01/2026"}""",
"field wajib hilang" to """{"id":9008,"mataUang":"IDR","tanggal":"2026-01-15"}""",
"JSON rusak" to """{"id":9009,"jumlah":,"mataUang":"IDR"}""",
"field terlalu panjang" to """{"id":9010,"jumlah":100,"mataUang":"IDR","tanggal":"2026-01-15","catatan":"${"x".repeat(600)}"}"""
)
for ((label, json) in testCases) {
println("--- Kasus: $label ---")
when (val r = decoder.decode(json)) {
is DecodeResult.Ok -> println("OK : ${r.value}")
is DecodeResult.Err -> println("TOLAK : [${r.kind}] ${r.reason}")
}
println()
}
println("--- DoS: payload besar ditolak di awal ---")
val bigPayload = """{"id":1,"jumlah":1,"mataUang":"IDR","tanggal":"2026-01-15","catatan":"${"x".repeat(2_000_000)}"}"""
val startTime = System.nanoTime()
val r = decoder.decode(bigPayload)
val elapsed = (System.nanoTime() - startTime) / 1_000_000
println("Hasil : [${(r as DecodeResult.Err).kind}] ${(r).reason.take(80)}...")
println("Waktu : ${elapsed}ms (ditolak dengan cepat)")
println()
println("--- Ringkasan: lapisan pertahanan ---")
println("1. Batasi ukuran payload di HTTP server sebelum parser (max request size)")
println("2. Batasi panjang field dan kedalaman nested di parser")
println("3. Tolak field tidak dikenali (jangan abaikan diam-diam)")
println("4. Validasi tipe, range, dan format sebelum dipakai")
println("5. Hindari polymorphic dari sumber tidak terpercaya")
println("6. JANGAN pakai Java ObjectInputStream untuk data eksternal")
println("7. Audit dependency library serialization untuk CVE terbaru")
}Output yang Dihasilkan:
=== Serialization Security ===
--- Kasus: valid lengkap ---
OK : TransaksiAman(id=9001, jumlah=150000, mataUang=IDR, tanggal=2026-01-15, catatan=pembayaran bulanan)
--- Kasus: valid tanpa catatan ---
OK : TransaksiAman(id=9002, jumlah=250000, mataUang=USD, tanggal=2026-01-15, catatan=null)
--- Kasus: payload berbahaya: field tidak dikenali ---
TOLAK : [TYPE_NOT_ALLOWED] Field tidak dikenali: [class]
--- Kasus: payload berbahaya: jumlah negatif ---
TOLAK : [INVALID_RANGE] jumlah harus 0 - 100_000_000
--- Kasus: payload berbahaya: jumlah ekstrim ---
TOLAK : [INVALID_RANGE] jumlah harus 0 - 100_000_000
--- Kasus: payload berbahaya: mata uang tidak valid ---
TOLAK : [INVALID_FORMAT] mataUang tidak diizinkan: XYZ
--- Kasus: payload berbahaya: tanggal format salah ---
TOLAK : [INVALID_FORMAT] tanggal tidak valid (yyyy-MM-dd)
--- Kasus: field wajib hilang ---
TOLAK : [REQUIRED_FIELD_MISSING] Field 'jumlah' wajib
--- Kasus: JSON rusak ---
TOLAK : [INVALID_FORMAT] Format JSON tidak valid
--- Kasus: field terlalu panjang ---
TOLAK : [FIELD_TOO_LONG] catatan maksimal 500 karakter
--- DoS: payload besar ditolak di awal ---
Hasil : [PAYLOAD_TOO_LARGE] Payload 2000072 bytes melebihi batas 1048576...
Waktu : 0ms (ditolak dengan cepat)
--- Ringkasan: lapisan pertahanan ---
1. Batasi ukuran payload di HTTP server sebelum parser (max request size)
2. Batasi panjang field dan kedalaman nested di parser
3. Tolak field tidak dikenali (jangan abaikan diam-diam)
4. Validasi tipe, range, dan format sebelum dipakai
5. Hindari polymorphic dari sumber tidak terpercaya
6. JANGAN pakai Java ObjectInputStream untuk data eksternal
7. Audit dependency library serialization untuk CVE terbaruMari kita bedah bagian kunci dari kode di atas:
class SafeDecoderConfig: konfigurasi decoder aman. Tiga batasan:maxPayloadBytes(default 1 MB),maxFieldLength(10 ribu karakter per field),maxNestingDepth(10 level nested).allowedTypesadalah whitelist class yang diperbolehkan—tidak ada polimorfisme dari JSON.enum class DecodeError: kategori error yang spesifik—PAYLOAD_TOO_LARGE,FIELD_TOO_LONG,NESTING_TOO_DEEP,TYPE_NOT_ALLOWED,INVALID_FORMAT,REQUIRED_FIELD_MISSING,INVALID_RANGE. Kategori membantu monitoring dan logging—kita dapat melihat jenis serangan yang paling sering muncul.SafeTransaksiDecoder.decode(json): langkah utama decoder aman. Tujuh tahap validasi: (1) cek ukuran payload; (2) parse JSON dengan batasan kedalaman; (3) cek field wajib; (4) cek tipe dan range; (5) cek enum (mata uang ISO 4217); (6) cek format tanggal; (7) cek panjang catatan; (8) tolak field tidak dikenali.- Tahap 1:
if (json.length > maxPayloadBytes): pertahanan pertama. Payload besar ditolak sebelum parse—menghemat CPU dan memori. HTTP server sebaiknya juga membatasiContent-Lengthdi tingkat request. - Tahap 2:
parseSimple(json, depth=0, config): parser dengan batasan kedalaman dan panjang field. Saat menemui nested terlalu dalam atau field terlalu panjang, parser melemparSecurityExceptionyang ditangani decoder. - Tahap 8: tolak field tidak dikenali: defense in depth. Field
"class":"java.lang.Runtime"yang dikirim penyerang untuk polymorphic RCE ditolak karena tidak ada diallowedKeys. Bahkan bila library serialization tidak sengaja mengaktifkan default typing, field tidak dikenali tidak akan diproses. - Output menunjukkan sembilan test case: valid lengkap, valid tanpa catatan, field tidak dikenali (RCE attempt), jumlah negatif, jumlah ekstrim, mata uang tidak valid, tanggal format salah, field wajib hilang, JSON rusak, field terlalu panjang. Setiap kasus menghasilkan respons spesifik—OK atau TOLAK dengan alasan.
- DoS test: payload 2 juta karakter ditolak dalam 0ms karena cek ukuran di awal. Decoder tidak pernah mem-parse payload besar—pertahanan paling efektif adalah menolak sebelum parse.
Tujuh Lapisan Pertahanan #
Berdasarkan demo di atas, tujuh lapisan pertahanan untuk serialization dari sumber tidak terpercaya. Pertama, batasi ukuran payload di HTTP server sebelum parser—maxRequestSize di Ktor, max-http-form-post-size di Spring. Kedua, batasi panjang field dan kedalaman nested di parser. Ketiga, tolak field tidak dikenali—jangan abaikan diam-diam yang dapat menyembunyikan upaya serangan. Keempat, validasi tipe, range, dan format sebelum data dipakai. Kelima, hindari polymorphic dari sumber tidak terpercaya—gunakan sealed class dengan subclass yang sudah diketahui, bukan Any atau default typing. Keenam, jangan pernah pakai Java ObjectInputStream untuk data eksternal—selalu pakai format modern seperti JSON atau ProtoBuf. Ketujuh, audit dependency library serialization untuk CVE terbaru—update segera bila ada vulnerability.
Pola Decoder Aman #
Pola decoder aman mengikuti prinsip fail secure, fail closed. Setiap tahap yang gagal langsung menolak payload—tidak ada fallback ke mode longgar. Hasil decode dibungkus dalam sealed class DecodeResult<T>—pemanggil dipaksa menangani kedua kasus (Ok dan Err) di when. Tidak ada null yang dapat lolos dan menyebabkan NullPointerException di kode bisnis.
when (val r = decoder.decode(json)) {
is DecodeResult.Ok -> prosesTransaksi(r.value)
is DecodeResult.Err -> {
log.warn("Decode ditolak: ${r.kind} - ${r.reason}")
respondBadRequest(r.reason)
}
}Referensi Lanjutan #
- OWASP Cheat Sheet Series — panduan OWASP untuk mencegah risiko keamanan saat deserialisasi data
- kotlinx.serialization README
— dokumentasi
kotlinx.serializationuntuk mengonfigurasi decoder yang aman
Penutup #
Serialisasi adalah pintu masuk yang harus dijaga ketat. Empat kategori ancaman—polymorphic RCE, DoS payload besar, billion laughs, gadget chain—memerlukan tujuh lapisan pertahanan: batasi ukuran, batasi field/kedalaman, tolak field tidak dikenali, validasi tipe/range/format, hindari polimorfisme tidak terpercaya, jangan pakai ObjectInputStream, audit dependency. Dengan decoder aman di boundary, kode bisnis dapat fokus pada logika tanpa defensif berlebihan. Ini menutup section Serialization dan Data Formats dengan pemahaman lengkap dari konsep hingga keamanan.