28.09.2026

Paint It Blue: Reversing Win32k's Callbacks

Ido Veltzman
starforkfollow
Table Of Contents

Prologue

I started this research while looking for a new PatchGuard bypass technique. That lead did not produce a bypass, but it pushed me into a part of Windows that is often mentioned without explaining its basics: how Win32k is split across modules, how an x64 GUI syscall reaches it, and how the kernel chooses the callback used for session-aware Win32 operations.
We will follow the path from win32u.dll to KiSystemCall64, look at how a thread becomes a GUI thread, and then focus on PsInvokeWin32Callout, PsWin32CallBack, and PsWin32NullCallBack.
Without further ado, grab your coffee, tea, or your favorite drink, and let's dive into Win32k.

The Win32k Subsystem

Intro

The Win32k subsystem implements much of the kernel side of the Windows graphical user interface (GUI), including user, GDI, composition, and graphics-facing services. User-mode applications reach those services through syscalls. Despite how central the subsystem is, public discussion often jumps directly to a new vulnerability in xxxWinSomeBizzareFunctionThatIsResponsibleForASinglePixel or to a short reference in the Windows Internals books. The ordinary control flow receives much less attention.
The name Win32k makes it sound like one component, but the modern implementation is split across several kernel modules and user-mode libraries. win32k.sys provides the service and session-facing front door, win32kbase.sys owns much of the shared USER, GDI, and composition state, and win32kfull.sys coordinates higher-level services and lifecycle callouts. The graphics process and thread objects live in the DXG stack, while ntoskrnl.exe owns the executive process, thread, and session links that connect everything together. In user mode, applications usually reach this machinery through gdi32full.dll and the NtUser* or NtGdi* syscall stubs exported by win32u.dll.
This split becomes especially important when looking at Win32k-related syscalls, or "GUI syscalls" in less formal terms. Their user-mode stubs enter the same kernel syscall dispatcher as ordinary native services. The difference is in how the service number selects the Win32k descriptor table and in the extra work Windows may perform to convert a regular thread into a GUI thread before dispatch can continue.

Win32k Architecture

Win32k component and call-flow overview
At a high level, there are two related paths to keep in mind: the service-call path and the GUI-state lifecycle path. They meet inside the same subsystem, but they solve different problems. The service-call path finds and invokes an NtUser* or NtGdi* implementation. The lifecycle path creates the process and thread state that lets those services operate safely in the correct session.
For a typical service call, a GUI application calls a USER or GDI API. GDI wrappers in gdi32full.dll eventually reach an imported function in win32u.dll, while many USER operations call a win32u.dll stub directly. On the normal x64 path covered here, the stub places a build-specific service number in EAX, copies the first argument into R10 to preserve the Windows x64 calling convention across SYSCALL, and enters the kernel. The analyzed stubs can also select the legacy int 2e path according to a flag in shared user data, but that compatibility entry is outside this post's scope. ntoskrnl.exe then decodes the service number and selects either the native service table or the Win32k service table. A valid Win32k entry resolves to code exposed through the Win32k module set.
The main components in that path have distinct responsibilities:
  • ▸

    win32u.dll contains the user-mode NtUser* and NtGdi* syscall stubs.

  • ▸

    gdi32full.dll implements and wraps user-mode GDI functionality before calling those stubs.

  • ▸

    ntoskrnl.exe performs syscall dispatch, owns the executive process and thread fields, and initiates GUI-thread conversion when it is needed.

  • ▸

    win32k.sys is the syscall and session-facing front door. It exposes service-table information and dispatches through session-local Win32k and graphics interfaces.

  • ▸

    win32kbase.sys allocates and manages much of the shared Win32 process and thread state and runs USER, GDI, and DirectComposition setup and teardown.

  • ▸

    win32kfull.sys orchestrates the upper process and thread callout chains and supplies higher-level USER and GDI services.

  • ▸

    win32kbase_rs.sys is a Rust patch companion that uses exports and allocators from the base module. Its presence does not mean every GUI entry passes through it.

  • ▸

    dxgkrnl.sys, together with dxgmms1.sys and dxgmms2.sys, owns the graphics-facing process, thread, scheduling, and video-memory state used by Win32k.

The lifecycle path begins in the executive rather than in a user-mode syscall stub. PsConvertToGuiThread checks whether the current process and thread already have GUI state and whether policy allows conversion. It obtains the Win32 callouts associated with the current session, initializes process state when necessary, invokes the thread callout, and rolls back the relevant flags if thread initialization fails. The process conversion can therefore involve both a process callout and a thread callout, even though the immediate goal is to make one thread capable of issuing GUI services.
win32kfull.sys coordinates these lifecycle callbacks. On the ordinary creation path, it asks win32kbase.sys to allocate the Win32 process object and then runs the USER, GDI, and DirectComposition process callbacks. The GDI branch crosses through the session graphics interface into dxgkrnl.sys, which creates or reuses its own graphics process object. Thread creation follows a similar chain for Win32k and DXG thread state. Teardown unwinds these relationships, although the exact order can vary by event and branch.
It is useful to separate ownership from storage. The executive owns fields in EPROCESS and ETHREAD/KTHREAD, but some of those fields point to objects managed by Win32k or DXG. The process's Win32Process slot points into Win32k-managed process state, while DxgProcess points to a graphics object managed by DXG. The Session pointer selects the session-private callbacks and interfaces used along the way; it is not itself either of those process objects.
The thread side has another subtlety. The executive's Win32 thread slot does not point directly to a THREADINFO. win32kbase.sys allocates a small wrapper, stores the THREADINFO pointer inside it, and installs that wrapper in the executive slot. Win32k accessors dereference the wrapper when they need the underlying USER thread state. Some accessors also verify that an attached process belongs to the same session before returning it, which prevents session-private GUI state from being used through the wrong process context.
This session boundary is present throughout the architecture. Win32k selects service callbacks and graphics interfaces using the current process's session, so a GUI pointer by itself is not enough to describe the active environment. It also explains why PsWin32CallBack, PsWin32NullCallBack, and PsSessionGetWin32Callouts matter later in this post: they sit on the executive-to-session boundary rather than being ordinary syscall implementations.

Different Types of Processes and Threads

Normal creation path for Win32k and DXG process and thread state
The diagram shows the normal creation path for both process-wide and thread-specific GUI state. The process path starts when ntoskrnl.exe invokes the Win32 process callout registered for the current session. win32kfull.sys coordinates the operation, while win32kbase.sys allocates the Win32 process object and publishes it back through the executive's Win32Process slot. USER, GDI, and DirectComposition can then initialize their own portions of that object.
The GDI branch continues into win32k.sys, which uses the session's graphics interface to reach dxgkrnl.sys. DXG creates the graphics process object and publishes it through the executive's separate DxgProcess slot. These two slots refer to state owned by different subsystems: Win32Process belongs to the Win32k lifecycle, while DxgProcess belongs to the graphics kernel. They cooperate, but they are not interchangeable.
The thread path follows the same division of responsibility. win32kfull.sys asks win32kbase.sys to allocate the Win32 thread wrapper and run the thread callbacks. The wrapper is stored in the executive thread slot and points to the underlying THREADINFO; the slot is not a direct THREADINFO pointer. The GDI thread callback then crosses the session graphics interface so DXG can establish its corresponding graphics thread state. The diagram focuses on creation. Teardown reverses these ownership relationships, although the exact callback order can differ between branches.
We can divide processes into two broad states for this discussion: processes that have initialized Win32k state and processes that have not. This distinction is important because GUI processes interact with session-private Win32k objects to manage windows and graphical elements. A process without that state is not necessarily permanently unable to make GUI-related syscalls; its first suitable Win32k service can cause Windows to convert the calling thread and initialize the required process state. Policy can still prohibit that conversion or block the syscall entirely.
Two useful signals when investigating whether that path is available are:
  • ▸

    MitigationFlags in EPROCESS that disallow GUI-related syscalls (specifically DisallowWin32kSystemCalls).

  • ▸

    Whether the thread is a GUI thread, which can be determined by checking the KTHREAD structure's ThreadFlags and GuiThread fields.

There are other useful indicators, such as the process's Win32Process, Win32KFilterSet, and DxgProcess fields, as well as the session's Win32 callout state. These fields do not all describe the same object or share the same owner. The important point is that GUI capability emerges from a coordinated process, thread, graphics, and session setup rather than from one definitive pointer or flag.

Syscalls In Depth

If you are unfamiliar with the concept of syscalls, please stop, read about them and then continue.
KiSystemCall64 is the common 64-bit Windows syscall entry and dispatcher. During processor initialization, Windows places either KiSystemCall64 or the KVA-shadow entry in the IA32_LSTAR model-specific register. There are no normal callers of the top-level entrypoint. When user mode executes SYSCALL, the processor loads the kernel instruction pointer from IA32_LSTAR and arrives with a small but important register contract.
At entry, RAX contains the encoded service number, RCX contains the user return address, and R11 contains the user's flags. RSP still points to the user stack because SYSCALL does not switch stacks by itself. The user-mode stub has already copied the first function argument from RCX to R10; the second through fourth arguments remain in RDX, R8, and R9, and any remaining arguments are on the user stack.

Overview of the Syscall Entry Path

KiSystemCall64 dispatch and return flow

Entering the Kernel

The direct entry begins with SWAPGS, which changes the GS base from user state to the processor's kernel control region. Windows saves the user stack pointer, selects the kernel stack, and creates a synthetic return frame containing the user stack, flags, code segment, and return address. It then expands that state into a trap frame and saves the volatile general-purpose, SIMD, and control state needed to call an ordinary kernel function and later return to user mode.
The entry path also applies the mitigations configured for the processor and kernel. Depending on the system, this can include speculation-control MSR updates, return-stack prediction refilling, branch-history-buffer flushing, SMAP access control, and CET shadow-stack transitions. A KVA-shadow entry performs the additional address-space transition before joining the common dispatcher. The exact flags that select these paths are deliberately omitted here because their meanings and locations are build-specific.
Once the architectural transition is complete, Windows records thread-visible syscall state. The active trap frame, previous processor mode, original service number, and first argument are associated with the current thread. An optional syscall-provider hook can handle or reject the call before ordinary table dispatch. If it does not, execution continues into the common service dispatcher.

Selecting a Service

The dispatcher separates the encoded service number into a table selector and a service index. In the analyzed kernel, bit 12 selects the second descriptor slot used for Win32k services, while the low 12 bits select an entry within that table.
For a Win32k-capable thread, the relevant descriptor array is KeServiceDescriptorTableShadow, commonly called the shadow SSDT. Despite the name, it is not a duplicate used only as a fallback. Its first descriptor represents the native NT services, while its second descriptor exposes the Win32k NtUser* and NtGdi* table. Thread state selects the shadow descriptor array, and service-number bit 12 selects its Win32k descriptor. A filtered variant can be selected when Win32k syscall filtering is active. Each chosen descriptor ultimately supplies a service-table base and the number of valid entries.
The word "shadow" here is unrelated to the KVA-shadow syscall entry described earlier. KVA shadowing controls the address-space transition around kernel entry and exit; KeServiceDescriptorTableShadow controls which service descriptors are available during dispatch.
If the index is valid, Windows reads the corresponding 32-bit SSDT entry. The entry is not a direct function pointer. Its signed upper bits encode the service target relative to the table base, while the low nibble records how many additional arguments must be copied from the caller's stack. This compact representation lets the dispatcher recover both the destination and the calling requirements from one value.
For stack arguments, the dispatcher reserves enough kernel stack space for the supported maximum and copies only the number encoded in the entry. User-mode source addresses are constrained by the user probe boundary so that an invalid or kernel-space address faults through the syscall exception path rather than becoming an unrestricted kernel read. The copy is implemented as an unrolled sequence rather than a conventional loop. The first four arguments stay in the standard x64 registers, so the resolved service receives a normal Windows x64 call frame.
The actual call can be direct or wrapped by dynamic tracing or performance instrumentation. All variants preserve the same service-call contract and collect the result from RAX before entering the shared exit path.

The Win32k Conversion Path

Win32k dispatch has one important exception to ordinary bounds failure. If the service number selects the GUI descriptor but the index cannot yet be resolved, the dispatcher can call PsConvertToGuiThread. A successful conversion creates any required process and thread GUI state through the session's registered Win32 callouts. The dispatcher then repeats descriptor selection and bounds checking instead of failing the original syscall immediately.
This behavior is what allows a thread's first Win32k syscall to participate in its transition into a GUI thread. Conversion is not guaranteed: mitigation policy, process state, session state, or callback failure can reject it. If conversion fails, or if an invalid service does not qualify for this path, the syscall returns a mapped failure or STATUS_INVALID_SYSTEM_SERVICE.
For a valid Win32k service, the dispatcher also checks the thread environment block's GDI batch count. When pending batched GDI work exists, it calls PsInvokeWin32Callout with callout ID 7 before invoking the requested service. This gives the session's Win32 callback an opportunity to flush the batch at the transition boundary. The same ID is used by an explicit GUI-thread and batch-flush helper, which is why ID 7 appears in the callback mapping later in this post.

Returning to User Mode

After the service completes, the shared exit code distinguishes a normal user syscall from an internal kernel invocation that entered the same dispatcher. The kernel case restores the previous thread and trap-frame state and returns to its caller. The user case performs much stricter validation.
Windows verifies that the service is not returning above passive IRQL, that APC accounting is consistent, and that the thread's previous mode still describes user mode. It delivers pending user APCs, synchronizes the user isolation domain, restores debug, floating-point, CET, and speculation-control state, and clears selected volatile registers to limit accidental disclosure of kernel state.
The ordinary fast path restores user state with SYSRET. KVA-shadow systems transfer through a dedicated exit helper that restores the user address space before returning, while exceptional or provider-specific contexts can use an IRETQ-based path. By the time control reaches user mode, the service result is in RAX and the kernel has unwound the architectural and thread state created at entry.
For the rest of this post, the most relevant part is the junction between the Win32k conversion path, GDI batch flushing, and PsInvokeWin32Callout. Syscall dispatch does not implement those session-specific operations itself. It asks the registered Win32 callback to perform them.

Win32 Callbacks

PsWin32CallBack and PsWin32NullCallBack are callback objects used when the executive needs Win32k to perform a session-aware operation. One place this happens is the GDI batch-flush path in KiSystemCall64, which reaches them through PsInvokeWin32Callout. GUI process and thread conversion also uses the registered callback directly. Numbered callouts additionally serve atom, object-manager, power, graphics, and session-related paths. Job notifications cross this boundary through separate exports, as we will see below.
PsInvokeWin32Callout is the common invocation wrapper. At a high level, its selection and reference-lifetime logic looks like this:
NTSTATUS __fastcall PsInvokeWin32Callout(unsigned int calloutId, void *ctx)
{
    if (!PspUpdateCalloutParameters(calloutId, ctx, /* additional callout state */))
    {
        return STATUS_INVALID_PARAMETER;
    }

    callback = PsSessionGetWin32Callouts();
    callbackReference = ExReferenceCallBackBlock(callback);

    if (!callbackReference )
    {
        return STATUS_INVALID_PARAMETER;
    }

    status = InvokeReferencedCallback(callbackReference, calloutId, ctx); // CFG protected call
    ExDereferenceCallBackBlock(callback, callbackReference);
    return status;
}
The function first passes the callout ID and context to PspUpdateCalloutParameters. This helper validates the ID and prepares a small routing header: a session mode followed by a session-ID pointer. Operation-specific data follows that header. If the validation fails, invocation stops with STATUS_INVALID_PARAMETER. InvokeReferencedCallback is a label I gave for the CFG indirect call, not a Windows export.
Next, PsSessionGetWin32Callouts selects the callback registration that applies to the current process context. ExReferenceCallBackBlock takes a stable reference to the currently registered callback block, which prevents the registration from disappearing while the call is in progress. If no block can be referenced, the wrapper again returns STATUS_INVALID_PARAMETER. The callback is then invoked through a Control Flow Guard-protected indirect call, its return status is preserved, and ExDereferenceCallBackBlock releases the temporary reference. In other words, PsInvokeWin32Callout handles parameter preparation and callback lifetime; the selected Win32k handler performs the operation associated with the numeric callout ID.
The following normalized pseudocode expresses the observed selection result without preserving redundant temporary variables introduced by the decompiler:
EX_CALLBACK* PsSessionGetWin32Callouts()
{
    if ((KeGetCurrentThread()->ApcState.Process.Flags & OverrideAddressSpace) == 0 )
    {
        return &PsWin32CallBack;
    }

    bool win32CalloutsAvailable = KeGetCurrentThread()->ApcState.Process.Session->Win32Callouts;

    if (!win32CalloutsAvailable )
    {
        return &PsWin32NullCallBack;
    }

    return &PsWin32CallBack;
}
PsSessionGetWin32Callouts decides whether the normal Win32k callback is valid for the current address-space context. In the ordinary case, where OverrideAddressSpace is clear, it returns PsWin32CallBack. That global callback registration represents the active Win32k callout path.
When OverrideAddressSpace is set, the current thread may be executing while attached to an address space that should not blindly inherit the normal callback context. The function therefore checks whether the process's session exposes Win32 callouts. If that session state is unavailable, it returns PsWin32NullCallBack instead of the normal registration. In this selection path, the null registration acts as the fallback for the special context: callers can use the same callback framework without dispatching through the normal session handler. If session callouts are available, the normal PsWin32CallBack registration remains usable. This selection logic establishes which callback object is chosen; by itself, it does not prove every behavior of the handler registered in the null object.
The important distinction is that these are callback objects selected by executive state, not direct pointers to a particular Win32k service. PsInvokeWin32Callout references whichever object PsSessionGetWin32Callouts returns and invokes the handler currently registered in it. This extra layer is what makes the behavior session-aware and safe against concurrent callback registration changes.
From this control flow, I recovered two conditions that must both be true for PsSessionGetWin32Callouts to select PsWin32NullCallBack instead of the normal PsWin32CallBack object:
  1. 1.

    The process must have OverrideAddressSpace flag set in its EPROCESS.

  2. 2.

    The process's session object must have Win32Callouts set to 0.

The Call Chain and Parameter ABI

The numeric ID is an operation selector, not a syscall number or the address of a callback. Here is the path recovered from the kernel, Win32k front door, and base worker:
Numbered callout validation, session routing, and handler dispatch
win32k.sys registers W32CalloutDispatchThunk with the executive through SysEntryPsEstablishWin32Callouts and PsEstablishWin32Callouts. PsInvokeWin32Callout prepares the request, selects and references the callback block, then invokes its function pointer with the ID and parameter block. Several callers, including GUI conversion, object-manager procedures, and atom APIs, validate their parameters and use ExCallCallBack directly; they still reach the registered numbered callback. W32SessionAttachAndCalloutDispatch chooses the session context, and W32CalloutDispatchWorker either handles the operation itself or calls an optional USER, GDI, or DXG interface.
For every accepted ID except 26, PspUpdateCalloutParameters writes a 32-bit session mode at the start of the parameter block and a pointer to the selected session ID at offset +8. Operation-specific fields begin at +0x10. If the caller supplies no block, PsInvokeWin32Callout creates a zeroed 16-byte one. ID 26 is special: validation accepts it without writing that header, and the session router dispatches it directly to the subsystem-process query. The header describes routing, while the remaining fields depend on the operation; interpreting one payload as another would be a mistake.
"Mode" is a session-routing selector stored in the first 32 bits of the callout parameter block. The callout ID still determines which operation Win32k performs; mode tells the router where to perform it. The labels 0, 1, and 2 describe the branches observed in these IDBs. The analysis did not find a named public enum for them.
  • ▸

    Mode 0: Dispatch in the current process's session.

  • ▸

    Mode 1: Read the session ID through the pointer at offset +8, then attach to that session before dispatch. The router rejects a session ID of -1.

  • ▸

    Mode 2: Fan the callout out across available sessions, attaching to their session processes as needed. The returned status comes from the initial, current-session dispatch; later visits do not replace it.

For example, ID 41 uses mode 1 to target a process's session, while the broadcast power path uses mode 2. ID 26 is the exception: it bypasses this routing header and the mode switch.
There are checks at several layers. The kernel validator accepts IDs 0-5, 7-16, 18-21, and 24-44. IDs 6, 17, 22, and 23 are rejected in this build, and the Win32k router and base worker have no corresponding operations. The worker creates temporary nonpaged Win32 thread context, rejects IDs 2 and above for a process marked system-critical, and may also return an unsupported result when an optional interface handler is absent. A route in a dispatch table therefore shows supported code, not that every session can execute it.
The front-door thunk has a limited fallback for IDs 0, 1, 24, 25, 43, and 44, but only when session dispatch reports that its route is unavailable. Other IDs do not acquire a fallback merely by reaching the thunk. This matters for interpreting an error: a failed session dispatch is not equivalent to a successful Win32kBase invocation, and the fallback does not implement the full ID map.

Caller-to-ID Quick Map

The caller-oriented map is useful as a lookup while reading the dispatcher. The grouped map below explains the supported ID range and the handlers in more detail.
Observed executive callers and their Win32k operations
CallerID or pathObserved purpose
PsConvertToGuiThread0, 1 (process/thread exit paths)Create or tear down Win32k process and thread state.
SeCaptureAtomTableCallout2 (atom system calls)Request an atom-table pointer subject to Win32k checks.
PopInvokeWin32Callout3, 4, 5Deliver power event, state, or information updates.
KiSystemCall647Flush a pending GDI batch before Win32k service dispatch.
PspEnsureGuiThreadAndBatchFlush7Convert the thread if needed, then flush a pending batch.
KeUserModeCallback7Flush before its user-mode callback transition.
ExpWin32OpenProcedure8, 16, 18, 27, 33, 37Select the open handler for the desktop, window-station, composition, Raw Input Manager, Core Messaging, or activation object type.
PspQueryProcessInterferenceCountCallback24Query the process's graphics interference count.
PfpQueryGpuUtilization25Query GPU usage statistics.
PspShutdownCsrProcess31Wake the RIT during CSR shutdown.
NtSetSystemInformation, class 17732Update session USER/DWM state for a selected process.
PspSetProcessTimerDelayForWin3241Update Win32 process timer-delay state.
PspChangeProcessExecutionState through PsApplyDeepFreezeOptimizations43Notify DXG that a process is freezing.
PspChangeProcessExecutionState through PsRemoveDeepFreezeOptimizations44Notify DXG that a process is thawing.
PopInvokeWin32CalloutWithWatchdogCaller-supplied IDMonitor a power callout with a watchdog; no new numeric ID is assigned.
PspJobDeleteSeparate exportCall Win32kJobTerminateNotify under its job-deletion conditions.
PspSetUILimitJobObjectSeparate exportCall Win32kJobUpdateUIRestrictionsNotify for job UI limits.
PspAssignProcessToJobSeparate exportCall Win32kJobAddProcessNotify when assigning a process to a job.
These three job rows are outside the numbered callback: numeric ID 6 is rejected in this build.

Callout ID Map

Note: In the object groups, open, okay-to-close, close, and delete are distinct object-manager events, so adjacent IDs do not all do the same thing.
  • ▸

    0 and 1 - GUI lifecycle. ID 0 creates or tears down Win32k process state; PsConvertToGuiThread and PspExitLastThread can request it. ID 1 creates or tears down thread state; conversion and PspExitThread use it. win32kfull.sys coordinates the USER, GDI, and composition callbacks described in the earlier lifecycle diagram.

  • ▸

    2 - atom table. SeCaptureAtomTableCallout and the atom system calls request a table pointer through UserGlobalAtomTableCallout. The token path only attempts to install a returned pointer when its existing pointer is absent. One observed modern-core handler branch is a zero-return stub, so this analysis does not establish that every request yields a non-null table.

  • ▸

    3, 4, and 5 - power. The power manager supplies ID 3 for a user/power event, ID 4 for a power-state transition, and ID 5 for power information. ID 5 has a second operation selector in its payload; observed callers cover display and monitor state, panel state, session information, engagement, and other power notifications.

  • ▸

    6 - unsupported here. The kernel validator rejects it. The job notification path does not use this numeric ID in the supplied binaries.

  • ▸

    7 - GDI batch flush. KiSystemCall64, PspEnsureGuiThreadAndBatchFlush, and KeUserModeCallback request a flush when pending batch state requires one. The optional NTGDI interface leads to win32kfull.sys's NtGdiFlushUserBatch.

  • ▸

    8-11 - desktop object. Open, okay-to-close, close, and delete reach DesktopOpenProcedure, OkayToCloseDesktop, UnmapDesktop, and FreeDesktop, respectively.

  • ▸

    12-16 - window-station object. IDs 12-14 are okay-to-close, close, and delete; ID 15 is parse; ID 16 is open. These reach the corresponding window-station handlers in Win32k.

  • ▸

    17 - unsupported here. Like ID 6, it has no accepted numbered route in the analyzed build.

  • ▸

    18-21 - composition object. Open and okay-to-close use named composition handlers. Close and delete use object methods through indirect slots, so the concrete method target depends on the object.

  • ▸

    22 and 23 - unsupported here. The validator and routers reject both IDs.

  • ▸

    24 and 25 - DXG queries. PspQueryProcessInterferenceCountCallback requests a process interference count with ID 24; PfpQueryGpuUtilization requests GPU usage statistics with ID 25. Win32kBase forwards through graphics interfaces, with limited front-door fallbacks if session dispatch is unavailable.

  • ▸

    26 - subsystem process query. The session router sends this ID directly to W32pQuerySubsystemProcess, which safely references the session's subsystem process. No direct caller was found in the inspected kernel, so the handler's presence is not evidence of runtime use.

  • ▸

    27-30 - Raw Input Manager object. Open, okay-to-close, close, and delete reach the Raw Input Manager object callback path.

  • ▸

    31 - CSR shutdown. PspShutdownCsrProcess invokes the callback while attached to the target process. WakeRITForShutdown checks the session subsystem process, wakes the Raw Input Thread (RIT), and begins shutdown work.

  • ▸

    32 - session system information. A class-177 branch of NtSetSystemInformation passes a process in its selected session. Win32kBase enters USER session state, conditionally drains deferred unlocks, and records DWM process state. The public name of class 177 was not recovered here.

  • ▸

    33-36 - Core Messaging object. Open, okay-to-close, close, and delete use the Core Messaging callback path. Open reaches CoreMsgObject::Open under its entry lock; close returns success without a deeper object method in the observed worker branch.

  • ▸

    37-40 - activation object. Open reaches ActivationObjectOpen; okay-to-close checks type and sometimes session; close and delete perform the corresponding worker checks and return success without a deeper operation in the observed branches.

  • ▸

    41 and 42 - process timer data. PspSetProcessTimerDelayForWin32 uses ID 41 to update per-process timer-delay state and wake the RIT timer scan. ID 42 reads a Win32 process timer statistic, but no direct kernel caller was found in this static pass.

  • ▸

    43 and 44 - DXG freeze and thaw. Process execution-state changes use ID 43 to notify DXG of freeze and ID 44 for thaw. The callers attach to the target process and use mode 0, so the current attached session determines routing. The corresponding DXG functions operate on the process's DxgProcess state.

The object-manager families deserve one extra distinction. ExpWin32OpenProcedure chooses ID 8 for a desktop, 16 for a window station, 18 for a composition object, 27 for a Raw Input Manager object, 33 for a Core Messaging object, and 37 for an activation object. The okay-to-close, close, and delete procedures choose the neighboring IDs for the same object family. Thus, ID 33 is specifically the Core Messaging open path, not a generic desktop or window-station callout.
The atom-table path shows why it helps to trace beyond the ID. SeCaptureAtomTableCallout looks at the effective token and requests ID 2 when token state requires an atom table but the stored pointer is missing. If the callback returns a table, the caller uses an interlocked compare-exchange to install it without overwriting another thread's result. NtFindAtom, NtAddAtomEx, NtDeleteAtom, and NtQueryInformationAtom also request ID 2 before using a table. Win32kBase can take a process-table path or, under modern-core support, check Win32k lockout and window-station state before calling an optional handler. The observed stub in that latter branch is the reason I would not treat ID 2 as a guarantee of a usable pointer.
ID 7 is the most visible bridge from the syscall discussion. Before a valid Win32k service, KiSystemCall64 checks for pending GDI batch work and requests ID 7. The explicit GUI-thread helper can convert the thread first and then make the same request. KeUserModeCallback has a third flush site before its user-mode transition. All three request the same operation, but they reach it at different boundaries. Win32kBase uses an optional NTGDI interface whose resolved target is NtGdiFlushUserBatch; the worker does not return a meaningful status from that void flush callback.
ID 24 shows how a callout can cross into graphics state. The kernel references the target process, selects its session, and passes the process plus an output pointer. Win32kBase invokes a DXG interface to gather the interference count from the process's graphics managers. The corresponding DXG implementation reads EPROCESS.DxgProcess. The interface name and matching implementation support this path, although the final import binding was not observed in a live loader. The same qualification applies to the DXG destinations for IDs 25, 43, and 44.
The session operations carry their own context. For ID 31, PspShutdownCsrProcess prepares shutdown events, attaches to the target CSR process, and calls into its session. WakeRITForShutdown checks that the current process is the session's subsystem process before waking the RIT and running shutdown work; a successful callback affects whether the kernel waits for the RIT-exited event. For ID 32, the class-177 NtSetSystemInformation branch selects a current or referenced process, rejects a missing session, and asks Win32kBase to update session state. Neither ID is a general purpose request to shut down any process or set arbitrary session information.
Timer and execution-state notifications illustrate two more ways to choose the session. ID 41 requires an existing Win32 process, then passes its process and delay values using mode 1 so Win32kBase can find the Win32 process object and ask SetProcessTimerDelay to update it. A missing object is an error. IDs 43 and 44 instead run after the kernel has attached to the target process and use mode 0 for the freeze or thaw notification. The matching DXG handlers retrieve DxgProcess, update graphics state, and may notify a guest VMBus. Matching interface names and behavior support the DXG destination; the imported slot was not resolved at runtime.
The power wrapper is another source of confusion. PopInvokeWin32CalloutWithWatchdog receives an ID from its caller, adds timeout monitoring around PsInvokeWin32Callout, and passes that same ID onward; the watchdog has no ID of its own. The statically located power callers use IDs 3, 4, and 5. For broadcast work, the parent wrapper normally enumerates sessions and issues requests in mode 1, with mode 2 used in its empty-enumeration path. This caller inventory does not exclude an indirect caller supplying another accepted ID.
Finally, job events belong beside this map but outside the numbered callback. PspJobDelete, PspSetUILimitJobObject, and PspAssignProcessToJob call the imported Win32kJobTerminateNotify, Win32kJobUpdateUIRestrictionsNotify, and Win32kJobAddProcessNotify exports under their respective conditions. win32k.sys attaches to the job session and forwards through a separate Win32kBase interface. There is no numbered ID 6 job fallback in the supplied kernel, router, or worker. That distinction matters when tracing an executive call site: reaching Win32k does not automatically mean passing through PsInvokeWin32Callout.

Conclusion

The original goal was to find a new PatchGuard bypass, and this path did not get me there. What it did produce was a much clearer model of the boundary between the executive, Win32k, and the graphics stack.
The most interesting part for me is that the numbered callback mechanism is not limited to one GUI syscall. PsInvokeWin32Callout provides a common, reference-counted path for operations ranging from GDI batch flushing to object and power events, while PsSessionGetWin32Callouts changes the selected callback when the current address-space and session state require it. Reused IDs describe an operation shared by several callers; job notifications show that some executive-to-Win32k work also uses a separate export path.
There is still much more to explore. While this post covered the basic flow from win32u.dll to PsInvokeWin32Callout and selected parts of the Win32k subsystem, many details remain undocumented. I hope you found this post interesting, learned a few new things, and are inspired to dig deeper into the inner workings of Win32k. Of course, feel free to reach out with questions and share your findings.