Skip to content

JavaSec 入门-02

约 3867 字大约 13 分钟

Java

2024-12-02

前言

离散被捞了,好事

反序列化入门和动态代理九月看过一次就没再学了,回顾一下。

sun.misc.Unsafe

sun.misc.Unsafe是 Java 底层 API(仅限Java内部使用,反射可调用)提供的一个神奇的Java类,Unsafe提供了非常底层的内存、CAS、线程调度、类、对象等操作、Unsafe正如它的名字一样它提供的几乎所有的方法都是不安全的

获取Unsafe对象

Unsafe是Java内部API,外部是禁止调用的,在编译Java类时如果检测到引用了Unsafe类也会有禁止使用的警告:Unsafe是内部专用 API, 可能会在未来发行版中删除。

sun.misc.Unsafe代码片段:

import sun.reflect.CallerSensitive;
import sun.reflect.Reflection;

public final class Unsafe {
    private static final Unsafe theUnsafe;
    static {
        theUnsafe = new Unsafe();
        省去其他代码......
    }
    private Unsafe() {
    }
    @CallerSensitive
    public static Unsafe getUnsafe() {
        Class var0 = Reflection.getCallerClass();
        if (var0.getClassLoader() != null) {
            throw new SecurityException("Unsafe");
        } else {
            return theUnsafe;
        }
    }
    省去其他代码......
}

由上代码片段可以看到,Unsafe类是一个不能被继承的类且不能直接通过new的方式创建Unsafe类实例,如果通过getUnsafe方法获取Unsafe实例还会检查类加载器,默认只允许Bootstrap Classloader调用。

既然无法直接通过Unsafe.getUnsafe()的方式调用,那么可以使用反射的方式去获取Unsafe类实例。

反射获取Unsafe类实例代码片段:

// 反射获取Unsafe的theUnsafe成员变量
Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");

// 反射设置theUnsafe访问权限
theUnsafeField.setAccessible(true);

// 反射获取theUnsafe成员变量值
Unsafe unsafe = (Unsafe) theUnsafeField.get(null);

当然我们也可以用反射创建Unsafe类实例的方式去获取Unsafe对象:

// 获取Unsafe无参构造方法
Constructor constructor = Unsafe.class.getDeclaredConstructor();

// 修改构造方法访问权限
constructor.setAccessible(true);

// 反射创建Unsafe类实例,等价于 Unsafe unsafe1 = new Unsafe();
Unsafe unsafe1 = (Unsafe) constructor.newInstance();

获取到了Unsafe对象我们就可以调用内部的方法了。

allocateInstance无视构造方法创建类实例

假设我们有一个叫com.anbai.sec.unsafe.UnSafeTest的类,因为某种原因我们不能直接通过反射的方式去创建UnSafeTest类实例,那么这个时候使用UnsafeallocateInstance方法就可以绕过这个限制了。

UnSafeTest代码片段:

public class UnSafeTest {

   private UnSafeTest() {
      // 假设RASP在这个构造方法中插入了Hook代码,我们可以利用Unsafe来创建类实例
      System.out.println("init...");
   }
}

使用Unsafe创建UnSafeTest对象:

// 使用Unsafe创建UnSafeTest类实例
UnSafeTest test = (UnSafeTest) unsafe1.allocateInstance(UnSafeTest.class);

Google的GSON库在JSON反序列化的时候就使用这个方式来创建类实例,在渗透测试中也会经常遇到这样的限制,比如RASP限制了java.io.FileInputStream类的构造方法导致我们无法读文件或者限制了UNIXProcess/ProcessImpl类的构造方法导致我们无法执行本地命令等。

defineClass直接调用JVM创建类对象

ClassLoader章节我们讲了通过ClassLoader类的defineClass0/1/2方法我们可以直接向JVM中注册一个类,如果ClassLoader被限制的情况下我们还可以使用UnsafedefineClass方法来实现同样的功能。

Unsafe提供了一个通过传入类名、类字节码的方式就可以定义类的defineClass方法:

public native Class defineClass(String var1, byte[] var2, int var3, int var4);
public native Class<?> defineClass(String var1, byte[] var2, int var3, int var4, ClassLoader var5, ProtectionDomain var6);

写一个想要被加载的类,注意这里由于类初始化的原因,我们要把想在类加载时触发的方法写到 static 里面

package test02;

import java.io.IOException;

public class Hello {
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    public static void main(String[] args) {

    }
}
package test02;

import sun.misc.Unsafe;

import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;

public class UnsafeTest {
    public static void main(String[] args) throws Exception {
        byte[] words =  Files.readAllBytes(Paths.get("D:\\Java\\test\\target\\classes\\test02\\Hello.class"));
        ClassLoader c = ClassLoader.getSystemClassLoader();
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        Unsafe unsafe = (Unsafe) f.get(null);
        Class<?> hello = unsafe.defineClass("test02.Hello", words, 0, words.length, c, null);
        hello.newInstance();
    }
}

为什么这么写呢,在这里我们获取的是 unsafe 的属性,而不是defineClass方法,同样的 unsafe 类也有 definclass方法,但是他是原生的类(底层C加载),因此反射过来无法调用,而Classloader里的definclass就不是了

Unsafe还可以通过defineAnonymousClass方法创建内部类,这里不再多做测试。

注意:

这个实例仅适用于Java 8以前的版本如果在Java 8中应该使用应该调用需要传类加载器和保护域的那个方法。Java 11开始Unsafe类已经把defineClass方法移除了(defineAnonymousClass方法还在),虽然可以使用java.lang.invoke.MethodHandles.Lookup.defineClass来代替,但是MethodHandles只是间接的调用了ClassLoaderdefineClass,所以一切也就回到了ClassLoader

动态代理

区别于静态代理,Java反射提供了一种类动态代理机制,可以通过代理接口实现类来完成程序无侵入式扩展。

Java动态代理主要使用场景:

  1. 统计方法执行所耗时间。
  2. 在方法执行前后添加日志。
  3. 检测方法的参数或返回值。
  4. 方法访问权限控制。
  5. 方法Mock测试。

为什么类可以动态的生成?

这就涉及到Java虚拟机的类加载机制了,推荐翻看《深入理解Java虚拟机》7.3节 类加载的过程。

Java虚拟机类加载过程主要分为五个阶段:加载、验证、准备、解析、初始化。其中加载阶段需要完成以下3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据访问入口

由于虚拟机规范对这3点要求并不具体,所以实际的实现是非常灵活的,关于第1点,获取类的二进制字节流(class字节码)就有很多途径:

  • 从ZIP包获取,这是JAR、EAR、WAR等格式的基础
  • 从网络中获取,典型的应用是 Applet
  • 运行时计算生成,这种场景使用最多的是动态代理技术,在 java.lang.reflect.Proxy 类中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为 *$Proxy 的代理类的二进制字节流
  • 由其它文件生成,典型应用是 JSP,即由 JSP 文件生成对应的 Class 类
  • 从数据库中获取等等

所以,动态代理就是想办法,根据接口或目标对象,计算出代理类的字节码,然后再加载到 JVM 中使用。但是如何计算?如何生成?情况也许比想象的复杂得多,我们需要借助现有的方案。

常见的字节码操作类库

这里有一些介绍:https://java-source.net/open-source/bytecode-libraries

  • Apache BCEL (Byte Code Engineering Library):是 Java classworking 广泛使用的一种框架,它可以深入到JVM汇编语言进行类操作的细节。
  • ObjectWeb ASM:是一个Java字节码操作框架。它可以用于直接以二进制形式动态生成stub根类或其他代理类,或者在加载时动态修改类。
  • CGLIB(Code Generation Library):是一个功能强大,高性能和高质量的代码生成库,用于扩展 JAVA 类并在运行时实现接口。
  • Javassist:是 Java 的加载时反射系统,它是一个用于在 Java 中编辑字节码的类库; 它使 Java 程序能够在运行时定义新类,并在 JVM 加载之前修改类文件。

JDK动态代理

先来个接口

package test;

public interface Rentinterace {
    void rent();
    void pay();
}

再来个实现类

package test;

public class RentDirect implements Rentinterace{

    @Override
    public void rent() {
        System.out.println("租房");
    }

    @Override
    public void pay() {
        System.out.println("付钱");
    }
}

再写我们的动态代理

package test;

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

public class Userinvocationhandler implements InvocationHandler {
    Rentinterace user;
    public Userinvocationhandler(Rentinterace user) {
        this.user=user;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("这里是动态代理,我调用了方法:"+method.getName());
        method.invoke(user,args);
        return null;
    }
}

在客户端,我们调用了Proxy.newProxyInstance方法:

package test;

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

public class Test {
    public static void main(String[] args) {
        Rentinterace user = new RentDirect();
        //动态代理
        //要代理的接口、要做的事情、classLoader
        InvocationHandler userinvocationhandler = new Userinvocationhandler(user);
        Rentinterace actionuser = (Rentinterace) Proxy.newProxyInstance(user.getClass().getClassLoader(), user.getClass().getInterfaces(),userinvocationhandler);
        actionuser.rent();
        actionuser.pay();

    }
}

可以看到上图中需要的参数就是,要代理的接口,类加载器,要处理的事情,其中要处理的事情就是一个InvocationHandler接口,我们要新建一个类去重写 invoke 方法

CGLIB动态代理

不太懂

maven 引入 CGLIB 包

<dependency>
    <groupId>cglib</groupId>
	<artifactId>cglib</artifactId>
	<version>3.3.0</version>
</dependency>

写一个类

public class UserDao {
    public void select() {
        System.out.println("UserDao 查询 selectById");
    }
    public void update() {
        System.out.println("UserDao 更新 update");
    }
}

编写一个 LogInterceptor ,继承了 MethodInterceptor,用于方法的拦截回调

import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
import java.util.Date;

public class LogInterceptor implements MethodInterceptor {
    /**
     * @param object 表示要进行增强的对象
     * @param method 表示拦截的方法
     * @param objects 数组表示参数列表,基本数据类型需要传入其包装类型,如int-->Integer、long-Long、double-->Double
     * @param methodProxy 表示对方法的代理,invokeSuper方法表示对被代理对象方法的调用
     * @return 执行结果
     * @throws Throwable
     */
    @Override
    public Object intercept(Object object, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        before();
        Object result = methodProxy.invokeSuper(object, objects);   // 注意这里是调用 invokeSuper 而不是 invoke,否则死循环,methodProxy.invokesuper执行的是原始类的方法,method.invoke执行的是子类的方法
        after();
        return result;
    }
    private void before() {
        System.out.println(String.format("log start time [%s] ", new Date()));
    }
    private void after() {
        System.out.println(String.format("log end time [%s] ", new Date()));
    }
}

写个代理

import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;

public class DaoProxy implements MethodInterceptor {
    @Override
    public Object intercept(Object object, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println("Logging: Method " + method.getName() + " is called.");
        return methodProxy.invokeSuper(object, objects);  // 调用原方法
    }
}

客户端

import net.sf.cglib.proxy.Enhancer;

public class CglibTest {
    public static void main(String[] args) {
        DaoProxy daoProxy = new DaoProxy();
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(UserDao.class);  // 设置超类,cglib是通过继承来实现的
        enhancer.setCallback(daoProxy);

        UserDao dao = (UserDao)enhancer.create();   // 创建代理类
        dao.update();
        dao.select();
    }
}

CGLIB 创建动态代理类的模式是:

  1. 查找目标类上的所有非final 的public类型的方法定义;
  2. 将这些方法的定义转换成字节码;
  3. 将组成的字节码转换成相应的代理的class对象;
  4. 实现 MethodInterceptor接口,用来处理对代理类上所有方法的请求

序列化与反序列化

Java 中的序列化与反序列化是将对象与其状态转换为字节流(序列化)并从字节流还原为对象(反序列化)的过程,主要用于对象的持久化、网络传输等。

这中间需要一个规则,规则中描述了序列化和反序列化时究竟该如何把一个对象处理成字符串,又如何把字符串变回对象,因为这一过程必须是可逆的。

序列化与反序列化

序列化过程

序列化将 Java 对象及其状态转换为字节流。这个过程通常会创建 FileOutputStream 或 ByteArrayOutputStream,指定文件或内存中的一个缓冲区作为目标来创建输出流,用 ObjectOutputStream 包装输出流,调用 writeObject() 方法将对象写入字节流,并且要求待序列化的类必须实现 Serializable 接口。

// 创建对象输出流
FileOutputStream fileOut = new FileOutputStream("object.ser");
ObjectOutputStream out = new ObjectOutputStream(fileOut);

// 序列化对象
out.writeObject(someObject);
out.close();
fileOut.close();

//简化
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
        oos.writeObject(obj);

这个过程会将对象的属性、状态以字节流的形式写入文件、内存或者网络流中。

反序列化过程

反序列化则是将字节流重新转换为 Java 对象的过程,通常使用 FileInputStream 或 ByteArrayInputStream 来指定从文件或内存读取字节流,使用 ObjectInputStream 包装输入流,调用 readObject() 方法读取字节流,并将其转换回对象,在此过程中类必须存在:反序列化时,JVM 必须能够找到与序列化时相同的类定义,否则会抛出 ClassNotFoundException ,并且该类也必须实现 Serializable 接口。

// 创建对象输出流
FileOutputStream fileOut = new FileOutputStream("object.ser");
ObjectOutputStream out = new ObjectOutputStream(fileOut);

// 序列化对象
out.writeObject(someObject);
out.close();
fileOut.close();

//简化
ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Paths.get(Filename)));
Object obj = ois.readObject();

如果有些字段不想进行序列化怎么办?

对于不想进行序列化的变量,可以使用 transient 关键字修饰。

transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。

关于 transient 还有几点注意:

  • transient 只能修饰变量,不能修饰类和方法。
  • transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0
  • static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。

这会引发什么问题呢?

反序列化漏洞

初见

几种常见形式:

  • 入口类的readObejct直接调用危险方法
  • 入口类参数包含可控类,可控类里有危险方法
  • 入口类参数包含可控类,该类又调用其他含危险方法的类
  • 构造函数/静态代码块等类加载时隐式执行

第一个的关键点是readObject,如果被反序列化的类重写了writeObject,readObject方法,就会在反序列化时调用,如果这个方法中存在一些恶意的调用就会产生危害

需满足的条件

  • 都继承了Serializeable接口
  • 入口类source(重写readObject、参数类型宽泛、jdk自带就更好、常见函数)
  • 调用链(gaget chain)
  • 执行类 sink (ssrf,rce….)

简单示例:

Person类

import java.io.*;

public class Person implements Serializable {

	private String name;

	private int age;

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

	private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException{
		Runtime.getRuntime().exec("calc");
	}
}

执行序列化操作类

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;

public class SerializableTest {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Person person = new Person("zhangsan", 24);
        serialize(person);
        unserialize("ser.bin");

    }

    public static void serialize(Object obj) throws IOException {
        ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
        oos.writeObject(obj);
    }

    public static void unserialize(String Filename) throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Paths.get(Filename)));
        Object obj = ois.readObject();
    }
}

原理( readObject 的具体实现)

下好断点开始调试

image-20240928161103976 实际调用readObject0 方法

image-20240928234844759readObject0 方法以字节的方式去读,如果读到 0x73,则代表这是一个对象的序列化数据,将会调用 readOrdinaryObject 方法进行处理

image-20240928235213134readOrdinaryObject 方法会调用 readClassDesc 方法读取类描述符,并根据其中的内容判断类是否实现了 Externalizable 接口,如果是,则调用 readExternalData 方法去执行反序列化类中的 readExternal,如果不是,则调用 readSerialData 方法去执行类中的 readObject 方法。

image-20240928235756578

readSerialData 方法中,首先通过类描述符获得了序列化对象的数据布局。通过布局的 hasReadObjectMethod 方法判断对象是否有重写 readObject 方法,如果有,则使用 invokeReadObject 方法调用对象中的 readObject

image-20240928235806654

image-20240929000506971

参考文章

P神《Java安全漫谈》

https://www.javasec.org/

https://boogipop.com/2023/03/02/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E7%A0%94%E7%A9%B6/#1X1-5-JDK%E5%8A%A8%E6%80%81%E4%BB%A3%E7%90%86

https://www.cnblogs.com/whirly/p/10154887.html