前言
最后决定按照零溢出师傅的教学路线为主要的学习路线,以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类对象来创建对象。这也很好的体现了反射是在运行时进行的。

代码实例
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调用时,结果如下

用反射时,结果如下

测试代码以及解析
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");
}
}
|

为什么不能生成实例呢?因为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");
}
}
|

另外,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);
}
}
|

漏洞点
在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 + '\'' +
'}';
}
}
|

JVM类加载器
参考链接:
类加载器详解
JVM类加载器
上文在反射时提到过类加载器,那时是说“我们将java代码编译成class字节码文件后会通过类加载器创建Class类对象”,现在我们详细说说类加载器是个啥,将之前先了解以下类的加载过程
类的加载
类加载分为三个主要阶段:加载、链接和初始化。

加载阶段主要由类加载器完成以下任务:
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实例都有一个相关的父类加载器(除了启动类加载器),当一个加载器识图亲自查到类或资源前,会先委托给父类加载器判断是否被加载。到最顶层后尝试再向下加载类

自定义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);
}
}
|

还有一种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");
}
}
|

这种代理模式有什么缺点呢?
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");
}
}
|

详细解释(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。
通过代理调用方法:
- 调用代理对象a的print方法,传入参数"aaaa"。
- 这个调用会被转发到InvocationHandler的invoke方法,所以实际执行的是invoke方法中的逻辑。
原理
动态代理实际上是JVM在运行期动态创建class字节码并加载的过程,也就是JVM帮我们自动编写了一个直接生成字节码的类。
小结
暂时学这些,以后遇到了需要学习的新知识就再补充在这里。