Java安全基础

前言

最后决定按照零溢出师傅的教学路线为主要的学习路线,以n1ar4师傅的路线作为补充。这篇是java安全基础,会学习反射、序列化和反序列化、JVM加载器的知识。

反射

什么是反射

java反射是一种间接操作目标对象的机制,核心是在运行状态时动态加载类并获取类的详细信息,它对于任意一个类都能够知道这个类所有的属性和方法,并且对于任意一个对象,都能够调用它的方法/访问属性。

在java中,我们通常使用new来实例化一个类,从而获取其成员属性和方法。

1
2
class a = new A();
a.method(1);  //调用A的method方法

而反射是使用JDK提供的反射API进行调用

1
2
3
4
5
class a = Class.forName("A");
Method method = a.getMethod("method",int.class);
Constructor constructor = a.getConstructor();
Object object = constructor.newInstance();
method.invoke(object,1);

反射机制

参考文章:Java安全|反射看这一篇就够了

了解了反射,再来了解一下反射的原理。

java程序在计算机有三个阶段:编译阶段、加载阶段、运行阶段。

我们将java代码编译成class字节码文件后会通过类加载器创建Class类对象(不是实例化的对象,用于表示当前字节码文件的结构信息,用数组存贮),然后在运行阶段,反射就是通过Class类对象来创建对象。这也很好的体现了反射是在运行时进行的。

image-20250720184155107

代码实例

A类的定义

 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
public class A {
    private String name;
    A(){
        this.name = "A";
    } //无参构造方法
    
    A(String name){
        this.name = name;
    } //有参构造方法
    
    void method(){
        System.out.println(name+":no digit");
    } //无参方法method
    
    void method(int i){
        System.out.println(name+":"+i);
    } //重载method

    @Override
    public String toString() {
        return "A{" +
                "name='" + name + '\'' +
                '}';
    } //tostring
}

用new调用时,结果如下

image-20250720130607491

用反射时,结果如下

image-20250720140013366

测试代码以及解析

 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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class Main {
    public static void main(String[] args) throws Exception {
/*
        通过new实例化对象
        A a = new A();
        System.out.println(a);
        a.method();
        System.out.print("\n");
        a.method(1);
*/

        //通过反射
        //获取类,并实例化对象
        Class a = Class.forName("A");   //获取类
        Object o = a.newInstance();   //a是获取的类,o是a的实例化对象
        System.out.println(o);

        //通过构造方法实例化对象
        Constructor ad = a.getDeclaredConstructor(String.class);   //通过构造方法实例化对象 a.getConstructor(String.class)获取public的构造方法
        Object o2 = ad.newInstance("a");
        System.out.println(o2);

        //获取类的属性
        for (Field f : a.getDeclaredFields()){   // 获取所有属性   a.getFields() 获取所有public属性
            System.out.println(f);
        }
        Field f1 = a.getDeclaredField("name");  //获取单个属性,并进行修改(修改通过构造方法实例化的对象的属性)
        f1.setAccessible(true);   //强制解除访问限制,不写这个就只能修改public的属性
        f1.set(o2,"b");
        System.out.println(o2);

        //调用类的方法
        for (Method m : a.getDeclaredMethods()){  //  获取所有方法   a.getMethods() 获取所有public方法
            System.out.println(m);
        }
        Method m1 = a.getDeclaredMethod("method");   //获取单个方法,可以用invoke()函数调用该方法
        m1.invoke(o);
        Method m2 = a.getDeclaredMethod("method", int.class);   //这里是有参的重载后的方法
        m2.invoke(o,1);
    }
}

手搓一遍之后就可以很清楚了解到一些重要的类

1
2
3
4
Java.long.Class:代表一个类,Class对象表示某个类加载后在堆中的对象
Java.lang.reflect.Method:代表类的方法
Java.lang.reflect.Field:代表类的成员变量
Java.lang.reflect.Constructor:代表类的构造方法

以及重要的方法方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Class.获取类的方法:forname(className类名)

Class.实例化类对象的方法:newInstance()

Class.获取函数的方法:getDeclaredMethod(methodname方法名)

Class.获取变量的方法getDeclaredField(fieldname属性名)

Field.更改变量值的方法setAccessible(bool布尔值);set(calss类,fieldname变量名);

Method.执行函数的方法:invoke(class类,args方法参数)

以上是反射基础实现,可以参考【Java安全-基础】反射(代码审计)

反射相关漏洞

通过反射构造Runtime类执行,达到命令执行

这里值得注意的是,这里只调用了Runtime的方法,并没有生成实例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import java.lang.reflect.Method;

public class Main {
    public static void main(String[] args) throws Exception {
        Class c = Class.forName("java.lang.Runtime");
        Method m = c.getMethod("exec",String.class);
        Method rm = c.getMethod("getRuntime");
        Object o = rm.invoke(c);
        m.invoke(o,"calc.exe");
    }
}

image-20250720181243206

为什么不能生成实例呢?因为Runtime是单例类,他的构造方法是私有的,无法通过newInstance()来生成实例,只能调用他的方法。但是我之前有提到过

1
Field.更改变量值的方法setAccessible(bool布尔值);set(calss类,fieldname变量名);

setAccessible()可以强制解除访问限制,就可以做到实例化,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;

public class Main {
    public static void main(String[] args) throws Exception {
/*
        Class c = Class.forName("java.lang.Runtime");
        Method m = c.getMethod("exec",String.class);
        Method rm = c.getMethod("getRuntime");
        Object o = rm.invoke(c);
        m.invoke(o,"calc.exe");

 */
        Class c = Class.forName("java.lang.Runtime");
        Constructor constructor = c.getDeclaredConstructor();
        constructor.setAccessible(true);
        Object o = constructor.newInstance();
        Method m = c.getMethod("exec", String.class);
        m.invoke(o,"calc.exe");
    }
}

image-20250720183310218

另外,setAccessible()也可以做到修改final字段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 反射获取Field类的modifiers
Field modifiers = field.getClass().getDeclaredField("modifiers");

// 设置modifiers修改权限
modifiers.setAccessible(true);

// 修改成员变量的Field对象的modifiers值
modifiers.setInt(field, field.getModifiers() & ~Modifier.FINAL);

// 修改成员变量值
field.set(类实例对象, 修改后的值);

另外反射也与反序列化有关,这个我们以后再探讨

反序列化

参考:

序列化和反序列化

序列化与反序列化

概念和php的反序列化差不多,都是一种对象持久化的技术。序列化将java对象转化为字节序列的过程,反序列化就是把字节序列恢复为java对象的过程

实现序列化的技术不唯一,常见有

XML&SOAP、JSON、Protobuf、Java Serializable接口

代码实现

以Java Serializable接口为主,代码实现如下

还是以上文的A类为主**(A类需要接入Serializable接口)**

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import java.io.*;

public class Main{
    public static void serializable(String path,Object obj) throws IOException {
        ObjectOutputStream oos = new  ObjectOutputStream(new FileOutputStream(path));  //创建了一个ObjectOutputStream对象,它包装了一个用于写入文件的FileOutputStream。文件路径由参数path指定。ObjectOutputStream可以将对象序列化后写入文件。
        oos.writeObject(obj);  //将传入的对象obj写入到ObjectOutputStream中,即序列化该对象并写入文件。
    }
    public static Object unserializable(String path) throws IOException, ClassNotFoundException {
        ObjectInputStream ois  = new ObjectInputStream(new FileInputStream(path));  //创建了一个ObjectInputStream对象,它包装了一个用于读取文件的FileInputStream。文件路径由参数path指定。ObjectInputStream可以从文件中读取序列化的数据并反序列化为对象。
        return ois.readObject();  //从ObjectInputStream中读取一个对象并返回。readObject方法会从流中反序列化对象。(注意,记住这里,后面要考)
    }
    public static void main(String[] args) throws Exception {
        A a = new A("a");
        serializable("ser.bin",a);
        Object o = unserializable("ser.bin");

        System.out.println(o);
    }
}

image-20250722100607421

漏洞点

在unserializable方法中有return ois.readObject();如果要被反序列化的的那个对象实现了readObject方法,那么就会自动执行,有点像__destruct()。如果在readObject方法中写入了恶意代码,那么就会产生漏洞

A类定义如下

 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
30
31
32
33
34
35
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class A implements Serializable {
    private String name;
    A(){
        this.name = "A";
    }
    A(String name){
        this.name = name;
    }
    void method(){
        System.out.println(name+":no digit");
    }
    void method(int i){
        System.out.println(name+":"+i);
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        Runtime.getRuntime().exec("calc");
    }  
 /* 
  重写了 `readObject` 方法。在反序列化过程中,当从输入流中读取对象时,会自动调用此方法。
  - `in.defaultReadObject()`: 调用默认的反序列化方法,恢复对象的非静态和非瞬态字段(即正常反序列化过程)。
  - `Runtime.getRuntime().exec("calc")`: 执行系统命令,在Windows系统中,`calc`会启动计算器。这是一个恶意代码,用于演示反序列化漏洞。
*/
    @Override
    public String toString() {
        return "A{" +
                "name='" + name + '\'' +
                '}';
    }
}

image-20250722105851691

JVM类加载器

参考链接:

类加载器详解

JVM类加载器

上文在反射时提到过类加载器,那时是说“我们将java代码编译成class字节码文件后会通过类加载器创建Class类对象”,现在我们详细说说类加载器是个啥,将之前先了解以下类的加载过程

类的加载

类加载分为三个主要阶段:加载、链接和初始化。

image-20250722192009432

加载阶段主要由类加载器完成以下任务:

1
2
3
4
5
通过全类名获取定义此类的二进制字节流

将字节流所代表的静态存储结构转换为方法区的运行时数据结构

在内存中生成一个该类的Class对象

链接阶段:验证—>准备—>解析

阶段 作用
验证 确保Class文件中的字节流中包含的信息符合规范约束要去
准备 为类变量分配内存并设置类变量初始值
解析 虚拟机将常量池内的符号引用替换为直接引用的过程

初始化阶段:执行初始化方法<clinit> ()方法的过程,在这一步中JVM才开始真正执行类中定义的Java程序代码。

类加载器

JVM类加载器是Java虚拟机中负责加载类文件的核心组件。它将类的字节码加载到内存中,并将其转换为JVM能够识别的运行时数据结构。

分为启动类加载器、扩展类加载器、应用程序类加载器、自定义加载器

加载器 作用
启动类加载器 由c++实现的最顶层的加载类,通常表示为null,主要用于加载/lib目录下的JDK内部核心类库
扩展类加载器 主要负责加载/lib/ext目录下的jar包
应用程序类加载器 面向用户的加载器,负责当前应用classpath包下的所有jar包和类
自定义加载器 用户加入自定义的加载器进行拓展,可以实现对class文件加解密的功能

双亲委派模型

ClassLoader类使用委托模型搜索类和资源,每个ClassLoader实例都有一个相关的父类加载器(除了启动类加载器),当一个加载器识图亲自查到类或资源前,会先委托给父类加载器判断是否被加载。到最顶层后尝试再向下加载类

image-20250722200923400

自定义ClassLoader实现

首先事MyClassLoader类实现

 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
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

import static java.nio.file.Files.readAllBytes;

public class MyClassLoader extends ClassLoader{
    String path;
    // 构造方法,传入一个路径字符串
    public MyClassLoader(String path) {
        this.path = path;
    }
    // 重写findClass方法,这是自定义类加载器的核心
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
         // 将类的全限定名转换为文件路径:将包名中的点替换为文件分隔符(这里用'/'),并加上.class后缀
        String fullpath = path+name.replace('.','/').concat(".class");
        byte[] bytes;
        try {
            // 读取类文件的字节码
             bytes = readAllBytes(Paths.get(fullpath));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        // 调用defineClass方法将字节数组转换为Class对象
        return defineClass(name,bytes,0,bytes.length);
    }
}

详细解释

继承 ClassLoader

- 自定义类加载器需要继承 ClassLoader 类,并重写 findClass 方法(而不是 loadClass 方法,因为 loadClass 方法实现了双亲委派机制,而 findClass 是留给子类实现的模板方法)。

路径处理

- path 成员变量:存储类文件所在的根目录(例如:"D:/classes/")。

- 在 findClass 方法中,将类名(如 com.example.MyClass)转换为文件路径(如 D:/classes/com/example/MyClass.class)。

读取字节码

- 使用 Files.readAllBytes 方法读取类文件的字节码。这是一个阻塞操作,会一次性将整个文件读入内存。

定义类

- defineClass 方法(继承自 ClassLoader)将字节数组转换为 Class 对象。这是类加载的核心步骤,JVM 会在内部进行类的验证、准备、解析等阶段。

然后是A.class(要被加载的class文件)

注意这里构造函数最好是public的,不然newInstance()的时候会报错,原因上文也提到过。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class A {
    private String name;
    public A(){
        this.name = "A";
    }
    public A(String name){
        this.name = name;
    }
    void method(){
        System.out.println(name+":no digit");
    }
    void method(int i){
        System.out.println(name+":"+i);
    }

    @Override
    public String toString() {
        return "A{" +
                "name='" + name + '\'' +
                '}';
    }
}

然后是测试函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class Main {
    public static void main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
        //  打印类A的类加载器 
        System.out.println(A.class.getClassLoader());
       //  打印String类的类加载器(核心类) 返回null 
        
 System.out.println(String.class.getClassLoader());
        //  创建自定义类加载器实例,指定类路径
        MyClassLoader cl = new MyClassLoader("D:\\网安\\acm\\javaweb\\");
        //  使用自定义类加载器加载类A
        Class<?> c = cl.findClass("A");
        //  通过反射创建类A的实例
        Object o = c.newInstance();
        System.out.println(o);
    }
}

image-20250722204408700

还有一种URLClassLoader,是可以从网络中获取class文件,这里就不写了。

JDK动态代理

参考文章:

Java 动态代理详解

动态代理

静态代理

先了解一下什么是静态代理,静态代理就是编写一个interface,然后用一个类去实现这个接口。

静态代理代码如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
 interface A{
	void print(String name);
}

class comeA implements A{
    public void print(String name){
        System.out.println("I am "+name);
    }
}

public class Main{
    public static void main(String[] args){
        A a = new comeA();
        a.print("aaa");
    }
}

image-20250722163313755

这种代理模式有什么缺点呢?

1.代理多个类时会导致代理类过于庞大或者产生过多的代理类

2.接口需要修改方法时,目标对象和代理类都要修改,不易维护

可以用动态代理直接在运行期创建某个interface的实例,这样就避免了上述的问题

动态代理

我们同样先定义一个interface,但是不去编写实现类,而是通过JDK提供的Proxy.newProxyInstance()创建一个接口对象。这就是动态代理

具体实现如下:

 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
30
31
32
33
34
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

interface A{
	void print(String name);
}

class Main{
    public static void main(String[] args) {
        // 创建InvocationHandler实例
        InvocationHandler in = new InvocationHandler() {
            // 实现invoke方法,该方法会在代理实例的方法被调用时执行
            @Override
            public Object invoke(Object proxy,Method method,Object[] args){
                // 打印被调用的方法信息
                System.out.println(method);
                // 判断方法名是否为"print"
                if(method.getName().equals("print")){
                    // 如果是,则打印参数
                    System.out.println("I am "+args[0]);
                }
                // 因为原方法返回void,所以返回null
                return null;
            }
        };
        // 使用Proxy.newProxyInstance创建代理实例
        //  Main.class.getClassLoader(), 使用当前类的类加载器
        // new Class[]{A.class}, 代理类实现的接口列表
        // in, InvocationHandler实例
        A a = (A)Proxy.newProxyInstance(Main.class.getClassLoader(),new Class[]{A.class},in);
        a.print("aaaa");
    }
}

image-20250722164549086

详细解释(ai生成)

创建InvocationHandler

- 使用匿名内部类实现InvocationHandler接口,并重写invoke方法。

- invoke方法有三个参数:

​ - Object proxy: 代理对象本身

​ - Method method: 被调用的方法

​ - Object[] args: 方法调用时传递的参数数组。

在重写的invoke方法中:

- 首先打印了被调用的方法(System.out.println(method)会输出方法的详细信息,包括方法名和参数类型)。

- 然后判断方法名是否为"print",如果是,则使用第一个参数(args[0])打印字符串。

- 最后返回null,因为接口A的print方法返回类型是void,所以返回null是可以的。

创建代理实例

- 通过Proxy.newProxyInstance方法动态创建代理对象。

- 参数说明:

​ - ClassLoader loader: 类加载器,这里使用Main.class.getClassLoader()

​ - Class<?>[] interfaces: 代理类要实现的接口数组,这里只有一个接口A.class。(可实现多个数组)

​ - InvocationHandler h: 上面定义的InvocationHandler实例in

- 返回一个实现了指定接口的代理对象,这里强制转型为接口A

通过代理调用方法

- 调用代理对象aprint方法,传入参数"aaaa"

- 这个调用会被转发到InvocationHandlerinvoke方法,所以实际执行的是invoke方法中的逻辑。

原理

动态代理实际上是JVM在运行期动态创建class字节码并加载的过程,也就是JVM帮我们自动编写了一个直接生成字节码的类。

小结

暂时学这些,以后遇到了需要学习的新知识就再补充在这里。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计