Java反射第一篇

Artio 于 2022-04-15 发布

说在前边:该文章是P牛在知识星球中的Java安全漫谈所讲,自己学习后做下记录。

什么是反射

反射是⼤多数语⾔里都必不可少的组成部分,对象可以通过反射获取他的类,类可以通过反射拿到所有方法(包括私有),拿到的方法可以调用,总之通过“反射”,我们可以将Java这种静态语言附加上动态特性:一段代码,改变其中的变量,将会导致这段代码产⽣功能性的变化,我们称之为动态特性。

PHP本身拥有很多动态特性,所以可以通过“一句话⽊⻢”来执行各种功能,Java虽不像PHP那么灵活, 但其提供的“反射”功能,也是可以提供一些动态特性。比如,这样一段代码,在你不知道传入的参数值的时候,你是不知道他的作用是什么的:

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

上边的例子中,我们演示了几个在反射中极为重要的方法:

基本上,这⼏个方法包揽了Java安全里各种和反射有关的Payload。

forName不是获取类的唯一途径,通常来说我们有以下三种方式获取一个类,也就是java.lang.Class对象:

在安全研究中,我们使用反射的目的有很多,其中之一就是用于绕过某些沙盒,比如上下文中如果只有Integer类型的数字,那我们如何获取到可以执行命令的Runtime类呢?

1.getClass().forName("java.lang.Runtime")

forName有两个函数重载:

第一个就是我们最常见的获取class的方法,其实可以理解为第二种方法的封装

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

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

ClassLoader 是什么呢? 它就是⼀个“加载器”,告诉Java虚拟机如何加载这个类。关于这个点,后面还有很多有趣的漏洞利⽤⽅法,这⾥等我学习完成后再分享。Java默认的 ClassLoader 就是据类名来加载类,这个类名是类完整路径,如 java.lang.Runtime 。

第二个参数initialize常常被人误解,比如:

Java反射_1.png

图中有说:”构造函数,初始化时执行”,其实在forName的时候,构造函数并不会执行,即使我们设置initialize=true,那么forName的初始化指的是什么?

可以将这个初始化理解为类的初始化,我们来看下如下类:

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

我们可以想一下上述三个初始化方法有什么区别,执行顺序是什么?

我们运行后发现,⾸先调用的是 static {} ,其次是 {} ,最后是构造函数。

其中, static {} 就是在“类初始化”的时候调用的,而 {} 中的代码会放在构造函数的 TrainPrint() 后面,但在当前构造函数内容的前面。

所以说, forName 中的 initialize=true 其实就是告诉Java虚拟机是否执行”类初始化“。

那么,假设我们有如下函数,其中函数的参数name可控:

public void ref(String name) throws Exception {
    Class.forName(name);
}

我们就可以编写一个恶意类,将恶意代码放置在 static {} 中,从⽽执⾏:

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就不需要,这样对我们攻击者特别有利,可以加载任意类。

另外我们可能会在一些源码中看到类名的部分包含$符号,$的作用是查找内部类。

Java的普通类c1中支持编写内部类c2,而在编译的时候会生成两个文件:c1.class和c1$c2.class,我们可以把他们看作两个不相关的类,通过Class.forName(“c1$c2”)即可加载这个内部类。

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

Class.newInstance()的作用是调用这个类的无参构造函数,但是有时候可能会调用失败,可能是以下两个原因:

最常见的情况就是java.lang.Runtime,这个类在我们构造命令执行payload的时候很常见,但我们不能直接这样执行:

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

这样会报错:

Java反射_2.png

原因就是因为Runtime类的构造方法是私有的,这里有一个疑问就是如果是私有的构造方法,那用户怎么调用这个类,这里涉及到”单例模式”,以后再说,可以通过下边这个例子理解一下:

对于web应用来说,数据库连接只需要建立一次,而不是每次连接都需要重新建立数据库,这个时候就可以把连接数据库的类的构造函数设置为私有,然后编写一个静态方法来获取:

public class TrainDB {
    private static TrainDB instance = new TrainDB();
    public static TrainDB getInstance() {
        return instance;
}
private TrainDB() { // 建立连接的代码...
} }

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

所以说,Runtime类就是单例模式,我们只能通过Runtime.getRuntime()来获取到Runtime对象,然后修改下payload:

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

这里用到了getMethod和invoke方法。

getMethod的作用是通过反射获取一个类的某个特定的公有方法,但是Java支持类的重载,我们不能仅通过一个函数名字就确定一个函数,所以在调用getMethod的时候,我们需要传给它你需要获取的函数的参数类型列表。比如:

Java反射_3.png

这里的Runtime.exec方法有6个重载,我们使用最简单的第一个,它只有一个参数,类型是string,所以我们使用getMethod(“exec”, String.class)来获取Runtime.exec方法。

invoke的作用是执行方法,它的第一个参数是:

我们正常执行方法是:1.method(2,3,4)

在反射中是:method.invoke(1,2,3,4)

所以我们分解一下上边的payload就是:

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");

上边说了简单的命令执行payload,但是有两个问题:

第一个问题,我们需要用到一个新的反射方法 getConstructor:

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

获取到构造函数后,我们使用newInstance来执行。

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

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

ProcessBuilder有两个构造函数:

上边用的是第一种情况,所以传入的是list.class。

但是我们看到,前边这个payload用到了java的强制类型转换,有时候我们利用漏洞的时候是没有这种语法的,所以我们需要用反射来实现这一步:

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

首先通过getMethod获取到start方法,然后invoke执行,invoke的第一个参数就是ProcessBuilder Object。

那么如果我们使用第二种情况public ProcessBuilder(String… command)这个构造函数,需要怎么用反射执行呢

这又涉及到java中的可变长参数(varargs)了,和其他语言一样,java也支持可变长参数,比如定义函数的时候不确定参数的数量的时候,这时可以用…来代替,表示这个函数的参数是可变的。

对于可变参数,java在编译的时候会编译为一个数组,也就是说下边这两种写法的作用是一样的:

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

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

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

对于反射来说,我们想要获取函数中的可变长参数,其实我们认为他是可变长参数就可以了。

所以,我们将字符串数组的类String[].class 传给getConstructor,获取ProcessBuilder的第二种构造函数:

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

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

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

再说一下上边提到的第二个问题,如果一个方法或者构造方法是私有方法,我们能否执行它?

这就涉及到getDeclared系列的反射了,与普通的getMethod、getConstructor的区别是:

getDeclaredMethod 的具体用法和 getMethod 类似, getDeclaredConstructor 的具体用法和getConstructor 类似。

举个例子,前边我们说过Runtime这个类的构造函数是私有的,我们需要用Runtime.getRuntime来获取对象,其实现在我们也可以直接用 getDeclaredConstructor 来获取这个私有的构造方法来实例化对象,进而执行命令:

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 修改它的作用域,否则仍然不能调用。