Can record canonical constructor validation be bypassed during deserialization or reflection in Java?

1 day ago 2
ARTICLE AD BOX

Bypassing canonical constructor

The Unsafe#allocateInstance(Class) method you mentioned does allow this. Though note that Unsafe is internal API and explicitly unsafe. However, there is also a public JNI method that does the same thing: AllocObject. The only documented restrictions are that the class must not be abstract or an interface and must not represent an array; a record class is a non-array concrete class.

With that said, an uninitialized object is very likely a bug that should be fixed (by calling a constructor) or the result of malicious code that should be avoided. Don't run code you don't trust, especially without proper isolation. If an actor manages to inject malicious code into your application you have bigger problems. If invalid objects returned from a serialization framework is a legitimate attack vector then perform external validation on the deserialized object.

And don't purposefully allocate an object without initializing it. The overwhelming majority of Java code is not written with that scenario in mind. It will cause exceptions, particularly NPEs, or silently corrupt logic in code that assumes certain preconditions.

Java Reflection

I'm not aware of any way to use the java.lang.reflect API to instantiate any class without invoking a constructor, let alone record classes. Nor am I aware of a way to do this via the java.lang.invoke API.

Java Object Serialization

When deserializing a serialized record it will be initialized via its canonical constructor. This is documented by §8.10 - Record Classes of the Java Language Specification:

The serialization mechanism treats instances of a record class differently than ordinary serializable or externalizable objects. In particular, a record object is deserialized using the canonical constructor (§8.10.4).

And by §1.13 - Serialization of Records of the Java Object Serialization Specification:

During deserialization, if the local class equivalent of the specified stream class descriptor is a record class, then first the stream fields are read and reconstructed to serve as the record's component values; and second, a record object is created by invoking the record's canonical constructor with the component values as arguments (or the default value for component's type if a component value is absent from the stream).

And by java.lang.Record:

During deserialization the record's canonical constructor is invoked to construct the record object.

However, you can replace the deserialized object via the readResolve() method. The replaced record instance can then be an instance created without invoking its canonical constructor. Of course, the readResolve() method would have to be a member of the record class. I don't know if there's a way to do the same thing from outside the serializable record class.

Other serialization frameworks

I can't speak to other other serialization frameworks. You'd have to read their documentation and/or their source code. But I'd be surprised if a legitimate one did not go through the canonical constructor for records, whether via reflection, hand written code, or generated code.

Note JEP 500: Prepare to Make Final Mean Final was implemented in Java 26. And note it claims the implicit fields of record components have already been rejecting mutations via reflection. This suggests a serialization framework trying to initialize a record without going through its canonical constructor is not particularly viable.


Here's an example of instantiating a record class without invoking its canonical constructor.

Source Code

Java

module-info.java

module com.example { requires jdk.unsupported; }

Message.java

package com.example; import java.util.Objects; public record Message(String content) { public Message { System.out.println("Canonical constructor invoked."); Objects.requireNonNull(content, "content"); } }

Main.java

package com.example; import sun.misc.Unsafe; public class Main { private static final Unsafe UNSAFE; static { try { var theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe"); theUnsafeField.setAccessible(true); UNSAFE = (Unsafe) theUnsafeField.get(null); } catch (ReflectiveOperationException ex) { throw new RuntimeException(ex); } System.loadLibrary("example"); } public static void main(String[] args) throws Exception { System.out.println("Running test via sun.misc.Unsafe"); runTestUnsafe(); System.out.println(); System.out.println("Running test via JNI"); runTestJni(); System.out.println(); } private static void runTestUnsafe() throws Exception { var message = (Message) UNSAFE.allocateInstance(Message.class); printMessage(message); } private static native void runTestJni() throws Exception; private static void printMessage(Message message) { System.out.printf("message = %s%n", message); } }

JNI

com_example_Main.h

/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class com_example_Main */ #ifndef _Included_com_example_Main #define _Included_com_example_Main #ifdef __cplusplus extern "C" { #endif /* * Class: com_example_Main * Method: runTestJni * Signature: ()V */ JNIEXPORT void JNICALL Java_com_example_Main_runTestJni (JNIEnv *, jclass); #ifdef __cplusplus } #endif #endif

example.cpp

#include <com_example_Main.h> void Java_com_example_Main_runTestJni(JNIEnv *env, jclass cls) { jclass message_record_cls = env->FindClass("com/example/Message"); if (!message_record_cls) return; jmethodID print_message_mid = env->GetStaticMethodID(cls, "printMessage", "(Lcom/example/Message;)V"); if (!print_message_mid) return; jobject message = env->AllocObject(message_record_cls); if (!message) return; env->CallStaticVoidMethod(cls, print_message_mid, message); }

Output

Running with OpenJDK 25.0.2 (Temurin).

> java -Djava.library.path=<libpath> --enable-native-access=com.example -p <modpath> -m com.example/com.example.Main Running test via sun.misc.Unsafe message = Message[content=null] Running test via JNI message = Message[content=null]

Note how the content component of the Message instance is null despite the check in the canonical constructor. Also note how the print statement in the canonical constructor is never called.

Read Entire Article