Recently we had a case where threat actors deployed CobaltStrike, which has become a common pattern over the years. CobaltStrike is a tool designed for red teaming exercises and provides a foothold into a target environment as well as extensive capabilities for staging further payloads. Unfortunately it is abused for malicious purposes just as often.
While doing forensic analysis of compromised systems, our Incident Response team is interested in how exactly CobaltStrike is configured. Having the configuration can give context to why certain operations were carried out, such as domains being contacted or processes being launched as a code injection target. There are more than a handful of public tools that extract the config, however we had a special situation in our particular case: We didn’t have the original payload used to launch the beacon. Often, threat actors are careless and drop it to disk or there are PowerShell logs that contain an encoded version of the beacon – none of that was the case here. The customer has an EDR solution that gave us insight into suspicious activities carried out by processes, which allowed us to figure out which process CobaltStrike was injected into. One of the first things we tell a customer during an incident is not to shutdown/reboot any machines, and luckily that advice was followed in this case. We were able to obtain RAM dumps of the compromised systems, and we could then use Volatility to generate dumps for those processes that were apparently running malicious code.
When working with such a memory dump, two problems present themselves:
- How to find the beacon code in memory?
- Once found, how to extract its config?
A YARA scan for known CobaltStrike signatures has come up empty. However, Volatility contains a module called “malfind” which looks for memory pages that are both executable and writable. You’ll typically only find that when malware is involved, or possibly if code is generated dynamically, e.g., by a Just-In-Time compiler. As it happens, our process of interest had exactly one match for that condition. It pointed to an area of memory spanning around 400 KB of high entropy data. Towards the end of the area there was a repeating sequence that suspiciously looked like XOR masking had been applied to a bunch of zeroes. Indeed, we were able to unmask the entire area using the repeating sequence as key. If this is the beacon we were looking for, how can it be that it is completely masked? After all, XORed code cannot be in a state of execution?!
It turns out CobaltStrike has a feature called “sleep mask”, which obfuscates its PE sections in memory while there are no tasks to execute. At a set interval, it will wake up, contact the C2 server to query for any tasks to execute, and then go back to sleep. Typically this will only take a few dozen milliseconds, so it is rather unlikely a dump is created in exactly the right moment while everything is decrypted. A small piece of code that is never masked is responsible for orchestrating the mask-sleep-unmask cycle. This feature can be customized to also include important heap areas, e.g., so that important strings such as the C2 domain are not visible in memory dumps.
As for the second point, you might think, “well, once you’ve extracted the beacon, just use one of the config extraction tools”. Sadly, it’s not that easy. One of the first things CobaltStrike does after starting is loading its embedded config and “unpacking” it into heap memory. The packed config is then overwritten with zeroes. What do nearly all tools out there look for? You guessed it, the packed config. We found one tool that can deal with unpacked configs (note: our search was probably non-exhaustive), however it is 32-bit only while our beacon is 64-bit, and it cannot deal with Volatility dumps, nor with the masking aspect.
We jerry-rigged some code that can deal with our particular situation, but first let’s talk about the config data structures that we’re dealing with. Packed beacon configs follow a sort of type-length-value (TLV) format:
- 16-bit ID specifying the meaning of the entry – e.g., 8 is the C2 server the beacon will talk to
- 16-bit kind:
1
(16-bit value),2
(32-bit value) or3
(binary blob) - 16-bit length: Length of the data value that follows
- Variable-length bytes for the actual data
All integers are encoded in big endian. Additionally, the entire packed config blob is XORed with a single byte key (0x2E by default).
At runtime, CobaltStrike turns this into a slightly different data structure that allows more efficient access. The unpacked config is an array of two machine words (so either 2x 32-bit or 2x 64-bit). The array index is the entry ID, the first word is the value kind (1
/2
/3
) or 0 if the entry is empty, and the second word is either the data value for kinds 1
& 2
(now in little endian), or a pointer to the binary data in case of kind 3
. The memory for the binary data is dynamically allocated using malloc
, and the same is true for the array itself. The pointer to the array is kept in the beacon’s data section, so that code working with the config can locate it. The following illustration shows the different data locations and references to them:
The observant reader might be wondering what happened to the length value for binary values. Indeed, it is not stored in the unpacked config. Since CobaltStrike knows what type of value it’s accessing, it has its own mechanisms for determining the appropriate data length without explicitly being told the length. Most data such as strings uses zero-termination, but if you consider an ASN.1-encoded public key for example, it has the length built into its format.
Upon closer inspection, we noticed the length is not completely lost. The beacon keeps a list of heap pointers and their size, and the config binary entries are added to that list. We suspect this list is given to the sleep mask functionality so that it can obfuscate “important” heap data. In fact, in our case the unpacked config allocation and all allocations for config binary values are masked individually, supporting that hypothesis.
Keeping all of the above in mind, a plan for dealing with this could look as follows:
- Manually find, extract and unmask the CobaltStrike beacon from the Volatility process dump. We used malfind, dd and CyberChef for this step. If you need to know the size to copy out of the process dump, scroll down in the memory map starting from the address given by malfind, until you notice a gap in the virtual addresses
- Use a regex to search for the data section reference in the config processing code, read the pointer from the data section
- Unmask the unpacked config heap memory
- Read up to 128 entries from the config array (for a 64-bit beacon, the allocation is 2048 bytes, which is 128*16). For binary values (kind
3
), read and unmask the heap memory they point to - Throw the resulting data into one of the existing CobaltStrike config parsers to get a readable output
Here’s an example memmap output. 0x1c451690000
is the address found by malfind, 0x1c451800000
marks a gap, so the beacon spans from 0x877000
up until 0x8fd000
in pid.XXXX.dmp
.
virt phys size file offset filename
0x1c451690000 0xbd6e8000 0x1000 0x877000 pid.XXXX.dmp
0x1c451691000 0xacdeb000 0x1000 0x878000 pid.XXXX.dmp
0x1c451692000 0x350ec000 0x1000 0x879000 pid.XXXX.dmp
... cut for brevity
0x1c451713000 0x3ef6d000 0x1000 0x8fa000 pid.XXXX.dmp
0x1c451714000 0x1208ea000 0x1000 0x8fb000 pid.XXXX.dmp
0x1c451715000 0x384e9000 0x1000 0x8fc000 pid.XXXX.dmp
0x1c451800000 0x6c1e6000 0x1000 0x8fd000 pid.XXXX.dmp
Check out our GitHub Gist for an implementation of steps 2 through 4. You need the following files/information:
- Volatility process dump
- Volatility memmap command output containing the memory map for the dump (so that virtual addresses can be mapped to offsets in the dump file)
- Beacon extracted & unmasked manually in step 1
- XOR key for unmasking (note: the rotation of this key can differ to the one you used in step 1, so try to rotate the bytes if it doesn’t work immediately)
The Volatility command to create a full process dump is python vol.py -f /path/to/ram-dump windows.memmap.Memmap --pid 12345 --dump > memmap.txt
.
The provided code is not an end-to-end solution since there are still manual steps and we didn’t include a CobaltStrike config parser for the final step, but perhaps you will find it helpful if you run into a similar case.