Saturday, 08 August, 2026
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.
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.
The Windows input stack has been a crime scene for over two decades, and credit where it’s due: I stand on shoulders here:
%windir%\system32.
In their words, “requiring an attacker to place a malicious file in the
system32 directory prevents the vulnerability from being a threat”.
Remember that sentence, ’cause my ..\ traversal is exactly
that assumption dying of old age.WM_INPUTLANGCHANGEREQUEST to the
shell, and Windows obligingly ran ImmLoadIME →
ImmGetImeInfoEx → LoadLibraryW on the
malicious “IME”. Same message family, same loader sink as my doors three
and four. But that one needed admin for the HKLM layout
registration and the system-folder file drop, and the observed sample
additionally hooked its own APIs to conceal the Ime File
value and to mess with one specific AV. Those hooks were that sample’s
choices, not something the technique required. My chain needs no
registry write, no hooks, and no admin, and it poisons the cached
descriptor instead of registering a layout.SetImeInfoEx
machinery, but as a kernel memory-corruption primitive.
Different bug class, different era, same neighborhood: Microsoft’s
advisory.consent.exe: spawn it with the runas verb, own
it through CTF, become SYSTEM. Same trophy, completely different weapon:
his chain needed protocol memory corruption. Mine needs no corruption at
all.ntimm.c source mirror shows the original
SetImeInfoEx behavior: find a layout by HKL, and copy the
complete caller-supplied descriptor while its load flag says
“not loaded”. That full-copy semantic is decades old. What nobody wrote
down publicly is the modern cross-process weaponization of it.WM_IME_SYSTEM operations
(IMS_LOADTHREADLAYOUT, IMS_ACTIVATELAYOUT) in
its window-creation
source. Useful prior art for the constants, but nothing about
addressing them cross-process into a poisoned cache.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.
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 copiedDoes 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.
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-selectedDecision 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 freedThe 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.
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 |
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.
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:
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!ImeWndProcWorker → ImeSystemHandler)
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.
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+0x1E0Read 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.
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 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:
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.%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.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.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.ShellExecuteExW
with lpVerb="runas" on a worker thread (it blocks until the
prompt resolves), launching cmd.exe it doesn’t even
want.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.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:
And because an autonomous SYSTEM shell is better seen than told, the recorded run, one exe in, shell out:
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:
WinSta0. Different sessions or window
stations don’t consume the descriptor. This is not a cross-session
primitive, by construction.tagKL must exist with a descriptor whose
load_status is zero. A nonzero status locks the descriptor
against the setter (and the setter still returns success, so re-query or
fool yourself).ImeDpi for X. If it cached the layout
before poisoning, ImmLoadIME(X) returns the stale
process-local object. Fresh processes are naturally uncached.HKLtoPKL.
For the automatic doors it must be the target thread’s current HKL at
first-window time.DefWindowProc processing, focus-window routing that
preserves X, and UIPI-compatible delivery. Door four wants a live
default IME window, a pumping target thread, and same-integrity delivery
(UIPI blocked Medium→High, twice, ERROR_ACCESS_DENIED).
Door five wants the writer to genuinely own the foreground input
queue when activating X. A background thread’s activation changes
nothing session-wide.EnableLUA=1, a
prompting UAC policy for the account type
(ConsentPromptBehaviorAdmin != 0 for filtered admins,
ConsentPromptBehaviorUser != 0 for standard users, both
prompt on stock Windows), an elevation request that actually produces
the prompt under that policy, and a UI session where the sender can take
the foreground. The prompt’s answer is never part of the
dependency.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.
The good news for defenders: this thing is quiet, but it’s not invisible. High-signal correlations:
NtUserSetImeInfoEx called by an ordinary desktop
process at all. It’s a rare, undocumented syscall with no business in
normal software.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.imm32!LoadIME →
LoadLibraryExW with flag 0x8, from a
user-writable path, right after an IMM32 call or a first-window
creation.ERROR_PROC_NOT_FOUND for ImeInquire
immediately after such a load, with the module retained.WM_IME_SYSTEM (0x287)
operation 25 delivered to another thread’s default IME window.msctf.dll, flags
0x2), or that changes without a layout installation to
explain it.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.
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!