Diving deep into Jetbrains TeamCity Part 1 - Analysing CVE-2024-23917 leading to Authentication Bypass
May 27, 2024 •
java
This article aims to explore the details of CVE-2024-23917 and explain the process of constructing an exploit leading to Authentication Bypass. This article is only intended for educational purposes for understanding how vulnerabilities occur in real world.
Introduction
JetBrains TeamCity is a continuous integration (CI) and continuous delivery (CD) tool developed by JetBrains. It is designed to automate the building, testing, and deployment processes in software development.
NOTE: This article is only intended for educational purposes understanding how vulnerabilities occur in real world.
Installing TeamCity
Download the 2023.11.2 release from the official Jetbrains TeamCity website and extract it. For the purpose of this article, I will be using the Intellij IDEA Community Edition to navigate through the codebase.
Decompiling the Jars
Exploring through the teamcity directories, we can see a lot of compiled Jars. In order to access the code, we can decompile these jars using one of the common decompilers. Since we are using Intellij IDEA, we can use the inbuilt decompiler to decompile the jars.
One of the problems here is that there are way too many jar files which needs to be decompiled. In order make the process easy, we have written a quick bash script which uses the default IntelliJ decompiler to extract all jar files into a central folder called fern
.
Save the file as sourcerer.sh
and run the script providing the extracted teamcity directory as the input. The script will repeatedly start extracting all jars inside.
NOTE: The script will take sometime complete since there are way too many jars. You can modify the PROCESSES
variable to make the script faster.
Once the extraction is complete, a new directory named fern
will be available inside the TeamCity project folder. From intellij, right click on this folder > Mark Directory as > Sources Root. This step is important without which we won’t be able to do dynamic analysis or search for classes etc…
Architecture
Exploring through the directory structure, especially within the webapps/ROOT
directory, we can see the web.xml
file inside the WEB-INF
directory. This file contains the Servlets, mappings and filters.
Servlet Filters
In general, Servlet mappings are used to define API endpoints to corresponding controllers. Let’s understand how the file and its mapping works:
File: teamcity/TeamCity/webapps/ROOT/WEB-INF/web.xml
The file starts creating servlets with a name, in the above example MaintenanceServlet
which is defined in the class jetbrains.buildServer.maintenance.StartupServlet
. Let’s look into it’s servlet-mapping:
Servlet mapping specifies the servlet name and the URL pattern. This means, if a request comes to the defined URL pattern, in this case, /mnt/*
, then the MaintenanceServlet
servlet will be invoked which further leading to the invocation of the controller jetbrains.buildServer.maintenance.StartupServlet
class.
Similarly we have filters defined in the web.xml
, which can be considered as middlewares that get to run before the requests gets into servlet class or the controllers. Let’s take an example to understand:
File: teamcity/TeamCity/webapps/ROOT/WEB-INF/web.xml
A filter named disableBrowserCacheFilter
is defined which is handled by the class jetbrains.buildServer.web.DisableBrowserCacheFilter
. Now let’s look at its filter-mapping:
Filter mappings specify which filters to be run for a given url pattern. Here, for any URL matching /update/*
, the disableBrowserCacheFilter
filter will be run which is defined in the class jetbrains.buildServer.web.DisableSessionIdFromUrlFilter
.
Now if we look at the servlet mapping for /update/*
, it points to buildServer
servlet.
So in-short, if any request comes to /update/*
endpoint, the filter will be triggered first and then the request is forwarded to Servlet (or controller class).
Similarly another thing we can see is error mapping:
So for a given request, if the controller returns the response code 401, then the /unauthorized.html
is rendered.
Spring Interceptors
Now filters is a functionality provided by native Java Servlets but what if, we want to achieve the same functionality but need more context before taking a decision? For example, we wanna setup a filter which checks for authentication but since the authentication part is handled by SprintBoot, the servlet filters won’t have context since it operates on a lower level as the following diagram depicts:
In order to achieve a similar functionality, Springboot has a concept of Interceptors which is a part of core Spring MVC framework which allows us to intercept and process HTTP requests and responses before they are handled by the controller or sent back to the client. It’s typically used for request transformations, logging (debug or performance logs) or security (AuthN or AuthZ).
Filters are similar to interceptors in functionality (can be used to interept requests and response) but it operates at the Servlet API level (and not specific to Spring framework since spring framework itself is built on top of Servlet API). This means that filters are executed before the request even reaches the Springboot. So, in general, we can use a combination of both (like what TeamCity does) but interceptors are commonly used when we need access to Spring application context. Example, while logging, caching can be done at Filter level, AuthN/AuthZ is generally done at Interceptor level (since it needs Spring context)
So how does all these work together ? The following diagram represents the flow (image source):
-
An incoming HTTP request is first recieved by the webserver (in this case tomcat) which then passes the request onto to Java Servlets.
-
Based on the route mapping, the servlet filters are run and then the request is forwarded to Springboot. The Dispatcher Servlet
is the default entry point for all the incoming requests into springboot.
-
Once the Dispatcher Servlet
recieves the requests, it consults with the URL HandlerMapping
which determines which controller does the request should be passed onto.
-
But before being processed by the controllers, the requests are passed through Spring Interceptors. A given request can be processed by a different number of Interceptors before it reaches to controllers.
Interceptors are classes that implement the handlerInterceptor
interface which has 3 methods:
preHandle()
: Returns a boolean value indicating whether the request should continue to the next interceptor/controller or not (ex: if AuthZ fails, return false and request won’t reach controller)
postHandle()
: Executed after the controller method is called but before sending the response to the client (modifying response header?).
afterCompletion()
: Executed after the response is sent to the client (any cleanup tasks).
There could be more than 1 interceptor which will be triggered for a given API endpoint, in which case, the flow works as per the diagram below (image source):
Now that the fundamentals for exploring the vulnerabilities are clear, let’s explore the security patch which got released.
Understanding the Patch (CVE-2024-23917)
From the official advisory, one of the ways to fix the vulnerability is to apply a security patch. Let’s download the patch and try to understand it better.
Decompiling and trying to read the source code of the jar, we can see that the patch is heavily obfuscated. Let’s try to deobfuscate the patch and then decompile (using cfr) it again to see if the code is more readable:
Using the deobfuscator detection technique, it recommends us to use com.javadeobfuscator.deobfuscator.transformers.zelix.StringEncryptionTransformer
. Let’s deobfuscate the jars and try to decompile it using cfr decompiler.
So we finally have 3 files. Let’s look at the 1.java
which we extracted from the original security-patch-plugin-1.0-SNAPSHOT.jar
file:
This is a much more readable code but still could be cleaned up a bit. After using ChatGPT (+some manual changes) to rewrite the code to make it even more readable, the code looks like the following:
The code reads the currentBuildNumber
and if the current build number is less than the maximum affected build number (147486
), then the installation is proceeded ahead, else skipped. For proceeding ahead, it triggers the code new SecurityInterceptor(eventDispatcher, serviceLocator).initializeSecurity(true);
which is coming from our extracted 0.java
file.
Now, let’s look at the 2.java
file first:
-
The code starts with accessing UrlMapping
class from within jetbrains.spring.web.UrlMapping
and then further trying to access it’s superClass. Searching through the codebase, we can see the superClass for UrlMapping
is TeamCitySimpleUrlHandlerMapping
.
-
The superClass for TeamCitySimpleUrlHandlerMapping
is AbstractHandlerMapping
. The code then further tries to fetch the adaptedInterceptors
field from the superClass which in our case is the TeamCitySimpleUrlHandlerMapping
.
-
Looking through the codebase, we won’t see a field named adaptedInterceptors
defined within TeamCitySimpleUrlHandlerMapping
but somehow the patch is trying to fetch it. Going through the findField
function, we can see that findField
attempts to find a field on the supplied class and if not found, it searches through it’s superclasses as well. So essentially we are fetching adaptedInterceptors
from UrlMapping
-> TeamCitySimpleUrlHandlerMapping
-> AbstractHandlerMapping
!
-
In Spring MVC, the collection of adaptedInterceptors
is generally accessed or modified through reflection, as shown in the above code snippet. This approach allows for dynamic changes to the request processing pipeline without modifying core configurations or restarting the server. This can be useful for applying security patches, introducing custom interceptors, or integrating third-party interceptors at runtime.
-
Finally they are adding a new Interceptor
into the list of adaptedInterceptors
, this means that most like this newly added Interceptor will ensure the authentication bypass vulnerability is prevented.
Now let’s finally look at the final file where all the logic for custom new interceptor is written:
-
We start by creating a class named SecurityInterceptor (custom name we gave during cleanup) inside which a lot of methods are defined. One of the most interesting methods is intercept
, which from the looks of it, handles all the main logic for fixing the vulnerability.
-
The function starts by checking if the property teamcity.CVE-2024-23917.patch.enabled
is set to true
. If it’s false, the function returns immediately.
-
In a servlet environment, requests can be “included” or “forwarded” which typically occurs when one servlet forwards or includes the request/response to/from another servlet as part of the request handling process.
The if (includeRequestUri != null || request.getAttribute("javax.servlet.forward.request_uri") != null)
block checks whether either the include or forward attribute is present. If either is present, it implies the request is either an included or a forwarded request, and the code returns true, meaning interceptor doesn’t need to run.
-
It then checks the path of the given request, if it starts with /app/agents/
or /update
, then the code returns immediately.
-
The patch has a set of whitelisted controllers on which this interceptor is not required to run. From the name of the controllers, for example, LoginController
or ForgotPasswordController
, we can assume that these controllers handle pre-authenticated endpoints which means no need to run the Interceptor.
-
Finally the intercept function calls checkAuthenticationRequirement
, let’s look at what this does. From the flow of the code, this looks to be the core function where the logic exists.
-
The function starts by reading myAuthorizationPaths
from AuthorizationInterceptorImpl
class which contains the list of paths.
-
Further down the code, using reflection, it reads the function isAuthenticationRequired
and explicitly invoking this function on the given paths.
This doesn’t make sense, why would the patch add a new interceptor which explicitly invokes an already inbuilt function used for checking if authentication is required or not ?
Authentication Bypass
A logical explanation could be that, in the newly added interceptor, there was a lot of “if else” conditions where authentication is explicitly bypassed. What if, in the original code, there exists a condition where the auth checks are explicity bypassed ?
Let’s explore the interceptors defined in detail to understand this. Grepping for the AuthorizationInterceptor
, we can see the full path of the file: jetbrains/buildServer/controllers/interceptors/AuthorizationInterceptorImpl.java
. Seems like all the interceptors are defined inside the folder jetbrains/buildServer/controllers/interceptors/
Exploring through AuthorizationInterceptorImpl.java
, even through the Interceptor has a lot of logic inside, it’s getting invoked somewhere else. Exploring further in the same directory, we can see another very interesting file named RequestInterceptors.java
where interceptors are explicity getting added and invoked. We can confirm this by setting a breakpoint at the preHandle
function:
Let’s explore this preHandle
function in detail:
-
The requestPreHandlingAllowed(request)
method checks if the pre-handling operations are permitted for this request. If not, the method returns true, allowing the request to proceed without additional processing.
-
Stack reqStack = this.requestIn(request)
: Retrieves a stack representing some state related to the request, likely tracking recursion.
-
If reqStack.size() >= 70
, it’s an indication that too many recursive operations (forwards or includes) have occurred, suggesting an infinite loop.
-
If recursion is detected, a warning is logged, and a ServletException
is thrown with a message about “Too much recurrent forward or include operations.”
-
If reqStack.size() == 1
, indicating this is the first request in a potential stack of recursive operations, the code iterates over myInterceptors.
-
For each HandlerInterceptor
in myInterceptors
, it calls preHandle on the current request, response, and handler. If any of these interceptors return false, indicating they do not allow the request to proceed, the method returns false.
Now it’s clear for us that RequestInterceptors.java
has a prehandle()
which internally invokes all other interceptors one by one. Let’s explore the requestPreHandlingAllowed(request)
function in detail since this method checks if the pre-handling operations are permitted for a given request. If there is a way in which can make the function requestPreHandlingAllowed(request)
return “true” then none of the interceptors are run, that includes the AuthorizationInterceptorImpl.java
.
File: TeamCity/fern/jetbrains/buildServer/controllers/interceptors/RequestInterceptors.java
So requestPreHandlingAllowed
internally calls WebUtil.isJspPrecompilationRequest
, which from the look of it, checks if the request is a precompiled JSP. Let’s explore the function in detail:
File: TeamCity/fern/jetbrains/buildServer/web/util/WebUtil.java
The method reads request path through getRequestURI()
to check if it ends with “.jsp” or “.jspf” and also checks if the request contains a GET parameter named jsp_precompile
, if so, the function returns true, essentially leading to the original if condition returning true, skipping the entire else part where interceptors are invoked.
Tomcat supports the use of ;
which is considered a parameter of the path itself. Ex: https://example.com/rest/abc;a=b?param=123. Here ;a=b
is considered a parameter for the path itself ! We can abuse the functionality here since the WebUtil.isJspPrecompilationRequest
reads the requestURI directly and runs endsWith()
to determine if it’s a pre-compiled request.
Exploit
So we can try and hit an endpoint which requires authentication but we can pass ;.jsp
at the end along with adding a GET parameter jsp_precompile=1
should bypass the authentication and give us the results.
Anirudh Anand
Product Security ♥ | CTF - @teambi0s | Security Trainer - @7asecurity | certs - eWDP, OSCP, OSWE