PivotGate: Syscalls Without Stack Fiction
PivotGate turns a hidden discontinuity in ntdll's syscall layout into an SSN resolver, then crosses the boundary through the genuine Windows wrapper—without decoding the requested stub, generating executable code or inventing a stack.
On this page
Every Gates technique starts with two mechanical problems: recover the system service number (SSN) for a requested Windows syscall, then execute it without walking through code a security product controls. A complete path has a third: the wrapper, arguments, return address and stack should still describe the operation that reaches the kernel.
Each familiar answer leaves something behind. Trust the target stub, search its neighbours, rebuild the syscall table, generate executable code, or manufacture a convincing return path. I wanted a route that did none of those.
That search led to a small break in the layout of ntdll. What first looked
like an awkward exception turned out to be enough to recover a requested SSN
from other, clean functions. From there, a one-shot hardware breakpoint could
hand execution back to the requested native routine's own syscall instruction
without replacing the stack the real Windows API had already built.
I called the composition PivotGate. It resolves an SSN without decoding the requested stub, crosses the boundary without generating executable code, and returns without invented frames. The public proof is intentionally harmless. Once the usual syscall artifacts are gone, the remaining control plane is much easier to isolate.
Three problems hiding inside one technique
I stopped treating “direct syscalls” as one indivisible trick and split the work into three questions:
- Recover the target system service number (SSN).
- Execute the transition without a private or suspicious stub.
- Preserve arguments, return provenance and call-stack semantics.
Hell's Gate, FreshyCalls and Canterlot's Gate mostly answer the first question through prologue decoding, export sorting or runtime-function ordering. HWSyscalls and LayeredSyscall move toward hardware-breakpoint execution and believable call stacks. But a cleaner layer can make the next one worse. Hiding a private stub buys little if the syscall comes from the wrong function or returns through a frame that never existed.
The discontinuity in ntdll
In the first Windows 11 image I opened in IDA, the native syscall entries were
almost boring.
The canonical functions occupied 32-byte slots. Their mov r10, rcx,
mov eax, SSN, shared-user-data check, syscall and ret sequences lined up at
the same offsets. Adjacent service numbers normally meant adjacent 0x20-byte
slots.

The exception sat at NtQuerySystemTime.

RtlQuerySystemTime does not issue that native service. It reads the system
time from shared user data. That redirect saves half a slot in the export
layout. The addresses around it on the primary image were:

SSN 0x59 RVA 0x162610 canonical stub
SSN 0x5A RVA 0x162630 jmp RtlQuerySystemTime
SSN 0x5B RVA 0x162640 canonical stub
SSN 0x5C RVA 0x162660 canonical stubMost gaps are 0x20; the gap across the redirect is 0x10. Treating every RVA
as part of one uniform line therefore puts every entry above the redirect half
a slot out of phase. Adding the missing distance above the pivot restores the
coordinate:
logical_rva(rva) =
rva + adjustment_if_above_NtQuerySystemTime
target_ssn =
anchor_ssn
+ (logical_rva(target) - logical_rva(anchor)) / measured_strideOn this build the nominal stride was 0x20 and the pivot adjustment was
0x10. Neither value appears as a resolver constant. The program measures them
from the loaded image and refuses to continue when the evidence disagrees.
Turning the observation into a resolver
One anchor is enough to produce a number, but not enough to trust it. PivotGate
locates a small fixed set of candidate exports on both sides of
NtQuerySystemTime. It accepts an anchor only when the whole expected stub is
canonical, including the immediate SSN. The requested target is never an anchor.
At least two clean anchors below the pivot and two above it must establish the same stride. Cross-pivot pairs then measure the missing distance. Every usable anchor independently derives the target SSN, and every result must match. Signed arithmetic matters here: a requested routine can sit below its anchors, so an unsigned delta would turn an ordinary backward calculation into a large positive candidate.
The core coordinate is small:
static int64_t pg_logical_rva(
uint32_t rva,
uint32_t pivot_rva,
int32_t adjustment) {
return (int64_t)rva + (rva > pivot_rva ? adjustment : 0);
}
static bool pg_resolve_from_anchor(
uint32_t target_rva,
const PG_ANCHOR_EVIDENCE* anchor,
uint32_t stride,
uint32_t pivot_rva,
int32_t adjustment,
uint32_t* ssn) {
const int64_t delta =
pg_logical_rva(target_rva, pivot_rva, adjustment) -
pg_logical_rva(anchor->rva, pivot_rva, adjustment);
if (stride == 0 || delta % stride != 0) {
return false;
}
const int64_t candidate = (int64_t)anchor->ssn + delta / stride;
if (candidate < 0 || candidate > UINT16_MAX) {
return false;
}
*ssn = (uint32_t)candidate;
return true;
}I still parse the export directory, but only to locate the target, pivot and
anchor candidates. PivotGate avoids a complete syscall-export sort; it does not
avoid export parsing. It also inspects the measured target slot for
exactly one 0F 05 C3 sequence. That read establishes the target-owned
transition, not the SSN. A missing or ambiguous transition is fatal, and the
resolver never borrows one from another function.
Taking the genuine path
Resolving the number answered only the first question. My early execution
experiments tried to reuse RtlQuerySystemTime as a control pivot too: break in
the function, edit its context and send it toward a syscall transition. The
machine could be made to do it. The story told by the execution could not be
made coherent. A time-reading helper suddenly issuing an unrelated service is
exactly the kind of wrapper-to-kernel mismatch a provenance sensor should
notice.
So NtQuerySystemTime stayed a layout pivot. Execution moved to the requested
operation itself:
prepare request
-> raise private arming exception
-> VEH sets DR0 on the requested Nt* entry
-> call the genuine Windows API
-> KernelBase reaches the requested Nt* entry
-> DR0 raises #DB before the prologue executes
-> VEH validates the hit and clears the breakpoint
-> RAX = resolved SSN, R10 = RCX, RIP = target-owned syscall; ret
-> kernel transition
-> original return into KernelBaseI register a vectored exception handler using the standard
Windows VEH mechanism.
Immediately before the operation, a private software exception supplies the
handler with a context in which it enables a local execution breakpoint in
DR0 at the requested ntdll!Nt* entry. This avoids a separate
SetThreadContext arming call, but it does not hide the exception or the
modified context. This way of arming a breakpoint is not new; CrowdStrike used
the same exception-context idea in its
VEH² research.
The application now calls the ordinary Windows API. KernelBase prepares the
native arguments and reaches the corresponding Nt* entry as usual. Before its
first instruction runs, the processor raises EXCEPTION_SINGLE_STEP. The
handler accepts the exception only when the request is active, the thread
matches, DR6.B0 is set and RIP equals the expected entry. DR6.B0 tells me
that breakpoint slot 0—not an unrelated single-step condition—caused it.
The full handler is short enough to show. Both exceptions share one state
object, so an unrelated single-step cannot consume the request.
pg_clear_owned_debug_state zeroes DR0 through DR3, DR6 and DR7, then
releases slot 0.
static LONG CALLBACK pg_exception_handler(EXCEPTION_POINTERS* pointers) {
PG_THREAD_STATE* state = pg_thread_state();
CONTEXT* context = pointers->ContextRecord;
const DWORD code = pointers->ExceptionRecord->ExceptionCode;
if (state == NULL || state->thread_id != GetCurrentThreadId()) {
return EXCEPTION_CONTINUE_SEARCH;
}
if (code == PG_EXCEPTION_ARM_CODE) {
if (state->status != PG_CONTROL_IDLE ||
state->request.target_entry == NULL ||
state->request.syscall_ret == NULL) {
state->status = PG_CONTROL_FAILED;
state->error = PG_CONTROL_ERROR_INVALID_REQUEST;
return EXCEPTION_CONTINUE_EXECUTION;
}
context->ContextFlags |= CONTEXT_DEBUG_REGISTERS;
context->Dr0 = (DWORD64)(uintptr_t)state->request.target_entry;
context->Dr1 = 0;
context->Dr2 = 0;
context->Dr3 = 0;
context->Dr6 = 0;
context->Dr7 = 0;
context->Dr7 |= 1u;
state->owns_dr0 = true;
state->status = PG_CONTROL_ARMED;
return EXCEPTION_CONTINUE_EXECUTION;
}
if (code == PG_EXCEPTION_DISARM_CODE) {
context->ContextFlags |= CONTEXT_DEBUG_REGISTERS;
if (state->owns_dr0) {
pg_clear_owned_debug_state(context, state);
}
if (state->status == PG_CONTROL_ARMED) {
state->error = PG_CONTROL_ERROR_MISSED_BREAKPOINT;
state->status = PG_CONTROL_FAILED;
}
return EXCEPTION_CONTINUE_EXECUTION;
}
if (code != EXCEPTION_SINGLE_STEP ||
state->status != PG_CONTROL_ARMED ||
(context->Dr6 & PG_DR6_B0) == 0 ||
context->Rip != (DWORD64)(uintptr_t)state->request.target_entry) {
return EXCEPTION_CONTINUE_SEARCH;
}
pg_clear_owned_debug_state(context, state);
++state->hit_count;
if (state->hit_count != 1) {
state->status = PG_CONTROL_FAILED;
state->error = PG_CONTROL_ERROR_MISSED_BREAKPOINT;
return EXCEPTION_CONTINUE_SEARCH;
}
context->Rax = state->request.ssn;
context->R10 = context->Rcx;
context->Rip = (DWORD64)(uintptr_t)state->request.syscall_ret;
state->status = PG_CONTROL_FIRED;
return EXCEPTION_CONTINUE_EXECUTION;
}RAX receives the consensus SSN. R10 = RCX performs the normal x64 syscall
register transfer. RIP moves to the verified transition inside the same
native routine. The controller clears the debug-register state before resuming.
It does not change RSP, the return address stored at [RSP], the wrapper's
arguments or any unwind data. When the target-owned ret executes, it returns
to the KernelBase instruction that really called the native routine.
The stack was already there
Stack spoofing looked like a third layer because so much related work treats it as one. Once the genuine wrapper path worked, the reason to do it disappeared. At the native entry the chain already looked like this:
ntdll!requested Nt routine
-> KernelBase
-> application
-> KERNEL32
-> ntdll thread startThose calls really happened. Their return addresses lead back to the callers that placed them there, and the normal unwind machinery can describe the chain. Adding synthetic frames or truncating it would only create state that later had to be repaired.
I unwound from the target-entry exception context rather than from inside the
handler. Every recovered frame belonged to an executable image mapping, the
chain contained the real KernelBase wrapper and application caller, and the
target-owned ret reached the untouched KernelBase address stored at [RSP].
The stack is genuine. The jump from the native entry to its interior is not. That distinction says more than a screenshot of plausible frames because it tells defenders where to look after a superficial stack check passes.
Reducing it to a safe public proof
The design is not tied to file writes. A target needs to fit the measured
geometry, contain one verifiable transition, and have a known genuine wrapper
path. For publication I chose WriteFile -> NtWriteFile because its effect can
be contained and checked without touching another process.

The public program is native AMD64 C17 and accepts no arguments. It creates a temporary file, writes one fixed marker, reads the marker back, removes the file and exits. It cannot select another syscall, process, handle, address or SSN.
static const unsigned char marker[] =
"PivotGate benign NtWriteFile proof\r\n";
request.target_entry = resolution.target_entry;
request.syscall_ret = resolution.syscall_ret;
request.ssn = resolution.ssn;
if (!pg_control_arm_current_thread(&request)) {
goto cleanup;
}
write_ok = WriteFile(
file,
marker,
(DWORD)(sizeof(marker) - 1),
&bytes_written,
NULL) != FALSE;
transition_ok = pg_control_complete_current_thread();The broader research harness contains synthetic entry hooks, a genuine Windows
threadpool callback and parameter-desynchronisation experiments. None is exposed
by the C proof. Only the harness modifies its own ntdll bytes to create test
hooks, and it restores them before exit; the published executable never patches
an executable page.
From a Visual Studio x64 developer prompt:
cl /nologo /c /TC /std:c17 /O2 /W4 /WX /GS /guard:cf /utf-8 resolver.c control.c main.c
link /nologo /guard:cf resolver.obj control.obj main.obj version.lib /out:build\pivotgate_poc.exe
build\pivotgate_poc.exeOn ntdll.dll 10.0.26100.8246, the resolver measured a 0x20 stride and a
0x10 pivot adjustment. Lower and upper anchors agreed on NtWriteFile SSN
0x08, and the unique target-owned transition was at +0x12. The fixed write
completed through clean and synthetic-hooked entries.

What remains visible
Removing the private stub and keeping a coherent stack gets rid of two easy signals. First-chance observation still catches this sequence on one thread:
private exception
-> resumed context enables DR0 at the requested ntdll!Nt* entry
-> EXCEPTION_SINGLE_STEP with DR6.B0 at that entryThe lifetime of DR0 is short, but the following debug exception arrives at
exactly the sensitive native export stored in that register. None of that
depends on the PoC's filename or strings.
I record the context produced by the controller separately and confirm that
RIP points at the target-owned transition with the expected SSN in RAX.
That final-context check is cooperative. A production detector would need an
equivalent protected instrumentation, debugger, kernel or hypervisor view to
see the entry-to-interior redirect itself.
| Observation | What it reveals | Limit |
|---|---|---|
private exception followed by DR0 | breakpoint arming on the same thread | requires first-chance context visibility |
entry-to-interior RIP change | the native prologue was skipped | cooperative in this lab; needs a protected observer in production |
| genuine KernelBase return | no synthetic return was used | measured with active DR1 instrumentation |
| wrapper/native argument disagreement | parameters changed after the wrapper | final context is cooperative in this lab |
| PoC-specific YARA match | the published artifact | not a family-level detection |
Return provenance behaves differently. I placed a one-shot DR1 breakpoint on
the original return address from [RSP]. After syscall; ret, execution landed
on that exact KernelBase address. This was active instrumentation, not passive
platform telemetry. It proves that the PoC uses no synthetic return or recovery
gadget. It also shows why return provenance alone misses PivotGate: the return
looks correct because it is correct.
The repository also contains a
YARA rule for the published proof.
It anchors on the private exception constants, fixed marker and reporting
strings. That makes it useful for finding this artifact, not the technique as a
family. I included an isolated-VM ETW recipe too, but did not replace the
system-owned kernel logger on the primary host just to produce a capture. The
result I can support independently is the arming exception followed by the
target-entry #DB on the same thread.
The mitigation that stopped it
The protected build ended differently. With /CETCOMPAT and context-IP
validation active, the controller could not redirect the exception context from
the native entry to the interior transition. It detected the policy and refused
to arm. There is no fallback that disables or weakens the mitigation.

Microsoft exposes context-IP validation through
PROCESS_MITIGATION_USER_SHADOW_STACK_POLICY,
alongside the user shadow-stack policy. For software under defender control,
build with /CETCOMPAT and /guard:ehcont, then query
ProcessUserShadowStackPolicy with GetProcessMitigationPolicy and verify that
both the user shadow stack and SetContextIpValidation are active. The denied
continuation returned STATUS_SET_CONTEXT_DENIED (0xC000060A) in the mitigation
experiment; the public controller checks the policy first and refuses instead
of deliberately triggering that failure.
An attacker-controlled executable can omit its own compatibility flag unless system or enterprise policy forces the control. Context-IP validation is therefore a concrete hardening measure for protected applications, not a claim that every Windows process already blocks this path.
Conclusion
I had a lot of fun pulling apart the existing Gates techniques and trying small
variations of each idea. GFIDs looked like a shortcut until their indices fell
apart. RtlQuerySystemTime worked as an execution pivot, but told the wrong
story. Stack spoofing seemed necessary until the genuine wrapper made it
unnecessary. Those dead ends shaped PivotGate as much as the path that survived.
The NtQuerySystemTime discontinuity made the first constraint workable. Clean
anchors and corrected RVA geometry derive the requested SSN by consensus. The
genuine Windows wrapper handles the other two: it reaches the correct native
entry with real arguments and frames, then a one-shot breakpoint redirects
execution to that same routine's verified syscall; ret.
The public proof stops at NtWriteFile because a temporary-file write is easy
to contain and verify. PivotGate itself is not tied to that syscall. The same
composition can be adapted to another native routine when its layout fits the
measured geometry, it owns one unambiguous transition, and a genuine wrapper
path is known. Extending it responsibly means validating those three facts, not
turning the PoC into an arbitrary syscall switchboard.
Removing the target-prologue dependency, private stub and fabricated stack did
not remove the control-plane history. The private arming exception and matching
target-entry #DB remain independently observable, the deeper continuation
checks require a protected observer, and Context-IP validation prevents the
redirect in a protected process. A believable stack does not make the route to
it believable.