0x01 Preface
As with the previous article, copy the demo and debug through the analysis step by step; doing so will make this article easier to understand.
0x02 Flow Analysis
In the previous article, Analysis of the Serialization Process, I wrote:
Serialization is the process of writing an object to an I/O stream. It usually begins by creating an
ObjectOutputStreamoutput stream, then callObjectOutputStreamobject'swriteObjectoutputs the serializable object in the specified format described above.
Deserialization is the reverse of serialization: it reads objects from an I/O stream. It usually begins by creating anObjectInputStreaminput stream, then callObjectInputStreamobject'sreadObjectreads serialized content.
Consider this demo:
package com.panda.alipay;
import java.io.*;
public class Main {
public static class Demo implements Serializable {
private String string;
transient String name = "hello";
public Demo(String s) {
this.string = s;
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
Demo demo = new Demo("panda");
ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("panda.out"));
outputStream.writeObject(new Demo("panda"));
outputStream.close();
ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("panda.out"));
inputStream.readObject();
}
}
}
The two most important lines are:
ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("panda.out"));
inputStream.readObject();
These two lines contain the entire deserialization flow.
First examineObjectInputStream,ObjectInputStreamandObjectOutputStream, an implementation ofObjectInputinterface'sInputStreamsubclass, defined as:
public class ObjectInputStream
extends InputStream implements ObjectInput, ObjectStreamConstants{
...
}
When we instantiateObjectInputStream, first callsObjectInputStreamconstructor.
ObjectInputStreamandObjectOutputStreamclass likewise has two constructors: onepublicsingle-argument constructor and aprotectedno-argument constructor
Likewise, when we instantiateObjectInputStreamand pass innew FileInputStream("panda.out")argument, callsObjectInputStreaminpublicsingle-argument constructor, whose body is:

andObjectOutputStreamconstructor: at its beginning, first callverifySubclassprocesses cached information and requires the class or subclass to validate that the instance can be constructed without violating security constraints.
Then compare withObjectOutputStreamThe difference is that inObjectOutputStream, the initialized object isbout、handles、subsandenableOverride, but inObjectInputStream, the initialized object becomesbin、handles、vlistandenableOverride。
/** filter stream for handling block data conversion */
private final BlockDataInputStream bin;
/** validation callback list */
private final ValidationList vlist;
/** wire handle -> obj/exception map */
private final HandleTable handles;
/** if true, invoke readObjectOverride() instead of readObject() */
private final boolean enableOverride;
Question:bin、handles、vlistandenableOverrideWhat does each mean?
First, forhandlesandenableoverrideis the same as inObjectOutputStreamhave the same meanings as in
handles: a hash table mapping objects to references
enableOverride: Boolean constant selecting which deserializationreadObjectOverridemethod orreadObjectmethod
Forbinis likewise treated as aboutfor understanding, because their roles are essentially identical
As forvlistmember field, used mainly to provide acallbackset of validations for the operation
WhenbinOnce initialized, this also means aBlockDataInputStream(if you do not understandBlockDataInputStream; see my previous article, Analysis of the Serialization Process, if unclear)
After the member fields are initialized, callreadStreamHeader()first verifies that the magic number and serialization version match

If they do not match, throw a serializationStreamCorruptedMismatchexception:

WhenObjectInputStreamofpublicOnly after the constructor completes does it callreadObject()begins writing object data. Its core code is:

This method isObjectInputStreamis the public deserialization entry point, but not the core method; it merely decides whether to callreadObjectOverrideorreadObject0method (enableOverridedetermines this)
Because inObjectInputStreamofpublicconstructor has initializedenableOverride = false, so skip the first if branch (do not callreadObjectOverridemethod), enterreadObject0method, shown below (it is lengthy):
/**
* Underlying readObject implementation.
*/
private Object readObject0(boolean unshared) throws IOException {
boolean oldMode = bin.getBlockDataMode();
if (oldMode) {
int remain = bin.currentBlockRemaining();
if (remain > 0) {
throw new OptionalDataException(remain);
} else if (defaultDataEnd) {
/*
* Fix for 4360508: stream is currently at the end of a field
* value block written via default serialization; since there
* is no terminating TC_ENDBLOCKDATA tag, simulate
* end-of-custom-data behavior explicitly.
*/
throw new OptionalDataException(true);
}
bin.setBlockDataMode(false);
}
byte tc;
while ((tc = bin.peekByte()) == TC_RESET) {
bin.readByte();
handleReset();
}
depth++;
totalObjectRefs++;
try {
switch (tc) {
case TC_NULL:
return readNull();
case TC_REFERENCE:
return readHandle(unshared);
case TC_CLASS:
return readClass(unshared);
case TC_CLASSDESC:
case TC_PROXYCLASSDESC:
return readClassDesc(unshared);
case TC_STRING:
case TC_LONGSTRING:
return checkResolve(readString(unshared));
case TC_ARRAY:
return checkResolve(readArray(unshared));
case TC_ENUM:
return checkResolve(readEnum(unshared));
case TC_OBJECT:
return checkResolve(readOrdinaryObject(unshared));
case TC_EXCEPTION:
IOException ex = readFatalException();
throw new WriteAbortedException("writing aborted", ex);
case TC_BLOCKDATA:
case TC_BLOCKDATALONG:
if (oldMode) {
bin.setBlockDataMode(true);
bin.peek(); // force header read
throw new OptionalDataException(
bin.currentBlockRemaining());
} else {
throw new StreamCorruptedException(
"unexpected block data");
}
case TC_ENDBLOCKDATA:
if (oldMode) {
throw new OptionalDataException(true);
} else {
throw new StreamCorruptedException(
"unexpected end of block data");
}
default:
throw new StreamCorruptedException(
String.format("invalid type code: %02X", tc));
}
} finally {
depth--;
bin.setBlockDataMode(oldMode);
}
}
and analyze it step by step
inreadObject0At the very beginning: oldMode = bin.getBlockDataMode();retrieves the current read mode and checks whether it isData Blockmode. If the check result isData Blockmode, first calculate the bytes remaining in the stream (currentBlockRemaining), and if the remaining count is greater than0ordefaultDataEndvalue istrue(defaultDataEndmarks the end of a data block—meaning no data remains here) and throwsjava.io.OptionalDataExceptionexception information
Question: why do these two cases throwjava.io.OptionalDataExceptionexception?
BecausereadObecjt0primarily reads object-type data. Although the data itself is aData Block, but the byte stream does not useTC_BLOCKDATALONGorTC_BLOCKDATAmarker to identify optional data blocks in this byte stream. If either of these two types ofData Blockdata block, immediately throwjava.io.OptionalDataExceptionexception. An analogy: you arrive at my house without announcing yourself, so I assume you are a robber and raise an alarm (exception).
After these checks, the if branch finally disablesData Blockmode;
begins reading the byte stream. If it readsTC_RESETmarker, callhandleResetto process it; otherwise continue reading:
- If it reads
TC_NULL— callsreadNullfunction;

- If it reads
TC_REFERENCE— callsreadHandlefunction;

- If it reads
TC_CLASS— callsreadClassfunction;

- If it reads
TC_CLASSDESCorTC_PROXYCLASSDESC— callsreadClassDescfunction;

- If it reads
TC_STRINGorTC_LONGSTRING— callsreadStringfunction;

- If it reads
TC_ARRAY— callsreadArrayfunction;

- If it reads
TC_ENUM— callsreadEnumfunction;

- If it reads
TC_OBJECT— callsreadOrdinaryObjectfunction;

- If it reads
TC_EXCEPTION— callsreadFatalExcceptionfunction and throws an exception;

- If it reads
TC_BLOCKDATAorTC_BLOCKDATALONG— throws an exception, except thatData BlockDifferent modes throw different exceptions. EnableData Blockmode;

- If it reads
TC_ENDBLOCKDATA— throws the same exception as above, but without enablingData Blockmode;

- All other cases throw an exception directly;

During this process, if it encountersTC_ARRAY,TC_ENUM,TC_OBJECT,TC_STRINGandTC_LONGSTRINGmarker, then callcheckResolvechecks whether the deserialized object overridesreadResolvemethod:

If overridden, the overriddenResolveflow; if not overridden, return obj
In this demo, execution ultimately reachesreadOrdinaryObjectmethod:

A breakpoint entersreadOradinaryObjectmethod is:

It first checks again whether the marker isTC_OBJECT; otherwise immediately throwInternalErrorerror
Then usereadClassDescreads the descriptor for the current Java object's class from the system:

Because Demo is a class object, execution entersreadNonProxyDesc:

Likewise, it checks again forTC_CLASSDESCmarker; if absent, throwInternalErrorerror
Then determine the read mode. If it isunshared, then fromhandlesobject mapping, reads a new desc; if it is notunshared, then fromunsharedMarkerreads the corresponding object from
Question:unsharedMarkerWhat is it?
unsharedMarkerstores object state; think ofunsharedMarkeras an identifier forunsharedstate marker. During reconstruction, itsunsharedstate and non-unsharedstate follows a slightly different deserialization flow.
Next enterreadClassDescriptormethod:

readClassDescriptorcallsreadNonProxyreads the current class metadata:

This method first reads the class name from the byte streamname = in.readUTF();, then reads from the byte streamserialVersionUIDinformation, then reads the variousSC_*marker information, sets the corresponding fields from it, then reads each field from the byte stream:

These field details include:TypeCode、fieldName、fieldType:
readNonProxyThe corresponding serialization method iswriteNonProxymethod. InwriteNonProxywritten intoTypeCode、fieldName、fieldTypeis read here.
After reading completes, execution returns throughreadNonProxy、readClassDescriptormethod, which returns after obtaining class informationreadNonProxyDescThen complete the following flow:

Following the flow above, first enableData Blockmode (bin.setBlockDataMode(true)), then callresolveClassprocesses the current class information:

In my earlier article, Analysis of the Serialization Process, I wrote:
annotateClass is provided for subclasses to implement and normally does nothing. A similar method is
ObjectInputStreaminresolveClassmethod.
In fact,ObjectInputStreaminresolveClass、resolveProxyClass、resolveObjectThese three methods correspond toObjectOutputStreamdefined inannotateClass、annotateProxyClassandreplaceObjectmethod. IfObjectOutputStreamsubclass overrides these three methods, then theObjectInputStreamsubclass must also override the correspondingresolvemethod.
Here,resolveClassloads the local class from its descriptor in the byte stream, using the familiarClass.forName()method. The root cause of deserialization vulnerabilities is precisely that it loadsRuntimeclass, then executesexec()method.
After processing the current class, callfilterCheckto validate:

If non-null, invoke the serialization filter, which callsserialFilter.checkInputchecks serialized data. If it detects an exception, it setsstatustoStatus.REJECTEDstate,filterCheckwill useserialFilter.checkInputdetermines whether to deserialize from the result of the check. IfcheckInput()method returnsStatus.REJECTED, deserialization is rejected and throwsInvalidClassException()error:

IfcheckInput()method returnsStatus.ALLOWED, the program performs deserialization

After validating the deserialized content, callskipCustomDataskips all data blocks and objects until it encountersTC_ENDBLOCKDATAmarker

Next, callObjectStreamClassininitNonProxymethod:

This method initializes a descriptor for a non-proxy class:

After initialization, callhandlesoffinishmethod completes the referenceHandleassignment:

Finally assign the result topassHandlemember field (initially defined asprivate int passHandle = NULL_HANDLE;)
readNonProxyDescmethod ends and assigns the class descriptor todescriptorvariable:

AftervalidateDescriptorafter validation, assignsdescriptoras the result back toreadOrdinaryObjectmethod.

After this chain of calls obtains the class descriptor, it checks—just as serialization began by doing—whether the current object can be deserialized (checkDeserialize()). If so, read the currentJavathe descriptor for the object's class (also called class metadata)
Then aftergetResolveExceptionchecks for an exception; if none, it returnsobjobject, then after several simple checks callshandlesoffinishmethod completes the referenceHandleassignment and finally assign the result topassHandlemember field;

After assignment and several routine checks, thereadOrdinaryObjectmethod
At this point execution returns toreadObject0method. InreadObject0method after a secondcheckResolvethen returnsreadObjectmethod

After deserialization completes, it callsvlistmember'sdoCallbacksfor its completion callback, ending the serialization flow.

Finally, review the complete serialization flow in the diagram:

0x03 Conclusion
Deserialization is more complex than serialization. Reading data involves recognizing numerous markers and class descriptors, as well as checking whether the content is safe.
Deserialization is unavoidable—and central—in Java security, so understanding Java serialization and deserialization in detail is worthwhile. This article is somewhat lengthy and imperfect; please be gentle.
0x04 References
https://docs.oracle.com/javase/7/docs/platform/serialization/spec/serialTOC.html
https://blog.csdn.net/silentbalanceyh/article/details/8294269