Extending The Covenant: Part 3

The currently last blog post about my work on the open source C2 framework Covenant. One important new task, two new Grunt templates and QoL improvements.

This is the fourth part of a series of blog posts about our recent work with the open source C2 framework Covenant. In this series, we showed several new tasks and other improvements to Covenant and while I have enjoyed working with and extending this framework, this will likely be the last post in this series for a while. Reading a lot about new evasion techniques and trying to understand the different methods and proof of concepts has shown me that I will need to broaden my research while also gaining a deeper understanding about processes, threads, stacks, their memory, debugging etc. One topic of interest that I will be looking at is the interactions between memory scanning, behavioral detections and Just In Time (JIT) compilation, but a bit more on that will come later in the blog post.

I will start this post with a warning: the new features that are presented are not 100% completed. While testing them, I have seen bugs or behaviors that I cannot explain, but I will list them in the relevant sections. While there is not a lot of community activity currently for Covenant, maybe someone with more experience in debugging can resolve these issues. As always, the additions will be in our GitHub repo and issues or pull requests are welcome: Covenant-Additions


Executing native binaries in memory in .NET is more complicated than one might think. While assemblies can very comfortably be loaded inline, native binaries pose more difficulties. This meant that if an operator wanted to for example use the Juicy Potato exploit to elevate their privileges on a vulnerable machine, the native EXE had to be uploaded to the compromised machine and then executed with a shell command (ShellCmd task). This means writing to disk, which can trigger AV alerts, leaves IoCs etc. and is overall not something that an attacker or an operator wants to do.

Luckily, Nettitude released RunPE on GitHub, a C# reflective loader for unmanaged binaries. This loader handles executing the binaries, argument parsing and API patching amongst other things and can be easily included into C2 frameworks. The GitHub readme explains some limitations, which also come into play for our Grunt task. If there are further improvements or updates to RunPE, I will update the task to reflect those changes.


To run the task, a DecoyFilePath must be chosen, the unmanaged binary has to be supplied as a file option (which means it gets uploaded from the operator's machine) and optional arguments can be supplied.


In this example, net.exe was executed in memory to enumerate the members of the Administrators group on the compromised system. To circle back to the initial example of Juicy Potato, here is the output of executing JuicyPotato.exe without additional arguments.


This task would have saved me some headaches in the past, and thanks to RunPE, it was easy to implement it into Covenant. Again, the YAML template for the task can be found on our GitHub. It is basically RunPE's source combined into one file with changes to how the output is returned, as we need it to be returned to our Grunt instead of on the compromised system's console. For his C2 SharpC2, Rasta Mouse additionally lowered the API calls from kernel32.dll to ntdll.dll. This most likely requires referencing SharpSploit for this task in Covenant and has not been done yet.

Disposable AppDomains Grunt Template

This Grunt template loads and executes task assemblies in disposable AppDomains that get unloaded when the task is done. This template also works for injected Grunts, where the Grunt assembly cannot be found by the CLR. Currently, long running tasks (for example a keylogger) cannot be run this way, because I have not found a way to get a required property from the assembly yet ("streamProp"). If you feel comfortable with JIT, AppDomains, and how assemblies are loaded from injected processes, you can skip to the images at the end of this section to see how this template behaves.

This has been something that kept me busy for quite some time. To elaborate a bit: reading about memory scanners and EDRs, their focus on unbacked R(W)X regions and the techniques trying to minimize detections from the scanners was concerning, as when I checked any of my Grunt's processes and their memory regions, they were full of "Private: Commit RWX" regions. Running Moneta on these processes showed several warnings about "Abnormal private executable memory". However, when running Moneta on e.g. a PowerShell process, the same warning messages are shown. This is because of Just In Time (JIT) compilation. This blog post describes it in greater detail but still in a concise manner.

So long story short: for me, sleep obfuscation and setting the executing memory region to RW during sleep turned out to be something that was not feasible in .NET. Yes, there is a .NET Gargoyle PoC, but from my understanding of the code, it more or less just reloads an assembly via a timer, without encryption. Encrypting the .text region where the MSIL is kept also did not seem feasible, as the code for the timer would most likely also get encrypted. I do not feel comfortable enough with ROP programming or other more advanced techniques to implement them and I think it would be difficult to have that in a Grunt template.

However, compared to other implants, a Grunt is basically just an assembly loader. The tasks are not part of the implant, they get compiled with Roslyn and are sent to the Grunt to load via Assembly.Load(). This means that without tasks, the Grunt should have a very low detection rate. The problems only start when tasks are getting loaded. A great blog post shows this in action and describes the problem with loading and running all assemblies in the same AppDomain.

The problem is: once an assembly gets loaded into an AppDomain, it cannot be unloaded, and resides in memory. That means that once you run for example a Rubeus task in Covenant, Rubeus is now in the memory of your Grunt process. So a memory scanner that might have missed the initial execution can now find Rubeus and kill your Grunt. Assemblies cannot be unloaded, but AppDomains and their loaded assemblies can. They are then removed and cleaned up.

Once that is implemented, an additional problem arises. Creating an AppDomain and loading an assembly in it requires the CLR to find the "base" assembly, which is the Grunt assembly (all these tests were done with a stageless Grunt). If the Grunt assembly is converted to shellcode using Donut and injected into a process, the CLR cannot find the Grunt's assembly, because it was never actually on disk in the first place.

Two projects were found that tackle this issue: aconite33's Fileless and thiagomayllart's DarkMelkor (with credits Accenture's CLRvoyance and their blog post about it). Fileless relies on MinHook.NET, which would mean that the DLL needs to be added as an embedded resource to the task (in the same way as Powerkatz to the Mimikatz task), but this has given me a lot of issues in previous tasks already so I went with CLRvoyance's solution. The solution is described better and clearer than I could in the "Assembly inception" section of their blog post. Two CrossAppDomainDelegates are used, one calling a function that can always be resolved (like Console.Beep()) and one calling our function that we want to execute in the new AppDomain. With memory patching, the resolvable function delegate is then changed to point to our function delegate instead.

All in all, the behavior of the new template can be seen in the images below. Using "Create ShellCode Launcher" to generate a Grunt with the new template with disposable AppDomains, this shellcode is then generated into a process (notepad.exe in this example). Before running any task, Process Hacker shows the following .NET assemblies:


The "9TR3TH4C" AppDomain is from Donut and has a new random 8 character string name whenever Covenant generates a shellcode launcher. The "ad2ej51c.fto" is the Grunt assembly that gets loaded initially. Its name is also randomly chosen upon creating the launcher.

The following screenshot shows how the situation looks during a task's execution:


We can see that there is a new AppDomain with an assembly loaded inside it. I derived the naming convention for these temporary AppDomains from a legitimate software product, but this can (and should) of course be modified in the template. This AppDomain is only visible during the task execution.

As soon as the task is done, the AppDomain is unloaded and garbage collected. The screenshot shows that the .NET assemblies look like they did before a task was executed:


If you try this template out, take a look at the .NET performance tab too, especially at the .NET CLR Loading category.

ApplicationRecoveryCallback Grunt Template

I've had this template for quite some time without ever finding a "real" use case for it. The idea for this template came from Wra7h's ARCInject repo, where they showcased a new injection method using application recovery callbacks that get executed when a process becomes unresponsive. As I have encountered several instances where a Grunt would become unresponsive, I thought this could be a hacky recovery mechanism. I have only used this template in a lab, but what it essentially allows me to do is to react to a Grunt becoming unresponsive and downloading and executing a new Grunt from the C2 server.

The mechanism is interesting and I'm sure there are use cases where this could come in handy. For more information on the implementation, check out Wra7h's repo or the code in the Grunt template.


Refreshable Listener Web Log

Covenant's listeners keep a web log of requests they receive. Each listener writes its events into a separate log file. The operator can view these web log entries in the listener details, however, the newest events get listed on the last page and are not refreshed automatically. So when I wanted to check if there was a new event (e.g. a hosted file was requested), I had to refresh the page, go to the Web Log tab and then go to the last page to see it.

To streamline this process, I have added a refresh button and reverted the log order. Newest events are now listed at the top and the listing can be refreshed without having to reload the page. To use the modified .razor file in an existing Covenant instance, replace the file and use "dotnet clean; dotnet run" at the next start. I do not use Covenant with Docker, so I can't provide the necessary command for it.


New Dashboard Layout

With the new graph layout (see our previous blog post), a refreshable web log and my personal preference that a dashboard should not extend past the window height, I modified Covenant's dashboard to better fit my needs.

The graph allows for a quick overview, the taskings log highlights the fact that Covenant was created as a collaborative command and control platform and the web log shows requests to the currently active listener.


Future Outlook

As I have written at the start of this blog post, this will be the last blog post related to Covenant for a while. I am still active in the #covenant channel on the BloodHoundGang Slack and will try to help when there are new issues on Covenant's GitHub. I hope the repo gets updated again soon and that some pull requests are joined into the dev branch. Covenant offers a lot of functionality and the way that tasks are loaded and executed makes developing new additions fun, so I really hope that Covenant does not just slowly become more and more abandoned.

I will focus my future research on the interactions between memory scanners, behavioral detections and JIT compiled code. I am sure that will lead me to Covenant again sooner or later!

Thanks for reading! As always, please feel free to send any questions or remarks to or contact me in the BloodHoundGang slack (user @jannlemm0913).

Similar posts