Ohhnews

分类导航

$ cd ..
foojay原文

Spring ApplicationContext层级导致Axelix高危JWT绕过漏洞

#spring boot#applicationcontext#jwt#安全漏洞#axelix

引言

大家好!

我叫 Mikhail Polivakha,是 Axelix 项目的技术负责人。如果你之前没听说过我们,Axelix 是一个用于管理和诊断 Spring Boot 应用程序的平台。项目的核心是开源的,因此源代码、变更历史,以及事实证明还有我们犯下的错误,都向所有人公开 😁。

最近,一位外部安全研究员 Andrei Kotov(greatkaa) 在 Axelix 中发现了一个漏洞。简言之:在某种特定的 Spring Boot 配置下,我们的 Actuator 端点可以在没有 JWT 的情况下被调用。我们已经在 1.2.1 版本中修复了它,并在安全公告中做了说明。Andrei,借此机会向你说一声大大的感谢。

我先把话说明白:这篇文章讲的不是这个漏洞可以如何被利用。说实话,那并不是最有趣的部分。更有意思的是它为什么会出现。我们并没有忘记添加过滤器,没有搞错 Token 签名校验,也没有把 if 里的条件写反。过滤器是在的,而且运行得完美无缺。只是它并不在我们以为的位置。

问题的根源在于 Spring Framework 中一个已经存在了大约二十年的机制,但许多现代 Spring Boot 开发者从来不会去考虑它。我说的就是 ApplicationContext 层次结构。

所以计划是这样的:首先,我们弄清楚什么是上下文层次结构,以及在层次结构中谁能“看见”谁。然后,我们看看 Spring Boot 自身会在哪里创建一个层次结构。只有到那之后,我们才会回到 Axelix,看看这一切是如何凑成一段令人不快的往事的。

让我们开始吧。

一切的起点

让我们从症状说起,因为在我看来它非常能说明问题。配置完全普通:

server:
  port: 8080

management:
  server:
    port: 8081

于是业务 API 运行在 8080 上,而 Actuator 被移到了单独的端口 8081。很多人都会这么做,这是一种好实践:管理端口很容易在网络层面被隔离开来,阻止外部的访问。

Axelix 通过 Actuator 暴露自己的端点(它们被称为 axelix-*),并且所有端点都由一个 JWT 过滤器保护。当 Actuator 运行在主端口上时,一个不带 token 的请求会得到预期的 401 Unauthorized。但在 8081 上,同样的请求却突然通过了。

朋友们,注意一个重要的细节:应用程序正常启动了。没有 NoSuchBeanDefinitionException,日志里没有警告,所有端点都在,依赖也都注入了。一切看起来都很好。这类 bug 恰恰是最令人不快的。一切似乎都正常工作。

为什么仅仅改变一个端口号,行为就会变化这么大?要回答这个问题,我们需要退后一步。

一个大上下文。或者并非如此?

ApplicationContext 是 Spring 的核心容器。它持有 bean 定义,创建 bean,解析依赖,让它们通过后处理器,发布事件,并管理生命周期。

如果你编写 Spring Boot 代码,你脑海中很可能有大致这样的模型:

我的应用有一个大的 Spring 上下文,所有 bean 都生活在里面。

这没问题,因为绝大多数情况下确实如此。SpringApplication.run(...) 会创建一个 ServletWebServerApplicationContext,它自己启动内嵌 Tomcat,自己发现 Servlet 和过滤器,并自己注册它们(ServletWebServerApplicationContext Javadoc)。一个上下文已经足够了。

但问题在于:Spring 中的上下文并不一定是唯一的。ApplicationContext 有一个 getParent() 方法,这种关联通过 ConfigurableApplicationContext#setParent 来设置。换句话说,上下文可以形成一棵树。

一点历史:Root 与 DispatcherServlet

在 Spring Boot 之前用过 Spring MVC 的人几乎一定接触过上下文层次结构,即使他们从未专门想过这件事。经典的配置是这样的:

root WebApplicationContext
  services
  repositories
  shared infrastructure
          |
          v
DispatcherServlet WebApplicationContext
  controllers
  handler mappings
  web-specific beans

根上下文由 web.xml 中的 ContextLoaderListener 创建,每个 DispatcherServlet 都会启动**它自己的(!)**子 WebApplicationContext。服务和仓储位于顶层并被共享,而某个特定 Servlet 的控制器以及所有 MVC 基础设施则位于底层。而且,一个根上下文可以拥有多个相互独立的子上下文(Spring MVC 文档)。

后来 Spring Boot 出现了,一切都合并成了一个单一上下文,大多数人便愉快地忘记了层次结构。但这一机制并未消失。今天第二个上下文仍然可以出现:

  • 通过对 SpringApplicationBuilder 显式调用 parent(...)、child(...) 或 sibling(...);
  • 通过使用 @ContextHierarchy 的测试;
  • 通过 Spring Cloud,它会启动自己的子上下文,例如为每个 Feign 客户端启动一个(惊不惊喜,朋友们!);
  • 以及对我们来说最重要的一点:当使用单独的管理端口时,由 Spring Boot Actuator 创建。

我们稍后会回到最后一点。现在我们只需理解主要的东西:层次结构中的上下文相互之间如何“看见”对方。

谁能看见谁

这里的规则相当简单,但是不对称的:

  • 子上下文能看到父上下文的 bean;
  • 父上下文**看不到(!)**子上下文的 bean;
  • 兄弟上下文,即同一父上下文下的子上下文,彼此一无所知;
  • 如果子上下文声明了一个与父上下文同名的 bean,则本地(子上下文)的定义会覆盖父上下文的定义。

这一点在 ApplicationContext Javadoc 中有明确说明。让我们用代码来看一下,这样更清楚:

@Configuration
class ParentConfig {

    @Bean
    SharedService sharedService() {
        return new SharedService();
    }
}

@Configuration
class ChildConfig {

    @Bean
    ChildOnlyService childOnlyService(SharedService sharedService) {
        return new ChildOnlyService(sharedService);
    }
}
var parent = new AnnotationConfigApplicationContext(ParentConfig.class);

var child = new AnnotationConfigApplicationContext();
child.setParent(parent);
child.register(ChildConfig.class);
child.refresh();

child.getBean(SharedService.class);          // OK,在父上下文中找到
child.containsBean("sharedService");         // true
child.containsLocalBean("sharedService");    // false,本地不存在

parent.getBean(ChildOnlyService.class);      // NoSuchBeanDefinitionException

注意 containsBean 和 containsLocalBean 之间的区别。前者会把父上下文考虑在内,后者只查看当前层级。这种区分稍后会非常有用。

所以,粗略地说,子上下文知道如果在自己家里找不到东西,该去哪里楼上找。但父上下文甚至可能不知道某个子上下文的存在。“父”这个词给人一种愉快的感觉,觉得长者认识所有的晚辈。而在 Spring 中,恰好相反。

看见一个 bean 并不意味着拥有它

现在是这篇文章最重要的观点。让我强调一下:

子上下文能够从父上下文获取一个 bean,并不意味着子上下文会把该 bean 作为自身基础设施的一部分来“使用”。

层次结构中的每个上下文仍然是一个完整独立的容器。它有自己的 bean 定义、自己的后处理器、自己的生命周期、自己的事件多播器。层次结构只给你一样东西:向上查找依赖。其余所有事情每个上下文都自己完成,而且只用自己的 bean。

以下几个例子展示了这一点的体现:

  • 在父上下文中声明的 BeanPostProcessor 不会处理子上下文中创建的 bean;
  • getBeansOfType() 只列出本地的 bean。要遍历祖先,需要使用 BeanFactoryUtils.beansOfTypeIncludingAncestors(...);
  • 子上下文中发布的事件会向上传播到父上下文。但父上下文的事件不会自行向下流向子上下文;
  • 父上下文没有通用的 API 来遍历它的所有子上下文。

关于事件还有一个有趣的细节。如果 @EventListener 声明在父上下文中,它也会收到子上下文的 ContextRefreshedEvent。因此,如果你在这个事件上做“应用启动时”的事情,那么在存在子上下文的情况下,你的代码可能会执行两次。这就是为什么检查事件实际来自哪个上下文是很有用的。

最后,是我们讨论这一切所围绕的那个例子。内嵌 Web 服务器(比如 Tomcat)是由特定的 ServletWebServerApplicationContext 启动的,并且在启动时,该上下文会把定义在该上下文自身中的 Servlet、Filter 和 ServletContextInitializer 注册到服务器中。如果你的 FilterRegistrationBean 位于父上下文中,而服务器是由子上下文启动的,那么该过滤器就不会进入那个服务器。子上下文当然“看得见”它,但这并不影响它为服务器构建什么样的过滤器链。

请记住这个观点,我们很快会回到它。

单独的 Actuator 端口就是一个单独的上下文

那么,当你设置 management.server.port 时会发生什么?

Spring Boot 区分两种情况。如果没有设置 management.server.port,或者它等于 server.port,Actuator 就简单地生活在主上下文中。但如果端口不同,Boot 就会创建一个单独的子管理上下文,其父上下文就是主上下文(ManagementContextAutoConfiguration)。

示意图如下:

application                          parent
  server.port=8080                     |
  web server :8080                     |
  controllers, services                |
                                       v
application:management               child
  management.server.port=8081
  web server :8081
  Actuator handler mappings
  its own servlet filter chain

所以 management.server.port 改变的东西远远、远远不止一个 socket 端口号!第二个 ApplicationContext 出现了,第二个内嵌服务器出现了,它有自己的处理器映射和自己的 Servlet 过滤器链。顺便说一句,Spring Boot 文档坦率地警告了这一点:位于不同端口上的管理上下文不会使用与主应用相同的 Web 基础设施(Spring Boot Actuator 2.7 参考文档)。但我们不读文档,我们“凭感觉”写代码,众所周知。

所以本质上,从 Web 层的角度来看,你现在拥有两个不同的应用。

一个库如何进入管理上下文

一个合理的问题:如果一个库(例如像我们这样的 starter)需要专门把某些东西放进管理上下文,该怎么办?Spring Boot 为此提供了一个专门的注解,@ManagementContextConfiguration。它有一个 ManagementContextType 参数:

  • SAME 表示当 Actuator 生活在主上下文中时应用该配置;
  • CHILD 表示该配置应用于单独的子管理上下文;
  • ANY 表示该配置在两种情况下都应该被应用。

不过光有注解还不够。该类还必须注册在一个特殊的文件中:

META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports

这与常规自动配置通过 AutoConfiguration.imports 注册的方式非常相似,不同之处在于 Boot 会在创建管理上下文时读取这个文件。(Spring Boot 2.7 出于怀旧的考虑,仍然也支持 spring.factories,所以如果你从旧文档中复制示例,请检查版本。Boot 2.7 中的选择器,Boot 3.5 中的选择器。)

Axelix 中发生了什么

现在,我想你已经能猜到发生了什么了。

先简单介绍一下 Axelix 是如何工作的。我们的 starter 被添加到应用中(axelix-spring-boot-2-starter、-3-starter 或 -4-starter,取决于 Boot 版本)。该 starter 暴露了一组 Actuator 端点,Axelix 通过这些端点收集关于应用的信息:bean、配置、logger、缓存等等。所有这些端点都由 JWT 保护,Token 校验由我们的 JwtAuthorizationFilter 处理。

在 1.2.1 版本之前,这个过滤器注册在常规自动配置中,也就是说,通过一个 FilterRegistrationBean 注册在主上下文中(Axelix 1.2.0 代码)。

当 Actuator 生活在主端口上时,一切正常:

single context
  web server :8080
  Actuator mappings
  JwtAuthorizationFilter

请求经过过滤器,没有 token,我们得到 401。

但当端口不同时,情况是这样的:

main context
  web server :8080
  JwtAuthorizationFilter       <- 过滤器在这里
          |
          v
management child
  web server :8081
  Actuator mappings            <- 但端点在那边
  no JwtAuthorizationFilter

管理子上下文能完美地从父上下文看到它需要的所有 bean,所以应用启动时没有出现任何错误。端点正常工作。但是 8081 上的服务器构建的是它自己的过滤器链,我们的过滤器并不在其中。这正是我们在“看见一个 bean 并不意味着拥有它”一节中所讨论的内容。

用一句话概括根因:我们把 JWT 过滤器注册在了父上下文中,而单独端口上的 Actuator 却是由子上下文中的另一个服务器来服务的。

最滑稽的部分是,JWT 校验本身运行得完美无缺。请求根本就没有到达它。

还有一个症状很好地说明了当时发生的事情。8081 上的一些端点返回的不是 200,而是 500,并带有消息 Security Context is expected to be present at this point。这些端点期望安全上下文已经被填充,而正是那个缺失的过滤器负责填充它。即便携带有效的 token 也是一样的:根本没有人来处理这个 token。

老实说,回过头来看,这个 bug 显得显而易见。但在写那段代码的时候,我们和许多人一样,脑子里装的就是那个“一个大上下文”的模型。而我们的测试只打到了主端口,所以什么也没抓到。幸好有像 Andrei 这样从不同角度审视代码的人。## 我们如何修复

解决方案不言自明:过滤器必须注册在启动带有 Actuator 的服务器的地方。在 1.2.1 中,三个 starter 都获得了一个单独的 JwtAuthManagementContextConfiguration(修复):

$ java
@ManagementContextConfiguration(
        value = ManagementContextType.CHILD,
        proxyBeanMethods = false
)
@ConditionalOnBean(WebIdentityAccessManager.class)
public class JwtAuthManagementContextConfiguration {

    @Bean
    public FilterRegistrationBean<JwtAuthorizationFilter>
            jwtAuthorizationFilterRegistration(
                    WebIdentityAccessManager webIdentityAccessManager,
                    SecurityContextExecutor securityContextExecutor,
                    WebEndpointProperties webEndpointProperties) {

        var filter = new JwtAuthorizationFilter(
                webIdentityAccessManager,
                securityContextExecutor,
                webEndpointProperties.getBasePath()
        );
        var registration = new FilterRegistrationBean<>(filter);
        registration.setName("jwtAuthorizationFilter");
        return registration;
    }
}

当然,这个类也被列在每个 starter 的 ManagementContextConfiguration.imports 中。

看看我们上面讨论的一切在这里是如何运作的。WebIdentityAccessManager 和 SecurityContextExecutor 仍然位于父上下文中,子上下文通过查找即可毫无问题地获取它们。但 FilterRegistrationBean 现在是在管理上下文中本地创建的,因此是管理服务器将过滤器纳入自己的过滤器链。这种层级结构完全按预期被使用:共享服务位于顶层,特定服务器的 Web 基础设施位于底层。

如果把我们所恢复的行为总结成一条规则,那就是:暴露 Axelix 端点的服务器本身必须在其过滤器链中包含 JWT 过滤器。

当然,还有测试。重要的是,它专门检查拓扑结构,而不是检查 Bean “存在于容器中的某个地方”:

$ java
@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = "management.server.port=0"
)
class JwtAuthorizationFilterSeparateManagementPortTest {

    @LocalManagementPort
    private int managementPort;

    @Test
    void shouldReturnUnauthorized_TokenIsMissing() {
        var response = restTemplate.getForEntity(
                "http://localhost:" + managementPort
                        + "/actuator/axelix-env",
                String.class
        );

        assertThat(response.getStatusCode())
                .isEqualTo(HttpStatus.UNAUTHORIZED);
    }
}

management.server.port=0 会让 Boot 在随机端口上启动管理上下文,而 @LocalManagementPort 会把该端口提供给我们。同一个类中的第二个测试会发送一个有效令牌,并期望返回 200 OK。Boot 2、3 和 4 都有相同的测试(1.2.1 中的测试)。

关于后续处理的一点题外话

发布之后,Andrei 又向我们指出了一件事。第一个补丁使过滤器同时注册在主上下文和子上下文中。安全性并未因此受损,但在使用独立端口时,主服务器上的过滤器基本上是多余的:Actuator 已经不在那里了。

在 PR #1818 中,我们从常规自动配置中移除了该注册,并将 ManagementContextType.CHILD 改为 ANY。现在,由单个管理配置在两种情况下负责该过滤器:无论端口是共享的还是独立的。截至撰写本文时,这项改动已经进入 main 分支,但尚未进入任何发布版本。这是一次架构清理,而不是额外的安全修复,因此 1.2.1 已经完全解决了该问题。

如何查看你自己应用中的层级结构

如果你一直以为自己只有一个上下文,那么第一个有用的步骤非常简单:别再猜了,直接看一看。

$ java
@Component
final class ContextTopologyLogger {

    @EventListener
    void onRefreshed(ContextRefreshedEvent event) {
        ApplicationContext context = event.getApplicationContext();
        ApplicationContext parent = context.getParent();

        System.out.printf(
                "context=%s class=%s parent=%s beans=%d%n",
                context.getId(),
                context.getClass().getName(),
                parent == null ? "<root>" : parent.getId(),
                context.getBeanDefinitionCount()
        );
    }
}

还记得事件会从子上下文向父上下文传播吗?正因如此,这个声明在主上下文中的监听器也会看到管理子上下文的刷新。也正因如此,这里重要的是从事件本身获取上下文,而不是从注入的 ApplicationContext 获取。使用 management.server.port 运行应用,你会在日志中看到两行,而不是一行。

还有几个有用的工具:

  • context.getParent() 返回直接父上下文;
  • containsLocalBean(name) 告诉你该 Bean 是否定义在当前层级;
  • containsBean(name) 也会考虑父上下文;
  • BeanFactoryUtils.beanNamesForTypeIncludingAncestors(...) 会显式搜索所有祖先上下文。

标准 Actuator 端点 /actuator/beans 也会返回一个 contexts 对象,其中上下文可以带有 parentId(Actuator beans API)。但有一个细节:该端点描述的是它所在的上下文及其祖先,并不要求去查找自己的后代。因此结果取决于你访问的是哪个端口。

对于那些编写自己的 starter,或者只是把某些东西挂到 Actuator 上的人,有一条建议:如果你的代码需要与独立的管理端口一起工作,那么就要专门在独立管理端口上测试它。只访问 @LocalServerPort 的测试根本不会检查管理服务器。这是我们自己从经验中学到的。

总结

让我们归纳一下要点:

  • Spring 中的 ApplicationContext 不必是唯一的。上下文会形成一棵树,而 Spring Boot 自身创建这种树的频率比看起来要高。
  • 层级中的可见性是自下而上的:子上下文能看到父上下文,父上下文看不到子上下文。
  • 能看到某个 Bean,并不意味着它属于另一个上下文的基础设施。后处理器、事件、过滤器链和 Web 服务器都是各个上下文自身所有的。
  • management.server.port 与 server.port 不同,意味着存在一个单独的子上下文和一个单独的 Web 服务器。请把它当作第二个 Web 应用来对待。
  • 如果某个库需要把某些东西放入管理上下文,可以使用 @ManagementContextConfiguration。

对我们来说,这个故事很好地提醒了我们:如果你依赖简化的心智模型,即使是非常熟悉的框架也可能让你措手不及。而一个简化的模型可以多年奏效,直到它不再奏效为止。

再次感谢 Andrei 发现这个问题,并仔细审查了补丁。如果你对 Axelix 的内部运作方式感到好奇,欢迎来到 GitHub,我们是开源的。

祝大家好运!## 加入讨论 Java [LOADING...]
[LOADING...]Mikhail Polivakha2026年10月27日 803 次浏览

新的 Java 特性不只是面试题:来自 Axelix 的两个真实世界用例

Java密封类 Jakarta EE [LOADING...]
[LOADING...]Luqman Saeed2026年3月26日 3,169 次浏览

面向 MicroProfile 开发者的 Spring Boot Actuator 健康检查

Jakarta EEJava微服务 Java [LOADING...]
[LOADING...]Steve Poole2026年4月19日 4,031 次浏览

Spring Boot 3.5 EOL——无人谈论的 CVE 盲区

Java安全Spring Databases [LOADING...]
[LOADING...]Matteo Rossi2026年3月5日 3,657 次浏览

Java 应用程序中的基于角色的访问控制

数据库JavaMongo Java [LOADING...]
[LOADING...]Jérôme LELEU2026年10月23日 198 次浏览

如何使用 OpenID Connect 和 pac4j 保护 Spring Boot 应用程序

JavaSpring安全