A while ago I was analyzing a Cobalt Strike sample that used Malleable C2 traffic and as no public documentation seems to exist on how it is actually implemented, I was curious to find out how the whole thing worked.
If you're familiar with Cobalt Strike, you'll have heard of its Malleable C2 feature. It allows you to customize how a Cobalt Strike server delivers commands to its beacons, allowing you to disguise the traffic in a customizable way.
Malleable C2 in practice
Customizing the server response is done by writing a small config script describing how to transform actual traffic into its final form. Available transformations are appending and prepending strings, base64 encodings, simple xors and a few others.
That of course means a beacon running on a target computer also needs to know how to extract the actual traffic from the received data, effectively performing the server script in reverse.
So how does a beacon do that?
The unknown config item
Turns out by running a small virtual machine! The Malleable C2 script is not hardcompiled into beacons, nor is it stored as plaintext. Instead, it is turned into a small instruction stream which is delivered inside the beacon config.
$ hd item_0b 00000000 00 00 00 04 00 00 00 01 00 00 05 f2 00 00 00 02 |................| 00000010 00 00 00 54 00 00 00 02 00 00 0f 5b 00 00 00 0d |...T.......[....| 00000020 00 00 00 0f 00 00 00 00 00 00 00 00 00 00 00 00 |................| 00000030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
When a beacon calls home, it takes the server response and feeds it into a small virtual machine with the instruction stream as its program. The result is plain Cobalt Strike command traffic for further parsing.
For config parsing I've been using the excellent Sentinel One Cobalt Strike Beacon Config Parser which actually has an uncommented line for the instruction stream item:
# Unknown data, silencing for now #self.settings['binary.http-get.server.output'] = packedSetting(11, ...
This item 11 is the one containing the instruction stream responsible for handling Malleable C2 traffic in a beacon, so mystery solved!
Under the hood Cobalt Strike receives the response data into a buffer, loads the instruction item into a stream structure and parses the instruction stream one opcode at a time.
The instruction stream consists of opcodes, which may have an argument, all stored as
DWORDs (big endian).
Each opcode performs some operation on the response data buffer and eventually the buffer is plain Cobalt Strike traffic which can then be parsed for commands.
Possible opcodes are, in accordance with the Cobalt Strike Malleable C2 docs:
- Opcode 1 - Remove
nbytes at the end (
nas argument) - opposite to the
- Opcode 2 - Remove
nbytes at the beginning (
nas argument) - opposite to the
- Opcode 3 - Base64 Decode
- Opcode 8 - NetBIOS Decode 'a'
- Opcode 11 - NetBIOS Decode 'A'
- Opcode 13 - Base64 URL-safe Decode
- Opcode 15 - XOR mask w/ random key
As an example I've looked for samples with more interesting configs. I've found a great writeup of a sample hiding its traffic in a jQuery file which I'll use here. The sample is rather old but everything relevant is still the same, so it'll do.
I manually extracted the interesting config item 11 and wrote a small script to parse it.
Running the script on the extracted item gives us:
$ ./parse_instructions.py item_0b Remove 1522 chars at the end Remove 84 chars from the beginning Remove 3931 chars from the beginning Base64 URL-safe decode XOR mask w/ random key
A command request from this sample looks like this:
so the server was configured to make it look like it's just delivering a minified jQuery script.
Manually performing steps 1-3 leaves us just:
After decoding we only have 4 bytes left (with proper base64 padding):
$ echo -n "jXwaYg==" | base64 -d | hd 00000000 8d 7c 1a 62 |.|.b| 00000004
And if we check back with the instructions above, the next step would be XOR with a random key, which in practice is 4 random bytes - which is all we are left with.
That means the response from the server was an empty command in this case.
So that's how Cobalt Strike's Malleable C2 feature works under the hood.