Java安全漫谈(1)——序列化与反序列化

起始

Java安全漫谈系列的名字来源于P牛,而目录来自于scz,他说自己是“带着一种我花开罢百花杀的神经病思维开始的”,看完目录觉得并非虚言。因为其中的许多文章并未在他的博客分享,所以想自己跟着这条路来学习一下。

名字中的漫谈二字代表此系列文章不会那么成体系化,略微零散。

0x1 基本使用

序列化和反序列化的概念不做介绍,直接来看如何使用。

要使某个类可以被序列化,它需要实现SerializableExternalizable接口,前者是一个空接口:

public interface Serializable {
}

它只用来标识此类可被序列化,后者继承前者。

一个Java对象的序列化的步骤:

  1. 创建一个java.io.ObjectOutputStream,可以包装一个其他类型的输出流
  2. 通过其writeObject方法写对象
// 序列化对象
public class SerializeCustomer {
	public static void main(String[] args) {
		Person person = new Person("Adan0s", 22);
		try {
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			ObjectOutputStream oos = new ObjectOutputStream(baos);
			oos.writeObject(person);
			oos.close();
			System.out.println("Serialized");
			System.out.println(Arrays.toString(baos.toByteArray()));
		}
		catch (Exception e) {
			e.printStackTrace();
		}
	}
}

一个Java对象的反序列化的步骤:

  1. 创建一个java.io.ObjectInputStream,可以包装一个其他类型的输入流
  2. 通过其readObject方法读取对象
//反序列化对象
public class DeserializeCustomer {
	public static void main(String[] args) {
		byte[] serializedData = new byte[]{-84, -19, 0, 5, 115, 114, 0, 27, 99, 111, 
    109, 46, 97, 100, 97, 110, 48,115, 46, 115, 101, 114, 105, 97, 108, 105, 122, 
    101, 46, 80, 101, 114, 115, 111, 110, 0, 0, 0, 0, 0,1, -65, 82, 2, 0, 2, 73, 
    0, 3, 97, 103, 101, 76, 0, 4, 110, 97, 109, 101, 116, 0, 18, 76, 106, 97, 118,
    97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 120, 112, 0, 0, 
    0, 22, 116, 0, 6, 65,100, 97, 110, 48, 115};
		try {
			ByteArrayInputStream bait = new ByteArrayInputStream(serializedData);
			ObjectInputStream ois = new ObjectInputStream(bait);
			Person p = (Person) ois.readObject();
			System.out.println(p.getName());
			System.out.println(p.getAge());
		}
		catch (Exception e) {
			e.printStackTrace();
		}
	}
}

0x2 序列化数据格式

将序列化数据写入文件,来观察一下:

20220405160914serializable-1

可以看到其中存在一些可读的字符串,包含了类名以及一部分成员变量名和值。

介绍一款工具:SerializationDumper,可以方便地还原序列化数据,比如对原始流文件:

20220405160941serializable-2

对16进制数据:

20220405160952serializable-3

完整的输出:

STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
  TC_OBJECT - 0x73
    TC_CLASSDESC - 0x72
      className
        Length - 27 - 0x00 1b
        Value - com.adan0s.serialize.Person - 0x636f6d2e6164616e30732e73657269616c697a652e506572736f6e
      serialVersionUID - 0x00 00 00 00 00 01 bf 52
      newHandle 0x00 7e 00 00
      classDescFlags - 0x02 - SC_SERIALIZABLE
      fieldCount - 2 - 0x00 02
      Fields
        0:
          Int - I - 0x49
          fieldName
            Length - 3 - 0x00 03
            Value - age - 0x616765
        1:
          Object - L - 0x4c
          fieldName
            Length - 4 - 0x00 04
            Value - name - 0x6e616d65
          className1
            TC_STRING - 0x74
              newHandle 0x00 7e 00 01
              Length - 18 - 0x00 12
              Value - Ljava/lang/String; - 0x4c6a6176612f6c616e672f537472696e673b
      classAnnotations
        TC_ENDBLOCKDATA - 0x78
      superClassDesc
        TC_NULL - 0x70
    newHandle 0x00 7e 00 02
    classdata
      com.adan0s.serialize.Person
        values
          age
            (int)22 - 0x00 00 00 16
          name
            (object)
              TC_STRING - 0x74
                newHandle 0x00 7e 00 03
                Length - 6 - 0x00 06
                Value - Adan0s - 0x4164616e3073

其字段含义可以在ObjectStreamConstants接口中找到,所以就算我们想手工还原序列化数据也是能做到的。

0x3 属性的影响

PHP序列化时,变量的作用域会影响到序列化数据,那么Java中是否同样存在类似的情况?

Person类加两个变量:

static String address = "ShenYangStreet";
transient String password = "admin123";

之后观察序列化数据,发现这两个变量都不存在:

20220405161010serializable-4

statictransient关键字修饰的变量不会出现在序列化数据里,这是为了一些敏感数据考虑的。

但如果尝试在反序列化后调用这两个变量,可以看到address正常输出,而password为null:

20220405161020serializable-5

这是因为address是静态变量,调用的是其在JVM中注册的值,而不是序列化后得到的值。

如果想序列化被transient关键字修饰的变量,就需要用到Externalizable接口:

public class ExternalizableCustomer implements Externalizable {
	public static String address = "ShenYangStreet";
	public transient String password = "admin123";
	@Override
	    public void writeExternal(ObjectOutput out) throws IOException {
		out.writeObject(password);
		out.writeObject(address);
	}
	@Override
	    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
		password = (String) in.readObject();
		address = (String) in.readObject();
	}
	public static void main(String[] args) {
		address = "WuHu";
		ExternalizableCustomer customer = new ExternalizableCustomer();
		try {
			ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.raw"));
			oos.writeObject(customer);
			oos.close();
			ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.raw"));
			ExternalizableCustomer customer2 = (ExternalizableCustomer) ois.readObject();
			System.out.println(customer2.password);
			System.out.println(customer2.address);
		}
		catch (Exception e) {
			e.printStackTrace();
		}
	}
}

这里的test.raw如果用SerializationDumper来解析,就会出现下面的错误:

20220405161031serializable-6

原因是实现了Externalizable接口的类,其序列化通过writeExternal方法写入流,那么解析也必须通过相应的readExternal方法,所以在不提供原始类的情况下,SerializationDumper无法解析这样的序列化数据。

0x4 ObjectStreamClass分析

ObjectStreamClass可以用来分析JVM中加载的序列化类的序列化特征,包括字段描述信息以及serialVersionUID等。

ObjectStreamClass有两个静态方法:

public static ObjectStreamClass lookup(Class<?> cl) {
	return lookup(cl, false);
}
public static ObjectStreamClass lookupAny(Class<?> cl) {
	return lookup(cl, true);
}

lookup(Class<?> cl)在提供的类可序列化的情况下会返回ObjectStreamClass实例,否则返回null:

public class ObjectStreamClassAnalyze {
	public ObjectStreamClass findNoSer(Class clazz) {
		ObjectStreamClass obj = null;
		try {
			obj = ObjectStreamClass.lookup(clazz);
		}
		catch (Exception e) {
			e.printStackTrace();
		}
		return obj;
	}
	public static void main(String[] args) {
		ObjectStreamClassAnalyze analyze = new ObjectStreamClassAnalyze();
		Person p = new Person("Adan0s", 22);
		ObjectStreamClass obj = analyze.findNoSer(p.getClass());
		System.out.println(obj.getName());
		System.out.println(obj.getSerialVersionUID());
	}
}
20220405161050serializable-7

lookupAny(Class<?> cl)方法不管提供的类是否可反序列化,都会返回相应实例。

获取到ObjectStreamClass实例后就可以调用相应方法获取信息:

  • getDeclaredSUID:提取序列号
  • getSerialFields:提取需要的序列化字段,如无则提取默认字段
  • ……

0x5 关于ObjectInputStream.resolveClass()

protected Class<?> resolveClass(ObjectStreamClass desc)throws IOException, ClassNotFoundException
    {
	String name = desc.getName();
	try {
		return Class.forName(name, false, latestUserDefinedLoader());
	}
	catch (ClassNotFoundException ex) {
		Class<?> cl = primClasses.get(name);
		if (cl != null) {
			return cl;
		} else {
			throw ex;
		}
	}
}

resolveClass方法接收一个ObjectStreamClass实例,获取其类名,再利用反射的方式返回一个此类的Class实例,实际上就是允许在反序列化中,返回对象之前进行替换或解析对象。

在Apache Shiro中对此方法进行了重写,影响了许多反序列化利用链:

@Override
    protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
	try {
		return ClassUtils.forName(osc.getName());
	}
	catch (UnknownClassException e) {
		throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", e);
	}
}

重写该方法也是防御反序列化漏洞的一种手段,例如SerialKiller这个项目:

20220405161114serializable-8

通过重写ObjectInputStream.resolveClass()来进行黑名单或白名单方式的防御。

0x6 细谈序列化与反序列化过程

到这里其实已经对Java序列化和反序列化有一些了解了,但还不够深入,所以看看其流程会有不小的帮助。

序列化的核心步骤就是这两句:

ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("person.raw"));
oos.writeObject(person);

ObjectOutputStreamOutputStream的子类,实现了ObjectOutput的接口,其构造方法有两个,分别为:

public ObjectOutputStream(OutputStream out)
protected ObjectOutputStream()

上面的例子中使用的是:

public ObjectOutputStream(OutputStream out) throws IOException {
	verifySubclass();
	bout = new BlockDataOutputStream(out);
	handles = new HandleTable(10, (float) 3.00);
	subs = new ReplaceTable(10, (float) 3.00);
	enableOverride = false;
	writeStreamHeader();
	bout.setBlockDataMode(true);
	if (extendedDebugInfo) {
		debugInfoStack = new DebugTraceInfoStack();
	} else {
		debugInfoStack = null;
	}
}

verifySubclass()是用来验证该类是否遵循安全约束,之后创建了几个新对象:

  • bout存放的是主类的相关成员属性
  • handles是一个对象到引用的映射哈希表
  • subs是一个对象到替换对象的映射哈希表
  • enableOverride表示是否重写了writeObject方法,如果是,则调用重写后的
20220405161130serializable-9

之后调用writeStreamHeader(),此方法是往bout里写入Java序列化数据头以及版本信息:

protected void writeStreamHeader() throws IOException {
    bout.writeShort(STREAM_MAGIC);
    bout.writeShort(STREAM_VERSION);
}

后面的setBlockDataMode方法是用来开启Data Block模式的,与字节流的兼容性有关,参考阅读:《Object Serialization Stream Protocol/对象序列化流协议》总结

构造方法之后,进入writeObject方法中,内容:

public final void writeObject(Object obj) throws IOException {
	if (enableOverride) {
		writeObjectOverride(obj);
		return;
	}
	try {
		writeObject0(obj, false);
	}
	catch (IOException ex) {
		if (depth == 0) {
			writeFatalException(ex);
		}
		throw ex;
	}
}

根据enableOverride的值确定是调用writeObjectOverride还是writeObject0,也就是进入重写的方法还是默认的,以默认的writeObject0为例,因为该方法较长,所以一部分一部分来分析。

boolean oldMode = bout.setBlockDataMode(false);

首先关闭了Data Block模式,将原始模式赋值给oldMode,进入下面的:

// handle previously written and non-replaceable objects
int h;
if ((obj = subs.lookup(obj)) == null) {
	writeNull();
	return;
} else if (!unshared && (h = handles.lookup(obj)) != -1) {
	writeHandle(h);
	return;
} else if (obj instanceof Class) {
	writeClass((Class) obj, unshared);
	return;
} else if (obj instanceof ObjectStreamClass) {
	writeClassDesc((ObjectStreamClass) obj, unshared);
	return;
}

这里会处理已处理过的对象和不可替换的对象,判断内容包括:

  • 是否能在ReplaceTable找到传入的对象
  • 写入方式是否是unshared方式
  • 判断当前对象是否是ClassObjectStreamClass

如果满足上面的任意一项,都会调用对应的处理方法并返回。

之后的部分:

if (enableReplace) {
	Object rep = replaceObject(obj);
	if (rep != obj && rep != null) {
		cl = rep.getClass();
		desc = ObjectStreamClass.lookup(cl, true);
	}
	obj = rep;
}

enableReplace用来判断当前对象是否开启了替换功能,通常为false.

如果对象被替换后,这里会进行二次检查,和之前类似:

if (obj != orig) {
	subs.assign(orig, obj);
	if (obj == null) {
		writeNull();
		return;
	} else if (!unshared && (h = handles.lookup(obj)) != -1) {
		writeHandle(h);
		return;
	} else if (obj instanceof Class) {
		writeClass((Class) obj, unshared);
		return;
	} else if (obj instanceof ObjectStreamClass) {
		writeClassDesc((ObjectStreamClass) obj, unshared);
		return;
	}
}

最后根据对象类型分别调用对应的方法写入字节流:

// remaining cases
if (obj instanceof String) {
	writeString((String) obj, unshared);
} else if (cl.isArray()) {
	writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
	writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
	writeOrdinaryObject(obj, desc, unshared);
} else {
	if (extendedDebugInfo) {
		throw new NotSerializableException(
		    cl.getName() + "n" + debugInfoStack.toString());
    } else {
        throw new NotSerializableException(cl.getName());
    }
}

writeString为例:

private void writeString(String str, Boolean unshared) throws IOException {
	handles.assign(unshared ? null : str);
	long utflen = bout.getUTFLength(str);
	if (utflen <= 0xFFFF) {
		bout.writebyte(TC_STRING);
		bout.writeUTF(str, utflen);
	} else {
		bout.writebyte(TC_LONGSTRING);
		bout.writeLongUTF(str, utflen);
	}
}

首先判断写入方式是否为unshared,如果不是将在handles的对象映射中插入当前字符串对象;之后调用getUTFLength获取字符串的长度,大于0xFFFF代表这是一个长字符串,否则是正常的字符串;最后写入字符串的内容和长度到bout.

借用两张图,阐述一下序列化和反序列化的流程:

20220405161145serializable-10

反序列化:

20220405161153serializable-11

0x7 readObjectNoData

在反序列化时,如果因为序列化时的类与反序列化时版本不同,造成序列化类的超类与反序列化类的超类不同,或因为接收到的序列化数据不完整,或序列化数据有危害,都会对初始化对象字段值造成影响。

所以可序列化的类应定义自己的readObjectNoData方法,在出现上述情况时就会用readObjectNoData替代readObject。如果没有此方法,类的字段就会初始化为它们的默认值。

举个例子,正常的序列化的类:

public class Person implements Serializable {
    private String name;

    public Person(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
}

序列化后,更新一下这个类,再进行反序列化:

public class Person extends Animals implements Serializable {
    private String name;

    public Person(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
}

class Animals implements Serializable {
    private String name;
    public Animals() {  }
    public void setName(String name){
        this.name = name;
    }
    public String getName(){
        return this.name;
    }
    private void readObjectNoData() {
        this.name = "Nobody";
    }
}

0x8 readUnshared

在上面的序列化流程分析中,有一个函数中存在了一处对unshared值的判断:

20220405161211serializable-12

这里的unshared牵扯到一个readUnshared方法:

public Object readUnshared() throws IOException, ClassNotFoundException {
	// if nested read, passHandle contains handle of enclosing object
	int outerHandle = passHandle;
	try {
		Object obj = readObject0(true);
		handles.markDependency(outerHandle, passHandle);
		ClassNotFoundException ex = handles.lookupException(passHandle);
		if (ex != null) {
			throw ex;
		}
		if (depth == 0) {
			vlist.doCallbacks();
		}
		return obj;
	}
	finally {
		passHandle = outerHandle;
		if (closed && depth == 0) {
			clear();
		}
	}
}

它与readObject类似,区别在于它返回的对象不允许后续的方法进行引用,可以用来防止内存泄露。

因为ObjectOutputStreamObjectInputStream会各自保留一个已发送或已接收对象引用的列表,垃圾收集器不会回收这些有引用的对象内存,久而久之就有可能造成内存泄漏,使用readUnshared方法可以避免这一问题。

参考链接

https://www.cnpanda.net/sec/893.html

https://www.cnpanda.net/sec/928.html