Java中的反射机制

Oyst3r 于 2024-06-07 发布

反射是⼤多数语⾔⾥都必不可少的组成部分,对象可以通过反射获取他的类,类可以通过反射拿到所有 ⽅法(包括私有),拿到的⽅法可以调⽤,总之通过反射,我们可以将 Java 这种静态语⾔附加上动态 特性。 – P 神

前言

近期温习,记录 📝 一些自己对于 Java 这门语言中反射的思考,文章结构从 0 到 1……

class 和 Class 和 .class 的区别

public class exp {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

.class 文件包含的是机器可读的字节码,这些字节码由 JVM 执行,Class 对象则是对该字节码的抽象描述,可以通过反射机制在运行时动态地操作类的结构。

类编译和类加载和类初始化的区别

背景:有一个 Test.java 文件

类可以被加载但类不一定被初始化,简单例子如下–>

public class exp {

    static{
        System.out.println("Loaded exp static block");
    }

    public static void main(String[] args) {
        try {
            Class.forName("com.reflection02.exp", false, exp.class.getClassLoader());
            Class.forName("com.reflection02.misc", false, misc.class.getClassLoader());
        }catch (ClassNotFoundException e){
            e.printStackTrace();
        }
    }
}

class misc{

    static{
        System.out.println("Loaded misc static block");
    }
}

输出结果:

Loaded exp static block

其中着重看 misc 类,它被加载了,但是没有被初始化,所以也没有去执行静态块,无输出。Loaded exp static block是在Class.forName("com.reflection02.exp", false, exp.class.getClassLoader());这条语句执行前就已经输出了,原因是 exp 类是 public 类,并且包含 main 方法,JVM 会在启动时自动加载并初始化 exp 类,因此静态初始化块被执行。

forName 函数

它有两个函数重载,第一个是forName(String className),第二个是forName(String className, boolean initialize, ClassLoader loader),其中initialize参数表示是否要初始化该类,loader参数表示要加载的类所在的类加载器。

其中initialize参数是false,那么就不会去初始化该类,initialize参数是true,那么就会去初始化该类。有关于类的初始化相关的内容,可以复习一下浅谈 Java 内存分配机制,弄清楚类初始化过程中各个部分执行的先后顺序。

为什么要获取 Class 对象

它是反射的必然产物且反射需要它,那么何为反射?最精辟的话总结一下:反射可以让一个类被使用,但是在程序开始运行时候不被 JVM 加载,自由控制类被加载的时机和方式。一旦类被加载了,那就会生成一个对应的有关这个类的描述型信息,也就是 Class 对象,之后的种种操作也都离不开这个 Class 对象。

获取 Class 对象的方式

1.misc.class,如果已经加载了某个类,只是想获取到它的 java.lang.Class 对象,那么就直接 拿它的 class 属性即可,但前提一定是这个类已经加载了。下面代码说明一下–>

public class exp {

    public static void main(String[] args) {
        Class<?> clazz = misc.class;
    }
}

class misc{

    static {
        System.out.println("Loaded misc static block");
    }
}

输出结果:

Loaded exp static block

显而易见的结果,misc 类被加载了但是没有去初始化,所以没有输出Loaded misc static block。这种其实不是反射,因为在写出这样的 exp.java 的时候,misc 类就已经要被 JVM 加载了。

2.Class.forName("com.reflection02.misc"),这个方法是通过类的全限定名来获取类的 Class 对象,它会加载并初始化该类。上文见过,这里就不再赘述。

3.object.getClass(),这个方法是通过对象来获取类的 Class 对象,它会加载并初始化该类。这种也属于反射,毕竟是在对对象进行操作,肯定是在运行时候去加载类的,而非一开始类就被 JVM 加载。举个例子–>

public class exp {

    public static void main(String[] args) {
        misc misc = new misc();
        Class<?> clazz = misc.getClass();
    }
}

class misc{

    static {
        System.out.println("Loaded misc static block");
    }
}

输出结果:

Loaded misc static block

反射中基础的 4 个方法

public class EXP {

    public static void main(String[] args) {
        try {
            Class<?> clazz = Class.forName("com.reflection04.Misc");
            Object obj = clazz.newInstance();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

class Misc{

    static {
        System.out.println("Loaded Misc static block");
    }

    Misc(){
        System.out.println("Loaded Misc block");
    }
}

输出结果:

Loaded Misc static block
Loaded Misc block

如果这个时候把 Misc 的构造函数重写为有参构造或者改为 private,那么就会报错–>

public class EXP {

    public static void main(String[] args) {
        try {
            Class<?> clazz = Class.forName("com.reflection04.Misc");
            Object obj = clazz.newInstance();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

class Misc{

    static {
        System.out.println("Loaded Misc static block");
    }

    Misc(String msg) {
        System.out.println("Loaded Misc block with message: " + msg);
    }
}

输出结果:

这就是写 EXP 时候,使用 newInstance 方法去实例化某些类会报错的原因,要么是使用的类没有无参构造函数,要么是该类构造函数是私有的,java.lang.Runtime这个类就是这样的,它的构造函数是私有的,所以不能通过 newInstance 去实例化它。

2.把 getMethod 和 invoke 放在一起记录,getMethod 的作用是通过反射获取一个类的某个特定的公有方法,注意点是要传函数名与参数类型(和方法的重载有关,光靠一个函数名是确定不了函数的功能的)。invoke 的作用是执行方法,注意点式 invoke 传入的第一个参数–>如果这个方法是一个普通方法,那么第一个参数是类对象,如果这个方法是一个静态方法,那么第一个参数是类。理解如下–>

正常执行方法: [1].method([2], [3], [4]...)
反射执行方法:method.invoke([1], [2], [3], [4]...)

3.实操:构造 EXP 拿java.lang.Runtime.exec()来执行命令

下面这种写法肯定是不可以 ❌ 的

public class EXP {

    public static void main(String[] args) {
        try{
            Class<?> clazz = Class.forName("java.lang.Runtime");
            clazz.getMethod("exec",String.class).invoke(clazz.newInstance(),"whoami");
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

输出结果:

原因是java.lang.Runtime这个类的构造方法是私有的,所以不能通过 newInstance 去实例化它,下面贴图 Runtime 类实现单例模式的关键的三块代码

则修改为如下代码–>

public class EXP {

    public static void main(String[] args) {
        try{
            Class<?> clazz = Class.forName("java.lang.Runtime");
            clazz.getMethod("exec",String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz),"ping 9mkbi.cxsys.spacestabs.top");
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

运行后触发命令执行

反射中的多态

这个本来打算放上面的 newInstance 函数这里,都放上面可能有点臃肿,还是单独拉出来了,给三行体现多态关键代码(还是从上面给的实例中摘出来的)

Class<?> clazz = Class.forName("com.reflection04.Misc");
Object obj = clazz.newInstance();
Misc misc = (Misc) obj;

这个和之前学习 Java 特性中的多态是一样的,回顾一下多态,简单写个代码示例–>

public class Father {

    public static void main(String[] args) {
        Father father = new Son();
        father.eat();
        Son son = (Son)father;
        son.eat();
    }

    public void eat() {
        System.out.println("Father Eating");
    }
}
class Son extends Father{

    public void eat() {
        System.out.println("Son Eating");
    }

    public void sleep() {
        System.out.println("Son Sleeping");
    }
}

不难发现都是先去实例化了子类(反射那个例子,所有类都默认继承了 Object 类,所以把 Object 类看作一个父类),然后用父类型的变量去接受这个实例化的子类,最后去做了一个强转。

反射中实例化类的几种方法

最理想的情况下就是要实例化的类有一个 public 的无参构造方法,那么就可以直接通过 newInstance 方法去实例化它,然后就引发出了两种情况,一种是该类没有无参构造方法、一种是该类的构造方法是 private 的。

1.先来看第一种情况–>该类没有无参构造函数,这种情况在学习 newInstance 方法的时候可能就遇到了,网上搜该方法的时候会遇到这样的一句话–>

newInstance() 已被弃用:从 Java 9 开始,newInstance() 方法已经被标记为弃用。推荐使用 Constructor.newInstance() 方法来创建对象,特别是当构造函数带有参数时。

上面提及到的 Constructor 就是要来获取类的构造器,其中关键是要用到这个方法–>getConstructor(),很简单然后就去跟一下这个 ProcessBuilder 这个类,平时也会去用里面的 start()方法去执行命令。为啥要选这个类,因为它的构造方法都是带参数的,详情见下图–>

and

第一个构造器的参数是一个列表,第二个构造器的参数是一个可变长参数(也就是字符串数组),针对第一个构造器来写一个命令执行的方法,代码如下–>

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

public class EXP {

    public static void main(String[] args) {
        try{
            Class<?> clazz = Class.forName("java.lang.ProcessBuilder");
            clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("ping","6h50h.cxsys.spacestabs.top")));
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

运行后触发命令执行,结果如下

相当于给 newInstance 这个方法增添了一双翅膀,让它有能力通过 Class 对象去实例化一个构造函数带参数的类。

那么再来看一下可变长参数如何去执行如上的 RCE,代码如下–>

public class EXP {

    public static void main(String[] args) {
        try{
            Class<?> clazz = Class.forName("java.lang.ProcessBuilder");
            clazz.getMethod("start").invoke(clazz.getConstructor(String[].class).newInstance(new String[][]ping));
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

运行后触发命令执行,结果如下–>

是二维数组的原因:去跟一下 newInstance 这个方法,它的参数也是一个变长参数,针对于数组这种类型,里面有一个默认解包的过程。 将上述关键代码改成newInstance(new String[]{"ping","08xak.cxsys.spacestabs.top"})的话,首先会报一个这样的错误–>参数数量与预期的数量不一致

显然到这里就已经解包了,那如果改成newInstance(new String[]{"ping"})这个样子,虽然可以过了这个数量的检测,但显然类型还是不一样的,一个是 string 类型,而一个是 string[]类型,于是乎就会出现如下的报错–>

更加简单一点的解释:ProcessBuilder 需要的参数是⼀个 String…类型的,也就是 String[] 类型的,然后后⾯的 [],表⽰ newInstance() 接收的参数是⼀个变⻓数组

2.再来看第二种情况–>该类的构造方法是 private 的该如何办?这里就用到了另外一个名词getDeclared,这个系列的反射方法和getMethodgetConstructor这种区别如下,对比着看吧。

这是一组:

这是另外一组:

然后来实操一个 RCE 的例子,代码如下–>

import java.lang.reflect.Constructor;

public class Exp {

    public static void main(String[] args) {
        try{
            Class<?> clazz = Class.forName("java.lang.Runtime");
            Constructor m = clazz.getDeclaredConstructor();
            m.setAccessible(true);
            clazz.getMethod("exec",String.class).invoke(m.newInstance(),"ping wrij0.cxsys.spacestabs.top");
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

运行后触发命令执行,结果如下–>

Tips:上面这种方式貌似在高版本打不通,换成 Java 8 就好了

反射从 0 到 1,基本结束 🔚