反射概念
在java之中 万物都是对象 包括“类本身也是一个对象”(属于java.lang.Class类的对象)
反射的本质就是在程序运行期间 通过操控这个Class对象 反向解析和操控它所代表的哪个类里面的内容(构造器,属性,方法)
实例分析
1 | // 假设这是一个系统的底层核心类,防护严密 |
代码分析
- 类本身是公开的
- secretKey使用private修饰符 意味着这个变量处理类自己的代码 外部无法直接使用
SecretValut.secretKey调用(正常路径拿不到 故而使用反射机制) private SecretVault():构造器设置为私有 未来控制对象的创建 把创建对象的控制权捏在自己手里
那么现在我们需要让password等于HACKED 但是由于私有方法的控制 没法直接赋值
构造反射payload
获取Class对象
在java虚拟机JVM之中 写的代码(比如 SecretVault 类)在被加载到内存时 JVM会自动为它创建一个特殊的对象 这个对象的类型就是 java.lang.Class
这个对象包含这个类的所有属性 方法等详细
为了获取这个镜像类 Java提供了三种方法
方法一:动态加载——Class.forName()
1 | // 假设我们想拿到 java.lang.Runtime 这个类的图纸 |
接收一个字符串作为参数 这个字符串必须是类的全限定名——包名+类名
攻击者在外部能传给服务器的通常只有字符串 而Class.forName() 恰好提供了一个将外部可控的字符串 转化为JVM内部执行代码的桥梁
值得注意的是 它会抛出ClassNotFoundException(找不到类异常)这是因为传的是字符串 编译器没法提前帮你检查写错没写错 只能在运行时候报错
方法二:静态获取——类名.class
这是最简单最安全的 是业务开发之中比较常用的方式
1 | Class<?> clazz2 = java.lang.Runtime.class; |
直接在已知的类名后面硬编码(将数据(如字符串、数字、类名等)直接写在源代码中,而不是通过变量、配置文件、用户输入或参数动态传入)写上.class
不需要处理异常 因为些这个代码的时候这个类不存在 编译器直接就标红报错了
方法三:运行时推断——对象.getClass()
当手里依旧有了一个活的对象实体 想反向查询它的图纸的时候使用
1 | // 假设由于某种原因,你已经得到了一个 runtimeObj 对象 |
- getClass()是java所有对象的(
java.lang.Object类)提供的方法 任何对象都能调用它
不管使用哪一种方法· JVM在内存里面都只会保存一份该类的Class对象
获取并突破构造器
对比两种获取方法
getConstructor(...):只能获取类之中被声明为public的构造器 如果使用这个获取private构造器 代码会直接报错并抛出异常getDeclaredConstructor(..,):代理Declared(声明过的)这个词 就像拥有了透视眼 无视任何修饰符只要这个类里面泄露的构造器它都能拿出来
但是拿到构造器并不等于能够使用它 java的访问控制机制还在起作用 这时候直接去实例化 java还是会拦住你并且抛出IllegalAccessException(非法访问异常)
设置可访问性为真
1 | constructor.setAccessible(true); |
在底层直接关闭了java语言的访问安全检查
创造实体
newInstance(...):创造实体 等同于写正常代码时候的new关键字
完整payload
将以下代码和SecretVault类java文件放在·同一目录下
1 | import java.lang.reflect.Constructor; |
简化版执行结果
payload解析
1 | import java.lang.reflect.Constructor; |
导入依赖:在java之中 反射相关的核心类都存放在java.lang.reflect包里面 这三行代码就是我们向系统调用
- Construcyor(构造器类)
- Field(属性类/字段类)
- Method(方法类)
1 | ublic class Main { |
public class ReflectionExploit:定义我们的攻击类 注意!!! 类名必须和文件名一致
public static void main(String[] args):这是 Java 程序的标准入口点。
public:保证 JVM 能从外部调用它static:静态方法,意味着 JVM 不需要先new ReflectionExploit()就能直接执行它String[] args:接收命令行传入的参数(比如java ReflectionExploit arg1 arg2)
1 | try { |
异常捕获 由于之前我们学到使用Class.forName会产生异常 所以我们在此处需要使用异常捕获
1 | Class<?> targetClass = Class.forName("SecretVault"); |
Class<?> targetClass:声明一个类型为Class的变量<?>是java的泛型语法 意思是“位置变量”因为在这个阶段 编译器只知道你拿到了一个“图纸” 但并不知道这张图纸具体是属于哪种类的 所以用?占位 之后给这个变量起名叫做targetClassClass.forName("SecretVault"):让 JVM 顺着系统的类路径(Classpath)去寻找一个名叫SecretVault的类 找到后,把它加载到内存中 并返回这个类的Class对象(也就是图纸)
1 | Constructor<?> constructor = targetClass.getDeclaredConstructor(); |
Constructor<?>:声明一个用于存放“构造器对象”的变量targetClass.getDeclaredConstructor():在这张图纸上 扫描并抓取那个**“没有参数”**的构造器 不管它是公有还是私有 只要声明了就抓出来
1 | constructor.setAccessible(true); |
取消对这个构造器的java语言访问检查
1 | Object vaultInstance = constructor.newInstance(); |
Object:这里为什么使用Object? 因为在编译器编译这行代码的时候 不知道newInstance会造出个什么玩意constructor.newInstance():正式下达“制造”指令 在内存的堆区强行划出一块地盘 生成一个SecretVault对象 并把它的内存地址交给了左边的vaultInstance变量
1 | Field secretKeyField = targetClass.getDeclaredField("secretKey"); |
Field secretKeyField:声明一个变量存放属性targetClass.getDeclaredField("secretKey"):锁定名称为secretKey的变量实体
1 | secretKeyField.setAccessible(true); |
消除这个private属性的私有
1 | secretKeyField.set(vaultInstance, "CTF_PWNED_BY_REFLECTION"); |
secretKeyField.set(A, B):这是一个赋值操作
参数A:指定要修改的具体对象的属性
参数B:要把这个属性改成什么值
1 | Method openVaultMethod = targetClass.getDeclaredMethod("openVault", String.class); |
Method openVaultMethod:声明一个存放方法的变量targetClass.getDeclaredMethod(...):获取这个方法
参数 1 ("openVault"):告诉 JVM 你要找哪个方法。
参数 2 (String.class):告诉 JVM 这个方法接收的参数类型是什么。如果 SecretVault 里面有两个 openVault,一个接收 String,一个接收 int,这个参数就能帮助 JVM 精准定位。
1 | openVaultMethod.setAccessible(true); |
废除方法的私有属性限制
1 | openVaultMethod.invoke(vaultInstance, "HACKED"); |
openVaultMethod.invoke(A, B):invoke是调用的意思
参数A:告诉JVM 这个方法属于哪个具体对象的动作 也就是“让vaultInstance这个对象去执行openVault方法”
参数B:传给这个方法的真实参数 此处传入HACKED满足判断条件
Class.forName的两个重要参数
1 | Class.forName(classname, initialize, currentLoader) |
- 类的全限定名称
- 是否执行类初始化
- 指定类加载器
重要结论initialize = true 并不会执行构造函数,而是执行类的静态初始化块(static {})
类初始化的三个阶段
1 | //诸如一下测试类 |
可以看到再这个类之中是由三个板块的 他们的执行顺序是
static {}—— 类加载时执行(仅一次)(静态初始化块){}—— 实例初始化块,在构造函数super()之后、构造函数内容之前执行(实例初始化块)构造函数 —— 最后执行(构造函数)
由此我们可以知道 假设这个类被创造了两个对象 输出结果如图
1 | Static initial class TrainPrint |
实例初始化块和构造函数的关系
编译器会将实例初始化的代码块复制到每一个构造函数的最前面(super()之后) 构造函数自己的diamagnetic之前
比如
1 | public class Demo { |
实际上编译之后等价于
1 | public class Demo { |
super()是什么
这是java之中调用父类无参构造方法的语句
基本作用
- 每个子类的构造方法中,第一行必须是
super(...)或this(...)。 - 如果程序员没有显式写
super(...);,编译器会自动插入super();(前提是父类有一个无参构造方法)。 super();用于先完成父类对象的初始化,再执行子类自己的构造代码。
总结来说这就是java继承体系之中保证对象完整初始化的机制
反射加载内部类
对于单例模式的突破
使用静态方法特性绕过
私有构造方法
我们可能会遇到这样的一种类
- 只允许全局拥有一个实例
- 通过静态方法(不需要创建对象就能使用)获取唯一实例
- 构造方法私有 防止外部new
那么我们使用clazz.newInstance() 由于它只能调用 无参的公共构造方法 就无法实现反射
此时我们就得利用静态方法实现反射
正确反射调用链条
获取类对象
获取 静态方法并调用它 → 得到 唯一 实例
获取 目标普通方法
在 唯一 实例上调用 目标方法
实例分析
我们选用java.lang.Runtime这个类来进行讲解
java.lang.Runtime简单介绍
这是java编程语言之中的一个重要类 提供了与Java应用程序运行时候的环境交互的方法 通过这个类 开发者可以访问JVM的一些底层功能
- 执行外部进程
- 获取内存信息
- 关闭JVM 这些……
1 | try { |
比如这样就能打开记事本
分析选择原因
我们使用一个简单的Runtime单例模式 说明为什么这种情况下反射不能直接newInstance() 以及为什么必须通过getRuntime静态方法获取实例
1 | public class Runtime { |
我们来分析一下这一段代码的实现逻辑
1 | private static Runtime currentRuntime; |
存放唯一实例 并且设定静态规定这个字段属于类本身 而不是某个实例 内存之中只有一份 所有对象共享他(解决了不能创建对象的困扰)
1 | private Runtime() {} |
设定一个私有构造器 防止外部new对象
1 | public static Runtime getRuntime() { |
设定返回值是之前创建的唯一对象
这是一个全局访问点 使用public static打破了私有构造的隔离 安全返回唯一实例的应用 让外部可以无对象获得单例对象
payload构造与解析
1 | Class<?> clazz = Class.forName("java.lang.Runtime"); |
我们来逐步解析一下这个构造过程
1 | Class<?> clazz = Class.forName("java.lang.Runtime"); |
这一步就是我们的传统方法 利用forName拿到类对象
1 | Method getRuntimeMethod = clazz.getMethod("getRuntime"); |
获取方法getRuntime 此时getRuntimeMethod代表静态方法getRuntime
1 | Object runtimeObj = getRuntimeMethod.invoke(null); |
调用这个方法 获取唯一实例
这里有一个知识点
invoke
第一个参数标识“调用这个方法的对象
- 如果方法是静态方法 第一个参数填null(填这个类本身也可以 但是一般都是使用null)
- 如果方法是普通的 第一个参数填具体的实例对象
这里由于getRuntime是静态方法 所以我们传入null
invoke之后的返回值就是Runtime.getRuntime() 返回的那个 Runtime 实例
相当于这一步完成了 Runtime rt = Runtime.getRuntime();
1 | Method execMethod = clazz.getMethod("exec", String.class); |
这一步是获取exec这个普通方法 由于java支持函数重载 所以要指定传参类型
1 | execMethod.invoke(runtimeObj, "calc.exe"); |
依旧是invoke调用这个方法 由于这个方法·此时是普通方法 所以第一个参数传入实例对象(也就是我们用来存放第一个invoke返回结果的对象runtimeobj)第二个参数传入执行命令即可
有参构造 且没有静态获取方法的情况
前两个场景我们解决了私有化和单例化的阻碍 假如目标类既不是单例 构造器也是public 我们还需要反射吗?
答案是需要 因为在安全攻防之中 我们能操控的大多知识用户输入的字符串 而传统的new方法并不能被我们使用 恰恰此时反射由于其将类名以字符串形式传入的方式 能够在此场景下帮助我们 这一点在之后的反序列化攻击之中至关重要
核心方法
与无参构造不同 处理有参构造需要两步精准匹配
clazz.getConstructor(Class<?>...parameterTypes):根据传入的参数类型列表定位唯一构造器constructor.newInstance(Object...initargs):创建实例时 传入与构造器参数类型匹配的实际数据
和我们之前提到的getDeclaredConstructor不同 这个getConstructor只能获取public修饰的构造器
实例分析
我们可以利用java执行系统命令的官方推荐类ProcessBuilder来进行一次测试
这个类有公共构造器
1 | // 构造器 1:接收一个 List<String> 参数 |
可以编写一下payload来哦实现反射调用代码
1 | package java_reflect; |
小结
到这里我们依据完整学习了java反射的三种场景
- 私有构造器
- 私有构造器+单例模式
- 有参公有构造器
参考文章
- 知识星球java安全漫谈反射篇
- Java安全第一篇 | 反射看这一篇就够了 - 小艾搞安全 - 博客园