Some of you love it and some of you hate it, but at this point it should come as no surprise that .NET tradecraft is here to stay a little longer than anticipated. The .NET framework is an integral part of Microsoft’s operating system with the most recent release of .NET being .NET core. Core is the cross-platform successor to the .NET Framework that brings .NET to Linux and macOS as well. This now makes .NET more popular than ever for post exploitation tradecraft among adversaries and red teams. This blog will dive into a new Beacon Object File (BOF) that allows operators to execute .NET assemblies in process via Cobalt Strike versus the traditional built-in execute-assembly module, which uses the fork and run technique.

Background

Cobalt Strike, a popular adversary simulation software, recognized the trend of red teams moving away from PowerShell tooling in favor of C# due to the increase in detection capability for PowerShell, and in 2018 with Cobalt Strike version 3.11 introduced the execute-assembly module. This allowed operators to take advantage of the power of post-exploitation .NET assemblies by executing them in memory without having the added risk of dropping those tools to disk. While the capability of loading .NET assemblies in memory via unmanaged code wasn’t new or unknown at the time of release, I would say Cobalt Strike brought the capability to the mainstream and helped continue to fuel the popularity of .NET for post-exploitation tradecraft.

Cobalt Strike’s execute-assembly module uses the fork and run technique, which is to spawn a new sacrificial process, inject your post-exploitation malicious code into that new process, execute your malicious code and when finished, kill the new process. This has both its benefits and its drawbacks. The benefit to the fork and run method is that execution occurs outside our Beacon implant process. This means that if something in our post-exploitation action goes wrong or gets caught, there is a much greater chance of our implant surviving. To simplify, it really helps with overall implant stability. However, due to security vendors catching on to this fork and run behavior it has now added what Cobalt Strike admits, an OPSEC expensive pattern.

As of version 4.1 released in June of 2020, Cobalt Strike introduced a new feature to try and help address this issue with the introduction of Beacon Object Files (BOFs). BOFs allow operators to avoid the well-known execution patterns as described above or other OPSEC failures such as using cmd.exe/powershell.exe by executing object files in memory within the same process as our beacon implant. While I won’t be going into the inner workings of BOFs, here are a few blog posts I found insightful:

If you read the above blogs, we should now see that BOFs weren’t exactly the saving grace we hoped for and if you were dreaming of re-writing all those awesome .NET tools and turning them into BOFs, those dreams have now been crushed. Sorry. Hope however is not lost as there are, in my opinion, some great things BOFs can offer, and I have recently had a lot of fun (and some frustration too) pushing the limits of what can be done with them. First, was by creating CredBandit which performs a complete in memory dump of a process such as LSASS and sends it back through your existing Beacon communication channel. Today I am releasing InlineExecute-Assembly, which can be used to execute .NET assemblies inside your beacon process with no modification to your favorite .NET tooling. Let’s dive into why I wrote the BOF, some of its key features, caveats, and how it could be useful when conducting adversary simulations/red teams.

Why InlineExecute-Assembly?

The reason behind building InlineExecute-Assembly is pretty simple. I wanted a way for our adversary simulation team to execute .NET assemblies in process to avoid some of those OPSEC pitfalls talked about above when using Cobalt Strike to operate in mature environments. I also needed the tool to not tax our team with extra development time by having to make modifications to most of our current .NET tooling. It also needed to be stable. Well, as stable as a complex BOF can be, since the last thing we want is to lose one of our few Beacons into the environment. Basically, it should work as smoothly for the operator as Cobalt Strike’s execute-assembly module as possible.

Key Features

Loading the Common Language Runtime (CLR)

I know, kind of obvious. We wouldn’t get too far without it, am I right! All joking aside, the intricacies of how the CLR works and what goes on in-depth could be a blog post unto itself, so we are going to review what the BOF uses at a very high level when loading the CLR via unmanaged code.

Loading the CLR

As shown in the simplified screenshot above, the main steps the BOF will take to load the CLR are as follows:

  1. Makes a call to CLRCreateInstance which will be used to retrieve our ICLRMetaHost interface.
  2. ICLRMetaHost ->GetRuntime is then used to get the runtime information for the version of .NET we request. If your assembly was built with .NET version 3.5 or below we will want to request v2.0.50727, and if your assembly was built with .NET 4.0 and above we will want to request v4.0.30319. There is actually a function in the BOF that will help us figure out what version our .NET assembly uses automatically but we will talk about that later.
  3. Once we have our runtime info, we use ICLRRuntimeInfo->IsLoadable to check if our runtime can be loaded into the process. This will also take into account if other runtimes may already be loaded and will set our BOOL value fLoadable to 1 (true) if our runtime can be loaded in process.
  4. If that all checks out, we will then run ICLRRuntimeInfo->GetInterface to load the CLR into our process and retrieve an interface to ICorRunTimeHost.
  5. Lastly, we will call ICorRuntimeHost->Start, which starts the CLR.

So now the CLR is initialized but there is still a little bit more that needs to happen before we actually get to executing our favorite .NET assemblies. We need to create our AppDomain instance that is what Microsoft explains as “an isolated environment where applications execute”. In other words, this will be used to load and execute our post exploitation .NET assemblies.

AppDomain being created and assembly being loaded/executed

As shown in the simplified screenshot above, the main steps the BOF will take to load and invoke our .NET assembly are as follows:

  1. Use ICorRuntimeHost->CreateDomain to create our unique AppDomain
  2. Use IUnknown->QueryInterface (pAppDomainThunk) to get a pointer to the AppDomain interface
  3. Create our SafeArray and copy our .NET assembly bytes to it
  4. Load our assembly via AppDomain->Load_3
  5. Get our entry point in our assembly via Assembly->EntryPoint
  6. Lastly, invoke our assembly via MethodInfo->Invoke_3

Hopefully you now have a high-level understanding of .NET execution via unmanaged code, but this still doesn’t get us anywhere near having an operationally sound tool, so we will look at some features that were implemented in the BOF to take it from meh to totes legit.

Redirecting Console STDOUT to Named Pipe or Mail Slot: Avoiding Tool Modification

You are probably wondering why this is important. Well, if you are like me and value your time, you don’t want to spend it modifying pretty much every .NET assembly out there so that its entry point returns a string back with all your data that would normally just be piped to console standard output, do you? Figured as much. In order to avoid that, what we need to do is redirect our standard output to either a named pipe or a mail slot, read the output after it has been written, and then revert it back to its original state. This way we can run our unmodified assemblies just like we would from cmd.exe or powershell.exe. Now, before we go over any code I do need to give thanks to @N4k3dTurtl3 and their blog post about in process execute assembly and mail slots. This is originally what got me down the path of implementing this technique into my own private C implant when it first came out and many months later I ported that same functionality to a BOF. Ok, now that props have been given let’s look at a simplified example of how this would be achieved by redirecting stdout to a named pipe below:

Redirecting console standard output to named pipe and reverting back

Determine .NET version of assembly

Remember when loading the CLR via ICLRMetaHost ->GetRuntime we had to specify what version of the .NET framework we need? Remember how that depends on what version our .NET assembly was compiled with? It wouldn’t be much fun to have to manually specify which version is needed each time, would it? Lucky for us, @b4rtik implemented a cool function to handle this in their execute-assembly module for the Metasploit framework that we can easily implement into our own tooling shown below:

Function that reads our .NET assembly and helps determine what .NET version we need when loading the CLR

Essentially what this function does is when passed our assembly bytes, it will read through those bytes and look for the hex values of 76 34 2E 30 2E 33 30 33 31 39, which when converted to ASCII is v4.0.30319. Hopefully that looks familiar. If that value is found when reading the assembly, the function returns 1 or true, and if it isn’t found it returns 0 or false. We can use this to easily determine what version to load with whether 1/true or 0/false comes back as shown in the code example below:

If/else statement to set .NET version variable

Patching Antimalware Scan Interface (AMSI)

We definitely couldn’t talk .NET offensive tradecraft and not talk about AMSI. While we won’t go in depth about what AMSI is and all the ways it can be bypassed as this has been covered many times over, we will talk a little about why patching AMSI may be necessary depending on what you decide to execute via the BOF. For example, if you decide to run Seatbelt without any obfuscation you will quickly notice that you didn’t get any output back and your beacon is dead. Yes, D.E.A.D. dead. This is because AMSI caught your assembly, determined it was malicious, and shut you down like a house party making too much noise. Not ideal, right? Now we have two good options here when it comes to AMSI, we can either obfuscate our .NET tooling via something like ConfuserX or Invisibility Cloak or we can disable AMSI using a variety of techniques. In our case, we will be using one by RastaMouse, which is to patch the amsi.dll in memory so it returns E_INVALIDARG and makes the scan result 0. Which as pointed out in their blog post, is usually interpreted as AMSI_RESULT_CLEAN. Let’s look at a simplified version of the code for an x64 process below:

In memory patching of AmsiScanBuffer

As you can see in the screenshot above, we simply do the following:

  1. Load amsi.dll and get a pointer to AmsiScanBuffer
  2. Change the memory protection
  3. Patch in our amsiPatch[] bytes
  4. Revert the memory protection to its original state

By implementing this into our tool we should now be able to run the default version of Seatbelt.exe using the –amsi flag to bypass AMSI detection as shown below:

InlineExecute-Assemby AMSI bypass example

Patching Event Tracing for Windows (ETW)

Fortunately for defenders, there is more than just AMSI to help out when it comes to picking up malicious .NET tradecraft by using ETW. Unfortunately like AMSI, this too can be fairly easy for adversaries to bypass and @xpn did some really awesome research into how this could be done. Let’s look at a simplified example of how you could patch ETW to completely disable it below:


In memory patching of EtwEventWrite

As you can see from the screenshot above, the steps are pretty much identical to how we patched AMSI, so I won’t go over the steps for this one. You can see a before and after screenshot of running the –etw flag below:

Using Process Hacker to view PowerShell.exe properties before running inlineExecute-Assembly with –etw flag

 Running inline-Execute-Assembly using the –etw flag

Using Process Hacker to view the same PowerShell.exe properties after running inlineExecute-Assembly

Unique AppDomains, Named Pipes, Mail Slots

By default, the AppDomain, Named Pipe or Mail Slot created uses the default value “totesLegit”. These values can be changed to better blend in within the environment you are testing by either changing them in the provided aggressor script or via command line flags on the fly. An example of changing them via the command line can be shown below:

InlineExecute-Assembly example using unique AppDomain Name and unique named pipe name

Example of unique AppDomain name ChangedMe

Example of unique named pipe LookAtMe

Example of AppDomain being removed after successful execution is finished

Example of named pipe being removed after successful execution is finished

Caveats

This section will pretty much be a repeat of what I mention in the GitHub repository, but I felt it was important to reiterate a few things you need to keep in mind when using this tool:

  1. While I have tried to make this as stable as possible, there are no guarantees things will never crash and beacons won’t die. We don’t have the added luxury of fork and run where if something goes wrong our beacon lives. This is the tradeoff with BOFs. With that said, I can’t stress how important it is that you test your assemblies beforehand to make sure they will work properly.
  2. Since BOF is executed in process and takes over your beacon while running, this should be taken into account before being used for long running assemblies. If you choose to run something that will take a long time to get back results, your beacon will not be active to run more commands till the results come back and your assembly finishes running. This also doesn’t adhere to sleep set. For example, if your sleep is set at 10 minutes and you run the BOF, you will get results back as soon as the BOF finishes executing.
  3. Unless modification is done to tools that load PE’s in memory (e.g., SafetyKatz), these will most likely kill your beacon. Many of these tools work fine with execute assembly because they are able to send their console output from the sacrificial process before exiting. When they exit via our in process BOF, they kill our process, which kills our beacon. These can be modified to work, but I would advise running these types of assemblies via execute assembly since other non-OPSEC friendly things could be loaded into your process that don’t get removed.
  4. If your assembly uses Environment.Exit this will need to be removed as it will kill the process and beacon.
  5. Named pipes and mail slots need to be unique. If you don’t receive data back and your beacon is still alive, the issue is most likely you need to select a different named pipe or mail slot name.

Defensive Considerations

Below are some defensive considerations:

  1. This uses PAGE_EXECUTE_READWRITE when performing AMSI and ETW memory patching. This was done on purpose and should be a red flag as very few programs have memory ranges with the memory protection of PAGE_EXECUTE_READWRITE.
  2. Default name of named pipe created is “totesLegit”. This was done on purpose and signature detections could be used to flag this.
  3. Default name of mail slot created is “totesLegit”. This was done on purpose and signature detections could be used to flag this.
  4. Default name of AppDomain loaded is “totesLegit”. This was done on purpose and signature detections could be used to flag this.
  5. Good tips on detecting malicious use of .NET (by @bohops) here, (by F-Secure) here, and here
  6. Looking for .NET CLR loading into suspicious processes, such as unmanaged processes which should never have the CLR loaded.
  7. More on Event Tracing.
  8. Looking for other known Cobalt Strike Beacon IOCs or C2 egress/communication IOCs.

More from Adversary Services

Getting “in tune” with an enterprise: Detecting Intune lateral movement

13 min read - Organizations continue to implement cloud-based services, a shift that has led to the wider adoption of hybrid identity environments that connect on-premises Active Directory with Microsoft Entra ID (formerly Azure AD). To manage devices in these hybrid identity environments, Microsoft Intune (Intune) has emerged as one of the most popular device management solutions. Since this trusted enterprise platform can easily be integrated with on-premises Active Directory devices and services, it is a prime target for attackers to abuse for conducting…

Racing Round and Round: The Little Bug That Could

13 min read - The little bug that could: CVE-2024-30089 is a subtle kernel vulnerability I used to exploit a fully updated Windows 11 machine (with all Virtualization Based Security and hardware security mitigations enabled) and scored my first win at Pwn2Own this year. In this article, I outline my straightforward approach to bug hunting: picking a starting point and intuitively following a path until something catches my attention. This bug is interesting because it can be reliably triggered due to a logic error.…

Q&A with Valentina Palmiotti, aka chompie

4 min read - The Pwn2Own computer hacking contest has been around since 2007, and during that time, there has never been a female to score a full win — until now.This milestone was reached at Pwn2Own 2024 in Vancouver, where two women, Valentina Palmiotti and Emma Kirkpatrick, each secured full wins by exploiting kernel vulnerabilities in Microsoft Windows 11. Prior to this year, only Amy Burnett and Alisa Esage had competed in the contest's 17-year history, with Esage achieving a partial win in…

Topic updates

Get email updates and stay ahead of the latest threats to the security landscape, thought leadership and research.
Subscribe today