Skip to content

Commit 166ceb7

Browse files
authored
Harden Range.readObject() to reject bad cached hash code (#1633)
* A serialized Range can't store a bad cached hashCode.
1 parent b1a659a commit 166ceb7

3 files changed

Lines changed: 101 additions & 1 deletion

File tree

src/main/java/org/apache/commons/lang3/Range.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
*/
1717
package org.apache.commons.lang3;
1818

19+
import java.io.IOException;
20+
import java.io.InvalidObjectException;
21+
import java.io.ObjectInputStream;
1922
import java.io.Serializable;
2023
import java.util.Comparator;
2124
import java.util.Objects;
@@ -106,6 +109,10 @@ public static <T> Range<T> between(final T fromInclusive, final T toInclusive, f
106109
return new Range<>(fromInclusive, toInclusive, comparator);
107110
}
108111

112+
private static int hash(final Object value1, final Object value2) {
113+
return Objects.hash(value1, value2);
114+
}
115+
109116
/**
110117
* Creates a range using the specified element as both the minimum
111118
* and maximum in this range.
@@ -235,7 +242,7 @@ public static <T> Range<T> of(final T fromInclusive, final T toInclusive, final
235242
this.minimum = element2;
236243
this.maximum = element1;
237244
}
238-
this.hashCode = Objects.hash(minimum, maximum);
245+
this.hashCode = hash(minimum, maximum);
239246
}
240247

241248
/**
@@ -527,6 +534,22 @@ public boolean isStartedBy(final T element) {
527534
return comparator.compare(element, minimum) == 0;
528535
}
529536

537+
/**
538+
* See {@link Serializable}.
539+
*
540+
* @param in See {@link Serializable}.
541+
* @throws IOException See {@link Serializable}.
542+
* @throws ClassNotFoundException See {@link Serializable}.
543+
*/
544+
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
545+
in.defaultReadObject();
546+
// Reject streams whose cached hashCode does not match the canonical hash of the deserialized minimum/maximum: a crafted stream cannot supply a forged
547+
// value.
548+
if (hashCode != hash(minimum, maximum)) {
549+
throw new InvalidObjectException("Range hashCode does not match minimum/maximum.");
550+
}
551+
}
552+
530553
/**
531554
* Gets the range as a {@link String}.
532555
*
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.commons.lang3;
19+
20+
import static org.junit.jupiter.api.Assertions.assertEquals;
21+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
22+
import static org.junit.jupiter.api.Assertions.assertThrows;
23+
24+
import java.io.InvalidObjectException;
25+
26+
import org.apache.commons.lang3.reflect.FieldUtils;
27+
import org.junit.jupiter.api.Test;
28+
29+
/**
30+
* Tests that a serialized Range can't store a bad cached hashCode.
31+
*/
32+
class RangeReadObjectTest {
33+
34+
@Test
35+
void testBadHashCodeRejected() throws Exception {
36+
final Range<Integer> range = Range.of(1, 100);
37+
final byte[] bytes = SerializationUtils.serialize(range);
38+
// Locate the legitimate hashCode int in the serialized stream and overwrite it.
39+
final int hashCode = (Integer) FieldUtils.readDeclaredField(range, "hashCode", true);
40+
final byte[] edited = SerializationUtilsTest.replaceLastInt(bytes, hashCode, 0xDEADBEEF);
41+
final SerializationException ex = assertThrows(SerializationException.class, () -> SerializationUtils.deserialize(edited),
42+
"Bad hashCode in stream must be rejected with InvalidObjectException");
43+
assertInstanceOf(InvalidObjectException.class, ex.getCause());
44+
assertEquals("java.io.InvalidObjectException: Range hashCode does not match minimum/maximum.", ex.getMessage());
45+
}
46+
47+
@Test
48+
void testRoundTripPreservesCorrectHashCode() throws Exception {
49+
final Range<String> range = Range.of("apple", "mango");
50+
final Range<String> roundtrip = SerializationUtils.roundtrip(range);
51+
assertEquals(range.hashCode(), roundtrip.hashCode(), "Round-trip serialization must preserve the correct hashCode");
52+
assertEquals(range, roundtrip);
53+
}
54+
}

src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import static org.junit.jupiter.api.Assertions.assertSame;
2828
import static org.junit.jupiter.api.Assertions.assertThrows;
2929
import static org.junit.jupiter.api.Assertions.assertTrue;
30+
import static org.junit.jupiter.api.Assertions.fail;
3031

3132
import java.io.ByteArrayInputStream;
3233
import java.io.ByteArrayOutputStream;
@@ -65,8 +66,29 @@ class SerializationUtilsTest extends AbstractLangTest {
6566
static final String CLASS_NOT_FOUND_MESSAGE = "ClassNotFoundSerialization.readObject fake exception";
6667
protected static final String SERIALIZE_IO_EXCEPTION_MESSAGE = "Anonymous OutputStream I/O exception";
6768

69+
static byte[] intToBytes(final int v) {
70+
return new byte[] { (byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v };
71+
}
72+
static byte[] replaceLastInt(final byte[] src, final int from, final int to) {
73+
final byte[] fromB = intToBytes(from);
74+
final byte[] toB = intToBytes(to);
75+
final byte[] out = src.clone();
76+
for (int i = out.length - 4; i >= 0; i--) {
77+
if (out[i] == fromB[0] && out[i + 1] == fromB[1] && out[i + 2] == fromB[2] && out[i + 3] == fromB[3]) {
78+
out[i] = toB[0];
79+
out[i + 1] = toB[1];
80+
out[i + 2] = toB[2];
81+
out[i + 3] = toB[3];
82+
return out;
83+
}
84+
}
85+
fail("No legitimate int in stream, serialization must keep hashCode in default field set");
86+
return null;
87+
}
6888
private String iString;
89+
6990
private Integer iInteger;
91+
7092
private HashMap<Object, Object> iMap;
7193

7294
@BeforeEach
@@ -112,6 +134,7 @@ void testCloneUnserializable() {
112134
assertThrows(SerializationException.class, () -> SerializationUtils.clone(iMap));
113135
}
114136

137+
@SuppressWarnings("deprecation")
115138
@Test
116139
void testConstructor() {
117140
assertNotNull(new SerializationUtils());

0 commit comments

Comments
 (0)