Java-反射及原生序列化和反序列化

Java 反射

核心就是

1
2
3
用字符串决定
调用哪个类
执行哪个方法

比如说

1
2
3
4
public void execute(String Dog, String bark) throws Exception {
Class a = Class.forName(Dog);//根据名字找到类
a.getMethod(bark).invoke(a.newInstance());
}

几个在反射里里极为重要的方法:

获取类的⽅方法: forName

实例例化类对象的方法: newInstance

获取函数的方法: getMethod

执行函数的方法: invoke

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

另外newInstance()Java 9 之后已经不推荐使用

1
a.getDeclaredConstructor().newInstance();
1
2
Class a = Class.forName("Dog");
Object obj = a.getDeclaredConstructor().newInstance();

如果攻击者能控制 Java 代码执行:

1
Runtime.getRuntime().exec("命令")

就可以进行命令执行了

除了 Runtime,还有一个类ProcessBuilder

1
2
ProcessBuilder pb = new ProcessBuilder("calc");
pb.start();

常见的还是runtime

获取类的其他方式

obj.getClass()

前提是已经有一个对象

1
Dog d = new Dog();

之后就可以

1
Class c = d.getClass();
1
d.getClass() = Dog.class

类名.class

前提是已经知道这个类了

1
Class c = Dog.class;

直接获取 Dog

类的 Class 对象

不过这种方法不属于反射 因为你编译的时候就已经知道类了

Class.forName()

只有字符串类名

1
Class c = Class.forName("Dog");

这是真正的反射常用方法

类名是动态的

函数重载

Java 的 Class.forName 有两个函数重载(就是同名函数但参数不一样):

1
2
Class<?> forName(String name)
Class<?> forName(String name, boolean initialize, ClassLoader loader)
  • 第一个:我们最常用,直接写类名就行。
  • 第二个:更底层、更灵活,可以控制 是否初始化用哪个 ClassLoader 来加载

为什么第一个可以理解为第二个的封装

第一个内部大概

1
Class.forName(className, true, 当前ClassLoader)

className→ 你想要的类名字,比如“java.lang.Runtime”

true → 表示 要初始化类

当前ClassLoader → JVM 默认的类加载器

参数解释

第一个是类名

第二个参数决定了类是否被初始化

初始化指的是:

  • 静态变量赋值
  • 静态代码块执行

eg

1
2
3
4
5
class Test {
static {
System.out.println("Test类初始化了!");
}
}
1
2
Class.forName("Test", true, loader);  // 输出: Test类初始化了!
Class.forName("Test", false, loader); // 没输出,类没有初始化

其实是加载类后要不要初始化类而不是要不要加载类

第三个参数是一个加载器

告诉 JVM 去哪里找这个类

Java 默认的 ClassLoader 会去 classpath(Java类路径)找

高级用法可以传自己写的 ClassLoader,比如从网络加载类、从 jar 文件动态加载等等

安全研究里,很多漏洞就是利用 自定义 ClassLoader + forName 动态加载一些隐藏类

1
2
3
4
5
6
7
8
9
10
11
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 {} 类初始化时执行
普通代码块 {} 每次创建对象时执行
构造函数 public TrainPrint() 创建对象时执行

首先调⽤用的是static {},其次是{},最后是构造函数。

如果我们是正常的调用一个类 那么

1
import java.lang.Runtime;

然后才能写:

1
Runtime r = Runtime.getRuntime();

反射不需要 import

只要你知道 类的完整名字:包名+类名 就可以去加载

为什么源码里会出现 $

很多 Java 类名里会看到 $

a$b大致意思就是b是在a里面class的

因此可以这样加载:

1
Class.forName("C1$C2");
$ 在安全研究里的意义

有些框架会过滤.

那么这时候就可以用$来进行绕过

所以像 Fastjson 在安全检查时会:

把 $ 替换成 .

避免绕过。

newInstance()函数
1
clazz.newInstance();

用来调用类的无参构造函数 等价于

1
new ClassName();

比如说

1
2
3
class Test {
public Test() {}
}

就可以

1
Class.forName("Test").newInstance();

有时候在写漏洞利用方法的时候,我们会发现使用 newInstance 总是不成功,这时候原因可能是:

  1. 你使用的类没有无参构造函数

1
2
3
class Test {
public Test(int a) {}
}

Test类定义了一个带参数的构造方法 public Test(int a) {},但没有显式定义无参构造方法 `Test()

当你定义了带参构造方法后,Java 编译器不会再自动生成默认的无参构造方法。而 newInstance() 方法在创建对象时,会强制调用类的无参构造方法。此时因为找不到这个方法,就会抛出异常,导致实例化失败

反射通过 newInstance() 创建对象时,严格要求类必须有 public 的无参构造方法;否则会因 “找不到可用的构造方法” 而失败

你使用的类构造函数是私有的

这个比较好理解

1
2
3
class Test {
private Test() {}
}
为什么 Runtime 不能 newInstance()

在java的源码里 runtimed的构造方法是private

所以说只有在这个类的内部才能使用这个方法

尝试反射调用时候没有办法进入类内部

当你用反射尝试创建实例时:

1
2
Class clazz = Class.forName("java.lang.Runtime");
clazz.newInstance();
  • newInstance() 会尝试调用类的无参构造方法,但 Runtime 的无参构造是 private 的。
  • 反射默认无法访问 private 构造,所以会抛出异常。

官方设计是这样获取的

1
Runtime.getRuntime()
1
2
Runtime r = Runtime.getRuntime();
r.exec("id");

如果用反射 那么就是

1
2
3
4
5
6
7
8
9
Class clazz = Class.forName("java.lang.Runtime");

Object runtime = clazz
.getMethod("getRuntime")
.invoke(null);

clazz
.getMethod("exec", String.class)
.invoke(runtime, "id");

简单来说就是

必须:

1
Runtime.getRuntime()

不能:

1
new Runtime()

然后来看这个

1
2
3
4
5
6
7
Class clazz = Class.forName("java.lang.Runtime");

clazz.getMethod("exec", String.class)
.invoke(
clazz.getMethod("getRuntime").invoke(clazz),
"calc.exe"
);

其实这个代码最后执行的就是

1
Runtime.getRuntime().exec("calc.exe");

对于getmethod

image-20260317203555455

漫谈里这段介绍比较容易懂

对于invoke()

作用就是去执行方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 获取 Runtime 类的 Class 对象(反射的“入口”)
Class clazz = Class.forName("java.lang.Runtime");

// 2. 获取 Runtime 类的 exec 方法(用于执行系统命令)
Method execMethod = clazz.getMethod("exec", String.class);

// 3. 获取 Runtime 类的 getRuntime 静态方法(获取单例实例的官方入口)
Method getRuntimeMethod = clazz.getMethod("getRuntime");

// 4. 调用 getRuntime 静态方法,拿到唯一的 Runtime 实例
Object runtime = getRuntimeMethod.invoke(clazz);

// 5. 调用 Runtime 实例的 exec 方法,执行 calc.exe(打开计算器)
execMethod.invoke(runtime, "calc.exe");
没有无参构造函数如何实例化类

newInstance() 只能调用无参构造方法

如果类 没有无参构造方法就会失败

那么我们就可以使用getconstructor来获取构造方法

1
2
3
Class clazz = Class.forName("java.lang.ProcessBuilder");

clazz.getConstructor(List.class)

这里表示获取:

1
ProcessBuilder(List<String>)

newInstance 调用构造方法

获取构造方法后:

1
Constructor.newInstance()

就等价于:

1
new ProcessBuilder(...)

代码:

1
2
clazz.getConstructor(List.class)
.newInstance(Arrays.asList("calc.exe"))

等价:

1
new ProcessBuilder(Arrays.asList("calc.exe"))

RMI

RMI是 Java 原生的远程方法调用机制,核心目标是:让运行在 JVM A 上的对象,像调用本地方法一样,调用运行在 JVM B 上的对象的方法。

RMI的通信分为三个阶段

image-20260320110851316

RMI 的所有数据传输(方法名、参数、返回值)都依赖 Java 序列化:

  • 客户端:把要调用的方法名、参数序列化成字节流,通过网络发给服务端。
  • 服务端:反序列化字节流,执行方法,再把结果序列化返回。

RMIServer.java(服务端)

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
47
48
49
50
51
package org.vulhub.RMI;

import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;

public class RMIServer {
// 第一步:定义远程接口(必须继承Remote,所有方法抛RemoteException)
// 底层逻辑:接口是客户端和服务端的“协议约定”,双方都知道能调用哪些方法
public interface IRemoteHelloWorld extends Remote {
String hello() throws RemoteException; // 远程方法必须抛RemoteException(网络异常)Remote 接口是标记接口(无方法),作用是告诉 JVM:这个接口的方法是 “远程方法”,需要走网络通信
}

// 第二步:实现远程接口(必须继承UnicastRemoteObject)
// 底层逻辑:UnicastRemoteObject会自动生成Stub/Skeleton,处理网络通信

public static class RemoteHelloWorld extends UnicastRemoteObject implements IRemoteHelloWorld {
// 构造方法必须抛RemoteException(父类构造要求)
protected RemoteHelloWorld() throws RemoteException {
super();
}

// 实现远程方法(核心业务逻辑,运行在服务端)
@Override
public String hello() throws RemoteException {
System.out.println("客户端调用了hello()方法,来源IP:" + java.net.InetAddress.getLocalHost());
return "Hello world from RMI Server";
}
}

// 第三步:启动服务端(创建注册中心、绑定对象)
private void start() throws Exception {
// 1. 创建RMI注册中心,监听1099端口(底层:启动一个Socket服务,监听1099)
LocateRegistry.createRegistry(1099);
System.out.println("RMI注册中心启动在1099端口");

// 2. 创建远程对象实例(真实处理方法调用的对象)
IRemoteHelloWorld helloService = new RemoteHelloWorld();

// 3. 绑定远程对象到注册中心(服务名:Hello,地址:本地1099)
// 底层逻辑:把“Hello”和对象的Stub引用存入注册中心,供客户端查询
Naming.rebind("rmi://127.0.0.1:1099/Hello", helloService);
System.out.println("RMI服务端启动完成,绑定服务名:Hello");
}

public static void main(String[] args) throws Exception {
new RMIServer().start();
}
}

UnicastRemoteObject 是 RMI 的核心类,作用:
自动生成 Stub(客户端代理)和 Skeleton(服务端代理);
处理 TCP 网络通信(默认随机端口);
实现对象的远程导出(让对象能被远程访问)

远程接口 IRemoteHelloWorld extends Remote 定义客户端能调用的远程方法,是「客户端和服务端的协议」 必须继承java.rmi.Remote,所有方法抛RemoteException(网络异常)
接口实现类 RemoteHelloWorld extends UnicastRemoteObject implements IRemoteHelloWorld 实现远程方法的真实业务逻辑,运行在服务端 必须继承UnicastRemoteObject(自动生成通信代理),构造方法抛RemoteException
主启动类 start()方法 +main() 启动 Registry、创建实现类实例、绑定对象到 Registry LocateRegistry.createRegistry(1099)启动注册中心,Naming.rebind()完成绑定

TrainMain.java(客户端)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.vulhub.Train;

import org.vulhub.RMI.RMIServer.IRemoteHelloWorld;
import java.rmi.Naming;

public class TrainMain {
public static void main(String[] args) throws Exception {
// 第一步:从注册中心查找服务(底层:向1099端口发送查询请求,获取Stub对象)
IRemoteHelloWorld helloStub = (IRemoteHelloWorld) Naming.lookup("rmi://127.0.0.1:1099/Hello");
//客户端向注册中心发送查询请求,获取远程对象的 Stub(代理),后续调用都是通过 Stub 完
// 第二步:调用远程方法(底层:实际调用Stub的hello(),Stub序列化方法名并发送给服务端)
String result = helloStub.hello();

// 第三步:打印结果(服务端返回的序列化数据被Stub反序列化)
System.out.println("从服务端获取的结果:" + result);
}
}

抓包的通信过程