用一个 case 去理解 jdk8u20 原生反序列化漏洞

本文最后更新于 2021.07.12,总计 37643 字 ,阅读本文大概需要 38 ~ 168 分钟
本文已超过 143天 没有更新。如果文章内容或图片资源失效,请留言反馈,我会及时处理,谢谢!

0x01 写在前面

jdk8u20原生反序列化漏洞是一个非常经典的漏洞,也是我分析过最复杂的漏洞之一。

在这个漏洞里利用了大量的底层的基础知识,同时也要求读者对反序列化的流程、序列化的数据结构有一定的了解

本文结合笔者自身对该漏洞的了解,写下此文,如有描述不当或者错误之处,还望各位师傅指出

0x02 jdk8u20 漏洞原理

jdk8u20其实是对jdk7u21漏洞的绕过,在《JDK7u21反序列化漏洞分析笔记》 一文的最后我提到了jdk7u21的修复方式:

首先来看存在漏洞的最后一个版本(611bcd930ed1):http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/611bcd930ed1/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.java

查看其 children 版本(0ca6cbe3f350):http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/0ca6cbe3f350/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.java

compare一下:

compare.png

// 改之前
        AnnotationType annotationType = null;
        try {
            annotationType = AnnotationType.getInstance(type);
        } catch(IllegalArgumentException e) {
            // Class is no longer an annotation type; all bets are off
           return;
        }

// 改之后
        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");
        }

可以发现,在第一次的修复中,官方采用的方法是网上的第二种讨论,即将以前的 return 改成了抛出异常。

我们来看第一次修复后的AnnotationInvocationHandler.readObejct()方法:

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();
        // If there are annotation members without values, that
        // situation is handled by the invoke method.
        for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) {
            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)));
                }
            }
        }
    }

AnnotationInvocationHandler类中,其重写了readObejct方法,那么根据 oracle 官方定义的 Java 中可序列化对象流的原则——如果一个类中定义了readObject方法,那么这个方法将会取代默认序列化机制中的方法读取对象的状态,可选的信息可依靠这些方法读取,而必选数据部分要依赖defaultReadObject方法读取;

可以看到在该类内部的readObject方法第一行就调用了defaultReadObject()方法,该方法主要用来从字节流中读取对象的字段值,它可以从字节流中按照定义对象的类描述符以及定义的顺序读取字段的名称和类型信息。这些值会通过匹配当前类的字段名称来赋予,如果当前这个对象中的某个字段并没有在字节流中出现,则这些字段会使用类中定义的默认值,如果这个值出现在字节流中,但是并不属于对象,则抛弃该值

在利用defaultReadObject()还原了一部分对象的值后,最近进行AnnotationType.getInstance(type)判断,如果传入的 type 不是AnnotationType类型,那么抛出异常。

也就是说,实际上在jdk7u21漏洞中,我们传入的AnnotationInvocationHandler对象在异常被抛出前,已经从序列化数据中被还原出来。换句话说就是我们把恶意的种子种到了运行对象中,但是因为出现异常导致该种子没法生长,只要我们解决了这个异常,那么就可以重新达到我们的目的。

这也就是jdk8u20漏洞的原理——逃过异常抛出。

那么具体该如何逃过呢?jdk8u20的作者用了一种非常牛逼的方式。

再具体介绍这种方式之前,先简单介绍一些与本漏洞相关的基础知识,以便读者更明白本文的分析流程和细节。

0x03 基础知识

1、Try/catch块的作用

写程序不可避免的出现一些错误或者未注意到的异常信息,为了能够处理这些异常信息或错误,并且让程序继续执行下去,开发者通常使用try ... catch语法。把可能发生异常的语句放在try { ... }中,然后使用catch捕获对应的Exception及其子类,这样一来,在 JVM 捕获到异常后,会从上到下匹配catch语句,匹配到某个catch后,执行catch代码块,从而达到继续执行代码的效果。

如jdk7u21中利用的正是这个:

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

当检测的结果不是AbbitatuibType时,匹配到了IllegalArgumentException异常,然后执行了catch中的代码块。

但如果try ... catch嵌套,又该如何判定呢?

可以看个例子

package com.panda.sec;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;

public class test {
    static double TEST_NUMBER = 0;
    public static void math(int a, int b){
        double c;
        if (a != b) {
            try {
                TEST_NUMBER = a*(a+b);
                c = a / b;
            } catch (Exception e) {
                System.out.println("内层出错了");
            }
        } else {
            c = a * b;
        }
    }
    public static void urlRequest(int a, int b, String url) throws IOException {
        
            try {
                math(a, b);
                URL realUrl = new URL(url);
                HttpURLConnection connection = (HttpURLConnection)realUrl.openConnection();
                connection.setRequestProperty("accept", "*/*");
                connection.connect();
                System.out.println("状态码:" + connection.getResponseCode());
            } catch (Exception e) {
                System.out.println("外层出错了");
                throw e;
            }
        
        System.out.println(TEST_NUMBER);
    }

    public static void main(String[] args) throws IOException {
        urlRequest(1,0,"https://www.cnpanda.net");
        System.out.println("all end");
    }

}

先来看看代码逻辑,首先定义了全局变量TEST_NUMBER=0,然后定义了mathurlRequest两个方法,并且在urlRequest方法里,调用了math方法,最后在main函数中执行urlRequest方法。

请读者不看下文的分析,先思考当变量值为以下情况时,这段代码会输出什么?

  • a=1,b=0,url地址是https://www.cnpanda.net
  • a=1,b=0,url地址是https://test.cnpanda.net
  • a=1,b=2,url地址是https://www.cnpanda.net
  • a=1,b=2,url地址是https://test.cnpanda.net

来看具体运行结果:

a=1,b=0,url地址是https://www.cnpanda.net时:

1.jpg

这种情况下,b=0使得a/b中的分母为0,导致内层出错,因此会进入catch块并打印出内层出错了字符串,但是由于内层的catch块并没有把错误抛出,因此继续执行剩余代码逻辑,向https://www.cnpanda.net地址发起http请求,打印状态码为200,由于在math方法中 TEST_NUMBER = a*(a+b)=1*(1+0)=1,因此打印出TEST_NUMBER1.0,最后打印all end结束代码逻辑。

a=1,b=0,url地址是https://test.cnpanda.net时:

2.jpg

这种情况下,b=0使得a/b中的分母为0,导致内层出错,因此会进入catch块并打印出内层出错了字符串,但是由于内层的catch块并没有把错误抛出,因此继续执行剩余代码逻辑,向https://test.cnpanda.net地址发起http请求,但是由于无法解析导致出错,进入catch块,在catch块中打印外层出错了字符串,然后抛出错误,结束代码逻辑。

a=1,b=2,url地址是https://www.cnpanda.net时:

3.jpg

这种情况下,b!=0,因此a/b会正常运算,不会进入catch块,继续执行剩余代码逻辑,向https://www.cnpanda.net地址发起http请求,打印状态码为200,由于在math方法中 TEST_NUMBER = a*(a+b)=1*(1+2)=3,因此打印出TEST_NUMBER为3,最后打印all end结束代码逻辑。

a=1,b=2,url地址是https://test.cnpanda.net时:

4.jpg

这种情况下,b!=0,因此a/b会正常运算,不会进入catch块,继续执行剩余代码逻辑,向https://test.cnpanda.net地址发起http请求,但是由于无法解析导致出错,进入catch块,在catch块中打印外层出错了字符串,然后抛出错误,结束代码逻辑。

从上面的示例可以得出一个结论,在一个存在try ... catch块的方法(有异常抛出)中去调用另一个存在try ... catch块的方法(无异常抛出),如果被调用的方法(无异常抛出)出错,那么会继续执行完调用方法的代码逻辑,但是若调用方法也出错,那么终止代码运行的进程

这是有异常抛出调用无异常抛出,那么如果是无异常抛出调用有异常抛出呢?

如下代码:

package com.panda.sec;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;

public class test {
    static double TEST_NUMBER = 0;
    public static void math(int a, int b,String url) throws IOException {
        double c;
        try {
            urlRequest(url);
            if (a != b) {
                    TEST_NUMBER = a*(a+b);
                    c = a / b;
            } else {
                c = a * b;
            }
        } catch (Exception e) {
            System.out.println("外层出错了");
        }
    }
    public static void urlRequest(String url) throws IOException {
        try {
             URL realUrl = new URL(url);
             HttpURLConnection connection = (HttpURLConnection)realUrl.openConnection();
             connection.setRequestProperty("accept", "*/*");
             connection.connect();
             System.out.println("状态码:" + connection.getResponseCode());
        } catch (Exception e) {
                System.out.println("内层出错了");
                throw e;
            }
        System.out.println(TEST_NUMBER);
    }
    public static void main(String[] args) throws IOException {
        math(1,0,"https://test.cnpanda.net");
         System.out.println("all end");
     }
}

同上面示例一样的代码逻辑(为了方便,做了略微调整,有些代码无意义也没有删除),只是,不同的是,这里在math方法中调用了urlRequest方法。

那么如下情况又会输出什么呢?

同样的,请读者不看下文的分析,先思考当变量值为以下情况时,这段代码会输出什么?

  • a=1,b=0,url地址是https://www.cnpanda.net
  • a=1,b=0,url地址是https://test.cnpanda.net
  • a=1,b=2,url地址是https://www.cnpanda.net
  • a=1,b=2,url地址是https://test.cnpanda.net

a=1,b=0,url地址是https://www.cnpanda.net

5.jpg

这种情况下,urlhttps://www.cnpanda.net,因此会在内层向该地址发起http请求,并且打印状态码为200,内层执行完毕后,继续执行外层剩余代码逻辑,b=0使得a/b中的分母为0,导致外层出错,因此会进入catch块并打印出外层层出错了字符串,最后打印all end结束代码逻辑。

a=1,b=0,url地址是https://test.cnpanda.net

6.jpg

这种情况下,urlhttps://test.cnpanda.net,因此会在内层向该地址发起http请求,但是由于无法解析导致出错,进入catch块,在catch块中打印内层出错了字符串,由于内层出错,导致外层也出错,直接进入外层的catch块并打印出外层层出错了字符串,最后打印all end结束代码逻辑。

a=1,b=2,url地址是https://www.cnpanda.net

7.jpg

这种情况下,urlhttps://www.cnpanda.net,因此会在内层向该地址发起http请求,并且打印状态码为200,内层执行完毕后,继续执行外层剩余代码逻辑,b!=0使得a/b中的分母不为0,外层不会出错,因此执行完外层的逻辑,最后打印all end结束整个代码逻辑。

a=1,b=2,url地址是https://test.cnpanda.net

8.jpg

这种情况下,urlhttps://test.cnpanda.net,因此会在内层向该地址发起http请求,因此会在内层向该地址发起http请求,但是由于无法解析导致出错,进入catch块,在catch块中打印内层出错了字符串,由于内层出错,导致外层也出错,直接进入外层的catch块并打印出外层层出错了字符串,最后打印all end结束代码逻辑。

从上面的示例可以得出一个结论,在一个存在try ... catch块的方法(无异常抛出)中去调用另一个存在try ... catch块的方法(有异常抛出),如果被调用的方法(有异常抛出)出错,那么会导致调用方法出错且不会继续执行完调用方法的代码逻辑,但是不会终止代码运行的进程

2、序列化数据的结构

序列化数据的结构可以参考:

《Object Serialization Stream Protocol/对象序列化流协议》总结 https://www.cnpanda.net/talksafe/892.html

或者直接阅读官方文档:https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html

使用SerializationDumper工具可以查看一段序列化数据的结构,如下图所示:

9.jpg

可以看到,序列化结构的骨架是由TC_*和各种字段描述符构成,各个TC_*及描述符的意思已经在《Object Serialization Stream Protocol/对象序列化流协议》一文中介绍了,想深入阅读的读者可以去看看。

3、序列化中的两个机制

引用机制

在序列化流程中,对象所属类、对象成员属性等数据都会被使用固定的语法写入到序列化数据,并且会被特定的方法读取;在序列化数据中,存在的对象有null、new objects、classes、arrays、strings、back references等,这些对象在序列化结构中都有对应的描述信息,并且每一个写入字节流的对象都会被赋予引用Handle,并且这个引用Handle可以反向引用该对象(使用TC_REFERENCE结构,引用前面handle的值),引用Handle会从0x7E0000开始进行顺序赋值并且自动自增,一旦字节流发生了重置则该引用Handle会重新从0x7E0000开始。

成员抛弃

在反序列化中,如果当前这个对象中的某个字段并没有在字节流中出现,则这些字段会使用类中定义的默认值,如果这个值出现在字节流中,但是并不属于对象,则抛弃该值,但是如果这个值是一个对象的话,那么会为这个值分配一个 Handle。

4、了解jdk7u21漏洞

这个是毋庸置疑要理解的,因为jdk8u20是对jdk7u21漏洞修复的绕过。

可以参考我之前写的文章:JDK7u21反序列化漏洞分析笔记:https://xz.aliyun.com/t/9704

0x04 从一个case说起

由于jdk8u20真的比较复杂,因此为了方便理解,我写了一个简单的case,用于帮助读者理解下文。

假设存在两个类AnnotationInvocationHandlerBeanContextSupport,具体内容如下:

AnnotationInvocationHandler.java

package com.panda.sec;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
public class AnnotationInvocationHandler implements Serializable {
    private static final long serialVersionUID = 10L;
    private int zero;
    public AnnotationInvocationHandler(int zero) {
        this.zero = zero;
    }
    public void exec(String cmd) throws IOException {
        Process shell = Runtime.getRuntime().exec(cmd);
    }
    private void readObject(ObjectInputStream input) throws Exception {
        input.defaultReadObject();
        if(this.zero==0){
            try{
                double result = 1/this.zero;
            }catch (Exception e) {
                throw new Exception("Hack !!!");
            }
        }else{
            throw new Exception("your number is error!!!");
        }
    }
}

BeanContextSupport.java

package com.panda.sec;
import java.io.ObjectInputStream;
import java.io.Serializable;
public class BeanContextSupport implements Serializable {
    private static final long serialVersionUID = 20L;
    private void readObject(ObjectInputStream input) throws Exception {
        input.defaultReadObject();
        try {
            input.readObject();
        } catch (Exception e) {
            return;
        }
    }
}

Question:当传入AnnotationInvocationHandler方法中的zero等于0的时候,如何能在序列化结束时调用AnnotationInvocationHandler.exec()方法达到RCE

我们首先令zero等于0,然后尝试调用AnnotationInvocationHandler.exec()方法看看:

import java.io.*;
public class Main {
    public static void payload() throws IOException, ClassNotFoundException {
        AnnotationInvocationHandler annotationInvocationHandler = new AnnotationInvocationHandler(0);
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("payload1"));
        out.writeObject(annotationInvocationHandler);
        out.close();
        ObjectInputStream in = new ObjectInputStream(new FileInputStream("payload1"));
        AnnotationInvocationHandler str = (AnnotationInvocationHandler)in.readObject();
        str.exec("open /System/Applications/Calculator.app");
    }
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        payload();
    }
}

不出意外,由于zero的值为0,所以使得result的分母为0,导致出现异常,抛出 Exception("Hack !!!")错误。

10.jpg

由于在代码中我们生成了序列化文件payload1,所以现在可以利用SerializationDumper工具来看看其数据结构:

STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
  TC_OBJECT - 0x73
    TC_CLASSDESC - 0x72
      className
        Length - 41 - 0x00 29
        Value - com.panda.sec.A