JAVA反射机制

Java反射机制与安全研究

from p牛 yq1ng Y4taker

01.反射机制

反射的定义

Java的反射(reflection)机制是指在程序的运行状态中,可以构造任意一个类的对象,可以了解任意一个对象所属的类,可以了解任意一个类的成员变量和方法,可以调用任意一个对象的属性和方法。这种动态获取程序信息以及动态调用对象的功能称为Java语言的反射机制。反射被视为动态语言的关键。

反射的核心方法

在反射机制中,有几个极为重要的方法,它们包揽了Java安全里各种和反射有关的Payload:

  • 获取类的方法forName
  • 实例化类对象的方法newInstance
  • 获取函数的方法getMethod
  • 执行函数的方法invoke

一段典型的反射代码可以清晰地展示这些方法的协作关系:

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

在这段代码中,传入不同的类名和方法名,就会产生完全不同的功能——这正是”动态特性”的体现。所谓动态特性,就是指”一段代码,改变其中的变量,将会导致这段代码产生功能性的变化”。Java虽不像PHP那样天生具有丰富的动态特性,但通过反射机制,同样可以获得类似的灵活性。

02.获取Class对象

在Java中,获取java.lang.Class对象并非只有forName一种途径。通常有如下三种方式:

obj.getClass()

如果上下文中存在某个类的实例obj,那么可以直接通过obj.getClass()来获取它的类。这种方式依赖于已存在的对象实例,是最直接的获取方式。

Test.class

如果已经加载了某个类,只是想获取到它的java.lang.Class对象,那么直接使用其class属性即可,例如Test.class。需要注意的是,这种方法其实并不属于反射机制。

Class.forName

如果知道某个类的名字,想动态获取这个类,就可以使用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)

三个参数的含义分别是:

  • 第一个参数:类名,即类的完整路径,如java.lang.Runtime
  • 第二个参数initialize,表示是否执行类初始化
  • 第三个参数ClassLoader,即类加载器,告诉Java虚拟机如何加载这个类

关于initialize参数

第二个参数initialize常常被人误解。需要明确的是:即使设置initialize=true,构造函数也不会在forName时执行。这个”初始化”指的是”类初始化”,它与构造函数是不同的概念。

为了理解这一点,观察如下这个类:

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

这段代码中有三种”初始化”方式,它们的调用顺序是:

  1. 首先调用static {} —— 在”类初始化”的时候调用
  2. 其次调用{} —— 放在构造函数的super()后面,但在当前构造函数内容的前面
  3. 最后调用:构造函数 —— 实例化对象时执行

因此,forName中的initialize=true就是告诉Java虚拟机是否执行第1步中的”类初始化”(即static {}块)。这一特性在安全上具有重要价值:如果某个函数的参数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的利用方法,后文会进一步讨论。

关于内部类与$符号

在一些源码中,经常可以看到类名部分包含$符号,例如fastjson在checkAutoType时就会先将$替换掉。$的作用是查找内部类。

Java的普通类c1中支持编写内部类c2,而在编译的时候,会生成两个文件:c1.classc1$c2.class。可以将它们看作两个无关的类,通过Class.forName("c1$c2")即可加载这个内部类。

03.调用方法

获得类以后,可以继续使用反射来获取这个类中的属性和方法,也可以实例化这个类并调用方法。

基本方法调用流程

以最常见的命令执行Payload为例,反射调用的完整流程如下:

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

这段代码分解开来就是:

  1. 通过forName获取java.lang.Runtime的Class对象
  2. 通过getMethod("exec", String.class)获取exec方法, String.class是为了指定类型(方法重载导致exec有多种型号)
  3. 通过getMethod("getRuntime")获取getRuntime静态方法
  4. 通过invoke(clazz)执行静态方法,获取Runtime实例
  5. 通过invoke(runtime, "calc.exe")在Runtime实例上执行exec方法

getMethod与invoke详解

getMethod的作用是通过反射获取一个类的某个特定的公有方法。由于Java中支持方法重载,不能仅通过函数名来确定一个函数,所以在调用getMethod的时候,需要传入需要获取的函数的参数类型列表。

例如Runtime.exec方法有6个重载,如果使用最简单的版本(只有一个String类型参数),就需要使用getMethod("exec", String.class)来精确获取。

invoke的作用是执行方法,它的第一个参数根据方法类型有所不同:

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

这与正常执行方法[1].method([2], [3], [4]...)的逻辑一致,在反射中就是method.invoke([1], [2], [3], [4]...)

为什么不用newInstance

class.newInstance()的作用是调用这个类的无参构造函数。
必须依赖一个公开的、无参数的构造方法才能成功
在编写漏洞利用方法时,有时会发现newInstance总是不成功,原因可能是:

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

最典型的情况就是java.lang.Runtime。这个类的构造方法是私有的,因此不能直接这样执行命令:

1
2
3
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "id");
// 会抛出 IllegalAccessException

Runtime类将构造函数设为私有,是因为它采用了单例模式的设计。对于Web应用来说,数据库连接只需要建立一次,而不是每次用到数据库的时候再新建一个连接,此时开发者就会将构造函数设置为私有,然后编写一个静态方法来获取实例:

1
2
3
4
5
6
7
8
9
10
11
public class TrainDB {
private static TrainDB instance = new TrainDB();

public static TrainDB getInstance() {
return instance;
}

private TrainDB() {
// 建立连接的代码...
}
}

这样,只有类初始化的时候会执行一次构造函数,后面只能通过getInstance获取这个对象,避免建立多个连接。Runtime类正是采用了这种模式,因此需要通过Runtime.getRuntime()来获取实例。
也就是前面payload中用到的

1
2
Method getRuntimeMethod = clazz.getMethod("getRuntime");
Object runtime = getRuntimeMethod.invoke(clazz);

04.有参构造

getConstructor

如果一个类没有无参构造方法,也没有类似单例模式的静态方法,就需要使用getConstructor来获取有参构造函数。

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

以另一种执行命令的方式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)

上面的Payload使用了第一个构造函数,所以在getConstructor时传入的是List.class

如果要在纯反射环境中使用(避免强制类型转换),可以改写为:

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

可变长参数的处理

如果要使用public ProcessBuilder(String... command)这个构造函数,就涉及Java中可变长参数(varargs)的处理。

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

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

因此,对于反射来说,如果要获取的目标函数里包含可变长参数,将其视为数组即可。获取第二种构造函数时传入String[].class

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

在调用newInstance的时候,因为newInstance本身接收的是一个可变长参数,传给ProcessBuilder的也是一个可变长参数,二者叠加为一个二维数组,完整Payload如下:

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

这个Payload中new String[][]{{"calc.exe"}}创建了一个二维数组,外层数组传递给newInstance作为可变长参数,内层数组{"calc.exe"}作为ProcessBuilder构造函数的参数。

05.私有方法

getDeclared系列

如果一个方法或构造方法是私有的,是否能执行它呢?答案是肯定的,这就需要用到getDeclared系列的反射方法。

getDeclared系列与普通的getMethodgetConstructor的区别是:

  • getMethod系列:获取当前类中所有公共方法,包括从父类继承的方法
  • getDeclaredMethod系列:获取当前类中”声明”的方法,是实实在写在这个类里的,包括私有的方法,但从父类里继承来的不包含

以Runtime类为例,前面说明了它的构造函数是私有的,需要通过Runtime.getRuntime()来获取对象。但通过getDeclaredConstructor可以直接获取私有构造方法来实例化对象:

1
2
3
4
Class clazz = Class.forName("java.lang.Runtime");
Constructor m = clazz.getDeclaredConstructor();
m.setAccessible(true);
clazz.getMethod("exec", String.class).invoke(m.newInstance(), "calc.exe");

setAccessible的重要性

在获取到一个私有方法后,必须使用setAccessible(true)修改它的作用域,否则仍然不能调用。setAccessible设置为true会取消Java安全检查,即可以访问到私有方法。

这一特性也体现了反射的安全风险:反射调用方法时可以忽略权限检查,这破坏了Java的封装性。将类内部的属性和方法暴露出来,是非常危险的。

值得注意的是,Java在较新的版本中已经提示了这种非法操作,并且未来版本中反射的非法访问可能会被彻底修复,届时这种绕过安全检查的方式将受到更严格的限制。

06.反射的优缺点

优点

Java是一个静态语言,它在编译期间就会检查变量的类型,所以在编写程序时需要声明所有变量的数据类型,否则编译失败。正是因为是静态语言,所以它无法像动态语言一样通过少量代码实现多数功能;而程序一旦确定后再去改变一些方法(例如实例化对象的更改)还需要重新编译。但反射的存在(Class.forName(className))可以使Java通过读取配置信息(类的全限定名)动态改变实例,从而获得类似于动态语言的灵活性。

典型应用场景

  1. 动态加载:JVM通过反射机制动态加载所需要的类
  2. 动态代理:在切面编程(AOP)中,需要拦截特定的方法,通常会选择动态代理方式,这也是通过反射来实现的
  3. 各种通用框架:例如Spring、Mybatis等都是通过读取配置文件信息来动态加载不同对象
  4. IDE的代码补全:IDEA等IDE的代码补全功能也是反射的实现

缺点

  1. 性能开销:反射涉及动态解析类型,无法执行某些Java虚拟机优化,因此反射操作的效率比非反射操作要低
  2. 破坏封装性:Java的封装是一大特性,但反射会把类内部的属性和方法暴露出来,而且反射调用方法时可以忽略权限检查,这是非常危险的,使得本应被隐藏的私有成员变得可被外部访问

07.漏洞

反射在安全研究中的一个主要目的,就是绕过某些沙盒限制。在安全研究中,利用反射实现远程命令执行(RCE)是最典型的场景。下面详细介绍几种常见的利用方式。

通过Runtime执行命令

java.lang.Runtime是Java中执行系统命令最基础的类。通过反射完整地执行命令的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.yq1ng;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class exec {
public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, IOException {
Class<?> runTime = Class.forName("java.lang.Runtime");
Method getRuntime = runTime.getMethod("getRuntime");
Method exec = runTime.getMethod("exec", String.class);
Object obj = getRuntime.invoke(null);
// 使用Process获取子进程的各种流
// win平台需要 cmd.exe /c 命令
Process p = (Process) exec.invoke(obj, "cmd.exe /c dir");
InputStream inputStream = p.getInputStream();
InputStreamReader isr = new InputStreamReader(inputStream);
BufferedReader br = new BufferedReader(isr);
String line = null;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
}

这个示例中,有几个关键点:

  • 通过getMethod("getRuntime")获取静态方法,并通过invoke(null)调用(静态方法的invoke第一个参数为null)
  • 通过getMethod("exec", String.class)获取带一个String参数的exec方法
  • 使用Process获取子进程的输入流并读取命令执行结果
  • Windows平台下,直接执行命令需要加上cmd.exe /c前缀

通过ProcessBuilder执行命令

跟进java.lang.Runtime.exec()方法的源码可以发现,其内部实际上也是调用了ProcessBuilder.start()来执行命令。因此可以直接利用ProcessBuilder来实现命令执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.yq1ng.ProcessBuilder;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class noParameter {
public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, IOException {
Class<?> processBuilder = Class.forName("java.lang.ProcessBuilder");
// 由于ProcessBuilder构造函数均有参,而className.newInstance()只能调用无参构造函数
// 所以此处用getConstructor()来调用有参函数
// 有参数的话需要将参数装到一个数组实例中
Object arg[] = new Object[]{new String[]{"ls", "-al"}};
Object obj = processBuilder.getConstructor(String[].class).newInstance(arg);

Method start = processBuilder.getMethod("start");
Process p = (Process) start.invoke(obj);
InputStream inputStream = p.getInputStream();
InputStreamReader isr = new InputStreamReader(inputStream);
BufferedReader br = new BufferedReader(isr);
String line = null;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
}

ProcessBuilder相比Runtime的优势在于:

  • 可以直接传入字符串数组作为命令参数,避免Runtime中字符串拼接的问题
  • 对于需要传递多个参数的命令(如ls -al)更加便捷
  • 但Windows平台下使用相对麻烦,通常建议在类Unix系统下使用

通过私有方法执行命令

利用getDeclaredConstructorsetAccessible可以直接绕过Runtime的私有构造函数限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.yq1ng;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class BypassSecurityChecks {
public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, IOException {
Class<?> runtime = Class.forName("java.lang.Runtime");
Constructor<?> runtimeDeclaredConstructor = runtime.getDeclaredConstructor();
// setAccessible设置为true会取消Java安全检查,即可以访问到私有方法
runtimeDeclaredConstructor.setAccessible(true);
Method exec = runtime.getMethod("exec", String.class);
Object obj = runtimeDeclaredConstructor.newInstance();
Process p = (Process) exec.invoke(obj, "uname -a");
InputStream inputStream = p.getInputStream();
InputStreamReader isr = new InputStreamReader(inputStream);
BufferedReader br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
}

这种方式直接实例化了Runtime类的私有构造函数,绕过了getRuntime()静态方法的要求。但需要注意,这种方式会触发Java的安全警告,并且在未来的版本中,反射的非法访问可能会被修复,届时这种绕过安全检查的方式将不再可用。

08.反序列化漏洞

反射是Java反序列化漏洞利用链的基础。在反序列化攻击中,攻击者通过构造精心设计的序列化数据,在反序列化过程中触发目标类的方法调用。而这些调用链路中,反射扮演着至关重要的角色——它允许在运行时动态加载类并调用其方法,从而构造出完整的Gadget Chain。

反序列化漏洞的攻击原理可以归纳为:

  1. 利用反射动态加载目标类
  2. 通过getMethodinvoke构造方法调用链
  3. 最终触发危险操作(如命令执行)

这也解释了为什么反射机制是Java安全研究的基石——无论是理解现有的漏洞利用链,还是挖掘新的漏洞,都离不开对反射原理的深入掌握。