Why is kotlinx.serialization's @Serializable generally considered safer against arbitrary-gadget deserialization attacks than reflection-based deserializers like Java's ObjectInputStream or Jackson default typing?
answer
- Codegen = fixed SerialDescriptor + explicit constructor call
- No reflective newInstance, no readObject callback chain
- Removes the gadget class, NOT input validation
- init {} for invariants; sealed for polymorphism
- Untrusted parse throws SerializationException
basics
~20 sKotlin generates serialization code at compile time. It only fills fields of types you declared, so attacker JSON cannot make it build a random object or run hidden code the way classic Java deserialization gadgets do.
solid answer
~40 sThe @Serializable annotation triggers a compiler plugin that generates a KSerializer at compile time. Decoding walks a fixed, statically-known SerialDescriptor: each property is read by name/index and the constructor is called explicitly, so the input controls field values, not which classes get instantiated or which methods run. There is no reflective newInstance of arbitrary types and no magic readObject/finalize callback chain, which is what classic Java ObjectInputStream and Jackson polymorphic default typing gadget attacks exploit. The closed set of constructable types makes RCE-by-payload effectively impossible by default. It is NOT a free pass, though: values are still untrusted, polymorphic 'type' discriminators must be restricted to a registered sealed/SerializersModule set, and you still validate ranges/invariants. Codegen removes the gadget class, not the need for input validation.
code
kotlin · 11 lines@Serializable
data class Transfer(val amount: Long, val currency: String) {
init {
require(amount > 0) { "amount must be positive" }
require(currency.length == 3) { "currency must be ISO-4217 code" }
}
}
fun parse(json: String): Result<Transfer> = runCatching {
Json.decodeFromString<Transfer>(json)
} // catches SerializationException + IllegalArgumentException from init {}go deeper
Knows @Serializable generates code at compile time and that you still must check values; may not articulate the gadget-chain mechanism.
Explains fixed SerialDescriptor + explicit constructor call removing arbitrary instantiation, and that values stay untrusted.
Contrasts with ObjectInputStream/Jackson default typing, ties safety to closed type set, prescribes init {} + sealed + SerializationException handling.
Frames it as eliminating one primitive (type choice) while reasoning about residual risks (polymorphic modules, DoS via size/depth, value invariants) across a trust boundary and team conventions.
## The threat: gadget-chain deserialization A *deserialization gadget attack* is when an attacker sends crafted serialized data and the deserializer, while rebuilding objects, instantiates attacker-chosen classes and triggers their side-effecting methods (constructors, `readObject`, setters, finalizers). Chaining these 'gadgets' present on the classpath can reach code that executes commands (RCE). Java's `ObjectInputStream` is the textbook case: the *type to instantiate is encoded in the stream*, and lifecycle callbacks run automatically. Jackson with **default typing** (`enableDefaultTyping`/`@JsonTypeInfo` with an open base) had the same problem: a JSON field named `@class` told Jackson which class to build via reflection. ## Why @Serializable removes the gadget class `@Serializable` is processed by the **kotlinx.serialization compiler plugin**. At *compile time* it generates a `KSerializer<T>` whose: - **`SerialDescriptor`** is a fixed list of the declared properties (names, indices, types) — frozen at compile time. - **`deserialize`** reads elements in a loop via a `CompositeDecoder` (`decodeElementIndex`, `decodeStringElement`, etc.) and then calls the **real constructor** `T(...)` with the decoded values. So the *shape of the object graph is decided by your source code*, not by the payload. The input can only supply *values* for *types you declared*. There is: - No reflective `Class.forName(...).newInstance()` of an arbitrary attacker-named class. - No automatic `readObject`/`finalize`/setter callback chain. That is exactly the property that makes gadget chains unbuildable by default. ```kotlin import kotlinx.serialization.* import kotlinx.serialization.json.Json @Serializable data class Transfer(val amount: Long, val currency: String) // Decoding can only ever produce a Transfer; it cannot be coerced // into instantiating some classpath gadget class. val t = Json.decodeFromString<Transfer>(untrustedJson) ``` ## Where the danger comes back — and the required guards 1. **Values are still untrusted.** `amount` could be negative, `currency` could be `""`. Codegen does not validate semantics. Enforce invariants in an `init {}` block so an invalid object can never be constructed. 2. **Polymorphism reintroduces type choice.** With `@JsonClassDiscriminator`/`PolymorphicSerializer`, a `"type"` field selects a subtype. If the base is *open polymorphic* and you register an unbounded `SerializersModule`, you are back toward type-driven instantiation. Prefer **sealed-class polymorphism**, where the constructable subtypes are a closed, compiler-known set; distrust the discriminator string and let it only resolve within that closed set. 3. **Parsing untrusted input throws.** Malformed/oversized input raises `SerializationException` (and `IllegalArgumentException` from your `init {}` / `require`). Untrusted-input boundaries must catch these and return a safe error — never let them bubble as a 500 or leak internals. ```kotlin @Serializable data class Transfer(val amount: Long, val currency: String) { init { require(amount > 0) { "amount must be positive" } require(currency.length == 3) { "currency must be ISO-4217" } } } ``` ## Summary Compile-time codegen eliminates the *arbitrary-class-instantiation* primitive, so payload-driven RCE is not possible by default — but it does **not** validate values, does **not** make polymorphic discriminators trustworthy, and the parse step still throws on hostile input. Treat decoded data as untrusted: validate in `init {}`, close the polymorphic type set, and handle `SerializationException`.
- Does @Serializable use reflection at runtime?No for the generated path: the compiler plugin emits a KSerializer, so decoding is direct code. Reflection-based fallback exists only for some runtime-resolved/contextual cases, which is exactly why the compiled path is preferred and safer.
- If codegen is safe, why still validate in init {}?Codegen guarantees only the right *shape/type*, never valid *values*. amount=-5 or a 50-char currency would happily decode; init {}/require makes an invalid instance unconstructable.
Java ObjectInputStream is a blank check ('build whatever class the data names'); @Serializable is a pre-printed form where only the values are fillable.
saying these in an interview costs you the question
- Claiming @Serializable validates business rules or ranges automatically
- Saying decoded objects are trusted because parsing succeeded
- Confusing 'no gadget RCE' with 'no input validation needed'
- Believing open polymorphic typing is as safe as sealed polymorphism
- Thinking it works by runtime reflection like Jackson by default