在Java中,反序列化或反射时,是否能够绕过记录的规范构造器校验?
Java记录通过它们的规范构造器来维护不变量:
public record User(String name) {
public User {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Invalid name");
}
}
}
常规实例化和反射(Constructor::newInstance)都会调用这个构造器。
我也回顾了Java的反序列化工作方式(例如,对可序列化的类并不会调用构造函数,且只会调用第一一个非序列化超类的构造函数),但这些解释假设的是标准的反序列化流程,并没有澄清是否有可能完全绕过构造函数的调用。
为了进一步探究,我尝试使用 Unsafe:
Unsafe unsafe = ...; // obtained via reflection
User user = (User) unsafe.allocateInstance(User.class);
System.out.println(user.name());
据我所知,allocateInstance 并不会调用构造函数。
我的问题是:
- 这段代码对记录来说有效吗,还是JVM会阻止以这种方式分配记录实例?
- 如果可行,是否等同于绕过规范构造器,从而违反Java语言规范对记录不变量的定义?
- 常见的序列化框架(例如Jackson、Java序列化)是否依赖于类似的底层机制,还是必须为记录调用规范构造器?
解决方案
Can the Canonical Constructor be Bypassed?
Yes, a record can be created without invoking its canonical constructor. There are at least two ways to do this:
- The
sun.misc.Unsafe#allocateInstance(Class)method.
Keep in mind the Unsafe class is technically internal API and unsupported.
Also, it is slowly being deprecated as safer public APIs are being added to
replace its functionality. I believe the goal is to eventually remove, or at
least encapsulate, the class completely.
2. The AllocObject(JNIEnv*,jclass) function from the Java Native
Interface (JNI).
This is a public function. The only restrictions on the given class are that it must not be an interface, an abstract class, or an array. A record class is none of those things.
Both will allocate an object of the specified class without invoking any of the
class's constructors. Any instance fields will be set to their default values:
false, zero, and null as appropriate.
Initializing Record Components
The above two functions only allocate the record, they don't set its components
to non-default values. Since we are trying to avoid the canonical constructor,
the only choice is to try and set the components directly. And this is where you
run into issues. The implicit field associated with a record component is
final.
Trying to set a final field is dubious at best. As far as I know, the only supported way a final field can be set is via the Java Object Serialization mechanism. But we can't use that mechanism here.
Reflection
Reflection is not a solution. The Field#set(Object,Object) method is
defined to throw an IllegalAccessException if the field's declaring class is a
record. Even VarHandle won't work. Its setXXX methods throw an
UnsupportedOperationException if the handle is to a record component's field.
Note this restriction on setting final fields will be applied to all fields by
default, not just those of records (and hidden classes). JEP 500: Prepare to
Make Final Mean Final was implemented in Java 26. And while that JEP adds
the --enable-final-field-mutation option, it will still always be impossible
to set a record component's field via reflection.
Java Native Interface (JNI)
There is the SetField(JNIEnv*,jobject,jfieldID,<NativeType>) function
provided by JNI. But this is also not a true solution. The documentation of that
function states:
Specifying the field ID of a final instance field is permitted but may result in an unexpected exception or a fatal crash.
However, it appears to work in Java 25.0.2 based on some testing. At least for a trivial, short-lived application where the code had no chance to be optimized by the JIT compiler. Who knows if it would still work after the code is optimized by JIT, or if the code was AOT compiled (e.g., by GraalVM native image).
Also, JEP 500 has this to say about the SetField and SetStaticField
functions:
In a future JDK release, we may change the JNI functions mentioned above so that they always return successfully when called on final fields, but never actually do any mutation.
Unsafe
The sun.misc.Unsafe class has methods for setting an object's fields. Though
these methods were deprecated-for-removal in Java 23. Regardless, these methods
require a "field offset". And based on some testing, trying to get the field
offset of a record component's field throws an UnsupportedOperationException.
Which means this is not a solution either.
Instrumentation
An agent could transform the record class and drop the final flag on the
record component fields. Then reflection will be able to set the
no-longer-final fields. Both Field and VarHandle will work. Technically, the
class file could be modified without an agent, so long as it's done before the
class is loaded.
To be honest, I'm a little surprised this doesn't result in an error when the
class is loaded. And that it circumvents the IllegalAccessException /
UnsupportedOperationException. The documentation of Field says it's an error
to call set on a field whose declaring class is a record, not simply if the
field is final. Perhaps a future release will fix this. But based on some
testing, dropping the final flag of a record component's field works as of
Java 26.
Viability
There's no supported way to set a final field, particularly for record classes,
and the unsupported ways are not guaranteed to work. The only seemingly-stable
workaround is to modify the record's class file to drop the final flag from
the component fields. Otherwise, although you can allocate a record without
invoking its canonical constructor, there's no good way to initialize its
components to anything other than the default values. You'll still end up with a
potentially invalid record but it will likely be more obvious since everything
will be zero, false, or null.
Serialization Frameworks
Java Object Serialization
When a serializable record (i.e., a record that implements
java.io.Serializable) is deserialized, 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.
Replacing deserialized instance
Technically, a serializable record class can define a readResolve()
method. That method can replace the deserialized instance with another instance.
Which means it could return an invalid record instance if one manages to create
one. Though the original deserialized record instance would have been
initialized via its canonical constructor, so it will had to have been valid.
Note the readResolve() method must be a member of the serializable class. I
don't know if there's a way to replace an instance during deserialization from
outside the class. Note an agent is capable of adding a readResolve() method
to a record class when it's first loaded, or modifying an existing one at any
time (assuming the agent is registered for retransformation).
Other Frameworks
I can't speak to other serialization frameworks. You'll have to read their documentation and/or source code. But I would be surprised if a legitimate one did not go through a record's canonical constructor, whether via reflection, hand-written code, or generated code.
Integrity
You should not be trying to bypass a record's canonical constructor. The vast majority of Java code is not written with this scenario in mind. It will cause exceptions, likely NPEs, to be thrown or silently corrupt logic in code that assumes certain preconditions.
If you have a legitimate concern about serialization frameworks then ultimately it comes down to: Don't run code you don't trust. Particularly not without isolation. But if you still have a legitimate concern after auditing your chosen serialization framework then externally validate the deserialized record after it's been returned to you. Given all record state is public this shouldn't be difficult. Or have dedicated DTOs and then perform the appropriate validation when mapping them to business types.
In addition to only running code you trust and additional validation, there are some more steps you can take to help mitigate problems. The use of JNI is "restricted" in modern versions of Java. This means code has to be granted permission to use JNI, thus you'll know exactly which code uses JNI. This is controlled via the following two options:
--enable-native-access=<modules>--illegal-native-access=<mode>(The current default mode iswarn. This option will be removed in a future release. The default mode will becomedenybefore that happens.)
There's also the ModuleLayer.Controller#enableNativeAccess(Module)
method. Though you only have access to a module layer's controller for layers
that you create (i.e., not the boot layer).
And with the implementation of JEP 500, using SetField or SetStaticField to
try and set a final field can be configured to log warnings with the
-Xcheck:jni option.
You can also disable dynamically loaded agents with the
-XX:-EnableDynamicAgentLoading option in modern versions of Java. Then you'll
know exactly which agents are used at launch via the -javaagent, -agentlib,
and -agentpath options. You can also disable the entire attach mechanism with
the -XX:DisableAttachMechanism option, though note that will block more than
just agents.
Note that if an actor manages to inject an agent into your application then you have more and bigger problems than just invalid records being returned from a serialization framework. Agents have a lot of power. The entire application would be compromised.
With all that said, I would not say invalid record instances is something you need to be particularly worried about.