Skip to content

Commit ac3af50

Browse files
authored
Defend against attempts to bypass JVM serial proxy (#522)
Also add serialVersionUID for exception classes. This ensures that unrelated changes in these exception classes don't affect the serialized form. The serialVersionUIDs are all set to 0 for simplicity. This change should not impact any legitimate users.
1 parent 1a1e102 commit ac3af50

File tree

6 files changed

+242
-0
lines changed

6 files changed

+242
-0
lines changed

core/jvm/src/LocalDate.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ public actual class LocalDate internal constructor(
5252
@Suppress("FunctionName")
5353
public actual fun Format(block: DateTimeFormatBuilder.WithDate.() -> Unit): DateTimeFormat<LocalDate> =
5454
LocalDateFormat.build(block)
55+
56+
// Even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
57+
// stable serialVersionUID is useful for testing, see MaliciousJvmSerializationTest.
58+
private const val serialVersionUID: Long = 0L
5559
}
5660

5761
public actual object Formats {
@@ -107,6 +111,9 @@ public actual class LocalDate internal constructor(
107111
@JvmName("toEpochDays")
108112
internal fun toEpochDaysJvm(): Int = value.toEpochDay().clampToInt()
109113

114+
private fun readObject(ois: java.io.ObjectInputStream): Unit =
115+
throw java.io.InvalidObjectException("kotlinx.datetime.LocalDate must be deserialized via kotlinx.datetime.Ser")
116+
110117
private fun writeReplace(): Any = Ser(Ser.DATE_TAG, this)
111118
}
112119

core/jvm/src/LocalDateTimeJvm.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,20 @@ public actual class LocalDateTime internal constructor(
106106
@Suppress("FunctionName")
107107
public actual fun Format(builder: DateTimeFormatBuilder.WithDateTime.() -> Unit): DateTimeFormat<LocalDateTime> =
108108
LocalDateTimeFormat.build(builder)
109+
110+
// Even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
111+
// stable serialVersionUID is useful for testing, see MaliciousJvmSerializationTest.
112+
private const val serialVersionUID: Long = 0L
109113
}
110114

111115
public actual object Formats {
112116
public actual val ISO: DateTimeFormat<LocalDateTime> = ISO_DATETIME
113117
}
114118

119+
private fun readObject(ois: java.io.ObjectInputStream): Unit = throw java.io.InvalidObjectException(
120+
"kotlinx.datetime.LocalDateTime must be deserialized via kotlinx.datetime.Ser"
121+
)
122+
115123
private fun writeReplace(): Any = Ser(Ser.DATE_TIME_TAG, this)
116124
}
117125

core/jvm/src/LocalTimeJvm.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,20 @@ public actual class LocalTime internal constructor(
8585
@Suppress("FunctionName")
8686
public actual fun Format(builder: DateTimeFormatBuilder.WithTime.() -> Unit): DateTimeFormat<LocalTime> =
8787
LocalTimeFormat.build(builder)
88+
89+
// Even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
90+
// stable serialVersionUID is useful for testing, see MaliciousJvmSerializationTest.
91+
private const val serialVersionUID: Long = 0L
8892
}
8993

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

9397
}
9498

99+
private fun readObject(ois: java.io.ObjectInputStream): Unit =
100+
throw java.io.InvalidObjectException("kotlinx.datetime.LocalTime must be deserialized via kotlinx.datetime.Ser")
101+
95102
private fun writeReplace(): Any = Ser(Ser.TIME_TAG, this)
96103
}
97104

core/jvm/src/UtcOffsetJvm.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public actual class UtcOffset(
4040
@Suppress("FunctionName")
4141
public actual fun Format(block: DateTimeFormatBuilder.WithUtcOffset.() -> Unit): DateTimeFormat<UtcOffset> =
4242
UtcOffsetFormat.build(block)
43+
44+
// Even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
45+
// stable serialVersionUID is useful for testing, see MaliciousJvmSerializationTest.
46+
private const val serialVersionUID: Long = 0L
4347
}
4448

4549
public actual object Formats {
@@ -48,6 +52,9 @@ public actual class UtcOffset(
4852
public actual val FOUR_DIGITS: DateTimeFormat<UtcOffset> get() = FOUR_DIGIT_OFFSET
4953
}
5054

55+
private fun readObject(ois: java.io.ObjectInputStream): Unit =
56+
throw java.io.InvalidObjectException("kotlinx.datetime.UtcOffset must be deserialized via kotlinx.datetime.Ser")
57+
5158
private fun writeReplace(): Any = Ser(Ser.UTC_OFFSET_TAG, this)
5259
}
5360

core/jvm/src/YearMonthJvm.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ public actual class YearMonth internal constructor(
5555
@Suppress("FunctionName")
5656
public actual fun Format(block: DateTimeFormatBuilder.WithYearMonth.() -> Unit): DateTimeFormat<YearMonth> =
5757
YearMonthFormat.build(block)
58+
59+
// Even though this class uses writeReplace (so serialVersionUID is not needed for a stable serialized form), a
60+
// stable serialVersionUID is useful for testing, see MaliciousJvmSerializationTest.
61+
private const val serialVersionUID: Long = 0L
5862
}
5963

6064
public actual object Formats {
@@ -73,6 +77,9 @@ public actual class YearMonth internal constructor(
7377

7478
override fun hashCode(): Int = value.hashCode()
7579

80+
private fun readObject(ois: java.io.ObjectInputStream): Unit =
81+
throw java.io.InvalidObjectException("kotlinx.datetime.YearMonth must be deserialized via kotlinx.datetime.Ser")
82+
7683
private fun writeReplace(): Any = Ser(Ser.YEAR_MONTH_TAG, this)
7784
}
7885

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*
2+
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
3+
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
4+
*/
5+
6+
package kotlinx.datetime.test
7+
8+
import kotlinx.datetime.test.MaliciousJvmSerializationTest.TestCase.Streams
9+
import java.io.ByteArrayInputStream
10+
import java.io.ObjectInputStream
11+
import java.io.ObjectStreamClass
12+
import java.io.Serializable
13+
import kotlin.reflect.KClass
14+
import kotlin.test.Test
15+
import kotlin.test.assertEquals
16+
import kotlin.test.assertFailsWith
17+
import kotlin.test.fail
18+
19+
class MaliciousJvmSerializationTest {
20+
21+
/**
22+
* This data was generated by running the following Java code (`X` was replaced with [clazz]`.simpleName`, `Y` with
23+
* [delegate]`::class.qualifiedName` and `z` with [delegateFieldName]):
24+
* ```java
25+
* package kotlinx.datetime;
26+
*
27+
* import java.io.*;
28+
* import java.util.*;
29+
*
30+
* public class X implements Serializable {
31+
* private final Y z = ...;
32+
*
33+
* @Serial
34+
* private static final long serialVersionUID = ...;
35+
*
36+
* public static void main(String[] args) throws IOException {
37+
* var bos = new ByteArrayOutputStream();
38+
* try (var oos = new ObjectOutputStream(bos)) {
39+
* oos.writeObject(new X());
40+
* }
41+
* System.out.println(HexFormat.of().formatHex(bos.toByteArray()));
42+
* }
43+
* }
44+
* ```
45+
*/
46+
private class TestCase(
47+
val clazz: KClass<out Serializable>,
48+
val delegateFieldName: String,
49+
val delegate: Serializable,
50+
/** `serialVersionUID` was set to the correct value (`0L`) in the Java code. */
51+
val withCorrectSVUID: Streams,
52+
/** `serialVersionUID` was set to an incorrect value (`42L`) in the Java code. */
53+
val withIncorrectSVUID: Streams,
54+
) {
55+
class Streams(
56+
/** `z` was set to [delegate] in the Java code. */
57+
val delegateValid: String,
58+
/** `z` was set to `null` in the Java code. */
59+
val delegateNull: String,
60+
)
61+
}
62+
63+
private val testCases = listOf(
64+
TestCase(
65+
kotlinx.datetime.LocalDate::class,
66+
delegateFieldName = "value",
67+
delegate = java.time.LocalDate.of(2025, 4, 26),
68+
withCorrectSVUID = Streams(
69+
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c4461746500000000000000000200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c446174653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c00007870770703000007e9041a78",
70+
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c4461746500000000000000000200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c446174653b787070",
71+
),
72+
withIncorrectSVUID = Streams(
73+
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c44617465000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c446174653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c00007870770703000007e9041a78",
74+
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c44617465000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c446174653b787070",
75+
),
76+
),
77+
TestCase(
78+
kotlinx.datetime.LocalDateTime::class,
79+
delegateFieldName = "value",
80+
delegate = java.time.LocalDateTime.of(2025, 4, 26, 11, 18),
81+
withCorrectSVUID = Streams(
82+
delegateValid = "aced00057372001e6b6f746c696e782e6461746574696d652e4c6f63616c4461746554696d6500000000000000000200014c000576616c75657400194c6a6176612f74696d652f4c6f63616c4461746554696d653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c00007870770905000007e9041a0bed78",
83+
delegateNull = "aced00057372001e6b6f746c696e782e6461746574696d652e4c6f63616c4461746554696d6500000000000000000200014c000576616c75657400194c6a6176612f74696d652f4c6f63616c4461746554696d653b787070",
84+
),
85+
withIncorrectSVUID = Streams(
86+
delegateValid = "aced00057372001e6b6f746c696e782e6461746574696d652e4c6f63616c4461746554696d65000000000000002a0200014c000576616c75657400194c6a6176612f74696d652f4c6f63616c4461746554696d653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c00007870770905000007e9041a0bed78",
87+
delegateNull = "aced00057372001e6b6f746c696e782e6461746574696d652e4c6f63616c4461746554696d65000000000000002a0200014c000576616c75657400194c6a6176612f74696d652f4c6f63616c4461746554696d653b787070",
88+
),
89+
),
90+
TestCase(
91+
kotlinx.datetime.LocalTime::class,
92+
delegateFieldName = "value",
93+
delegate = java.time.LocalTime.of(11, 18),
94+
withCorrectSVUID = Streams(
95+
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c54696d6500000000000000000200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c54696d653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c000078707703040bed78",
96+
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c54696d6500000000000000000200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c54696d653b787070",
97+
),
98+
withIncorrectSVUID = Streams(
99+
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c54696d65000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c54696d653b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c000078707703040bed78",
100+
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e4c6f63616c54696d65000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f4c6f63616c54696d653b787070",
101+
),
102+
),
103+
TestCase(
104+
kotlinx.datetime.UtcOffset::class,
105+
delegateFieldName = "zoneOffset",
106+
delegate = java.time.ZoneOffset.UTC,
107+
withCorrectSVUID = Streams(
108+
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e5574634f666673657400000000000000000200014c000a7a6f6e654f66667365747400164c6a6176612f74696d652f5a6f6e654f66667365743b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c000078707702080078",
109+
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e5574634f666673657400000000000000000200014c000a7a6f6e654f66667365747400164c6a6176612f74696d652f5a6f6e654f66667365743b787070",
110+
),
111+
withIncorrectSVUID = Streams(
112+
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e5574634f6666736574000000000000002a0200014c000a7a6f6e654f66667365747400164c6a6176612f74696d652f5a6f6e654f66667365743b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c000078707702080078",
113+
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e5574634f6666736574000000000000002a0200014c000a7a6f6e654f66667365747400164c6a6176612f74696d652f5a6f6e654f66667365743b787070",
114+
),
115+
),
116+
TestCase(
117+
kotlinx.datetime.YearMonth::class,
118+
delegateFieldName = "value",
119+
delegate = java.time.YearMonth.of(2025, 4),
120+
withCorrectSVUID = Streams(
121+
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e596561724d6f6e746800000000000000000200014c000576616c75657400154c6a6176612f74696d652f596561724d6f6e74683b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c0000787077060c000007e90478",
122+
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e596561724d6f6e746800000000000000000200014c000576616c75657400154c6a6176612f74696d652f596561724d6f6e74683b787070",
123+
),
124+
withIncorrectSVUID = Streams(
125+
delegateValid = "aced00057372001a6b6f746c696e782e6461746574696d652e596561724d6f6e7468000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f596561724d6f6e74683b78707372000d6a6176612e74696d652e536572955d84ba1b2248b20c0000787077060c000007e90478",
126+
delegateNull = "aced00057372001a6b6f746c696e782e6461746574696d652e596561724d6f6e7468000000000000002a0200014c000576616c75657400154c6a6176612f74696d652f596561724d6f6e74683b787070",
127+
),
128+
),
129+
)
130+
131+
@OptIn(ExperimentalStdlibApi::class)
132+
private fun deserialize(stream: String): Any? {
133+
val bis = ByteArrayInputStream(stream.hexToByteArray())
134+
return ObjectInputStream(bis).use { ois ->
135+
ois.readObject()
136+
}
137+
}
138+
139+
@Test
140+
fun deserializeMaliciousStreams() {
141+
for (testCase in testCases) {
142+
testCase.ensureAssumptionsHold()
143+
val className = testCase.clazz.qualifiedName!!
144+
testStreamsWithCorrectSVUID(className, testCase.withCorrectSVUID)
145+
testStreamsWithIncorrectSVUID(className, testCase.withIncorrectSVUID)
146+
}
147+
}
148+
149+
private fun TestCase.ensureAssumptionsHold() {
150+
val className = clazz.qualifiedName!!
151+
val objectStreamClass = ObjectStreamClass.lookup(clazz.java)
152+
153+
val actualSerialVersionUID = objectStreamClass.serialVersionUID
154+
if (actualSerialVersionUID != 0L) {
155+
fail("This test assumes that the serialVersionUID of $className is 0, but it was $actualSerialVersionUID.")
156+
}
157+
158+
val field = objectStreamClass.fields.singleOrNull()
159+
if (field == null || field.name != delegateFieldName || field.type != delegate.javaClass) {
160+
fail(
161+
"This test assumes that $className has a single serializable field named '$delegateFieldName' of " +
162+
"type ${delegate::class.qualifiedName}. The test case for $className should be updated with new " +
163+
"malicious serial streams that represent the changes to $className."
164+
)
165+
}
166+
}
167+
168+
private fun testStreamsWithCorrectSVUID(className: String, streams: Streams) {
169+
val testFailureMessage = "Deserialization of a serial stream that tries to bypass kotlinx.datetime.Ser and " +
170+
"has the correct serialVersionUID for $className should fail"
171+
172+
val expectedIOEMessage = "$className must be deserialized via kotlinx.datetime.Ser"
173+
174+
// this would actually create a valid instance, but serialization should always go through the proxy
175+
val ioe1 = assertFailsWith<java.io.InvalidObjectException>(testFailureMessage) {
176+
deserialize(streams.delegateValid)
177+
}
178+
assertEquals(expectedIOEMessage, ioe1.message)
179+
180+
// this would create an instance that has null in a non-nullable field (e.g., the field
181+
// kotlinx.datetime.LocalDate.value)
182+
// see https://github.com/Kotlin/kotlinx-datetime/pull/373#discussion_r2008922681
183+
val ioe2 = assertFailsWith<java.io.InvalidObjectException>(testFailureMessage) {
184+
deserialize(streams.delegateNull)
185+
}
186+
assertEquals(expectedIOEMessage, ioe2.message)
187+
}
188+
189+
private fun testStreamsWithIncorrectSVUID(className: String, streams: Streams) {
190+
val testFailureMessage = "Deserialization of a serial stream that tries to bypass kotlinx.datetime.Ser but " +
191+
"has a wrong serialVersionUID for $className should fail"
192+
193+
val expectedICEMessage = "$className; local class incompatible: stream classdesc serialVersionUID = 42, " +
194+
"local class serialVersionUID = 0"
195+
196+
val ice1 = assertFailsWith<java.io.InvalidClassException>(testFailureMessage) {
197+
deserialize(streams.delegateValid)
198+
}
199+
assertEquals(expectedICEMessage, ice1.message)
200+
201+
val ice2 = assertFailsWith<java.io.InvalidClassException>(testFailureMessage) {
202+
deserialize(streams.delegateNull)
203+
}
204+
assertEquals(expectedICEMessage, ice2.message)
205+
}
206+
}

0 commit comments

Comments
 (0)