Defend against deserialization-based attacks (#2150)

* Defend against deserialization-based attacks

Added the `SafeObjectInputStream` class that defends attacks using
malformed serialized data, including remote code execution and
denial-of-service attacks.

Started using the new class to handle EPP resource VKeys and
PendingDeposits, which are passed across credential-boundaries: between
TaskQueue and AppEngine server, and between AppEngine server and the RDE
pipeline on GCE. Note that the wireformat of VKeys do not change,
therefore existing tasks sitting in the TaskQueue are not affected.

Also removed an unused class: JaxbFragment.
This commit is contained in:
Weimin Yu
2023-09-20 16:56:56 -04:00
committed by GitHub
parent fc1857717d
commit 46fdf2c996
11 changed files with 610 additions and 193 deletions
@@ -0,0 +1,108 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.util;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectStreamClass;
import java.util.Collection;
import java.util.Map;
/**
* Safely deserializes Nomulus http request parameters.
*
* <p>Serialized Java objects may be passed between Nomulus components that hold different
* credentials. Deserialization of such objects should be protected against attacks through
* compromised accounts.
*
* <p>This class protects against three types of attacks by restricting the classes used for
* serialization:
*
* <ul>
* <li>Remote code execution by referencing bad classes in compromised jars. When a class with
* malicious code in the static initialization block or the deserialization code path (e.g.,
* the {@code readObject} method) is deserialized, such code will be executed. For Nomulus,
* this risk comes from third-party dependencies. To counter this risk, this class only allows
* Nomulus (google.registry.**) classes and specific core Java classes, and forbid others
* including third-party dependencies. (As a side note, this class does not use allow lists
* for Nomulus or third-party classes because it is infeasible in practice. Super classes of
* the instance being deserialized must be resolved, and therefore must be on the allow list;
* same for the field types of the instance. The allow list for the Joda {@code DateTime}
* class alone would have more than 10 classes. Generated classes, e.g., by AutoValue, present
* another problem: their real names are not meant to be a concern to the user).
* <li>CPU-targeting denial-of-service attacks. Containers and arrays may be used to construct
* object graphs that require enormous amount of computation during deserialization and/or
* during invocations of methods such as {@code hashCode} or {@code equals}, taking minutes or
* even hours to complete. See <a
* href="https://owasp.org/www-community/vulnerabilities/Deserialization_of_untrusted_data">
* here</a> for an example of such object graphs. To counter this risk, this class forbids
* lists, maps, and arrays for deserialization.
* <li>Memory-targeting denial-of-service attacks. By forbidding container and arrays, this class
* also prevents some memory-targeting attacks, e.g., using wire format that claims to be an
* array of a huge size, causing the JVM to preallocate excessive amount of memory and
* triggering the {@code OutOfMemoryError}. This is actually a small risk for Nomulus, since
* the impact of each error is limited to a single (spurious) request.
* </ul>
*
* <p>Nomulus classes with fields of array, container, or third-party Java types must implement
* their own serialization/deserialization methods to be safely deserialized. For the common use
* case of passing a collection of `safe` objects, {@link
* SafeSerializationUtils#serializeCollection} and {@link
* SafeSerializationUtils#safeDeserializeCollection} may be used.
*/
public final class SafeObjectInputStream extends ObjectInputStream {
/**
* Core Java classes allowed in deserialization. Add new classes as needed but do not add
* third-party classes.
*/
private static final ImmutableSet<String> ALLOWED_CORE_JAVA_CLASSES =
ImmutableSet.of(String.class, Byte.class, Short.class, Integer.class, Long.class).stream()
.map(Class::getName)
.collect(toImmutableSet());
public SafeObjectInputStream(InputStream in) throws IOException {
super(in);
}
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws ClassNotFoundException, IOException {
String clazz = desc.getName();
if (isNomulusClass(clazz) || ALLOWED_CORE_JAVA_CLASSES.contains(clazz)) {
return checkNotArrayOrContainer(super.resolveClass(desc));
}
throw new ClassNotFoundException(clazz + " not found or not allowed in deserialization.");
}
private Class<?> checkNotArrayOrContainer(Class<?> clazz) throws ClassNotFoundException {
if (isContainer(clazz) || clazz.isArray()) {
throw new ClassNotFoundException(clazz.getName() + " not allowed as non-root object.");
}
return clazz;
}
private boolean isNomulusClass(String clazz) {
return clazz.startsWith("google.registry.");
}
private boolean isContainer(Class<?> clazz) {
return Collection.class.isAssignableFrom(clazz) || Map.class.isAssignableFrom(clazz);
}
}
@@ -0,0 +1,104 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.util;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import com.google.common.collect.ImmutableList;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collection;
import javax.annotation.Nullable;
/**
* Helpers for using {@link SafeObjectInputStream}.
*
* <p>Please refer to {@code SafeObjectInputStream} for more information.
*/
public final class SafeSerializationUtils {
private SafeSerializationUtils() {}
/**
* Maximum number of elements allowed in a serialized collection.
*
* <p>This value is sufficient for parameters embedded in a {@code URL} to typical cloud services.
* E.g., as of Fall 2023, AWS limits request line size to 16KB and GCP limits total header size to
* 64KB.
*/
public static final int MAX_COLLECTION_SIZE = 32768;
/**
* Serializes a collection of objects that can be safely deserialized using {@link
* #safeDeserializeCollection}.
*
* <p>If any element of the collection cannot be safely-deserialized, deserialization will fail.
*/
public static byte[] serializeCollection(Collection<?> collection) {
checkNotNull(collection, "collection");
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ObjectOutputStream os = new ObjectOutputStream(bos)) {
os.writeInt(collection.size());
for (Object obj : collection) {
os.writeObject(obj);
}
} catch (IOException e) {
throw new RuntimeException("Failed to serialize: " + collection, e);
}
return bos.toByteArray();
}
/** Safely deserializes an object using {@link SafeObjectInputStream}. */
@Nullable
public static Serializable safeDeserialize(@Nullable byte[] bytes) {
if (bytes == null) {
return null;
}
try (ObjectInputStream is = new SafeObjectInputStream(new ByteArrayInputStream(bytes))) {
Serializable ret = (Serializable) is.readObject();
return ret;
} catch (IOException | ClassNotFoundException e) {
throw new IllegalArgumentException("Failed to deserialize: " + Arrays.toString(bytes), e);
}
}
/**
* Safely deserializes a collection of objects previously serialized with {@link
* #serializeCollection}.
*/
public static <T> ImmutableList<T> safeDeserializeCollection(Class<T> elementType, byte[] bytes) {
checkNotNull(bytes, "Serialized list must not be null.");
try (ObjectInputStream is = new SafeObjectInputStream(new ByteArrayInputStream(bytes))) {
int size = is.readInt();
checkArgument(size >= 0, "Malformed data: negative collection size.");
if (size > MAX_COLLECTION_SIZE) {
throw new IllegalArgumentException("Too many elements in collection: " + size);
}
ImmutableList.Builder<T> builder = new ImmutableList.Builder<>();
for (int i = 0; i < size; i++) {
builder.add(elementType.cast(is.readObject()));
}
return builder.build();
} catch (IOException | ClassNotFoundException | ClassCastException e) {
throw new IllegalArgumentException("Failed to deserialize: " + Arrays.toString(bytes), e);
}
}
}
@@ -74,10 +74,20 @@ public final class SerializeUtils {
private SerializeUtils() {}
/** Encodes a byte array as a URL-safe string. */
public static String encodeBase64(byte[] bytes) {
return Base64.encodeBase64URLSafeString(bytes);
}
/** Turns a string encoded by {@link #encodeBase64} back into a byte array. */
public static byte[] decodeBase64(String objectString) {
return Base64.decodeBase64(objectString);
}
/** Turns an object into an encoded string that can be used safely as a URI query parameter. */
public static String stringify(Serializable object) {
checkNotNull(object, "Object cannot be null");
return Base64.encodeBase64URLSafeString(SerializeUtils.serialize(object));
return encodeBase64(SerializeUtils.serialize(object));
}
/** Turns a string encoded by stringify() into an object. */
@@ -86,6 +96,6 @@ public final class SerializeUtils {
checkNotNull(type, "Class type is not specified");
checkNotNull(objectString, "Object string cannot be null");
return SerializeUtils.deserialize(type, Base64.decodeBase64(objectString));
return SerializeUtils.deserialize(type, decodeBase64(objectString));
}
}
@@ -0,0 +1,126 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.util;
import static com.google.common.collect.Lists.newArrayList;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.util.SerializeUtils.serialize;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.base.Objects;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
import java.io.ByteArrayInputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import org.joda.time.Duration;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link SafeObjectInputStream}. */
public class SafeObjectInputStreamTest {
@Test
void javaUnitarySuccess() throws Exception {
String orig = "some string";
try (SafeObjectInputStream sois =
new SafeObjectInputStream(new ByteArrayInputStream(serialize(orig)))) {
assertThat(sois.readObject()).isEqualTo(orig);
}
}
@Test
void javaCollectionFailure() throws Exception {
ArrayList<String> orig = newArrayList("a");
try (SafeObjectInputStream sois =
new SafeObjectInputStream(new ByteArrayInputStream(serialize(orig)))) {
assertThrows(ClassNotFoundException.class, () -> sois.readObject());
}
}
@Test
void javaMapFailure() throws Exception {
HashMap<Object, Object> orig = Maps.newHashMap();
try (SafeObjectInputStream sois =
new SafeObjectInputStream(new ByteArrayInputStream(serialize(orig)))) {
assertThrows(ClassNotFoundException.class, () -> sois.readObject());
}
}
@Test
void javaArrayFailure() throws Exception {
int[] orig = new int[] {1};
try (SafeObjectInputStream sois =
new SafeObjectInputStream(new ByteArrayInputStream(serialize(orig)))) {
// For array, the parent class converts ClassNotFoundException in an undocumented way. Safer
// to catch Exception than the one thrown by the current JVM.
assertThrows(Exception.class, () -> sois.readObject());
}
}
@Test
void nonJavaNonNomulusUnitaryFailure() throws Exception {
Serializable orig = Duration.millis(1);
try (SafeObjectInputStream sois =
new SafeObjectInputStream(new ByteArrayInputStream(serialize(orig)))) {
assertThrows(ClassNotFoundException.class, () -> sois.readObject());
}
}
@Test
void nonJavaCollectionFailure() throws Exception {
ImmutableList<String> orig = ImmutableList.of("a");
try (SafeObjectInputStream sois =
new SafeObjectInputStream(new ByteArrayInputStream(serialize(orig)))) {
assertThrows(ClassNotFoundException.class, () -> sois.readObject());
}
}
@Test
void nomulusEntitySuccess() throws Exception {
NomulusEntity orig = new NomulusEntity(1);
byte[] serialized = serialize(orig);
try (SafeObjectInputStream sois =
new SafeObjectInputStream(new ByteArrayInputStream(serialized))) {
Object deserialized = sois.readObject();
assertThat(deserialized).isEqualTo(orig);
}
}
static class NomulusEntity implements Serializable {
Integer value;
NomulusEntity(int value) {
this.value = value;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof NomulusEntity)) {
return false;
}
NomulusEntity that = (NomulusEntity) o;
return Objects.equal(value, that.value);
}
@Override
public int hashCode() {
return Objects.hashCode(value);
}
}
}
@@ -0,0 +1,110 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.util;
import static com.google.common.collect.Lists.newArrayList;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.util.SafeSerializationUtils.safeDeserialize;
import static google.registry.util.SafeSerializationUtils.safeDeserializeCollection;
import static google.registry.util.SafeSerializationUtils.serializeCollection;
import static google.registry.util.SerializeUtils.serialize;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import java.util.ArrayList;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link SafeSerializationUtils}. */
public class SafeSerializationUtilsTest {
@Test
void deserialize_array_failure() {
assertThat(
assertThrows(
IllegalArgumentException.class, () -> safeDeserialize(serialize(new byte[0]))))
.hasMessageThat()
.contains("Failed to deserialize:");
}
@Test
void deserialize_null_success() {
assertThat(safeDeserialize(serialize(null))).isNull();
}
@Test
void deserialize_map_failure() {
assertThat(
assertThrows(
IllegalArgumentException.class,
() -> safeDeserialize(serialize(ImmutableMap.of()))))
.hasMessageThat()
.contains("Failed to deserialize:");
}
@Test
void serializeDeserialize_null_success() {
assertThat(safeDeserialize(null)).isNull();
}
@Test
void serializeDeserialize_notCollection_success() {
Integer orig = 1;
assertThat(safeDeserialize(serialize(orig))).isEqualTo(orig);
}
@Test
void serializeDeserializeCollection_success() {
ArrayList<Integer> orig = newArrayList(1, 2, 3);
ImmutableList<Integer> deserialized =
safeDeserializeCollection(Integer.class, serializeCollection(orig));
assertThat(deserialized).isEqualTo(orig);
}
@Test
void serializeDeserializeCollection_withMaxSize_success() {
Integer[] array = new Integer[SafeSerializationUtils.MAX_COLLECTION_SIZE];
Arrays.fill(array, 1);
ArrayList<Integer> orig = newArrayList(array);
assertThat(safeDeserializeCollection(Integer.class, serializeCollection(orig))).isEqualTo(orig);
}
@Test
void serializeDeserializeCollection_tooLarge_Failure() {
Integer[] array = new Integer[SafeSerializationUtils.MAX_COLLECTION_SIZE + 1];
Arrays.fill(array, 1);
ArrayList<Integer> orig = newArrayList(array);
assertThat(
assertThrows(
IllegalArgumentException.class,
() -> safeDeserializeCollection(Integer.class, serializeCollection(orig))))
.hasMessageThat()
.contains("Too many elements");
}
@Test
void serializeDeserializeCollection_wrong_elementType_success() {
ArrayList<Integer> orig = newArrayList(1, 2, 3);
assertThrows(
IllegalArgumentException.class,
() -> safeDeserializeCollection(Long.class, serializeCollection(orig)));
}
@Test
void deserializeCollection_null_failure() {
assertThrows(NullPointerException.class, () -> safeDeserializeCollection(Integer.class, null));
}
}