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 {
        
    }
}

C2C1的内部类,在编译时这两个类会被编译成两个单独的字节码文件:C1.classC1$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类;
  • 获取ProcessBuilderstart方法并invoke执行,invoke的第一个参数就是通过getConstructor()获取的实例;