Java的动态特性—反射
基本概念
动态特性是P神一直提到的概念,P神给的含义是:”一段代码,改变其中的变量,将会导致这段代码产生功能性的变化,这称之为动态特性。“
可以通过”一句话木马“来理解这一概念,例如PHP、ASP、ASPX都有一句话木马,即通过传入特定的参数来使得代码实现不同的功能,但在Java中就没有一句话木马,Java的WebShell功能都需要硬编码在jsp文件中。
在P神的文章中提到:”Java虽不像PHP那么灵活,但其提供的’反射‘功能,也是可以提供⼀些动态特性。“,那么何为”反射“?
参考Java 反射机制详解 | JavaGuide,我的理解如下:
反射是一种机制,它允许在程序运行时,通过在实例对象上使用反射机制,获取到该对象所属的类,并对这个类进行分析和执行类中的方法;通过反射机制可以获取任意一个类的所有属性和方法,还可以调用这些方法和属性。
4种获取类的方法
P神在文章中提到4中获取类的方法:
forName()
:适用于知道某个类的名称时,如:Class.forName("java.lang.Runtime")
;getClass()
:适用于对象,如:obj.getClass()
.class
:适用于已经加载了某个类,例如:TestClass.class
ClassLoader.getSystemClassLoader().loadClass()
:利用类加载机制,获取Class对象
一个典型的poc
poc1:
"".getClass().forName("java.lang.Runtime").getMethod("exec", String.class)
.invoke(
String.class.getClass().forName("java.lang.Runtime").getMethod("getRuntime")
.invoke(
String.class.getClass().forName("java.lang.Runtime")
),
"calc"
);
关于这个poc的一些理解:
先看invoke
的用法:
-
第一个参数obj是java对象,即需要反射调用的对象;
-
第二个参数args是通过反射调用的方法所需要的参数值;
因为java.lang.Runtime
不能直接实例化,所以先用了一次invoke
来调用getRuntime()
获取Runtime
对象:
String.class.getClass().forName("java.lang.Runtime").getMethod("getRuntime")
.invoke(
String.class.getClass().forName("java.lang.Runtime")
)
获取到Runtime
对象之后,再将其作为反射调用的对象obj传入外层的invoke
中,同时传入通过反射调用的方法exec
所需要的参数calc,从而实现了执行系统命令的目的。
以上poc等价于:
java.lang.Runtime.getRuntime().exec("calc");
为什么要使用反射?
在安全研究中,我们使⽤用反射的⼀⼤⽬的,就是为了绕过某些沙盒。
比如在上下文中只有Interger
类型时,就无法使用上述的poc,而是要进行一些改变:
poc2:
Integer.class.forName("java.lang.Runtime").getMethod("exec", String.class)
.invoke(
String.class.getClass().forName("java.lang.Runtime").getMethod("getRuntime")
.invoke(
String.class.getClass().forName("java.lang.Runtime")
),
"calc"
);
forName()
forName()
函数有两个函数重载:
forName(String className)
forName(String name, boolean initialize, ClassLoader loader)
实际上,第一种是对第二种的封装。
来看forName(String name, boolean initialize, ClassLoader loader)
:
第⼀个参数是类名
;第⼆个参数表示是否初始化
;第三个参数是ClassLoader
。
ClassLoader
顾名思义,是一个“类加载器”,作用是告诉java虚拟机该如何加载指定的类。
initialize
的作用是什么?
第二个参数initialize
,表示要加载的类是否需要”初始化“。
先看下面的代码:
public class Main {
public static class TestPrint {
{
System.out.println("Empty initial");
}
static {
System.out.println("Static initial");
}
public TestPrint() {
System.out.println("Simple initial");
}
}
public static void main(String[] args) {
TestPrint tp = new TestPrint();
}
}
显然,三个”初始化方法“的执行顺序为:static {} --> {} --> 构造函数
。
事实上,static {}
就是在”类初始化“时执行的,而{}
会在该类的父类构造函数super()
之后,该类的构造函数之前,即:
static {} --> super() --> {} --> 构造函数
例如:
public class Main {
public static class Father {
public Father() {
System.out.println("Father initial");
}
}
public static class Child extends Father {
{
System.out.println("Child empty initial");
}
static {
System.out.println("Child static initial");
}
public Child() {
System.out.println("Child initial");
}
}
public static void main(String[] args) {
Child child = new Child();
}
}
forName
中的initialize
其实就是告诉Java虚拟机是否执行”类初始化“,恶意代码通常就放在static {}
中执行。
$符号的作用
$
符号的作用是获取内部类。
例如:
public class C1 {
public static class C2 {
}
}
C2
是C1
的内部类,在编译时这两个类会被编译成两个单独的字节码文件:C1.class
和C1$C2.class
:
在加载类时,通过 Class.forName("C1$C2")
即可加载到内部类C2
,获得类以后,可以继续使用反射来获取这个类中的属性、方法,也可以实例化这个类,并调用方法。
newInstance()为什么会失败?
class.newInstance()
的作用是实例化一个对象,但是有时候这个方法会失效,例如:
public class Main {
public static void main(String[] args) throws Exception {
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "calc");
}
}
运行这段代码会报错:
这是因为class.newInstance()
实际上是调用了类的无参构造函数,但当类的无参构造函数不存在或是私有的时候,就会报以上错误。
java.lang.Runtime
的无参构造函数就是私有的:
为什么会出现类的无参构造函数是私有的?
这实际上是”单例模式“的思想,例如数据库连接实例,只需要在程序启动时创建一次,而不需要多次创建,此时就可以将数据库连接所使用的类的构造函数设置为私有,然后通过一个静态方法类获取连接实例,这样,只有类初始化的时候会执行一次构造函数,之后只能通过指定的静态方法来获取这个对象。
上面提到的Runtime
类就是一个单例模式,所以只能通过Runtime.getRuntime()
方法来获取Runtime对象,这样,上面的代码修改为以下就可以正常执行了:
public class Main {
public static void main(String[] args) throws Exception {
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec", String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz), "calc");
}
}
getConstructor()
有一个新的问题,如果一个类中没有无参构造函数,也没有类似单例模式中的静态方法可以获取类对象,该如何通过反射来实例化这个类?
为了解决这个问题,需要使用一个新的方法:getConstructor()
。
和getMethod()
类似,getConstructor()
接收的参数是构造函数列表类型,这个方法就是用来获取构造函数的。获取到构造函数之后,使用newInstance()
来实例化并执行。因为函数重载,所以必须用参数列表类型才能唯一确定一个构造函数。
例如:常用的另一种执行命令的方式ProcessBuilder
。
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args) throws Exception {
Class clazz = Class.forName("java.lang.ProcessBuilder");
ProcessBuilder pb = (ProcessBuilder) clazz.getConstructor(List.class).newInstance(Arrays.asList("calc"));
pb.start();
}
}
为什么getConstructor(List.class)
这样写?
来看ProcessBuilder
的构造函数:
有两个函数重载:
public ProcessBuilder(List<String> command)
public ProcessBuilder(String... command)
显然,上面的payload用的是第一个函数重载:public ProcessBuilder(List<String> command)
,所以传入的参数是List.class
,这个参数就是要执行的命令。
注意:
上面的payload中用到了强制类型转换(ProcessBuilder)
,但是在表达式上下文中(也就是漏洞利用的时候),是无法进行强制类型转换的,所以还是需要使用反射来获取实例并执行。
利用上面学到的知识,改写这个payload:
import java.util.Arrays;
import java.util.List;
public class Main {
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")
)
);
}
}
简单理解一下:
- 通过
forName()
获取到java.lang.ProcessBuilder
类; - 获取
ProcessBuilder
的start
方法并invoke
执行,invoke
的第一个参数就是通过getConstructor()
获取的实例;