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.

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 Gatearrow-up-right (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.

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.


Complete Indirect Syscall Implementation


SysWhispers3 with Indirect Syscalls

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

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


Technique Comparison


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

Last updated