浅谈Java反射机制

这篇文章算是我做javasec的开始吧,这也是我学习P牛的Java安全漫谈以及Java Web 安全的文档的第一篇笔记,下一篇应该得到寒假写了,要期末周了,有点痛苦。

Java反射(Reflection)是Java非常重要且好玩的动态特性,通过使用反射我们不仅可以获取到任何类的成员方法(Methods)、成员变量(Fields)、构造方法(Constructors)等,还可以动态创建Java类实例、调用任意的类方法、修改任意的类成员变量值等。

反射机制原理

在Java 程序中,JVM 加载完一个类后,在堆内存中就会产生该类的一个 Class 对象,一个类在堆内存中最多只会有一个 Class 对象,这个Class 对象包含了该类的完整结构信息,我们通过这个 Class 对象便可以得到该类的完整结构信息

反射就是在运行时才知道要操作的类是什么,并且可以在运行时获取类的完整构造,并调用对应的方法。

反射机制原理示意图👇

那么如果我给出下面这段代码,在你不知道传入的参数值的时候,你能知道他的作用吗

1
2
3
4
public void execute(String className, String methodName) throws Exception {
Class clazz = Class.forName(className);
clazz.getMethod(methodName).invoke(clazz.newInstance());
}

在这个例子中,有几个在反射里极为重要的方法:

  • 获取类的方法:forname
  • 实例化类对象的方法:newInstannce
  • 获取函数的方法:getMethod
  • 执行函数的方法:invoke

那么先来聊聊获取对象有哪些方式吧

获取Class对象

Java反射操作的是java.lang.Class对象,所以我们要先想办法获取到Class对象,通常有下面几种方式来获取一个类的Class对象

  • 类名.class,比如com.deicide.sec.classloader.TestHelloWorld.class,这个类是已经加载过了的,如果想获取到他的java.lang.Class对象,那么就可以直接拿他的class属性
  • Class.forName("com.deicide.sec.classloader.TestHelloWorld"),前提是知道这个类的名字
  • obj.getClass()如果上下文中存在某个类的实例obj,那么就可以直接通过obj.getClass来获取它的类
  • classLoader.loadClass("com.deicide.sec.classloader.TestHelloWorld")

但是当我们要获取数组类型时,类名必须使用JVM可以识别的签名形式,比如👇

1
2
Class<?> doubleArray = Class.forName("[D");//相当于double[].class
Class<?> cStringArray = Class.forName("[[Ljava.lang.String;");// 相当于String[][].class

获取Runtime类Class对象代码片段

1
2
3
4
String className     = "java.lang.Runtime";
Class runtimeClass1 = Class.forName(className);
Class runtimeClass2 = java.lang.Runtime.class;
Class runtimeClass3 = ClassLoader.getSystemClassLoader().loadClass(className);

这里用了三种方式获取java.lang.Runtime类的Class对象。我们经常在一些源码里看到,类名的部分包含替换成.:https://github.com/alibaba/fastjson/blob/fcc9c2a/src/main/java/com/alibaba/fastjson/parser/ParserConfig.java#L1038来代替.com.deicide.Test类有一个叫做Hello的内部类,那么调用的时候就应该将类名写成:com.deicide.Test$Hello

Java的普通类C1中支持编写内部类C2,而在编译的时候,会生成两个文件:C1.calssC1$C2.class,我们可以把他们看作两个无关的类,通过Class.forname("C1$C2")即可加载这个内部类。

我们在使用反射的一大目的,就是来绕过某些沙盒。

java.lang.Runtime有一个exec方法可以执行本地系统命令,从而绕过沙盒。

那么上下文中如果只有Integer类型的 数字,我们如何获取到可以执⾏命令的Runtime类呢?也许可以这样1.getClass().forName("java.lang.Runtime")

接下来就说一下获取java.lang.Runtime类的一些操作

反射java.lang.Runtime

不使用反射执行本地命令

1
2
// 输出命令执行结果
System.out.println(org.apache.commons.io.IOUtils.toString(Runtime.getRuntime().exec("whoami").getInputStream(), "UTF-8"));

我在win上的输出结果:

反射Runtime执行本地命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 获取Runtime类对象
Class runtimeClass1 = Class.forName("java.lang.Runtime");

// 获取构造方法
Constructor constructor = runtimeClass1.getDeclaredConstructor();
constructor.setAccessible(true);

// 创建Runtime类示例,等价于 Runtime rt = new Runtime();
Object runtimeInstance = constructor.newInstance();

// 获取Runtime的exec(String cmd)方法
Method runtimeMethod = runtimeClass1.getMethod("exec", String.class);

// 调用exec方法,等价于 rt.exec(cmd);
Process process = (Process) runtimeMethod.invoke(runtimeInstance, cmd);

// 获取命令执行结果
InputStream in = process.getInputStream();

// 输出命令执行结果
System.out.println(org.apache.commons.io.IOUtils.toString(in, "UTF-8"));

输出同上

反射调用Runtime实现本地命令执行的流程:

  1. 反射获取Runtime类对象(Class.forName("java.lang.Runtime"))。
  2. 使用Runtime类的Class对象获取Runtime类的无参数构造方法(getDeclaredConstructor()),因为Runtime的构造方法是private的我们无法直接调用,所以我们需要通过反射去修改方法的访问权限(constructor.setAccessible(true))。
  3. 获取Runtime类的exec(String)方法(runtimeClass1.getMethod("exec", String.class);)。
  4. 调用exec(String)方法(runtimeMethod.invoke(runtimeInstance, cmd))。

forName

另外说一点,前面提到的forName,它拥有两个函数重装:

  • Class<?> forName(String name)
  • Class<?> forName(String name,**boolean** initialize, ClassLoader loader)

第一个就是比较常见的获取class的方法,可以理解为第二种方式的一个封装

1
2
3
Class.forName(className)
// 等于
Class.forName(className, true, currentLoader)

默认情况下,forName的第⼀个参数是类名;第⼆个参数表示是否初始化;第三个参数就是 ClassLoader

ClassLoader是什么呢?它就是⼀个“加载器”,告诉Java虚拟机如何加载这个类。ClassLoade到时候我再写一篇文章来好好讲一下。Java默认的ClassLoader就是根据类名来加载类,这个类名是类完整路径,如java.lang.Runtime

本来想看看Java安全漫谈反射篇(1)中提到的勾陈安全实验室的那篇将反射机制的文章,但是那个网址似乎无了…额,然后自己去找,结果就是没找到。

主要就是使用功能 “.class” 来创建Class对象的引用时,不会自动初始化该Class对象,使用forName()会自动初始化该Class对象

浅偷一下图(x

上面这个图里有说构造函数,初始化时执行,其实在forName的时候,构造函数并不会执行即使设置了initiallize=true

那么什么是初始化

有一点Java开发基础的师傅应该知道我们在new一个类时就会触发这个被调用的类里面的初始化方法。

比如下面的这个类

1
2
3
4
5
6
7
8
9
10
11
public class TrainPrint {
{
System.out.printf("Empty block initial %s\n", this.getClass());
}
static {
System.out.printf("Static initial %s\n", TrainPrint.class);
}
public TrainPrint() {
System.out.printf("Initial %s\n", this.getClass());
}
}

我们再来一个测试类用main函数来调用这个类

1
2
3
4
5
public class Test {
public static void main(String[] args) throws Exception {
TrainPrint tp = new TrainPrint(); // new的时候就会触发那三个方法
}
}

首先调用的是static {},其次是{},最后是构造函数。

那么为什么会这样

在java里面直接用{}包含几个语句会放在构造函数的super()后面,但在构造函数内容的前面,即在调用这个对象的构造方法之前会执行花括号的语句块。

static{}:代表在JVM加载类的时候或者说”类初始化”的时候才会执行这个语句块,也就是说无论这个类有无被调用,只要被加载了就会执行语句块

那么,现在我们有如下的一个函数,其中的参数name可控:

1
2
3
public void ref(String name) throws Exception {
Class.forname(name);
}

那么我们就可以编写一个恶意类,将恶意代码放在static {}中,再让他去调用这个恶意类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.lang.Runtime;
import java.lang.Process;

public class TouchFile {
static {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/success"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {
// do nothing
}
}
}

那么如何将这个恶意类如何带到目标机器中,就需要ClassLoader的一些利用方法了

在正常情况下,除了系统类,都需要先import才能使用。而使用forname就不需要,这跟python就不太一样了,在pyjail中我们需要执行os里的系统命令都要先import os 或者 __import__('os'),不过也可以简单把forname理解成__import__。使用forname就可以让我们加载任意类。

反射创建类实例

在Java的任何一个类都必须有一个或多个构造方法,如果代码中没有创建构造方法那么在类编译的时候会自动创建一个无参数的构造方法。

下面是java.lang.Runtime类的代码片段

从上面的Runtime类代码注释可以知道它是不希望除了其自身的任何人去创建该类实例,这是一个私有的类构造方法,所以不能new一个Runtime类实例,也就是不能使用Runtime rt = new Runtime();的方式来创建Runtime对象,但是我们可以借助反射机制来修改方法访问权限间接创建Runtime对象。

class.newInstance() 的作用就是调用这个类的无参构造函数,不过有时候写漏洞利用方法的时候,会发现使用newInstance总是不成功,这时候可能是因为:

  1. 使用的类没有无参构造函数
  2. 使用的类构造函数是私有的

就比如说上面的java.lang.Runtime,所以我们不能这样直接来执行命令:

1
2
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "id");

你会得到这样的一个错误

就是因为Runtime类的构造方法是私有的。

其实这种私有的构造方式涉及到一个很常见的设计模式→“单例模式”。(有时候工厂模式也会写成类似)

比如,对于Web应用程序来说,数据库只需要建立一次,,而不是每次用到数据库的时候再新建立一个连接,那么此时作为开发者就可以将数据库连接使用的类的构造函数设置为私有,然后编写一个静态方法来获取:

1
2
3
4
5
6
7
8
9
public class TrainDB {
private static TrainDB instance = new TrainDB();
public static TrainDB getInstance() {
return instance;
}
private TrainDB() {
// 建立连接的代码...
}
}

这样,只有类初始化的时候会执行一次构造函数,后面只能通过getInstance获取这个对象,避免建立多个数据库连接。

Runtime类就是单例模式,我们只能通过Runtime.getRuntime()来获取到Runtime对象。那么就将上述Payload进行修改就可以正常执行命令了

1
2
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec",String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz),"calc.exe");

反射调用类方法

上面这个例子用到了getMethodinvoke方法。getMethod的作用是通过反射获取一个类的某个特定的公有方法。而在Java中 支持类的重载,我们不能仅通过函数名来确定一个函数。所以在调用getMethod的时候,我们需要传给他你需要获取的函数的参数类型列表。

这里的Runtime.exec方法有6个重载

使用最简单的就是第一个,只有一个参数,类型是String,所以我们使用getMethod("exec", String.class)来获取Runtime.exec方法。

除了getMethod外,还有getDeclareMethods方法。

获取当前类所有的成员方法:

1
Method[] methods = clazz.getDeclaredMethods()

获取当前类指定的成员方法

1
2
Method method = clazz.getDeclaredMethod("方法名");
Method method = clazz.getDeclaredMethod("方法名", ...); //参数类型如String.class,多个参数用","号隔开

getMethodgetDeclaredMethod都能够获取到类成员方法,区别在于getMethod只能获取到当前类和父类的所有有权限的方法(如:public),而getDeclaredMethod能获取到当前类的所有成员方法(不包含父类)。

反射调用方法

invoke的作用是执行方法,可以用它来调用类方法。它的第一个参数有两种情况:

  • 如果这个方法是一个普通方法,那么第一个参数是类对象
  • 如果这个方法是一个静态方法,那么第一个参数是类

method.invoke的第二个参数不是必须的,如果当前调用的方法没有参数,那么第二个参数可以不传,如果有参数那么就必须严格的依次传入对应的参数类型

这也比较好理解了,我们正常执行方法是[1].method([2], [3], [4]...),其实在反射里就是 method.invoke([1], [2], [3], [4]...)

所以我们将上述命令执行的Payload分解一下就是:

1
2
3
4
5
Class clazz = Class.forName("java.lang.Runtime");
Method execMethod = clazz.getMethod("exec", String.calss);
Method getRuntimeMethod = clazz.getMethod("getRuntime");
Object runtime = getRuntimeMethod.invoke(clazz);
execMethod.invoke(runtime, "calc.exe");

这个就比较好看懂了。

反射获取构造函数

如果一个类没有无参构造方法,也没有单例模式里的静态方法,那么就可以用getConstructor这个反射方法

getMethod类似,getConstructor接收的参数是构造函数列表类型,因为构造函数也支持重载,所以必须用参数列表类型才能唯一确定一个构造函数。

另外runtimeClass1.getDeclaredConstructorruntimeClass1.getConstructor都可以获取到类构造方法,区别在于后者无法获取到私有方法,所以一般在获取某个类的构造方法时候我们会使用前者去获取构造方法。如果构造方法有一个或多个参数的情况下我们应该在获取构造方法时候传入对应的参数类型数组,如:clazz.getDeclaredConstructor(String.class, String.class)

如果想获取类的所有构造方法可以使用:clazz.getDeclaredConstructors来获取一个Constructor数组。

获取到Constructor以后就可以通过constructor.newInstance()来创建类实例,同理如果有参数的情况下我们应该传入对应的参数值,如:constructor.newInstance("admin", "123456")。当我们没有访问构造方法权限时我们应该调用constructor.setAccessible(true)修改访问权限就可以成功的创建出类实例了。

反射java.lang.ProcessBuilder

比如,我们常用的另一种执行命令的方式ProcessBuilder,我们使用反射来获取其构造函数,然后调用start()来执行命令:

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

ProcessBuilder有两个构造参数:

  • public ProcessBuilder(List<String> command)
  • public ProcessBuilder(String... command)

上面的代码用到了第一个形式的构造函数,所以在getConstructor的时候传入的是List.class

但是,前面的这个Payload用到了Java里的强制类型转换,有时候我们利用漏洞的时候(在表达式上下文中)是没有这种语法的。所以我们仍需要利用反射来进行这一步

用的就是前面讲的

1
2
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

那么,如果我们要使用public ProcessBuilder(String... command)这个构造函数,需要怎样用反射执行呢?

这里有涉及到Java里的可变长参数(varargs)了。正如其他语言一样,Java也支持可变长参数,就是当你定义函数的时候不确定参数数量的时候,可以使用...这样的语法来表示“这个函数的参数个数是可变的”

对于可变长参数,Java其实在编译的时候会编译成一个数组,也就是说,如下这两种写法在底层是等价的(也就不能重载):

1
2
public void hello(String[] names) {}
public void hello(String... names) {}

也由此可以得出,如果我们有一个数组,想传给hello函数,直接传即可

1
2
String[] names = {"hello", "world"};
hello(names)

那么对于反射来说,如果要获取的目标函数里包含可变长参数,其实我们认为它是数组就行了。 所以,我们将字符串数组的类String[].class传给getConstructor,获取ProcessBuilder的第二种构造函数:

1
2
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getConstructor(String[].class)

在调用newInstance的时候,因为这个函数本身接收的是一个可变长参数,我们传给ProcessBuilder的也是一个可变长参数,二者叠加为一个二维数组,下面就是整个Payload

1
2
Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder)clazz.getConstructor(String[].class).newInstance(new String[][]{{"calc.exe"}})).start();

那么我们来尝试一下把这个Payload改为完全反射编写

1
2
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(String[].class).newInstance(new String[][]{{"calc.exe"}}));

反射调用成员变量

除了以上说的这些,Java反射还可以获取类所有的成员变量名称,甚至可以无视权限修饰符实现修改对应的值。

获取当前类的所有成员变量:

1
Field fields = clazz.getDeclaredFields();

获取当前类指定的成员变量:

1
Field field  = clazz.getDeclaredField("变量名");

getFieldgetDeclaredField的区别同getMethodgetDeclaredMethod

获取成员变量值:

1
Object obj = field.get(类实例对象);

修改成员变量值:

1
field.set(类实例对象, 修改后的值);

同理,当我们没有修改的成员变量权限时可以使用: field.setAccessible(true)的方式修改为访问成员变量访问权限。

如果我们需要修改被final关键字修饰的成员变量,那么我们需要先修改方法

1
2
3
4
5
6
7
8
9
10
11
// 反射获取Field类的modifiers
Field modifiers = field.getClass().getDeclaredField("modifiers");

// 设置modifiers修改权限
modifiers.setAccessible(true);

// 修改成员变量的Field对象的modifiers值
modifiers.setInt(field, field.getModifiers() & ~Modifier.FINAL);

// 修改成员变量值
field.set(类实例对象, 修改后的值);

Java反射机制总结

反射的方法大致可以分成两类:

  1. get系列的反射,这系列的方法获取的是当前类的公共部分也就是public修饰的部分
  2. getDeclared系列反射,这系列的方法获取的是当前类中“声明”的方法,是实在写在这个类里的,包括私有的方法,但从父类里继承来的就不包含了

java反射就先说到这,寒假开始(1.27)之后再写点ClassLoader的东西。该好好复习咯