Skip to content

Defend against attempts to bypass JVM serial proxy #522

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions core/common/src/Exceptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ public class DateTimeArithmeticException: RuntimeException {
public constructor(message: String): super(message)
public constructor(cause: Throwable): super(cause)
public constructor(message: String, cause: Throwable): super(message, cause)

private companion object {
private const val serialVersionUID: Long = -3207806170214997982L
}
}

/**
Expand All @@ -23,11 +27,19 @@ public class IllegalTimeZoneException: IllegalArgumentException {
public constructor(message: String): super(message)
public constructor(cause: Throwable): super(cause)
public constructor(message: String, cause: Throwable): super(message, cause)

private companion object {
private const val serialVersionUID: Long = 1159315966274264801L
}
}

internal class DateTimeFormatException: IllegalArgumentException {
constructor(): super()
constructor(message: String): super(message)
constructor(cause: Throwable): super(cause)
constructor(message: String, cause: Throwable): super(message, cause)

private companion object {
private const val serialVersionUID: Long = 4231196759387994100L
}
}
8 changes: 7 additions & 1 deletion core/common/src/internal/format/parser/Parser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,13 @@ internal value class Parser<Output : Copyable<Output>>(
)
}

internal class ParseException(errors: List<ParseError>) : Exception(formatError(errors))
// note that the message of this exception could be anything (even null) after deserialization of a manually constructed
// or corrupted stream (via Java Object Serialization)
internal class ParseException(errors: List<ParseError>) : Exception(formatError(errors)) {
private companion object {
private const val serialVersionUID: Long = 5691186997393344103L
}
}

private fun formatError(errors: List<ParseError>): String {
if (errors.size == 1) {
Expand Down
8 changes: 8 additions & 0 deletions core/jvm/src/LocalDate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ public actual class LocalDate internal constructor(
@Suppress("FunctionName")
public actual fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat<LocalDate> =
LocalDateFormat.build(block)

// even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
// stable serialVersionUID means exceptions caused by deserialization of malicious streams will be consistent
// (InvalidClassException vs. InvalidObjectException, see MaliciousJvmSerializationTest)
private const val serialVersionUID = 7026816023079564263L
}

public actual object Formats {
Expand Down Expand Up @@ -103,6 +108,9 @@ public actual class LocalDate internal constructor(
@JvmName("toEpochDays")
internal fun toEpochDaysJvm(): Int = value.toEpochDay().clampToInt()

private fun readObject(ois: java.io.ObjectInputStream): Unit =
throw java.io.InvalidObjectException("kotlinx.datetime.LocalDate must be deserialized via kotlinx.datetime.Ser")

private fun writeReplace(): Any = Ser(Ser.DATE_TAG, this)
}

Expand Down
9 changes: 9 additions & 0 deletions core/jvm/src/LocalDateTimeJvm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,21 @@ public actual class LocalDateTime internal constructor(
@Suppress("FunctionName")
public actual fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat<LocalDateTime> =
LocalDateTimeFormat.build(builder)

// even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
// stable serialVersionUID means exceptions caused by deserialization of malicious streams will be consistent
// (InvalidClassException vs. InvalidObjectException, see MaliciousJvmSerializationTest)
private const val serialVersionUID: Long = -4261744960416354711L
}

public actual object Formats {
public actual val ISO: DateTimeFormat<LocalDateTime> = ISO_DATETIME
}

private fun readObject(ois: java.io.ObjectInputStream): Unit = throw java.io.InvalidObjectException(
"kotlinx.datetime.LocalDateTime must be deserialized via kotlinx.datetime.Ser"
)

private fun writeReplace(): Any = Ser(Ser.DATE_TIME_TAG, this)
}

Expand Down
8 changes: 8 additions & 0 deletions core/jvm/src/LocalTimeJvm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,21 @@ public actual class LocalTime internal constructor(
@Suppress("FunctionName")
public actual fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat<LocalTime> =
LocalTimeFormat.build(builder)

// even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
// stable serialVersionUID means exceptions caused by deserialization of malicious streams will be consistent
// (InvalidClassException vs. InvalidObjectException, see MaliciousJvmSerializationTest)
private const val serialVersionUID: Long = -352249606036216323L
}

public actual object Formats {
public actual val ISO: DateTimeFormat<LocalTime> get() = ISO_TIME

}

private fun readObject(ois: java.io.ObjectInputStream): Unit =
throw java.io.InvalidObjectException("kotlinx.datetime.LocalTime must be deserialized via kotlinx.datetime.Ser")

private fun writeReplace(): Any = Ser(Ser.TIME_TAG, this)
}

Expand Down
8 changes: 8 additions & 0 deletions core/jvm/src/UtcOffsetJvm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ public actual class UtcOffset(
@Suppress("FunctionName")
public actual fun Format(block: DateTimeFormatBuilder.WithUtcOffset.() -> Unit): DateTimeFormat<UtcOffset> =
UtcOffsetFormat.build(block)

// even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
// stable serialVersionUID means exceptions caused by deserialization of malicious streams will be consistent
// (InvalidClassException vs. InvalidObjectException, see MaliciousJvmSerializationTest)
private const val serialVersionUID: Long = -6636773355667981618L
}

public actual object Formats {
Expand All @@ -47,6 +52,9 @@ public actual class UtcOffset(
public actual val FOUR_DIGITS: DateTimeFormat<UtcOffset> get() = FOUR_DIGIT_OFFSET
}

private fun readObject(ois: java.io.ObjectInputStream): Unit =
throw java.io.InvalidObjectException("kotlinx.datetime.UtcOffset must be deserialized via kotlinx.datetime.Ser")

private fun writeReplace(): Any = Ser(Ser.UTC_OFFSET_TAG, this)
}

Expand Down
182 changes: 182 additions & 0 deletions core/jvm/test/MaliciousJvmSerializationTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package kotlinx.datetime

import kotlinx.datetime.MaliciousJvmSerializationTest.SerDat.Streams
import java.io.ByteArrayInputStream
import java.io.ObjectInputStream
import java.io.Serializable
import kotlin.reflect.KClass
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.fail

// TODO investigate other null stream (it's different from the one I got) from this comment:
// https://github.com/Kotlin/kotlinx-datetime/pull/373#discussion_r2009789491
// aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c44617465618443f17dae33e70200014c00097472756556616c75657400154c6a6176612f74696d652f4c6f63616c446174653b787070

class MaliciousJvmSerializationTest {

/**
* This data was generated by running the following Java code (`X` was replaced with the simple name of [clazz]):
* ```java
* package kotlinx.datetime;
*
* import java.io.*;
* import java.util.*;
*
* public class X implements Serializable {
* private final java.time.X <delegate field name> = ...;
*
* @Serial
* private static final long serialVersionUID = ...;
*
* public static void main(String[] args) throws IOException {
* var bos = new ByteArrayOutputStream();
* try (var oos = new ObjectOutputStream(bos)) {
* oos.writeObject(new X());
* }
* System.out.println(HexFormat.of().formatHex(bos.toByteArray()));
* }
* }
* ```
*/
private class SerDat(
val clazz: KClass<out Serializable>,
/** serialVersionUID had the correct value in the Java code. */
val withCorrectSVUID: Streams,
/** serialVersionUID had an incorrect value (42) in the Java code. */
val withSVUID42: Streams,
) {
class Streams(
/** <delegate field name> was set to `null` in the Java code. */
val delegateNull: String,
/** <delegate field name> was set to a valid (non-null) instance in the Java code. */
val delegateValid: String,
)
}

@Suppress("RemoveRedundantQualifierName")
private val data = listOf(
SerDat(
kotlinx.datetime.LocalDate::class,
// generated using "value" as <delegate field name> and
// java.time.LocalDate.of(2025, 4, 26) as the valid delegate
withCorrectSVUID = Streams(
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c44617465618443f17dae33e70200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c446174653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c00007870770703000007e9041a78",
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c44617465618443f17dae33e70200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c446174653b787070",
),
withSVUID42 = Streams(
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c44617465000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c446174653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c00007870770703000007e9041a78",
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c44617465000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c446174653b787070",
),
),
SerDat(
kotlinx.datetime.LocalDateTime::class,
// generated using "value" as <delegate field name> and
// java.time.LocalDateTime.of(2025, 4, 26, 11, 18) as the valid delegate
withCorrectSVUID = Streams(
delegateValid = "aced00057372001e6b6f746c696e782e6461746574696d652e4c6f63616c4461746554696d65c4db3d89c7126e690200014c000576616c75657400194c6a6176612f74696d652f4c6f63616c4461746554696d653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c00007870770905000007e9041a0bed78",
delegateNull = "aced00057372001e6b6f746c696e782e6461746574696d652e4c6f63616c4461746554696d65c4db3d89c7126e690200014c000576616c75657400194c6a6176612f74696d652f4c6f63616c4461746554696d653b787070",
),
withSVUID42 = Streams(
delegateValid = "aced00057372001e6b6f746c696e782e6461746574696d652e4c6f63616c4461746554696d65000000000000002a0200014c000576616c75657400194c6a6176612f74696d652f4c6f63616c4461746554696d653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c00007870770905000007e9041a0bed78",
delegateNull = "aced00057372001e6b6f746c696e782e6461746574696d652e4c6f63616c4461746554696d65000000000000002a0200014c000576616c75657400194c6a6176612f74696d652f4c6f63616c4461746554696d653b787070",
),
),
SerDat(
kotlinx.datetime.LocalTime::class,
// generated using "value" as <delegate field name> and
// java.time.LocalTime.of(11, 18) as the valid delegate
withCorrectSVUID = Streams(
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c54696d65fb1c8ed97ff0a5fd0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c54696d653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c000078707703040bed78",
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c54696d65fb1c8ed97ff0a5fd0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c54696d653b787070",
),
withSVUID42 = Streams(
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c54696d65000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c54696d653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c000078707703040bed78",
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c54696d65000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c54696d653b787070",
),
),
SerDat(
kotlinx.datetime.UtcOffset::class,
// generated using "zoneOffset" as <delegate field name> and
// java.time.ZoneOffset.UTC as the valid delegate
withCorrectSVUID = Streams(
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e5574634f6666736574a3e571cbd0a1face0200014c000a7a6f6e654f66667365747400164c6a6176612f74696d652f5a6f6e654f66667365743b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c000078707702080078",
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e5574634f6666736574a3e571cbd0a1face0200014c000a7a6f6e654f66667365747400164c6a6176612f74696d652f5a6f6e654f66667365743b787070",
),
withSVUID42 = Streams(
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e5574634f6666736574000000000000002a0200014c000a7a6f6e654f66667365747400164c6a6176612f74696d652f5a6f6e654f66667365743b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c000078707702080078",
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e5574634f6666736574000000000000002a0200014c000a7a6f6e654f66667365747400164c6a6176612f74696d652f5a6f6e654f66667365743b787070",
),
),
)

@OptIn(ExperimentalStdlibApi::class)
private fun deserialize(stream: String): Any? {
val bis = ByteArrayInputStream(stream.hexToByteArray())
return ObjectInputStream(bis).use { ois ->
ois.readObject()
}
}

@Test
fun deserializeMaliciousStreams() {
for (d in data) {
val className = d.clazz.qualifiedName!!
testStreamsWithCorrectSVUID(className, d.withCorrectSVUID)
testStreamsWithSVUID42(d.clazz, className, d.withSVUID42)
}
}

private fun testStreamsWithCorrectSVUID(className: String, streams: Streams) {
val testMessage = "Deserialization of a serial stream that tries to bypass kotlinx.datetime.Ser and has the " +
"correct serialVersionUID for $className should fail"

val expectedIOEMessage = "$className must be deserialized via kotlinx.datetime.Ser"

// this would actually create a valid instance, but serialization should always go through the proxy
val ioe1 = assertFailsWith<java.io.InvalidObjectException>(testMessage) {
deserialize(streams.delegateValid)
}
assertEquals(expectedIOEMessage, ioe1.message)

// this would create an instance that has null in a non-nullable field (e.g., the field
// kotlinx.datetime.LocalDate.value)
// see https://github.com/Kotlin/kotlinx-datetime/pull/373#discussion_r2008922681
val ioe2 = assertFailsWith<java.io.InvalidObjectException>(testMessage) {
deserialize(streams.delegateNull)
}
assertEquals(expectedIOEMessage, ioe2.message)
}

private fun testStreamsWithSVUID42(clazz: KClass<out Serializable>, className: String, streams: Streams) {
val testMessage = "Deserialization of a serial stream that tries to bypass kotlinx.datetime.Ser but has a " +
"wrong serialVersionUID for $className should fail"

val serialVersionUID = clazz.java
.getDeclaredField("serialVersionUID")
.apply { isAccessible = true }
.get(null) as Long
if (serialVersionUID == 42L) {
fail("This test assumes that the tested classes don't have a serialVersionUID of 42 but $className does.")
}

val expectedICEMessage = "$className; local class incompatible: stream classdesc serialVersionUID = 42, " +
"local class serialVersionUID = $serialVersionUID"

val ice1 = assertFailsWith<java.io.InvalidClassException>(testMessage) {
deserialize(streams.delegateValid)
}
assertEquals(expectedICEMessage, ice1.message)

val ice2 = assertFailsWith<java.io.InvalidClassException>(testMessage) {
deserialize(streams.delegateNull)
}
assertEquals(expectedICEMessage, ice2.message)
}
}