PhantomLayout - Ghost Layouts, Real SYSTEM

DZONERZY

Saturday, 08 August, 2026

A ghost in the keyboard tray

PhantomLayout hero image

Open any “Windows process injection” cheatsheet and they all start the same way: OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread. Or the fancier cousins: APCs, thread hijacking, SetWindowsHookEx, section mapping. Every single one of them requires the attacker to touch the target: get a handle, write into its address space, nudge a thread. Every EDR on the planet knows that dance by heart.

So here’s the thing that made me fall in love with this one: at its core, this technique never touches the target process at all. No handle, no remote allocation, no memory write, no thread, no APC, no hook. And the strongest trigger route doesn’t even send it a window message. The cross-process carrier is a 352-byte keyboard-layout descriptor that win32k keeps in window-station scope, and window stations are shared by every process in your interactive session, Medium integrity and High alike. One process poisons it, and any other process on the station that politely asks IMM32 about that layout ends up mapping an attacker-selected PE file into itself, on its own thread, through its own ordinary loader path, under the loader lock. The target injects itself. You’re just the one who left the loaded weapon lying around.

And because apparently that wasn’t spicy enough, the same primitive chains into my favorite kind of LPE: a medium-integrity user lands code execution as NT AUTHORITY\SYSTEM inside consent.exe (yes, the UAC prompt process, on the secure desktop) before the user answers the prompt. In the final PoC the user never answers anything: the payload itself dismisses the prompt unapproved, AppInfo reports ERROR_CANCELLED, and a SYSTEM shell is already sitting on the desktop. Nothing was ever approved.

I named it PhantomLayout Injection, ’cause the victim believes it’s resolving a legitimate keyboard layout, while the descriptor it consumes is a phantom written by somebody else. And yes, I promise I’ll drop the branding after this paragraph. The rest of the post is pure win32k plumbing.

One honest note before the fun starts, ’cause it matters for the science: abusing the Windows input stack is not new, and I’ll walk through the prior art in a dedicated section: an in-the-wild IME injection from 2015, shatter attacks, Tavis Ormandy’s MSCTF work, and a win32k CVE that touched the exact same syscall. What’s new here is the complete chain: window-station IME cache poisoning, loader-route bit flip, a System32-relative path traversal, a direct cross-process internal IME message, and a message-free foreground-layout inheritance route that carries the poison into fresh elevated processes. A focused prior-art search on 2026-08-07 found no materially equivalent public description of that composition.

Grab a coffee. We’re going from “what is an HKL” to a SYSTEM shell that clicks its own UAC prompt away.

IME school: everything you need in five minutes

IME layer cake

Quick background so we’re on the same page. If you already speak IMM32 fluently, skip ahead. If not, stay with me, ’cause the whole technique is four ideas.

What’s an IME. If you type English, your keyboard layout maps keys to characters and life is simple. If you type Japanese, Chinese or Korean, that’s not enough: you press keys, an Input Method Editor composes phonetic text, shows a candidate window, and converts it into final characters. On Windows, a legacy IME is literally a DLL that gets loaded into the application process and exports a fixed family of functions (ImeInquire, ImeProcessKey, ImeSetCompositionString, …). The system component that manages this is IMM32, the legacy Input Method Manager, with the newer Text Services Framework (TSF/msctf.dll + ctfmon.exe) layered on top. Applications talk to it through imm32.dll APIs like ImmGetProperty.

What’s an HKL. An HKL is a handle to a keyboard layout, an input locale identifier. The low word is a language ID (0x0409 US English, 0x0407 German, 0x0406 Danish), the high word identifies the specific layout/device for that language, so loading KLID 00000407 gives you HKL 0x04070407. Layouts are registered under HKLM\SYSTEM\CurrentControlSet\Control\Keyboard Layouts, loaded with LoadKeyboardLayoutW, and, this is the part that matters, every GUI thread has a current HKL. You switch it with ActivateKeyboardLayout, you read it with GetKeyboardLayout, and the system keeps per-window-station state describing each loaded layout. Windows input is fundamentally per-thread, and the HKL is how a thread knows which layout (and which IME) should handle its keys.

The default IME window. When a GUI thread creates its first eligible visible window, win32k attaches a hidden companion window of class "IME" to the thread. You normally never see it, but any process can find another window’s one with the documented API ImmGetDefaultIMEWnd. This window is the thread’s mailbox for internal IME control traffic, including an undocumented message, WM_IME_SYSTEM (0x287), whose wParam selects an internal operation. Two operations matter for us: 33, “load the thread’s current layout”, and 25, “activate this HKL”. Remember those two numbers.

Window stations. A window station is the win32k container for desktops, the clipboard, atoms and, critically, the keyboard-layout table, including a 352-byte extended IME descriptor (tagIMEINFOEX) per layout. Normal interactive processes, Medium and High integrity alike, usually share WinSta0. UIPI (User Interface Privilege Isolation, the Vista-era answer to shatter attacks) filters window messages sent from lower to higher integrity. Keep that in mind, ’cause it shapes which trigger routes work across privilege boundaries and which never send a message at all.

Previous visitors to the IME crime scene

The IME crime scene

The Windows input stack has been a crime scene for over two decades, and credit where it’s due: I stand on shoulders here:

So: IME injection, layout-change triggering, and even the ImmLoadIME sink are all old news. Hexacorn’s 2015 post has the same final API sequence. What a focused prior-art search as of 2026-08-07 did not find is the modern composition: window-station cache poisoning with no registry write, the loader-route bit flip, the System32-relative traversal that shrugs off the MS12-034 containment, foreground-layout propagation into fresh elevated processes, and pre-approval execution inside consent.exe. If you know a prior public description of that chain, my inbox is open.

The bug: a mutable sticky note in win32k

A bug rewriting the shared sticky note

Here’s the whole vulnerability in three trust decisions, none of which is memory corruption.

Decision one: who may rewrite a shared descriptor. For every layout in the window-station table, win32k keeps that 352-byte tagIMEINFOEX. There’s a syscall, NtUserSetImeInfoEx, reachable from any ordinary process, whose entire input-validation philosophy can be summarized as:

// win32kfull!SetImeInfoEx (0x140212558 on 10.0.26100.8972), cleaned
if (cached->load_status == 0) {
    memcpy(cached, caller_supplied, 0x160);   // all 352 bytes, no field allowlist
}
return TRUE;   // ...returned even when load_status != 0 and NOTHING was copied

Does it look weird, eh?? The destination is window-station scoped, shared with every process on the station, yet any caller can rewrite it wholesale while its load-status is zero, which is exactly the transient state of a layout that was loaded but whose IME was never initialized. No ownership check, no field filtering. The IME filename and the loader-routing flags cross the trust boundary along with legitimate metadata. And that cheerful return TRUE when the copy was skipped is a false-positive trap I had to design around: after every setter call, the PoC re-queries and byte-compares, ’cause the Boolean alone tells you nothing.

tagIMEINFOEX field map

Three fields do all the damage. load_status (+0x4C) is the gate: zero means mutable, and the states I observed for “resolved” are 1 (failed) and 2 (loaded). A nonzero value locks the descriptor against this setter path. ime_file (+0xBC, 79 UTF-16 chars) is the filename IMM32 will later load for this layout. flags (+0x15C) chooses how it gets loaded.

Decision two: which loader route runs. A normal, previously-unused layout caches LoadFlag=0, Flags=0x2, ImeFile="msctf.dll". Bit 0x2 selects a built-in, trusted function table. Clear it, and imm32!LoadImeDpi takes the legacy route instead:

// imm32!LoadImeDpi (0x180007420), cleaned
if (info.flags & 1) return nullptr;         // early reject
if (info.load_status == 1) return nullptr;  // previously failed
...
if (info.flags & 0x2)
    candidate->exports = BuiltInMsctfFunctionTable;   // trusted route
else
    LoadIME(&info, ...);                              // legacy route - attacker-selected

Decision three: what “in System32” means. The legacy route builds the path like this:

// imm32!LoadIME (0x180007A78), cleaned
GetSystemDirectoryW(path, 260);
StringCchCatW(path, 260, L"\\");
StringCchCopyW(path + len, 260 - len, info->ime_file);  // length-checked, NOT content-checked
CheckAndApplyAppCompat(path);                           // apphelp!ApphelpCheckIME
*module = LoadLibraryExW(path, NULL, LOAD_WITH_ALTERED_SEARCH_PATH);
...
if (!GetProcAddress(*module, "ImeInquire"))
    return FALSE;    // early return - and the module is never freed

The prefix C:\Windows\System32\ looks like containment, but nothing rejects separators, drive syntax, or .. components, and the result is never canonicalized back under System32. So a cached name like ..\..\Users\<user>\AppData\Local\Temp\payload.dll cheerfully resolves to an attacker-writable file. And ApphelpCheckIME is a compatibility decision: if the compat database has no entry, it returns allow. It is not path containment, not an ownership check, not a signature check.

Two lovely implementation accidents make this a joy to research. First, the IME export validation (ImeInquire and friends) happens after LoadLibraryExW, so a real DLL’s entry point runs before anyone asks whether it’s a valid IME. Second, that early return FALSE on the missing export never calls FreeLibrary: the image stays mapped and observable. My safe proof marker exploits exactly that: a DLL with AddressOfEntryPoint == 0, no executable section, no imports, no TLS callbacks, linked /DLL /NOENTRY /NODEFAULTLIB. It can be mapped, never executed, and it remains in the module list as undeniable evidence. The harness even refuses to run if the marker file fails any of those six structural checks, so a stale or replaced DLL can’t silently turn a mapping test into a code-execution test.

That’s the bug. A shared, caller-writable descriptor. A caller-controlled route bit. A path that isn’t a path. None of it corrupts a single byte of memory.

Handle-less is the whole point

Let me put the injection taxonomy next to this thing, ’cause the absence is the feature:

Classic injection step PhantomLayout
OpenProcess(PROCESS_VM_*) on the target nothing: the injection needs no target handle
VirtualAllocEx / NtAllocateVirtualMemory remotely nothing allocated in the target
WriteProcessMemory / section mapping nothing written to the target
CreateRemoteThread / APC / thread-context hijack nothing scheduled in the target
SetWindowsHookEx / callback planting nothing planted in the target
One poisoned descriptor, every consumer on the window station

The writer process performs exactly one kind of suspicious syscall: NtUserSetImeInfoEx on its own window station. Everything else it does is ordinary keyboard-layout APIs on its own thread. (For the pedantic: the only OpenProcess in the whole autonomous PoC is a SYNCHRONIZE wait on consent.exe after the payload already ran. It just keeps the cleanup code from deleting the DLL while it’s still mapped. The injection itself never needs a handle to anything.) Everything else (the descriptor retrieval, the route selection, the path building, the LoadLibraryExW) happens inside the consumer, on its own GUI thread, in its own address space, executed by the completely legitimate, Microsoft-signed imm32!LoadIME. From the target’s perspective, it’s just having a normal day initializing input support. If you’re an EDR watching the autonomous route, there is no target-directed cross-process operation to see: only a weird NtUserSetImeInfoEx from a desktop app, and later an image load whose call stack passes through imm32. (Doors three and four do send a target-directed window message, an ordinary SendMessage, still no handle, so those two are at least visible to message monitoring.) Both detectable (we’ll get to that), but neither is on the usual injection dashboard.

It also dissolves the integrity segregation in a way worth stating precisely. The Medium↔︎High boundary for GUI processes is enforced by UIPI, which is a message filter, and by object ACLs, which gate handles. This technique uses neither: the shared window-station descriptor is visible across integrity levels (proven three times: Medium wrote it, a High process consumed it), so the “segregation” turns out to be a wall with a shared basement. Same story for the user→SYSTEM boundary at the UAC prompt: no message crosses to the secure desktop. The poisoned layout state is simply inherited by the SYSTEM process’s brand-new GUI thread.

Five ways to knock

Five doors, one loader

So you poisoned the descriptor. Now somebody has to consume it, meaning a target process has to reach imm32!LoadImeDpi for your HKL while its process-local IME cache (ImeDpi) has no entry for it yet. Reversing the Windows 11 25H2 26200.8973 corpus (whose win32k and User32 components report 10.0.26100.8972) plus dynamic reproduction on my 22621.4317 test host gave me five distinct doors into the same sink:

Five trigger routes into imm32!LoadIME

Door one: the explicit API call (the lab trigger). Seventeen IMM32 functions call FindOrLoadImeDpi directly (ImmGetProperty, ImmConfigureIMEA/W, ImmGetConversionListA/W, the register-word family), and 25 exported APIs reach it in total. Even ImmGetProperty, documented as a read-only property query, lazily loads the IME DLL when the process-local cache misses. This is the cooperative trigger: a fresh child calls ImmGetProperty(X) and boom:

[map-child-cache] HKL=0x0000000004070407 LoadFlag=0 Flags=0x00000000
[*] ImmGetProperty returned 0x00000000 (last-error=127).
[*] Loader notification=YES base=000002B30DB90000 retained=000002B30DB90000.
[+] SAFE MAP RESULT: the target mapped the attacker-selected, no-entry PE through IMM32; no injected code ran.

Error 127 is ERROR_PROC_NOT_FOUND, the deliberately absent ImeInquire, and retained is that FreeLibrary-leak gift. Useful as a proof sink, but it needs the target to call IMM32 itself. The interesting doors are the ones that don’t.

Door two: the first visible window (automatic). Reversing win32kfull!xxxCreateWindowEx turned up a beautiful initialization branch: when a GUI thread creates its first visible, IME-eligible window, the kernel creates the thread’s hidden default IME window and immediately sends it WM_IME_SYSTEM operation 33, “load the thread’s current layout”:

// win32kfull!xxxCreateWindowEx (0x14003A99C), cleaned
if (SessionImeSupportEnabled() &&
    thread->defaultImeWindow == nullptr &&
    IsImeEligibleWindow(newWindow) &&
    (newWindow->style & WS_VISIBLE) != 0) {
    thread->defaultImeWindow = xxxCreateDefaultImeWindow(newWindow, ...);
    if (thread->defaultImeWindow)
        SendMessageTimeout(thread->defaultImeWindow,
                           WM_IME_SYSTEM /* 0x287 */, 33 /* load thread layout */, 0);
}

Operation 33 lands in user32!ImeSystemHandler, which calls CtfLoadThreadLayout, which reads the current thread’s HKL (straight out of the TEB’s Win32ClientInfo) and calls ImmLoadIME on it. Meaning: any fresh GUI thread whose current HKL is X will make its process map my file when it creates its first eligible visible window. No message from me, no IMM call in the application, no nothing. I proved it with a controlled child that activates X while windowless (a negative control: activation alone must not map anything), then creates its first WS_VISIBLE window:

[*] ActivateKeyboardLayout previous=0000000004100410 active=0000000004070407 last-error=0.
[*] First visible window=000000000047065E.
[*] Automatic loader notification=YES base=000001D246940000 retained=000001D246940000.
[+] SAFE AUTOMATIC RESULT: first-visible-window initialization mapped the attacker-selected no-entry PE; the child made no IMM query.

The whole security question of this door is “how does an unmodified target end up with current HKL = X”. Hold that thought. The autonomous route answers it.

Door three: WM_INPUTLANGCHANGEREQUEST (the public message). The documented way to ask an app to switch input language. When the target’s WndProc passes it to DefWindowProc, the kernel activates the requested layout in the receiving process (KLF_SETFORPROCESS) and notifies the thread’s default IME window with internal operation 25, which reaches ImmActivateLayout(X)ImmLoadIME(X) → the poisoned descriptor. Elegant, but wrapped in fine print. My favorite gotcha, from xxxRealDefWindowProc:

// win32kfull!xxxRealDefWindowProc, WM_INPUTLANGCHANGEREQUEST (0x50), cleaned
HWND focus = CurrentThreadQueue()->focusWindow;
if (focus == nullptr || focus == hwnd || IsDesktopSpecialCase(hwnd)) {
    xxxActivateKeyboardLayout(windowStation, lParamHkl, KLF_SETFORPROCESS, hwnd);
} else {
    // the requested HKL is NOT preserved on this path:
    SendMessageTimeout(focus, WM_INPUTLANGCHANGEREQUEST, 0, 0);
}

Send the request to a window that isn’t the thread’s focus window, and the kernel redirects it while throwing your HKL away. You also need the target to actually default-process the message, X to differ from its current layout, and UIPI to allow delivery. Against auto-elevated Task Manager it didn’t: SendMessageTimeoutW returned ERROR_ACCESS_DENIED and the target’s HKL never changed. Door three is statically complete end-to-end, but it’s the fussiest route, and it’s definitely not a cross-boundary key.

Door four: direct operation 25 to the default IME window. Here’s where it gets rude. ImmGetDefaultIMEWnd(normalHwnd) hands you the target-owned hidden IME window, whose window procedure (user32!ImeWndProcWorkerImeSystemHandler) contains:

// user32!ImeSystemHandler (0x180011E98), cleaned
switch (operation) {
case 25: return ImmActivateLayout((HKL)lParam);  // lParam is the requested HKL
case 33: CtfLoadThreadLayout(state); return 0;   // loads current thread HKL
}

No application WndProc involved, no WM_INPUTLANGCHANGEREQUEST acceptance required, X doesn’t need to be the current layout, and there’s no sender-identity check on this path. My sender launches a separate target process, waits for its window and IME window to exist, verifies the target’s HKL is not X, poisons X, waits a 250 ms negative-control interval to prove nothing happens by itself, then sends WM_IME_SYSTEM/25/X straight to the target’s IME window. The target, which calls zero IMM APIs, maps the marker. And then I did it to completely unmodified stock applications, watching from outside with read-only Toolhelp module snapshots:

[*] Unmodified target: C:\Windows\System32\charmap.exe
[*] STOCK TARGET READY: PID=32216 TID=47900 HWND=0000000000CF0BA0 HKL=0000000004100410 title="Character Map".
[stock-seeded] HKL=0x0000000004070407 LoadFlag=0 Flags=0x00000000
[*] Unmodified-target negative control: marker module=ABSENT.
[*] Unmodified-target WM_IME_SYSTEM/25 delivery=YES result=1 error=0.
[*] External exact-module observation=PRESENT base=0000016741710000.
[+] SAFE UNMODIFIED-TARGET RESULT: operation 25 made the stock application map the exact no-entry, non-executable marker; evidence came from an external read-only module snapshot.

Stock Character Map (twice, clean), stock WordPad, Medium-integrity Disk Cleanup: all mapped the marker. Same-integrity unmodified process injection, no cooperation from the target whatsoever. But Medium→High? UIPI again: error=5, ERROR_ACCESS_DENIED, twice. Door four is a same-integrity monster and a cross-integrity dud.

That’s four doors. The fifth is the message-free one, and it gets its own section, ’cause it’s the one that walks straight through the integrity boundary.

The autonomous route: no messages, no handles, no cooperation

The foreground is the inheritance

Doors two through four left one open question: how do you make a fresh, unmodified, elevated process consume X without sending it anything? Door two loads the thread’s current HKL at first-window creation, and CreateProcess has no HKL parameter. So where does a new GUI thread’s current HKL come from?

My first two hypotheses died in instructive ways. Activating X in a background launcher thread before launching Task Manager: target still starts with 00000410. That only changes the launcher’s private thread HKL. SPI_SETDEFAULTINPUTLANG (with or without SPIF_SENDCHANGE): same negative, fresh targets keep the logged-on default. Windows research is 10% courage and 90% removing things you assumed. Yes, it’s the third post in a row I say that, and it’s still true 😔. The answer was hiding in win32kbase:

// win32kbase!xxxInternalActivateKeyboardLayout (0x14007B270), cleaned
if (CurrentProcessOwnsForegroundInputQueue())
    xxxChangeForegroundKeyboardTable(prev, newLayout);  // session+0x3798 = X

// win32kbase!xxxCreateThreadInfo (0x140156DD8), new GUI thread:
thread->layout = session->foregroundLayout;             // tagTHREADINFO+0x1E0

Read it twice, ’cause it’s the missing link. When the process that owns the actual foreground window activates a layout, win32k records that layout in session-wide foreground state. And every new ordinary GUI thread created afterwards inherits its initial HKL from that session state (there’s a separate branch for special system input threads, not relevant here). Not from the parent process, not from the default-input-language setting, but from the foreground owner’s last activation. My earlier negative tests simply never owned the foreground.

So the autonomous sequence is: the Medium process creates a real window, takes the foreground (SetForegroundWindow and friends, verified by reading back the foreground owner), disables IME on its own thread with ImmDisableIME so it doesn’t eat its own poison, seeds the descriptor, activates X… and launches a completely unmodified, auto-elevated Task Manager through Shell/AppInfo. The result:

[automatic-seeded] HKL=0x0000000004070407 LoadFlag=0 Flags=0x00000000 ImeFile="..\..\Users\dzonerzy\AppData\Local\Temp\plm_54360_282488203.dll"
[*] Foreground Medium sender selected X: previous=0000000004100410 active=0000000004070407.
[*] Sender marker remains ABSENT after selecting X (IME disabled for sender thread).
[*] Automatic target: sender=Medium(0x2000) target=High(0x3000) PID=45612 TID=11956 target-HKL=0000000004070407 inherited=YES.
[*] Read-only High observer=High(0x3000), target=High(0x3000), exact marker=PRESENT base=0000019FC6490000.

A fresh High Task Manager inherited the poisoned layout and mapped the exact marker during its ordinary first-window initialization. No cross-integrity message (UIPI never woke up), no target-side helper, no explicit IMM call, no process-memory write, no remote thread, no hook. The Medium↔︎High segregation, crossed with a keyboard layout. One fun side effect, reported for honesty: the foreground transition also made an already-running Medium Zed editor map and retain the marker. Existing GUI processes can react to the transition too. I didn’t isolate that trigger and I won’t generalize it.

consent.exe: SYSTEM before the prompt

And now the boss fight. If a fresh High GUI process consumes the session foreground layout automatically, who else starts as a fresh GUI process? Right: consent.exe, spawned by AppInfo as NT AUTHORITY\SYSTEM whenever an elevation request actually produces an interactive prompt, before the user answers anything, ’cause it has to draw the prompt. With UAC at EnableLUA=1, ConsentPromptBehaviorAdmin=2, PromptOnSecureDesktop=1, reversing my local build (10.0.22621.3672) shows the fatal ordering inside CuipWindowThread:

CuiSetThreadDesktopToSecureDesktop("winlogon")   // switch to the secure desktop
StartSystemTextInputProcesses(true)              // text-input init...
CreateWindowExW(...)                             // ...BEFORE the windows exist

A new GUI thread on the secure desktop, initializing text input, with the session foreground layout I control. Its GUI/text-input initialization does the rest: the new thread inherits X, and the same first-window machinery as door two runs ImmLoadIME(X), LoadIME, System32 + ..\..\Users\..., LoadLibraryExW. The descriptor doesn’t care that the consumer lives on another desktop. The window-station cache and the session layout state cross that bridge for free.

For the first proof I used a deliberately defanged payload: an identity-report DLL whose entry point refuses every host except consent.exe and writes only token fields to a text file. I seeded disposable KLID 00000406, owned the foreground, activated X, requested elevation of Task Manager, and left the prompt open. Before anyone touched the dialog, consent.exe had already mapped the DLL and run its entry point:

proof=PhantomLayout consent identity DLL entry point executed
host=C:\Windows\system32\consent.exe
pid=46236
session=1
username=SYSTEM
user_sid=S-1-5-18
integrity_sid=S-1-16-16384
integrity_rid=0x00004000
integrity_name=System
token_elevated=1
process_protection_level=0xFFFFFFFE

S-1-5-18, integrity RID 0x4000: SYSTEM, running on the secure desktop, before approval. (0xFFFFFFFE is PROTECTION_LEVEL_NONE, for the readers keeping score.) Then I pressed No. AppInfo returned ERROR_CANCELLED (1223), no Task Manager ever elevated, and the harness restored the full 352-byte descriptor byte-for-byte, restored the foreground HKL, and deleted the DLL. Entry-point execution inside a SYSTEM process does not depend on the prompt being approved. Sit with that for a second.

The fully autonomous PoC: the payload clicks “No” itself

The payload clicks No

The manual version still needed a human to dismiss the prompt, which felt… inelegant. So the advanced PoC (poc_advanced/phantomlayout_single.exe) is a single statically-linked orchestrator with the payload DLL embedded as a byte array, and it automates the entire arc:

  1. Preflight. Requires EnableLUA=1 and a prompting UAC behavior for the caller’s account type: ConsentPromptBehaviorAdmin != 0 for a filtered admin, ConsentPromptBehaviorUser != 0 for a genuine standard user (a zero in either means silent elevation or silent denial: no prompt, no consent.exe, no ride). It warns on PromptOnSecureDesktop=0 (supported, via a fallback) and refuses to run from an already-elevated token.
  2. Payload staging. Writes the DLL to %TEMP%\pls_<pid>_<tick>.dll with CREATE_NEW, then builds the System32-relative traversal with PathRelativePathToW and round-trip verifies it: GetFullPathNameW(System32 + "\" + relative) must resolve back to the real file. Fits the 79-char field or fails loudly.
  3. Layout shopping. A candidates mode scans 40 known KLIDs for a descriptor that’s LoadFlag==0, Flags&2, ImeFile=="msctf.dll", an untouched legacy entry, and backs up all 352 original bytes in memory.
  4. Poison + own the foreground. ImmDisableIME on its own thread, a topmost tool window plus a retry loop until GetForegroundWindow() really belongs to it, then ActivateKeyboardLayout(X). Session foreground state updated.
  5. Elevation trigger. ShellExecuteExW with lpVerb="runas" on a worker thread (it blocks until the prompt resolves), launching cmd.exe it doesn’t even want.
  6. The payload’s side. DllMain checks the host basename is exactly consent.exe and self-ejects from anything else (returning FALSE, so accidental consumers unmap it immediately). Inside consent it spawns C:\Windows\System32\cmd.exe on winsta0\default, inheriting consent’s SYSTEM token, on your desktop, while the prompt is still glowing on the secure one. Then it signals the orchestrator through a named event derived from its own filename (Local\PLS<pid>x<tick>, with the filename carrying the PID/tick pair, zero config), and goes looking for the prompt’s “No” button: enumerates the Winlogon secure desktop, finds the Button whose text is No, BM_CLICKs it, with fallbacks through WM_COMMAND/IDCANCEL, plus a final TerminateProcess(GetCurrentProcess()) self-kill, ’cause a consent.exe that dies unanswered is also a denial.
  7. Cleanup. Restores the original foreground HKL, rewrites and byte-compares all 352 descriptor bytes, waits for consent.exe to exit, deletes the payload (up to 100 retries), and reports each stage with distinct exit codes.

End result: run one exe as a normal medium-integrity user, watch a SYSTEM shell appear on your desktop, and the UAC machinery itself records that the request was cancelled. Reproduced end-to-end as a genuine standard user on Windows 11 25H2 (26200.8875), the same 25H2 family as the static-analysis corpus. The whole choreography, in one picture:

PhantomLayout full chain

And because an autonomous SYSTEM shell is better seen than told, the recorded run, one exe in, shell out:

PhantomLayout demo run: standard user to SYSTEM, fully autonomous

Preconditions, ’cause they matter

This is a state-sensitive primitive, not a magic wand, and the preconditions are the difference between science and marketing. The complete list, proven on the tested host:

The exposure

Medium-integrity standard user to SYSTEM code execution, riding the OS’s own IME loader and the UAC consent process: bypass UAC, own other users’ data, install privileged persistence, game over for the box. Suggested severity High, CVSS 3.1 7.8 (AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). UI:N because in the autonomous form the prompt is answered by the payload, not the user. AC:L because no race is required, just the state preconditions above.

Component Version Result
win32kfull.sys / user32.dll (static analysis) OS 26200.8973 (25H2), components 10.0.26100.8972 All five trigger chains statically verified end-to-end
imm32.dll (static analysis) OS 26200.8973, component 10.0.26100.8521 Route-bit selection + traversal + loader sink verified
Dynamic test host Windows 11 22621.4317 Same-integrity stock-target mapping, automatic Medium→High Task Manager mapping, consent.exe SYSTEM execution reproduced
consent.exe (execution proof) 10.0.22621.3672 Entry point ran as SYSTEM before UAC approval, user pressed No (ERROR_CANCELLED 1223)
Dynamic test host 2 Windows 11 25H2 26200.8875 Full autonomous chain as a genuine standard user: payload executed as SYSTEM inside consent.exe, prompt dismissed unapproved

CWE-22 (path traversal) for the System32 escape, CWE-862 (missing authorization) for a station-scoped setter that accepts a complete caller-controlled descriptor, with the route-bit confusion as the glue.

Catching ghosts

The good news for defenders: this thing is quiet, but it’s not invisible. High-signal correlations:

  1. NtUserSetImeInfoEx called by an ordinary desktop process at all. It’s a rare, undocumented syscall with no business in normal software.
  2. A zero-status IME descriptor whose ime_file contains .., separators, or a user-profile component. Note the value is stored as System32\..\..\Users\..., so match the canonical path too, not just literals.
  3. An image load whose call stack includes imm32!LoadIMELoadLibraryExW with flag 0x8, from a user-writable path, right after an IMM32 call or a first-window creation.
  4. ERROR_PROC_NOT_FOUND for ImeInquire immediately after such a load, with the module retained.
  5. Cross-process WM_IME_SYSTEM (0x287) operation 25 delivered to another thread’s default IME window.
  6. A station IME descriptor whose filename/flags deviate from the legitimate registry-backed default (msctf.dll, flags 0x2), or that changes without a layout installation to explain it.
  7. A session foreground-layout change to a layout that was never legitimately used, immediately followed by an elevated GUI launch.

Fixing it properly is defense in depth. The kernel setter should never accept a complete caller-controlled descriptor for station-wide storage (restrict mutable fields, bind initialization to the legitimate loader, and fail when load_status != 0 instead of returning success). IMM32 should canonicalize the final path, require it beneath an admin-owned IME directory, reject ../drive/UNC syntax, validate the file before executable mapping, and FreeLibrary on every failure path. And telemetry should audit station-wide descriptor changes with caller PID, integrity, session and canonical filename.

The last layout

This one had everything I love about Windows research: a decades-old full-copy semantic hiding in an undocumented syscall, a “read-only” property query that’s secretly a lazy DLL loader, a System32 prefix that’s containment in name only, and a final boss that turned out to be the UAC prompt initializing its own text input on a poisoned keyboard layout. No memory corruption anywhere, just trust, scope, and path validation failing in the right order. The chain where the exploit’s last act is politely clicking “No” on the prompt it just bypassed is my favorite sentence I’ve ever written.

Huge respect to Chris Paget and Tavis Ormandy. Shatter attacks and the MSCTF work are the reason I read input-stack code at all, and Ormandy’s consent.exe finale set the bar for what a proper ending looks like. The full technical writeup with exact addresses, the state machine, the annotated pseudocode, and the evidence logs lives in the research notes. The PoCs will be published once coordinated disclosure runs its course.

If you take one thing home: when a security boundary is enforced on messages and handles, look for the shared state underneath. You can’t filter what was never sent. Happy hunting 😀.

Have fun and happy hacking!