Spring ApplicationContext层级导致Axelix高危JWT绕过漏洞
引言
大家好!
我叫 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,看看这一切是如何凑成一段令人不快的往事的。
让我们开始吧。
一切的起点
让我们从症状说起,因为在我看来它非常能说明问题。配置完全普通:
于是业务 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 的人几乎一定接触过上下文层次结构,即使他们从未专门想过这件事。经典的配置是这样的:
根上下文由 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 中有明确说明。让我们用代码来看一下,这样更清楚:
注意 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)。
示意图如下:
所以 management.server.port 改变的东西远远、远远不止一个 socket 端口号!第二个 ApplicationContext 出现了,第二个内嵌服务器出现了,它有自己的处理器映射和自己的 Servlet 过滤器链。顺便说一句,Spring Boot 文档坦率地警告了这一点:位于不同端口上的管理上下文不会使用与主应用相同的 Web 基础设施(Spring Boot Actuator 2.7 参考文档)。但我们不读文档,我们“凭感觉”写代码,众所周知。
所以本质上,从 Web 层的角度来看,你现在拥有两个不同的应用。
一个库如何进入管理上下文
一个合理的问题:如果一个库(例如像我们这样的 starter)需要专门把某些东西放进管理上下文,该怎么办?Spring Boot 为此提供了一个专门的注解,@ManagementContextConfiguration。它有一个 ManagementContextType 参数:
SAME表示当 Actuator 生活在主上下文中时应用该配置;CHILD表示该配置应用于单独的子管理上下文;ANY表示该配置在两种情况下都应该被应用。
不过光有注解还不够。该类还必须注册在一个特殊的文件中:
这与常规自动配置通过 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 生活在主端口上时,一切正常:
请求经过过滤器,没有 token,我们得到 401。
但当端口不同时,情况是这样的:
管理子上下文能完美地从父上下文看到它需要的所有 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(修复):
当然,这个类也被列在每个 starter 的 ManagementContextConfiguration.imports 中。
看看我们上面讨论的一切在这里是如何运作的。WebIdentityAccessManager 和 SecurityContextExecutor 仍然位于父上下文中,子上下文通过查找即可毫无问题地获取它们。但 FilterRegistrationBean 现在是在管理上下文中本地创建的,因此是管理服务器将过滤器纳入自己的过滤器链。这种层级结构完全按预期被使用:共享服务位于顶层,特定服务器的 Web 基础设施位于底层。
如果把我们所恢复的行为总结成一条规则,那就是:暴露 Axelix 端点的服务器本身必须在其过滤器链中包含 JWT 过滤器。
当然,还有测试。重要的是,它专门检查拓扑结构,而不是检查 Bean “存在于容器中的某个地方”:
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 已经完全解决了该问题。
如何查看你自己应用中的层级结构
如果你一直以为自己只有一个上下文,那么第一个有用的步骤非常简单:别再猜了,直接看一看。
还记得事件会从子上下文向父上下文传播吗?正因如此,这个声明在主上下文中的监听器也会看到管理子上下文的刷新。也正因如此,这里重要的是从事件本身获取上下文,而不是从注入的 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 次浏览