Skip to content
fl0sec
Windows Internals

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.

11 minestimated reading time

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:

  1. Recover the target system service number (SSN).
  2. Execute the transition without a private or suspicious stub.
  3. Preserve arguments, return provenance and call-stack semantics.
1Recover the target SSNindependent questionPivotGateclean anchors + measured RVA geometry2Cross the user/kernel boundaryindependent questionPivotGatetarget entry HWBP + target-owned syscall; ret3Preserve semantic provenanceindependent questionPivotGatereal wrapper, arguments, return and stack
The three questions can be solved independently. PivotGate uses measured ntdll geometry for resolution, the target's own transition for execution, and the real Win32 wrapper for provenance.

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.

IDA disassembly showing the canonical NtWorkerFactoryWorkerReady syscall stub beginning at address 0x171B10 and the following NtAcceptConnectPort export beginning at 0x171B30. A red brace marks the 0x20-byte interval between their entries.
The ordinary case in IDA: one canonical syscall entry begins at 0x171B10 and the next at 0x171B30, exactly 0x20 bytes later.

The exception sat at NtQuerySystemTime.

IDA disassembly showing ZwQuerySystemTime at address 0x172630 as a jump to RtlQuerySystemTime, followed by the next exported syscall entry at 0x172640.
The pivot: NtQuerySystemTime redirects to RtlQuerySystemTime, and the next syscall entry begins only 0x10 bytes later.

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:

IDA pseudocode for RtlQuerySystemTime showing a read from shared user data at address 0x7FFE0014 followed by return zero, with no syscall instruction.
RtlQuerySystemTime reads the time from shared user data instead of entering the kernel.
SSN 0x59  RVA 0x162610  canonical stub
SSN 0x5A  RVA 0x162630  jmp RtlQuerySystemTime
SSN 0x5B  RVA 0x162640  canonical stub
SSN 0x5C  RVA 0x162660  canonical stub

Most 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_stride
measured RVASSN 0x59RVA 0x162610syscall stubSSN 0x5ARVA 0x162630jmp RtlQuerySystemTimeSSN 0x5BRVA 0x162640syscall stubSSN 0x5CRVA 0x162660syscall stub+0x20+0x10+0x20compensate above the pivotlogical(rva) = rva + (rva > pivot ? adjustment : 0)target SSN = anchor SSN + logical distance / measured stride
On the tested image, NtQuerySystemTime compresses one interval to 0x10. A measured adjustment above that pivot restores the otherwise regular SSN-to-RVA coordinate.

On 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:

resolver.c
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 KernelBase
applicationWindows APIKernelBasereal wrappertarget Nt*entry#DB / VEHone shottarget-owned tailsyscall; retuntouched ret returns to the actual KernelBase callerDefender correlationprivate exceptionarms DR0single-stepat Nt entryRIP discontinuityentry to +0x12returnoriginal address
The genuine Windows API path creates the stack. PivotGate intervenes only at the requested native entry, then uses that function's own transition and untouched return address. The lower timeline marks the correlated defensive sequence.

I 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.

IDLErequest preparedraiseprivate arm exceptionDR0 = target Nt* entryDR7.L0 = 1ARMEDone calling threadreal wrapper reaches target#DB / single-stepthread + ARMED + DR6.B0RIP == target entryconsume exactly onceclear DR0-DR7; RAX = SSNR10 = RCX; RIP = target tailFIREDtarget-owned syscall; retgenuine returnoriginal KernelBase callerwrong state, thread, DR6 or RIP: continue search; unconsumed request: disarm and fail
The controller has two exception transitions. The private exception arms one target-entry breakpoint; the matching single-step consumes it, clears the debug state and redirects once. A request that misses its target is explicitly disarmed and failed.

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.

control.c
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 start

Those 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.

IDA disassembly of NtWriteFile showing mov r10, rcx, service number 8 loaded into eax, and the highlighted syscall instruction followed by ret.
The public target in the tested ntdll: NtWriteFile owns SSN 0x08 and the syscall; ret transition used by the PoC.

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.

main.c
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.

View the PoC on GitHub

From a Visual Studio x64 developer prompt:

build and run
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.exe

On 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.

Console output from the normal PivotGate build showing ntdll version 10.0.26100.8246, measured pivot and slot geometry, two anchors, NtWriteFile SSN 0x08, one successful transition, matching file contents, cleanup, and exit code zero.
The bounded run derives the geometry and SSN, fires one transition, verifies the marker and removes the temporary file.

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 entry

The 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.

Signals that survive the cleaner path
ObservationWhat it revealsLimit
private exception followed by DR0breakpoint arming on the same threadrequires first-chance context visibility
entry-to-interior RIP changethe native prologue was skippedcooperative in this lab; needs a protected observer in production
genuine KernelBase returnno synthetic return was usedmeasured with active DR1 instrumentation
wrapper/native argument disagreementparameters changed after the wrapperfinal context is cooperative in this lab
PoC-specific YARA matchthe published artifactnot 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.

Console output from the CET-compatible PivotGate build showing the same resolved geometry and target, followed by context-IP policy detection, refusal to arm, and exit code three.
With Context-IP validation active, resolution still completes but the controller refuses to arm the breakpoint path.

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.

Was this useful?