From 5ea8cee82b37d32f1f18915c65d98d946f4189e2 Mon Sep 17 00:00:00 2001 From: Nexory Date: Mon, 3 Aug 2026 06:00:16 +0200 Subject: [PATCH 1/2] Add an optional class filter to JavaSerializer deserialization JavaSerializer.read deserializes with ObjectInputStream.readObject and previously applied no restriction on which classes may be deserialized; Kryo's setRegistrationRequired does not apply on this path because the bytes are handed to the JDK serialization mechanism. Add an opt-in setClassFilter(Predicate), checked in resolveClass, so a class for which the predicate returns false is rejected with a KryoException before it is used. The default is unchanged (no filter) and the existing classloader resolution is preserved. --- .../kryo/serializers/JavaSerializer.java | 44 ++++++++++++++----- .../kryo/serializers/JavaSerializerTest.java | 40 +++++++++++++++++ 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/com/esotericsoftware/kryo/serializers/JavaSerializer.java b/src/com/esotericsoftware/kryo/serializers/JavaSerializer.java index bc8a5d6a7..1096249cd 100644 --- a/src/com/esotericsoftware/kryo/serializers/JavaSerializer.java +++ b/src/com/esotericsoftware/kryo/serializers/JavaSerializer.java @@ -32,6 +32,7 @@ import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.ObjectStreamClass; +import java.util.function.Predicate; /** Serializes objects using Java's built in serialization mechanism. Note that this is very inefficient and should be avoided if * possible. @@ -40,6 +41,21 @@ * @see KryoSerializable * @author Nathan Sweet */ public class JavaSerializer extends Serializer { + private Predicate classFilter; + + /** Sets an optional filter applied to each class encountered while deserializing. When set, a class for which the predicate + * returns false is rejected with a {@link KryoException} before it is used, providing opt-in, defense-in-depth protection when + * reading serialized data from an untrusted source. When null (the default) no filtering is applied and behavior is unchanged. + * @param classFilter May be null. */ + public void setClassFilter (Predicate classFilter) { + this.classFilter = classFilter; + } + + /** @return May be null. */ + public Predicate getClassFilter () { + return classFilter; + } + public void write (Kryo kryo, Output output, Object object) { try { ObjectMap graphContext = kryo.getGraphContext(); @@ -60,7 +76,7 @@ public Object read (Kryo kryo, Input input, Class type) { ObjectMap graphContext = kryo.getGraphContext(); ObjectInputStream objectStream = (ObjectInputStream)graphContext.get(this); if (objectStream == null) { - objectStream = new ObjectInputStreamWithKryoClassLoader(input, kryo); + objectStream = new ObjectInputStreamWithKryoClassLoader(input, kryo, classFilter); graphContext.put(this, objectStream); } return objectStream.readObject(); @@ -75,23 +91,31 @@ public Object read (Kryo kryo, Input input, Class type) { * https://issues.apache.org/jira/browse/GROOVY-1627 */ private static class ObjectInputStreamWithKryoClassLoader extends ObjectInputStream { private final Kryo kryo; + private final Predicate classFilter; - ObjectInputStreamWithKryoClassLoader (InputStream in, Kryo kryo) throws IOException { + ObjectInputStreamWithKryoClassLoader (InputStream in, Kryo kryo, Predicate classFilter) throws IOException { super(in); this.kryo = kryo; + this.classFilter = classFilter; } protected Class resolveClass (ObjectStreamClass type) { + Class resolved; try { - return Class.forName(type.getName(), false, kryo.getClassLoader()); - } catch (ClassNotFoundException ignored) {} - try { - return super.resolveClass(type); - } catch (ClassNotFoundException ex) { - throw new KryoException("Class not found: " + type.getName(), ex); - } catch (IOException ex) { - throw new KryoException("Could not load class: " + type.getName(), ex); + resolved = Class.forName(type.getName(), false, kryo.getClassLoader()); + } catch (ClassNotFoundException ignored) { + try { + resolved = super.resolveClass(type); + } catch (ClassNotFoundException ex) { + throw new KryoException("Class not found: " + type.getName(), ex); + } catch (IOException ex) { + throw new KryoException("Could not load class: " + type.getName(), ex); + } + } + if (classFilter != null && !classFilter.test(resolved)) { + throw new KryoException("Deserialization is not allowed for class: " + type.getName()); } + return resolved; } } } diff --git a/test/com/esotericsoftware/kryo/serializers/JavaSerializerTest.java b/test/com/esotericsoftware/kryo/serializers/JavaSerializerTest.java index 078672299..32dbec82e 100644 --- a/test/com/esotericsoftware/kryo/serializers/JavaSerializerTest.java +++ b/test/com/esotericsoftware/kryo/serializers/JavaSerializerTest.java @@ -19,7 +19,10 @@ package com.esotericsoftware.kryo.serializers; +import com.esotericsoftware.kryo.KryoException; import com.esotericsoftware.kryo.KryoTestCase; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; import java.io.Serializable; import java.net.URL; @@ -27,6 +30,9 @@ import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + /** @author Nathan Sweet */ class JavaSerializerTest extends KryoTestCase { @Test @@ -55,6 +61,40 @@ void testJavaSerializerFallbackToDefaultClassLoader () { roundTrip(139, test); } + @Test + void testClassFilterRejectsDisallowedClass () { + JavaSerializer serializer = new JavaSerializer(); + serializer.setClassFilter(type -> type != TestClass.class); + kryo.register(TestClass.class, serializer); + + TestClass test = new TestClass(); + test.stringField = "fubar"; + test.intField = 54321; + + Output output = new Output(1024, -1); + kryo.writeObject(output, test); + + Input input = new Input(output.toBytes()); + assertThrows(KryoException.class, () -> kryo.readObject(input, TestClass.class)); + } + + @Test + void testClassFilterAllowsClass () { + JavaSerializer serializer = new JavaSerializer(); + serializer.setClassFilter(type -> true); + kryo.register(TestClass.class, serializer); + + TestClass test = new TestClass(); + test.stringField = "fubar"; + test.intField = 54321; + + Output output = new Output(1024, -1); + kryo.writeObject(output, test); + + Input input = new Input(output.toBytes()); + assertEquals(test, kryo.readObject(input, TestClass.class)); + } + public static class TestClass implements Serializable { String stringField; int intField; From def08a2dd74f4404d80fb39df99d71e75a257de6 Mon Sep 17 00:00:00 2001 From: Nexory Date: Thu, 20 Aug 2026 19:31:28 +0200 Subject: [PATCH 2/2] Check the class filter on the name, before the class is resolved Applied to the resolved Class, the filter could not refuse a name that does not resolve at all, and it let the class be loaded first. It now runs on the name as the first thing resolveClass does, so a refused class is never loaded. The filter type changes from Predicate to Predicate accordingly. Measured with -verbose:class on a gadget nested inside an allowed outer type: with the filter refusing, the class does not appear in the load list and its static initializer does not run; with it allowing, both happen. --- .../kryo/serializers/JavaSerializer.java | 44 +++++++++---------- .../kryo/serializers/JavaSerializerTest.java | 31 ++++++++++++- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/com/esotericsoftware/kryo/serializers/JavaSerializer.java b/src/com/esotericsoftware/kryo/serializers/JavaSerializer.java index 1096249cd..a44760d3e 100644 --- a/src/com/esotericsoftware/kryo/serializers/JavaSerializer.java +++ b/src/com/esotericsoftware/kryo/serializers/JavaSerializer.java @@ -41,18 +41,19 @@ * @see KryoSerializable * @author Nathan Sweet */ public class JavaSerializer extends Serializer { - private Predicate classFilter; + private Predicate classFilter; - /** Sets an optional filter applied to each class encountered while deserializing. When set, a class for which the predicate - * returns false is rejected with a {@link KryoException} before it is used, providing opt-in, defense-in-depth protection when - * reading serialized data from an untrusted source. When null (the default) no filtering is applied and behavior is unchanged. + /** Sets an optional filter applied to the name of each class encountered while deserializing. When set, a class whose name the + * predicate rejects is refused with a {@link KryoException} before the class is resolved, so an unwanted class is never loaded + * and a name that does not resolve at all is still refused. This is opt-in, defense-in-depth protection for reading serialized + * data from an untrusted source. When null (the default) no filtering is applied and behavior is unchanged. * @param classFilter May be null. */ - public void setClassFilter (Predicate classFilter) { + public void setClassFilter (Predicate classFilter) { this.classFilter = classFilter; } /** @return May be null. */ - public Predicate getClassFilter () { + public Predicate getClassFilter () { return classFilter; } @@ -91,31 +92,30 @@ public Object read (Kryo kryo, Input input, Class type) { * https://issues.apache.org/jira/browse/GROOVY-1627 */ private static class ObjectInputStreamWithKryoClassLoader extends ObjectInputStream { private final Kryo kryo; - private final Predicate classFilter; + private final Predicate classFilter; - ObjectInputStreamWithKryoClassLoader (InputStream in, Kryo kryo, Predicate classFilter) throws IOException { + ObjectInputStreamWithKryoClassLoader (InputStream in, Kryo kryo, Predicate classFilter) throws IOException { super(in); this.kryo = kryo; this.classFilter = classFilter; } protected Class resolveClass (ObjectStreamClass type) { - Class resolved; - try { - resolved = Class.forName(type.getName(), false, kryo.getClassLoader()); - } catch (ClassNotFoundException ignored) { - try { - resolved = super.resolveClass(type); - } catch (ClassNotFoundException ex) { - throw new KryoException("Class not found: " + type.getName(), ex); - } catch (IOException ex) { - throw new KryoException("Could not load class: " + type.getName(), ex); - } - } - if (classFilter != null && !classFilter.test(resolved)) { + // Checked on the name, before the class is resolved: a rejected class is never loaded, and a name that + // does not resolve at all is still refused rather than reported as missing. + if (classFilter != null && !classFilter.test(type.getName())) { throw new KryoException("Deserialization is not allowed for class: " + type.getName()); } - return resolved; + try { + return Class.forName(type.getName(), false, kryo.getClassLoader()); + } catch (ClassNotFoundException ignored) {} + try { + return super.resolveClass(type); + } catch (ClassNotFoundException ex) { + throw new KryoException("Class not found: " + type.getName(), ex); + } catch (IOException ex) { + throw new KryoException("Could not load class: " + type.getName(), ex); + } } } } diff --git a/test/com/esotericsoftware/kryo/serializers/JavaSerializerTest.java b/test/com/esotericsoftware/kryo/serializers/JavaSerializerTest.java index 32dbec82e..5f20f16f8 100644 --- a/test/com/esotericsoftware/kryo/serializers/JavaSerializerTest.java +++ b/test/com/esotericsoftware/kryo/serializers/JavaSerializerTest.java @@ -25,6 +25,8 @@ import com.esotericsoftware.kryo.io.Output; import java.io.Serializable; +import java.util.List; +import java.util.ArrayList; import java.net.URL; import java.net.URLClassLoader; @@ -32,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** @author Nathan Sweet */ class JavaSerializerTest extends KryoTestCase { @@ -64,7 +67,7 @@ void testJavaSerializerFallbackToDefaultClassLoader () { @Test void testClassFilterRejectsDisallowedClass () { JavaSerializer serializer = new JavaSerializer(); - serializer.setClassFilter(type -> type != TestClass.class); + serializer.setClassFilter(name -> !name.equals(TestClass.class.getName())); kryo.register(TestClass.class, serializer); TestClass test = new TestClass(); @@ -81,7 +84,7 @@ void testClassFilterRejectsDisallowedClass () { @Test void testClassFilterAllowsClass () { JavaSerializer serializer = new JavaSerializer(); - serializer.setClassFilter(type -> true); + serializer.setClassFilter(name -> true); kryo.register(TestClass.class, serializer); TestClass test = new TestClass(); @@ -95,6 +98,30 @@ void testClassFilterAllowsClass () { assertEquals(test, kryo.readObject(input, TestClass.class)); } + @Test + void testClassFilterSeesTheClassName () { + List seen = new ArrayList<>(); + JavaSerializer serializer = new JavaSerializer(); + serializer.setClassFilter(name -> { + seen.add(name); + return true; + }); + kryo.register(TestClass.class, serializer); + + TestClass test = new TestClass(); + test.stringField = "fubar"; + test.intField = 54321; + + Output output = new Output(1024, -1); + kryo.writeObject(output, test); + + Input input = new Input(output.toBytes()); + assertEquals(test, kryo.readObject(input, TestClass.class)); + // The filter runs on the name before the class is resolved, so a caller can refuse a class + // without it being loaded, and can refuse a name that would not resolve at all. + assertTrue(seen.contains(TestClass.class.getName())); + } + public static class TestClass implements Serializable { String stringField; int intField;