Prologue
win32u.dll to KiSystemCall64, look at how a thread becomes a GUI thread, and then focus on PsInvokeWin32Callout, PsWin32CallBack, and PsWin32NullCallBack.The Win32k Subsystem
Intro
xxxWinSomeBizzareFunctionThatIsResponsibleForASinglePixel or to a short reference in the Windows Internals books. The ordinary control flow receives much less attention.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.Win32k Architecture
NtUser* or NtGdi* implementation. The lifecycle path creates the process and thread state that lets those services operate safely in the correct session.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.- ▸
win32u.dllcontains the user-modeNtUser*andNtGdi*syscall stubs. - ▸
gdi32full.dllimplements and wraps user-mode GDI functionality before calling those stubs. - ▸
ntoskrnl.exeperforms syscall dispatch, owns the executive process and thread fields, and initiates GUI-thread conversion when it is needed. - ▸
win32k.sysis the syscall and session-facing front door. It exposes service-table information and dispatches through session-local Win32k and graphics interfaces. - ▸
win32kbase.sysallocates and manages much of the shared Win32 process and thread state and runs USER, GDI, and DirectComposition setup and teardown. - ▸
win32kfull.sysorchestrates the upper process and thread callout chains and supplies higher-level USER and GDI services. - ▸
win32kbase_rs.sysis 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 withdxgmms1.sysanddxgmms2.sys, owns the graphics-facing process, thread, scheduling, and video-memory state used by Win32k.
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.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.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.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
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.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.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.- ▸
MitigationFlagsinEPROCESSthat disallow GUI-related syscalls (specificallyDisallowWin32kSystemCalls). - ▸
Whether the thread is a GUI thread, which can be determined by checking the
KTHREADstructure'sThreadFlagsandGuiThreadfields.
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
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.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
Entering the Kernel
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.Selecting a Service
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.KeServiceDescriptorTableShadow controls which service descriptors are available during dispatch.RAX before entering the shared exit path.The Win32k Conversion Path
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.STATUS_INVALID_SYSTEM_SERVICE.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
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.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;
}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.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.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.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.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.PsSessionGetWin32Callouts to select PsWin32NullCallBack instead of the normal PsWin32CallBack object:- 1.
The process must have
OverrideAddressSpaceflag set in itsEPROCESS. - 2.
The process's session object must have
Win32Calloutsset to 0.
The Call Chain and Parameter ABI
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.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 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.
Caller-to-ID Quick Map
| Caller | ID or path | Observed purpose |
|---|---|---|
PsConvertToGuiThread | 0, 1 (process/thread exit paths) | Create or tear down Win32k process and thread state. |
SeCaptureAtomTableCallout | 2 (atom system calls) | Request an atom-table pointer subject to Win32k checks. |
PopInvokeWin32Callout | 3, 4, 5 | Deliver power event, state, or information updates. |
KiSystemCall64 | 7 | Flush a pending GDI batch before Win32k service dispatch. |
PspEnsureGuiThreadAndBatchFlush | 7 | Convert the thread if needed, then flush a pending batch. |
KeUserModeCallback | 7 | Flush before its user-mode callback transition. |
ExpWin32OpenProcedure | 8, 16, 18, 27, 33, 37 | Select the open handler for the desktop, window-station, composition, Raw Input Manager, Core Messaging, or activation object type. |
PspQueryProcessInterferenceCountCallback | 24 | Query the process's graphics interference count. |
PfpQueryGpuUtilization | 25 | Query GPU usage statistics. |
PspShutdownCsrProcess | 31 | Wake the RIT during CSR shutdown. |
NtSetSystemInformation, class 177 | 32 | Update session USER/DWM state for a selected process. |
PspSetProcessTimerDelayForWin32 | 41 | Update Win32 process timer-delay state. |
PspChangeProcessExecutionState through PsApplyDeepFreezeOptimizations | 43 | Notify DXG that a process is freezing. |
PspChangeProcessExecutionState through PsRemoveDeepFreezeOptimizations | 44 | Notify DXG that a process is thawing. |
PopInvokeWin32CalloutWithWatchdog | Caller-supplied ID | Monitor a power callout with a watchdog; no new numeric ID is assigned. |
PspJobDelete | Separate export | Call Win32kJobTerminateNotify under its job-deletion conditions. |
PspSetUILimitJobObject | Separate export | Call Win32kJobUpdateUIRestrictionsNotify for job UI limits. |
PspAssignProcessToJob | Separate export | Call Win32kJobAddProcessNotify when assigning a process to a job. |
Callout ID Map
- ▸
0 and 1 - GUI lifecycle. ID 0 creates or tears down Win32k process state;
PsConvertToGuiThreadandPspExitLastThreadcan request it. ID 1 creates or tears down thread state; conversion andPspExitThreaduse it.win32kfull.syscoordinates the USER, GDI, and composition callbacks described in the earlier lifecycle diagram. - ▸
2 - atom table.
SeCaptureAtomTableCalloutand the atom system calls request a table pointer throughUserGlobalAtomTableCallout. 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, andKeUserModeCallbackrequest a flush when pending batch state requires one. The optional NTGDI interface leads towin32kfull.sys'sNtGdiFlushUserBatch. - ▸
8-11 - desktop object. Open, okay-to-close, close, and delete reach
DesktopOpenProcedure,OkayToCloseDesktop,UnmapDesktop, andFreeDesktop, 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.
PspQueryProcessInterferenceCountCallbackrequests a process interference count with ID 24;PfpQueryGpuUtilizationrequests 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.
PspShutdownCsrProcessinvokes the callback while attached to the target process.WakeRITForShutdownchecks the session subsystem process, wakes the Raw Input Thread (RIT), and begins shutdown work. - ▸
32 - session system information. A class-177 branch of
NtSetSystemInformationpasses 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::Openunder 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.
PspSetProcessTimerDelayForWin32uses 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
DxgProcessstate.
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.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.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.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.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.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.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.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
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.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.