Aggressor script to automatically download and load an arsenal of open source and private tooling. Hopefully, this saves other teams time and helps the community!
A red team often spends a lot of time and effort on collecting, developing, building and updating toolsuites. Furthermore, it is challenging to distribute everything securely and ensure everyone in the team has access to the same toolkits. In this article, we will discuss how you can create one Cobalt Strike aggressor script, which pulls in the latest releases of your tooling. This warrants that every red team operator has the same toolkit at the beginning of every red team operation.
Please note that although our approach is implemented with an internal Gitlab, the examples in this article were changed to use public Github repositories instead of our own private instance to make the proof of concept easy to reproduce. Keep in mind that pulling in public tooling without review is considered bad practice and should be avoided.
That said, let's begin our journey!
Sleep
Cobalt Strike agressor scripts are written in Sleep. This obscure Perl-like Java scripting language allows you to customise many components of Cobalt Strike, resulting in increased stealth or additional functionality/quality of life improvements. The Cobalt Strike Community Kit is a great example of what can be accomplished.
The first challenge was to download the latest build.zip releases from Gitlab via Aggressor script in the Cobalt Strike client. Luckily, @mariuszbit already came up with the following awesome implementation, making use of the interoperability between Sleep scripting and Java. The script effectively enables the Cobalt Strike client to send HTTP requests, making it perfect to interact with services like GitHub (or private Gitlab).
Let's create a script that downloads the zip of Trustedsec's popular Remote Ops BOF and Situational Awareness BOF repositories. Both projects are awesome examples of helpful open source tooling to extend Cobalt Strike, without increasing detection. The following script is based on the work of our intern, Lucas Cornette, and mgeeky's httprequest implementation.
import java.io.BufferedInputStream;import java.io.FileOutputStream;import java.io.FileInputStream;import java.io.InputStream;import java.io.File;import java.net.URL;import java.net.URLConnection;import java.io.IOException;# ===== Global Variables =====$saveFilePath = script_resource() . "/downloads"; # Location to store downloaded zips$destPath = script_resource(); # Unzipped repositories destination folder is relative to this scripts location# ============================# ==== Git Repositories ====# Name, repository ID, branch, [CNA script paths to load]# Name => repository will be downloaded into this folder, the name has to match the path that is used to load the bofs! (see the modified cna script)# URL to download zip from => GitLab repository ID# Paths => relative path from root directory to the cna script to load@repositories = @( @("CS-Situational-Awareness-BOF", "https://github.com/trustedsec/CS-Situational-Awareness-BOF/archive/refs/heads/master.zip", "CS-Situational-Awareness-BOF-master/SA/SA.cna"), @("CS-Remote-OPs-BOF", "https://github.com/trustedsec/CS-Remote-OPs-BOF/archive/refs/heads/main.zip", "CS-Remote-OPs-BOF-main/Remote/Remote.cna", "CS-Remote-OPs-BOF-main/Injection/Injection.cna"),# add other entries here as well );# ============================# ==== Functions ====# Download all repository zip files one by onesubloadArsenal {for($i = 0; $i < size(@repositories); $i++) { downloadRepo(@repositories[$i][1],@repositories[$i][0]); }}# createDir($path, $type);subcreateDir { $path = $1; $type = $2;mkdir($path)if (checkError($error)) {warn("Unable to create $type directory: $error"); $eMsg = "true"; }}# createFile($path);subcreateFile { $path = $1; createNewFile($path);}# writeFile($handle, $path);subwriteFile { $handle = $1; $path = $2; $outFile = openf(">" . $path); writeb($outFile, readb($handle, -1)); closef($outFile);}# Download a single repository and write the contents to downloads/repositoryname.zipsubdownloadRepo { $downloadUrl = $1; $fileName = $2 . ".zip";# Create downloads dir if it does not existif (!-exists $saveFilePath) { createDir($saveFilePath); } println("> Downloading " . $fileName . "..."); println("\tDownload url: $downloadUrl");# Check if the zip file can be created $saveFilePathProper = $saveFilePath . "/" . $fileName; createFile($saveFilePathProper);# Obtain the content and write to file $handle = httpGet($downloadUrl); writeFile($handle, $saveFilePathProper);}# httpGet($url);# Adapted from https://gist.github.com/mgeeky/2d7f8c2a6ffbfd23301e1e2de0312087subhttpGet { $method = "GET"; $url = $1; $n = 0;if(size(@_) == 2) { $n = $2; } $maxRedirectsAllowed = 10;if ($n > $maxRedirectsAllowed) {warn("Exceeded maximum number of redirects: $method $url ");return""; } $USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/111.0"; try {# Build the HTTP Request $urlobj = [new java.net.URL: $url];if (checkError($error)) {warn("1: $error"); } $con = $null; $con = [$urlobj openConnection]; [$con setRequestMethod: $method]; [$con setInstanceFollowRedirects: true]; [$con setRequestProperty: "Accept", "*/*"]; [$con setRequestProperty: "Cache-Control", "max-age=0"]; [$con setRequestProperty: "Connection", "keep-alive"]; [$con setRequestProperty: "User-Agent", $USER_AGENT];# Send the request and obtain the content $inputstream = [$con getInputStream]; $handle = [SleepUtils getIOHandle: $inputstream, $null]; $responseCode = [$con getResponseCode];# If a redirect is returned, try again with the redirect target (max 10 times)if (($responseCode >= 301) && ($responseCode <= 304)) {warn("Redirect"); $loc = [$con getHeaderField: "Location"]; httpRequest($loc, $n + 1); }# return the handle with the contentreturn $handle; } catch $message {warn("HTTP Request failed: $method $url : $message "); printAll(getStackTrace());return""; }}# Create Cobalt Strike menu to download Arsenalpopup Arsenal { item("&Download", { loadArsenal() });}menubar("Arsenal", "Arsenal");
The script can now be loaded via the Cobalt Strike > Script Manager.
After loading the script, a new menu appears in the UI.
Clicking the "Download" button should initiate the download of the Remote Ops BOF and Situational Awareness BOF collections. However, it seems that Java does not trust GitHub and the download fails. Ouch!
Challenge 1: Root CA Trust in Sleep
There are several ways to get around this:
Ignore SSL/TLS certificate warnings and force the connection. Have you ever implemented this? This would break trust and would be just as complex to implement in Sleep as any other solution.
Find the Java truststore that is used by Cobalt Strike and add GitHub's (or your private GitLab's) root CA to it. This would require you to essentially patch the client jar on each release, which would be very impractical.
Dynamically load GitHub's root CA from a base-64 encoded string inline in the cna script, using the interoperability with Java. I gave this a shot at first, but could not figure out how to convert the certificate string to a proper Java byte array in Sleep (dm me on Twitter if you know the answer!).
Dynamically load a custom trustore and apply it to the SSLContext of the HTTP request implementation. This was a process of trial and error to get it to work in Sleep, but ultimately the most viable solution. It only requires you to distribute the Arsenal cna script and truststore together in the same directory. This also greatly simplifies the process if you ever have to include another root CA or you need to replace/remove trusted certificates.
Truststore Creation
Adding GitHub's root CA to the truststore is trivial. First, inspect what the name of the CA is by browsing to github.com in e.g. Chrome and inspect the certificate. It appears that DigiCert Global Root is the CA for this website.
If you are on a mac, you should already have this CA somewhere in your keychain. Simply search for the root CA and export it in the .cer file format. You can simply select the row and do File > Export Items..
Finally, create the truststore and import the CA.cer file.
The next step is to load the trustore dynamically. This again was a process of trial and error, but I came up with the following approach. The idea was to use the interoperability with Java to load the newly created trustore and apply it to the SSLContext of the HTTP request.
import javax.net.ssl.HttpsURLConnection;import javax.net.ssl.SSLContext;import javax.net.ssl.TrustManagerFactory;import javax.net.ssl.TrustManager;import javax.net.ssl.X509TrustManager;import java.security.cert.X509Certificate;import java.security.KeyStore;...# httpGet($url);# Adapted from https://gist.github.com/mgeeky/2d7f8c2a6ffbfd23301e1e2de0312087subhttpGet { $method = "GET"; $url = $1; $n = 0;if(size(@_) == 2) { $n = $2; } $maxRedirectsAllowed = 10;if ($n > $maxRedirectsAllowed) {warn("Exceeded maximum number of redirects: $method $url ");return""; } $USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/111.0"; try {# When using private git server, load the truststore with the custom root CA or self-signed CA from the current directory $filepath = script_resource("truststore-public.jks");# The trust store file and optional password to unlock it $trustStoreFile = [new File: $filepath]; $trustStorePassword = "changeit";# Load the trust store from file into an object, the type is "jks" $trustStore = [KeyStore getInstance: "JKS"]; [$trustStore load: [new FileInputStream: $trustStoreFile], $trustStorePassword];# init TrustManagerFactory with truststore containing root CA $tmf = [TrustManagerFactory getInstance: [TrustManagerFactory getDefaultAlgorithm]]; [$tmf init: $trustStore];# Set the SSLContext of the HTTP requests to ensure the root CA certificate is trusted $sslContext = [SSLContext getInstance: "TLS"]; [$sslContext init: $null, [$tmf getTrustManagers], $null]; [javax.net.ssl.HttpsURLConnection setDefaultSSLSocketFactory: [$sslContext getSocketFactory]];...
This resulted in version two of the script, capable of downloading zips from GitHub.
import java.io.BufferedInputStream;import java.io.FileOutputStream;import java.io.FileInputStream;import java.io.InputStream;import java.io.File;import java.net.URL;import java.net.URLConnection;import java.io.IOException;import javax.net.ssl.HttpsURLConnection;import javax.net.ssl.SSLContext;import javax.net.ssl.TrustManagerFactory;import javax.net.ssl.TrustManager;import javax.net.ssl.X509TrustManager;import java.security.cert.X509Certificate;import java.security.KeyStore;# ===== Global Variables =====$saveFilePath = script_resource() . "/downloads"; # Location to store downloaded zips$destPath = script_resource(); # Unzipped repositories destination folder is relative to this scripts location# ============================# ==== Git Repositories ====# Name, repository ID, branch, [CNA script paths to load]# Name => repository will be downloaded into this folder, the name has to match the path that is used to load the bofs! (see the modified cna script)# URL to download zip from => GitLab repository ID# Paths => relative path from root directory to the cna script to load@repositories = @( @("CS-Situational-Awareness-BOF", "https://github.com/trustedsec/CS-Situational-Awareness-BOF/archive/refs/heads/master.zip", "CS-Situational-Awareness-BOF-master/SA/SA.cna"), @("CS-Remote-OPs-BOF", "https://github.com/trustedsec/CS-Remote-OPs-BOF/archive/refs/heads/main.zip", "CS-Remote-OPs-BOF-main/Remote/Remote.cna", "CS-Remote-OPs-BOF-main/Injection/Injection.cna"),# add other entries here as well );# ============================# ==== Functions ====# Download all repository zip files one by onesubloadArsenal {for($i = 0; $i < size(@repositories); $i++) { downloadRepo(@repositories[$i][1],@repositories[$i][0]); }}# createDir($path, $type);subcreateDir { $path = $1; $type = $2;mkdir($path)if (checkError($error)) {warn("Unable to create $type directory: $error"); $eMsg = "true"; }}# createFile($path);subcreateFile { $path = $1; createNewFile($path);}# writeFile($handle, $path);subwriteFile { $handle = $1; $path = $2; $outFile = openf(">" . $path); writeb($outFile, readb($handle, -1)); closef($outFile);}# Download a single repository and write the contents to downloads/repositoryname.zipsubdownloadRepo { $downloadUrl = $1; $fileName = $2 . ".zip";# Create downloads dir if it does not existif (!-exists $saveFilePath) { createDir($saveFilePath); } println("> Downloading " . $fileName . "..."); println("\tDownload url: $downloadUrl");# Check if the zip file can be created $saveFilePathProper = $saveFilePath . "/" . $fileName; createFile($saveFilePathProper);# Obtain the content and write to file $handle = httpGet($downloadUrl); writeFile($handle, $saveFilePathProper);}# httpGet($url);# Adapted from https://gist.github.com/mgeeky/2d7f8c2a6ffbfd23301e1e2de0312087subhttpGet { $method = "GET"; $url = $1; $n = 0;if(size(@_) == 2) { $n = $2; } $maxRedirectsAllowed = 10;if ($n > $maxRedirectsAllowed) {warn("Exceeded maximum number of redirects: $method $url ");return""; } $USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/111.0"; try {# When using private git server, load the truststore with the custom root CA or self-signed CA from the current directory $filepath = script_resource("truststore-public.jks");# The trust store file and optional password to unlock it $trustStoreFile = [new File: $filepath]; $trustStorePassword = "changeit";# Load the trust store from file into an object, the type is "jks" $trustStore = [KeyStore getInstance: "JKS"]; [$trustStore load: [new FileInputStream: $trustStoreFile], $trustStorePassword];# init TrustManagerFactory with truststore containing root CA $tmf = [TrustManagerFactory getInstance: [TrustManagerFactory getDefaultAlgorithm]]; [$tmf init: $trustStore];# Set the SSLContext of the HTTP requests to ensure the root CA certificate is trusted $sslContext = [SSLContext getInstance: "TLS"]; [$sslContext init: $null, [$tmf getTrustManagers], $null]; [javax.net.ssl.HttpsURLConnection setDefaultSSLSocketFactory: [$sslContext getSocketFactory]];# Build the HTTP Request $urlobj = [new java.net.URL: $url];if (checkError($error)) {warn("1: $error"); } $con = $null; $con = [$urlobj openConnection]; [$con setRequestMethod: $method]; [$con setInstanceFollowRedirects: true]; [$con setRequestProperty: "Accept", "*/*"]; [$con setRequestProperty: "Cache-Control", "max-age=0"]; [$con setRequestProperty: "Connection", "keep-alive"]; [$con setRequestProperty: "User-Agent", $USER_AGENT]; # Send the request and obtain the content $inputstream = [$con getInputStream]; $handle = [SleepUtils getIOHandle: $inputstream, $null]; $responseCode = [$con getResponseCode];# If a redirect is returned, try again with the redirect target (max 10 times)if (($responseCode >= 301) && ($responseCode <= 304)) {warn("Redirect"); $loc = [$con getHeaderField: "Location"]; httpRequest($loc, $n + 1); }# return the handle with the contentreturn $handle; } catch $message {warn("HTTP Request failed: $method $url : $message "); printAll(getStackTrace());return""; }}# Create Cobalt Strike menu to download Arsenalpopup Arsenal { item("&Download", { loadArsenal() });}menubar("Arsenal", "Arsenal");
After loading this version of the script and clicking the Arsenal > Download menu item, the Script Console confirms that the zips were downloaded.
The downloads folder was created and now contains the zip files, as expected.
Challenge 2: Unzip Repositories
The next step would be to unzip these repositories, so we can load the cna scripts and start using the BOFs. For this proof of concept, we use the prebuilt BOFs included in both repositories. This is a bad practice in production and you should ALWAYS BUILD YOUR OWN FROM A TRUSTED SOURCE. Lucas came up with the following approach to use Sleep and Java to unzip the files.
import java.util.zip.ZipEntry;import java.util.zip.ZipInputStream;...# Unzips the downloaded zipfile and puts the folder in the main project directory# unzip($name, $folderName);subunzip { $zipFileName = $1 . ".zip"; $folderName = $2; $zipFile = $saveFilePath . "/" . $zipFileName; $destPathProper = $destPath . "/" . $folderName; $currDir = cwd();# load and unzip zipfile $fis = [new java.io.FileInputStream: $zipFile]; $zis = [new java.util.zip.ZipInputStream: $fis];if ([$fis available] == 0) { println("Archive is empty. Aborting."); [$zis closeEntry]; [$fis close];exit(); }#Create and go to the destination directory createDir($destPathProper, "zip destination");chdir($destPathProper);# Write all zip contents to the directory $entry = [$zis getNextEntry];while ($entry) {# Check if $entry is an empty directoryif ([$entry isDirectory]) { createDir(getFileProper($entry), "empty"); # Create empty directory }# Else $entry is a fileelse { $fullPath = getFileProper($destPathProper, $entry); createDir(getFileProper(getFileParent($entry)), "parent"); # Create the parent directory createFile($fullPath); # Create the file $zipHandle = [SleepUtils getIOHandle: $zis, $null]; writeFile($zipHandle, $fullPath); } $entry = [$zis getNextEntry]; } [$zis closeEntry]; [$fis close];# Change directory back to initialchdir($currDir); println("> Files unzipped");}
This brings us to version three of the script.
import java.util.zip.ZipEntry;import java.util.zip.ZipInputStream;import java.io.BufferedInputStream;import java.io.FileOutputStream;import java.io.FileInputStream;import java.io.InputStream;import java.io.File;import java.net.URL;import java.net.URLConnection;import java.io.IOException;import javax.net.ssl.HttpsURLConnection;import javax.net.ssl.SSLContext;import javax.net.ssl.TrustManagerFactory;import javax.net.ssl.TrustManager;import javax.net.ssl.X509TrustManager;import java.security.cert.X509Certificate;import java.security.KeyStore;# ===== Global Variables =====$saveFilePath = script_resource() . "/downloads"; # Location to store downloaded zips$destPath = script_resource(); # Unzipped repositories destination folder is relative to this scripts location# ============================# ==== Git Repositories ====# Name, repository ID, branch, [CNA script paths to load]# Name => repository will be downloaded into this folder, the name has to match the path that is used to load the bofs! (see the modified cna script)# URL to download zip from => GitLab repository ID# Paths => relative path from root directory to the cna script to load@repositories = @( @("CS-Situational-Awareness-BOF", "https://github.com/trustedsec/CS-Situational-Awareness-BOF/archive/refs/heads/master.zip", "CS-Situational-Awareness-BOF-master/SA/SA.cna"), @("CS-Remote-OPs-BOF", "https://github.com/trustedsec/CS-Remote-OPs-BOF/archive/refs/heads/main.zip", "CS-Remote-OPs-BOF-main/Remote/Remote.cna", "CS-Remote-OPs-BOF-main/Injection/Injection.cna"),# add other entries here as well );# ============================# ==== Functions ====# Download all repository zip files one by onesubloadArsenal {for($i = 0; $i < size(@repositories); $i++) { downloadRepo(@repositories[$i][1],@repositories[$i][0]); unzip(@repositories[$i][0], @repositories[$i][0]); }}# createDir($path, $type);subcreateDir { $path = $1; $type = $2;mkdir($path)if (checkError($error)) {warn("Unable to create $type directory: $error"); $eMsg = "true"; }}# createFile($path);subcreateFile { $path = $1; createNewFile($path);if (checkError($error)) {warn("Cannot create file $path: $error"); $eMsg = "true"; }}# writeFile($handle, $path);subwriteFile { $handle = $1; $path = $2; $outFile = openf(">" . $path); writeb($outFile, readb($handle, -1)); closef($outFile);if (checkError($error)) {warn("Error while writing to file: $error"); $eMsg = "true"; }}# Unzips the downloaded zipfile and puts the folder in the main project directory# unzip($name, $folderName);subunzip { $zipFileName = $1 . ".zip"; $folderName = $2; $zipFile = $saveFilePath . "/" . $zipFileName; $destPathProper = $destPath . "/" . $folderName; $currDir = cwd();# load and unzip zipfile $fis = [new java.io.FileInputStream: $zipFile]; $zis = [new java.util.zip.ZipInputStream: $fis];if ([$fis available] == 0) { println("Archive is empty. Aborting."); [$zis closeEntry]; [$fis close];exit(); }#Create and go to the destination directory createDir($destPathProper, "zip destination");chdir($destPathProper);# Write all zip contents to the directory $entry = [$zis getNextEntry];while ($entry) {# Check if $entry is an empty directoryif ([$entry isDirectory]) { createDir(getFileProper($entry), "empty"); # Create empty directory }# Else $entry is a fileelse { $fullPath = getFileProper($destPathProper, $entry); createDir(getFileProper(getFileParent($entry)), "parent"); # Create the parent directory createFile($fullPath); # Create the file $zipHandle = [SleepUtils getIOHandle: $zis, $null]; writeFile($zipHandle, $fullPath); } $entry = [$zis getNextEntry]; } [$zis closeEntry]; [$fis close];# Change directory back to initialchdir($currDir); println("> Files unzipped");}# Download a single repository and write the contents to downloads/repositoryname.zipsubdownloadRepo { $downloadUrl = $1; $fileName = $2 . ".zip";# Create downloads dir if it does not existif (!-exists $saveFilePath) { createDir($saveFilePath); } println("> Downloading " . $fileName . "..."); println("\tDownload url: $downloadUrl");# Check if the zip file can be created $saveFilePathProper = $saveFilePath . "/" . $fileName; createFile($saveFilePathProper);# Obtain the content and write to file $handle = httpGet($downloadUrl); writeFile($handle, $saveFilePathProper);}# httpGet($url);# Adapted from https://gist.github.com/mgeeky/2d7f8c2a6ffbfd23301e1e2de0312087subhttpGet { $method = "GET"; $url = $1; $n = 0;if(size(@_) == 2) { $n = $2; } $maxRedirectsAllowed = 10;if ($n > $maxRedirectsAllowed) {warn("Exceeded maximum number of redirects: $method $url ");return""; } $USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/111.0"; try {# When using private git server, load the truststore with the custom root CA or self-signed CA from the current directory $filepath = script_resource("truststore-public.jks");# The trust store file and optional password to unlock it $trustStoreFile = [new File: $filepath]; $trustStorePassword = "changeit";# Load the trust store from file into an object, the type is "jks" $trustStore = [KeyStore getInstance: "JKS"]; [$trustStore load: [new FileInputStream: $trustStoreFile], $trustStorePassword];# init TrustManagerFactory with truststore containing root CA $tmf = [TrustManagerFactory getInstance: [TrustManagerFactory getDefaultAlgorithm]]; [$tmf init: $trustStore];# Set the SSLContext of the HTTP requests to ensure the root CA certificate is trusted $sslContext = [SSLContext getInstance: "TLS"]; [$sslContext init: $null, [$tmf getTrustManagers], $null]; [javax.net.ssl.HttpsURLConnection setDefaultSSLSocketFactory: [$sslContext getSocketFactory]];# Build the HTTP Request $urlobj = [new java.net.URL: $url];if (checkError($error)) {warn("1: $error"); } $con = $null; $con = [$urlobj openConnection]; [$con setRequestMethod: $method]; [$con setInstanceFollowRedirects: true]; [$con setRequestProperty: "Accept", "*/*"]; [$con setRequestProperty: "Cache-Control", "max-age=0"]; [$con setRequestProperty: "Connection", "keep-alive"]; [$con setRequestProperty: "User-Agent", $USER_AGENT]; # Send the request and obtain the content $inputstream = [$con getInputStream]; $handle = [SleepUtils getIOHandle: $inputstream, $null]; $responseCode = [$con getResponseCode];# If a redirect is returned, try again with the redirect target (max 10 times)if (($responseCode >= 301) && ($responseCode <= 304)) {warn("Redirect"); $loc = [$con getHeaderField: "Location"]; httpRequest($loc, $n + 1); }# return the handle with the contentreturn $handle; } catch $message {warn("HTTP Request failed: $method $url : $message "); printAll(getStackTrace());return""; }}# Create Cobalt Strike menu to download Arsenalpopup Arsenal { item("&Download", { loadArsenal() });}menubar("Arsenal", "Arsenal");
After loading and executing the new script, the script console indicates that files are successfully downloaded and unzipped.
When checking our project folder, it seems to confirm this. Success!
Challenge 3: Nested Aggressor Script Load
One of the major challenges we faced in our Cobalt Strike OffSecOps approach was that when loading our "master" script in the UI, included scripts do not show up and cannot be individually unloaded via the default Cobalt Strike functionality.
Another issue was that if script A in the main directory wants to include trustedsec SA.cna in the SA subdirectory, SA.cna would be loaded as if it was loaded from the main directory. This is because SA.cna also uses script_resource() to determine the relative path to the BOF object file, breaking the link. Consider the following example where a script importer.cna loads importee.cna from a subdirectory.
importer.cna
# default Sleep way to load nested script$cnaScriptPath = script_resource("subdirectory/importee.cna");include($cnaScriptPath);
The importer.cna script can be loaded in Cobalt Strike.
In the Script Console, it displays that the importee.cna script was successfully loaded from the subdirectory. Additionally, it displays the output of script_resource(), indicating that the importee.cna script uses importer.cna's working directory.
This means that BOFs and assets loaded by the importee, use the script_resource() value of the importer. Therefore, without modifications, it would break most existing scripts that load other resources. This is something we would like to avoid during our approach, so let's come up with a better approach.
Decompiling Cobalt Strike
Luckily, Sleep can interact with Java and the Cobalt Strike client itself is written in Java. We will decompile the client to find an alternative, more native way to load agressor scripts, without relying on the default Sleep "include" function. Time to put those OSWE skills to work and analyse the Cobalt Strike client's source code!
First, let's extract the contents of the Cobalt Strike application. On MacOS, this can be done by right-clicking the application > Show Package Contents.
Next, the files of Contents/Java can be copied to a new folder.
We are mainly interested in the cobaltstrike-client.jar, as this is the client-side application. Let's decompile it with JD-GUI. Download the JD-GUI jar from this website and run it.
java-jarjd-gui-1.6.6.jar
Drag the cobaltstrike-client.jar file into the window. You now successfully decompiled the application. Congratulations.
Find The Loader
Now it's a matter of finding the correct class. We can try looking for keywords to find the functionality. For example, when pressing the "Reload" button in the script manager, the UI displays a popup with "Reloaded /path/to/script.cna" in the message.
if we search for this string in JD-GUi, we only get one result, which is related to the ScriptManager class. Awesome!
After navigating to the aggressor > windows > ScriptManager.class file, we can observe that before displaying the Dialog, the code simply unloads the selected script and calls the this.client.getScriptEngine().loadScript(str) method to reload it. We already know that str is the path to the aggressor script from the popup output.
Interesting. The next thing on the list is to find out what this.client is. It seems to be an AggressorClient object if we double-click it in JD-GUI.
Implement Loader In Sleep
Somewhere in the Cobalt Strike documentation the getAggressorClient() function is already described as a way to obtain an AggressorClient object. Could this be easier than first thought?
$client = getAggressorClient();
Let's adapt the importer.cna script to try to load importee.cna via this method.
# Cobalt Strike-native way of loading a script$cnaScriptPath = script_resource("subdirectory/importee.cna");# https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics_aggressor-scripts/as-resources_functions.htm#getAggressorClient$client = getAggressorClient();[[$client getScriptEngine] loadScript: $cnaScriptPath];
After reloading importer.cna, we achieve partial success. The path of the imported script is now the subdirectory hosting the importee script, which means that we already fixed the context problem!
However, sometimes it would be great if we could also unload individual scripts that were loaded in this way. This is currently not supported, as can be observed in the script manager UI. The importee.cna script is not visible. Let's #TryHarder.
Locate Script Settings
So how does Cobalt Strike save which scripts are loaded? The answer lies within a code piece of the Script Manager right before the discovered loadScript functionality.
It seems that it keeps track of the loaded cortana.scripts linked list via the Prefs object. When a new script is loaded (add()) or removed (remove()) via the UI, the cortana.scripts preference is updated and saved. But were is it stored? We can find this in the Prefs class itself.
The .aggressor.prop file in the user's home directory is the location where Cobalt Strike-related settings are saved.
In the configuration file, we can indeed find the cortana.scripts entry with only the loaded importer.cna script. This also means that the script load of importee.cna will not persist when the Cobalt Strike client is restarted.
cat~/.aggressor.prop
Save (Scripts) Yourself
Of course, we could try to parse the config file in sleep and update the entry manually to include the importee.cna aggressor script. However, we can also use Cobalt Strike's native way of doing this via the Sleep-Java interoperability. The following implementation achieves this.
import aggressor.Prefs;import java.util.LinkedList;# Iterate the config list of loaded scripts# If a script is already present, it should not be loaded for a second timesubcheckUnique{ $unique = 1;for ($i = 0; $i < [$1 size]; $i++) {if([$1 get: $i] eq $2) { $unique = 0; } }return $unique;}# betterLoadScript($pathtoscript);subbetterLoadScript{# Load prefs $scriptpath = $1; # Full path to script as arg, e.g. $list = [[Prefs getPreferences] getList: "cortana.scripts"]; $result = checkUnique($list, $scriptpath);# Only add if it was not yet in the list# Avoids loading scripts twiceif($result == 1) { [$list add: $scriptpath]; [[Prefs getPreferences] setList: "cortana.scripts", $list]; [[Prefs getPreferences] save];# Actually Loading the script# https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics_aggressor-scripts/as-resources_functions.htm#getAggressorClient $client = getAggressorClient(); [[$client getScriptEngine] loadScript: $scriptpath];# No need to call refresh, since it will be called once the main script's execution finished# and all subscripts are loaded before this }}$cnaScriptPath = script_resource("subdirectory/importee.cna");betterLoadScript($cnaScriptPath);
Updating importer.cna with this code and reloading the script automagically loads import.cna from the appropriate directory. The script was updated to also include a built-in routine to avoid loading the same script twice and writing it to the configuration.
Bringing Everything Together
Now it is simply a matter of including our betterLoadScript functionality in the OffSecOps Arsenal script to automatically load our scripts. We also added openScriptManager(); to conveniently open a new Script Manager console to show the operator that all script are loaded and visible in the UI.
import java.util.zip.ZipEntry;import java.util.zip.ZipInputStream;import java.io.BufferedInputStream;import java.io.FileOutputStream;import java.io.FileInputStream;import java.io.InputStream;import java.io.File;import java.net.URL;import java.net.URLConnection;import java.io.IOException;import javax.net.ssl.HttpsURLConnection;import javax.net.ssl.SSLContext;import javax.net.ssl.TrustManagerFactory;import javax.net.ssl.TrustManager;import javax.net.ssl.X509TrustManager;import java.security.cert.X509Certificate;import java.security.KeyStore;import aggressor.Prefs;import java.util.LinkedList;# ===== Global Variables =====$saveFilePath = script_resource() . "/downloads"; # Location to store downloaded zips$destPath = script_resource(); # Unzipped repositories destination folder is relative to this scripts location# ============================# ==== Git Repositories ====# Name, repository ID, branch, [CNA script paths to load]# Name => repository will be downloaded into this folder, the name has to match the path that is used to load the bofs! (see the modified cna script)# URL to download zip from => GitLab repository ID# Paths => relative path from root directory to the cna script to load@repositories = @( @("CS-Situational-Awareness-BOF", "https://github.com/trustedsec/CS-Situational-Awareness-BOF/archive/refs/heads/master.zip", "CS-Situational-Awareness-BOF-master/SA/SA.cna"), @("CS-Remote-OPs-BOF", "https://github.com/trustedsec/CS-Remote-OPs-BOF/archive/refs/heads/main.zip", "CS-Remote-OPs-BOF-main/Remote/Remote.cna", "CS-Remote-OPs-BOF-main/Injection/Injection.cna"),# add other entries here as well );# ============================# ==== Functions ====# Download all repository zip files one by onesubloadArsenal {for($i = 0; $i < size(@repositories); $i++) { $name = @repositories[$i][0]; $url = @repositories[$i][1]; $size = size(@repositories[$i]); downloadRepo($url, $name); unzip($name, $name); # need to find out if and why this errorsif ($size <= 2) { println("No scripts to load"); }else {for ($y = 2; $y < $size; $y++) { $cnaScriptPath = script_resource($name . "/" . @repositories[$i][$y]); println($cnaScriptPath); betterLoadScript($cnaScriptPath); } println("> Scripts loaded."); } println("> Completed " . $name); }# Open the script manager again since we don't know how to call refresh() from this script# Shows all scripts are loaded openScriptManager();}# createFile($path);subcreateFile { $path = $1; createNewFile($path);if (checkError($error)) {warn("Cannot create file $path: $error"); $eMsg = "true"; }}# writeFile($handle, $path);subwriteFile { $handle = $1; $path = $2; $outFile = openf(">" . $path); writeb($outFile, readb($handle, -1)); closef($outFile);if (checkError($error)) {warn("Error while writing to file: $error"); $eMsg = "true"; }}# createDir($path, $type);subcreateDir { $path = $1; $type = $2;mkdir($path)if (checkError($error)) {warn("Unable to create $type directory: $error"); $eMsg = "true"; }}# Unzips the downloaded zipfile and puts the folder in the main project directory# unzip($name, $folderName);subunzip { $zipFileName = $1 . ".zip"; $folderName = $2; $zipFile = $saveFilePath . "/" . $zipFileName; $destPathProper = $destPath . "/" . $folderName; $currDir = cwd();# load and unzip zipfile $fis = [new java.io.FileInputStream: $zipFile]; $zis = [new java.util.zip.ZipInputStream: $fis];if ([$fis available] == 0) { println("Archive is empty. Aborting."); [$zis closeEntry]; [$fis close];exit(); }#Create and go to the destination directory createDir($destPathProper, "zip destination");chdir($destPathProper);# Write all zip contents to the directory $entry = [$zis getNextEntry];while ($entry) {# Check if $entry is an empty directoryif ([$entry isDirectory]) { createDir(getFileProper($entry), "empty"); # Create empty directory }# Else $entry is a fileelse { $fullPath = getFileProper($destPathProper, $entry); createDir(getFileProper(getFileParent($entry)), "parent"); # Create the parent directory createFile($fullPath); # Create the file $zipHandle = [SleepUtils getIOHandle: $zis, $null]; writeFile($zipHandle, $fullPath); } $entry = [$zis getNextEntry]; } [$zis closeEntry]; [$fis close];# Change directory back to initialchdir($currDir); println("> Files unzipped");}# Download a single repository and write the contents to downloads/repositoryname.zipsubdownloadRepo { $downloadUrl = $1; $fileName = $2 . ".zip";# Create downloads dir if it does not existif (!-exists $saveFilePath) { createDir($saveFilePath); } println("> Downloading " . $fileName . "..."); println("\tDownload url: $downloadUrl");# Check if the zip file can be created $saveFilePathProper = $saveFilePath . "/" . $fileName; createFile($saveFilePathProper);# Obtain the content and write to file $handle = httpGet($downloadUrl); writeFile($handle, $saveFilePathProper);}# httpGet($url);# Adapted from https://gist.github.com/mgeeky/2d7f8c2a6ffbfd23301e1e2de0312087subhttpGet { $method = "GET"; $url = $1; $n = 0;if(size(@_) == 2) { $n = $2; } $maxRedirectsAllowed = 10;if ($n > $maxRedirectsAllowed) {warn("Exceeded maximum number of redirects: $method $url ");return""; } $USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/111.0"; try {# When using private git server, load the truststore with the custom root CA or self-signed CA from the current directory $filepath = script_resource("truststore-public.jks");# The trust store file and optional password to unlock it $trustStoreFile = [new File: $filepath]; $trustStorePassword = "changeit";# Load the trust store from file into an object, the type is "jks" $trustStore = [KeyStore getInstance: "JKS"]; [$trustStore load: [new FileInputStream: $trustStoreFile], $trustStorePassword];# init TrustManagerFactory with truststore containing root CA $tmf = [TrustManagerFactory getInstance: [TrustManagerFactory getDefaultAlgorithm]]; [$tmf init: $trustStore];# Set the SSLContext of the HTTP requests to ensure the root CA certificate is trusted $sslContext = [SSLContext getInstance: "TLS"]; [$sslContext init: $null, [$tmf getTrustManagers], $null]; [javax.net.ssl.HttpsURLConnection setDefaultSSLSocketFactory: [$sslContext getSocketFactory]];# Build the HTTP Request $urlobj = [new java.net.URL: $url];if (checkError($error)) {warn("1: $error"); } $con = $null; $con = [$urlobj openConnection]; [$con setRequestMethod: $method]; [$con setInstanceFollowRedirects: true]; [$con setRequestProperty: "Accept", "*/*"]; [$con setRequestProperty: "Cache-Control", "max-age=0"]; [$con setRequestProperty: "Connection", "keep-alive"]; [$con setRequestProperty: "User-Agent", $USER_AGENT]; # Send the request and obtain the content $inputstream = [$con getInputStream]; $handle = [SleepUtils getIOHandle: $inputstream, $null]; $responseCode = [$con getResponseCode];# If a redirect is returned, try again with the redirect target (max 10 times)if (($responseCode >= 301) && ($responseCode <= 304)) {warn("Redirect"); $loc = [$con getHeaderField: "Location"]; httpRequest($loc, $n + 1); }# return the handle with the contentreturn $handle; } catch $message {warn("HTTP Request failed: $method $url : $message "); printAll(getStackTrace());return""; }}# Iterate the config list of loaded scripts# If a script is already present, it should not be loaded for a second timesubcheckUnique{ $unique = 1;for ($z = 0; $z < [$1 size]; $z++) {if([$1 get: $z] eq $2) { $unique = 0; } }return $unique;}# betterLoadScript($pathtoscript);subbetterLoadScript{# Load prefs $scriptpath = $1; # Full path to script as arg, e.g. $list = [[Prefs getPreferences] getList: "cortana.scripts"]; $result = checkUnique($list, $scriptpath);# Only add if it was not yet in the list# Avoids loading scripts twiceif($result == 1) {# Save the script to the cortana.scripts list [$list add: $scriptpath]; [[Prefs getPreferences] setList: "cortana.scripts", $list]; [[Prefs getPreferences] save];# Actually Loading the script# https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics_aggressor-scripts/as-resources_functions.htm#getAggressorClient $client = getAggressorClient(); [[$client getScriptEngine] loadScript: $scriptpath]; println("\tLoaded $scriptpath"); }}# Create Cobalt Strike menu to download Arsenalpopup Arsenal { item("&Download", { loadArsenal() });}menubar("Arsenal", "Arsenal");
Again, we follow the procedure to download and load the repositories via the Download button of the "Arsenal" menu in the UI.
The console prints output that indicates successful download, unzip and cna script load.
Finally, a new Script Manager window opens, showing the scripts were successfully loaded!
Conclusion
In this article, we showen how to build a Cobalt Strike aggressor script to effectively load other scripts from a remote source. The approach integrates well with DevOps and can ensure that all red team operators in the team have a secure way of downloading and keeping tools in sync with the latest builds.
Potential improvements
The approach can be tailored to your red team needs. The following are some ideas for further improvement, some of which are implemented in DXC Strikeforce's internal version:
Private Gitlab integration with custom pipelines, root CA and access control;
Automatically or on-demand trigger new Arsenal builds with custom obfuscation from inside Cobalt Strike;
Download latest tool releases based on tags instead of just the repositories;