A Study of the Deserialization Process

Summary0x01 Preface As with the previous article, copy the demo and follow this analysis in a debugger. 0x02 Flow Analysis In Analysis of the Serialization Process, I noted that serialization writes an object to an I/O stream. It usually begins by creating an ObjectOutputStream…

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 anObjectOutputStreamoutput 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:

JAVA
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:

JAVA
	ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("panda.out"));
          inputStream.readObject();

These two lines contain the entire deserialization flow.

First examineObjectInputStreamObjectInputStreamandObjectOutputStream, an implementation ofObjectInputinterface'sInputStreamsubclass, defined as:

JAVA
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:

1.jpg

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 isbouthandlessubsandenableOverride, but inObjectInputStream, the initialized object becomesbinhandlesvlistandenableOverride

JAVA
 /** 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:binhandlesvlistandenableOverrideWhat 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

2.png

If they do not match, throw a serializationStreamCorruptedMismatchexception:

3.jpg

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

4.jpg

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):

JAVA
/**
     * 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 istruedefaultDataEndmarks 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 readsTC_NULL— callsreadNullfunction;
6.jpg
  • If it readsTC_REFERENCE— callsreadHandlefunction;
7.jpg
  • If it readsTC_CLASS— callsreadClassfunction;
8.jpg
  • If it readsTC_CLASSDESCorTC_PROXYCLASSDESC— callsreadClassDescfunction;
9.jpg
  • If it readsTC_STRINGorTC_LONGSTRING— callsreadStringfunction;
10.jpg
  • If it readsTC_ARRAY— callsreadArrayfunction;
11.jpg
  • If it readsTC_ENUM— callsreadEnumfunction;
12.jpg
  • If it readsTC_OBJECT— callsreadOrdinaryObjectfunction;
13.jpg
  • If it readsTC_EXCEPTION— callsreadFatalExcceptionfunction and throws an exception;
14.jpg
  • If it readsTC_BLOCKDATAorTC_BLOCKDATALONG— throws an exception, except thatData BlockDifferent modes throw different exceptions. EnableData Blockmode;
15.jpg
  • If it readsTC_ENDBLOCKDATA— throws the same exception as above, but without enablingData Blockmode;
16.jpg
  • All other cases throw an exception directly;
17.jpg

During this process, if it encountersTC_ARRAYTC_ENUMTC_OBJECTTC_STRINGandTC_LONGSTRINGmarker, then callcheckResolvechecks whether the deserialized object overridesreadResolvemethod:

18.jpg

If overridden, the overriddenResolveflow; if not overridden, return obj

In this demo, execution ultimately reachesreadOrdinaryObjectmethod:

13.jpg

A breakpoint entersreadOradinaryObjectmethod is:

19.jpg

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:

20.jpg

Because Demo is a class object, execution entersreadNonProxyDesc

21.jpg

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:

22.jpg

readClassDescriptorcallsreadNonProxyreads the current class metadata:

23.jpg

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:

24.jpg

These field details include:TypeCodefieldNamefieldType

readNonProxyThe corresponding serialization method iswriteNonProxymethod. InwriteNonProxywritten intoTypeCodefieldNamefieldTypeis read here.

After reading completes, execution returns throughreadNonProxyreadClassDescriptormethod, which returns after obtaining class informationreadNonProxyDescThen complete the following flow:

25.jpg

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

26.jpg

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 isObjectInputStreaminresolveClassmethod.

In fact,ObjectInputStreaminresolveClassresolveProxyClassresolveObjectThese three methods correspond toObjectOutputStreamdefined inannotateClassannotateProxyClassandreplaceObjectmethod. 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:

27.jpg

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:

28.jpg

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

29.jpg

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

30.jpg

Next, callObjectStreamClassininitNonProxymethod:

31.jpg

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

32.jpg

After initialization, callhandlesoffinishmethod completes the referenceHandleassignment:

33.jpg

Finally assign the result topassHandlemember field (initially defined asprivate int passHandle = NULL_HANDLE;

readNonProxyDescmethod ends and assigns the class descriptor todescriptorvariable:

34.jpg

AftervalidateDescriptorafter validation, assignsdescriptoras the result back toreadOrdinaryObjectmethod.

35.jpg

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;

36.jpg

After assignment and several routine checks, thereadOrdinaryObjectmethod

At this point execution returns toreadObject0method. InreadObject0method after a secondcheckResolvethen returnsreadObjectmethod

37.jpg

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

38.jpg

Finally, review the complete serialization flow in the diagram:

Deserialization flow.jpg

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

https://blog.csdn.net/u011315960/article/details/89963230