ENGINEERING

The cursor that freezes on admin windows (UIPI, uiAccess, and the outage in between)

Last time we drove the Windows login screen from a browser tab - the hard 20% of remote desktop that doesn’t show up in the demo. This is the next 20%, and it has a tell anyone who’s used a remote-control tool will recognize: you’re remoted into a machine, everything works, then you click into an admin terminal or a “Run as administrator” app - and your mouse and keyboard go completely dead. The cursor freezes mid-screen. Keys do nothing. Click away to a normal window and control snaps right back.

That’s not a network hiccup. That’s Windows doing exactly what it was designed to do. Here’s the rabbit hole - including the part where the “obvious” fix took our whole lab fleet offline, and the one-digit constant that cost us a week of staring at a privilege we already had.

Why the cursor freezes: UIPI

Recall the architecture: the RFF agent runs as LocalSystem in session 0, and it can’t touch the user’s screen directly, so it launches a small per-session helper into the interactive desktop by borrowing explorer.exe’s token. That helper captures the screen and injects mouse/keyboard with SendInput.

The catch is which token it borrowed. Explorer runs at medium integrity - the normal level for a logged-in user’s apps. So the helper runs at medium integrity too. And Windows has a rule called UIPI - User Interface Privilege Isolation - that says: a lower-integrity process may not send input to a higher-integrity window.

An elevated app (Task Manager “as admin”, an admin PowerShell, Hyper-V Manager) runs at high integrity. Our medium-IL helper’s SendInput calls aren’t rejected with an error - they’re silently dropped on the floor. No exception, no return code to check. The frame stream keeps flowing (capture is allowed; reading pixels isn’t a privilege escalation), so you see the admin window perfectly. You just can’t touch it. Frozen cursor, live video. The worst kind of bug: it looks connected.

This is the same isolation that stops malware from clicking “Yes” on your UAC prompts. Which is, once again, precisely the thing we now need to do - on purpose, with permission.

The supported bypass: uiAccess

Windows leaves exactly one sanctioned door open here, and it’s the one the accessibility tools use. Magnifier, the on-screen keyboard, screen readers - they all have to drive arbitrary windows including elevated ones, or they’d be useless to someone who can’t reach the mouse. The mechanism is a manifest flag:

<requestedExecutionLevel level="asInvoker" uiAccess="true" />

uiAccess="true" tells Windows: this process is allowed to cross the UIPI boundary and inject into higher-integrity windows. level="asInvoker" is the important neighbor - we do not want to elevate the helper to high integrity (that changes what it can do to the box); we just want the UIPI bypass bit while staying at the user’s medium integrity.

Windows doesn’t hand this out for free. A uiAccess image is only honored when all three of these hold:

  1. It’s Authenticode-signed by a certificate the machine trusts.
  2. It lives in a secure location - under %ProgramFiles% or %SystemRoot%.
  3. The process launching it holds SeTcbPrivilege (the “act as part of the operating system” privilege - SYSTEM has it).

Three conditions, each of which became its own small adventure. And #2 isn’t paperwork - it’s enforced by the kernel at the moment of launch: set the token bit but run the helper from a dev build outside %ProgramFiles% and CreateProcessAsUser refuses it with 740, ERROR_ELEVATION_REQUIRED. File that away. It’s an “elevation” error for what is really a path check - and it won’t be the last time a Win32 error code in this story points squarely at the wrong thing.

The fix that took the lab offline

Here’s where we tripped. The naive move is: add that manifest to the agent and ship it. So we did - and an hour later the entire lab fleet was reporting offline.

The problem is that our agent EXE has two jobs. It’s the remote-desktop helper (the --remote-helper mode), and it’s the Windows service host that the SCM starts at boot. And it turns out:

The Service Control Manager won’t start a uiAccess-manifested image as a service.

A uiAccess process has to be launched from a trusted path with a UAC token - not spun up by the SCM as a service. So the moment that manifest was on the binary, StartServices during the MSI install failed with Error 1920, which escalated to 1603 (fatal), which triggered the installer’s MajorUpgrade to roll back.

And a MajorUpgrade rollback is brutal by design: it removes the old product first, then installs the new one. Abort in the middle and you’re left with neither - no agent, no updater, no service, and (the cherry on top) nothing left running that could self-heal, because the updater that would retry was removed in the same transaction. Every box that auto-updated went dark at once.

(We were pre-revenue with only our own lab on it, so the blast radius was us. Had customers been on it, this is the kind of incident you write a very different blog post about. The recovery - and the watchdog changes that now stop a rollback from stranding a box - are their own story.)

The lesson was sharp and simple: never put a uiAccess manifest on a binary that the SCM has to start.

The right shape: a separate, signed helper

So the helper became its own executable: RFF.RemoteHelper.exe. Same source as the agent’s helper mode - the .cs files are shared via the project file, not copy-pasted, so there’s one source of truth - but it’s a standalone exe that the SCM never touches. It carries the uiAccess manifest. The build signs it (the signing step now hard-fails if the signature doesn’t verify, because Windows silently denies uiAccess to an image whose signature it won’t trust - and a silent denial sends you straight back to debugging “why is the cursor frozen” when the real cause was a signing gap). The MSI installs it into C:\Program Files\RFF\ - secure path, condition #2 satisfied.

At session start the agent picks which helper to launch: a paid, elevated-remote session gets the signed RFF.RemoteHelper.exe (can drive admin windows); a Free or un-entitled session gets the agent’s own plain --remote-helper mode (medium IL, normal windows only). That choice is the entitlement gate, and it’s a clean one: UIAccess is a per-process property, set on the primary token at the moment of launch - not a machine-wide mode you flip on. A Free session launches the plain medium-IL helper, never gets the token-flip, and stays UIPI-blocked from elevated windows no matter what else is signed and sitting in %ProgramFiles%. Nothing global is toggled. The only thing that decides whether a session can cross UIPI is which of the two helpers we hand the launch.

Signed: ✓. Secure path: ✓. Launcher is SYSTEM and holds SeTcbPrivilege: ✓. Three for three. Ship it, watch the cursor glide onto an admin PowerShell and-

UIAccess=off. Still frozen.

The last bug: a one-digit constant wearing a privilege error’s clothes

The agent logs its own diagnosis on every launch, and this is what it said:

UIAccess=off (revert=True SeTcb=on err=1314).
CreateProcessAsUser failed - Win32 error 740 (ERROR_ELEVATION_REQUIRED).

Read it backwards. To launch a uiAccess process you can’t just hand CreateProcessAsUser the borrowed token - you first have to flip the UIAccess bit on the token itself with SetTokenInformation. This is the part worth internalizing: the manifest, the signature, and the secure path make the image eligible for uiAccess, but they don’t grant it. When a user double-clicks a uiAccess app, the UAC AppInfo service is what sets that token bit for them. We launch the helper ourselves with CreateProcessAsUser - there’s no AppInfo in the loop - so we have to set the bit. You need the manifest and the manual token-flip; neither alone is enough.

That call failed with 1314, ERROR_PRIVILEGE_NOT_HELD, the bit never landed, and so CreateProcessAsUser then looked at the uiAccess-manifested exe, decided the token wasn’t entitled to launch it, and bounced it with 740 - at which point the agent fell back to the plain helper and we were back to a frozen cursor.

So: 1314, privilege not held. The whole stack points at one thing - we don’t have SeTcbPrivilege. Except the same log line says SeTcb=on. And revert=True says the thread wasn’t even impersonating a weaker token (the tempting culprit - a thread can wear a medium-IL identity that doesn’t hold SeTcb even when the process token does; we added a RevertToSelf() specifically to rule that out, and the diag confirmed it was a no-op). The privilege was enabled, on the right identity, and SetTokenInformation still said we didn’t hold it.

That contradiction is the tell. ERROR_PRIVILEGE_NOT_HELD here wasn’t really about a privilege. It was the kernel rejecting the wrong TOKEN_INFORMATION_CLASS. The constant we were passing to SetTokenInformation was 23 - and 23 is TokenVirtualizationAllowed, a completely different token attribute. TokenUIAccess is 26. We were carefully enabling SeTcb and then asking the kernel to set the wrong thing, and a mismatched/disallowed class set comes back as 1314 - a privilege error for a problem that has nothing to do with privileges.

The fix was one digit:

// MUST be 26. 23 is TokenVirtualizationAllowed - a DIFFERENT class. Using 23 made
// SetTokenInformation set the wrong info class and fail 1314 ("UIAccess=off SeTcb=on err=1314");
// the UIAccess bit never got set, so CreateProcessAsUser rejected the helper with 740.
private const int TokenUIAccess = 26; // TOKEN_INFORMATION_CLASS.TokenUIAccess

The maddening part: the reader side of our own code - the helper checking whether it had UIAccess once running - had always used 26. The two halves were out of sync, and the writer side’s 23 had been quietly losing from 1.2.129 through 1.2.137, every failure dressed up as a privilege problem we’d already fixed.

Publish, update, start a session, click into an admin terminal:

UIAccess=on.

Clicks land. Keys type. The elevated window is just another window now.

What reaches what today

Window in focusIntegrityBeforeNow
Normal user appmediumworksworks
Elevated app / admin terminal (Hyper-V, Task Manager, admin PowerShell)highfrozenworks
UAC consent prompthigh - secure desktopfrozena different rabbit hole - next post

That last row is the catch, and it’s worth being precise about: uiAccess lets you drive elevated application windows that live on the user’s desktop. The UAC consent dialog itself doesn’t live there - Windows renders it on the secure desktop (\Winlogon), a desktop your medium-IL helper can’t capture or reach no matter how much uiAccess you have. Clicking “Yes” on a real UAC prompt is therefore not a uiAccess problem at all; it needs a SYSTEM-context follower that captures and drives the secure desktop directly - the same desktop-switch-following trick from the login-screen post, applied mid-session. That’s the next post, and it’s why anyone who tells you “just turn on uiAccess to click UAC” is quietly also telling you to disable the secure desktop.

The one principle

Every bug in this chain was a variation on the same theme: on Windows, having a permission and being able to use it are two different things - and the error code will lie to you about which. A privilege has to be enabled, on the identity that’s actually in effect, and you have to be asking the kernel to do the thing you think you’re asking. A one-digit-off constant came back as ERROR_PRIVILEGE_NOT_HELD and sent us chasing a privilege we’d held the whole time. Get the manifest right, keep it off anything the SCM starts, sign it, put it where Windows trusts it, pass the right token-info class - and then a browser tab can drive an elevated admin window three time zones away. The UAC consent prompt on the secure desktop? That’s the next desktop over, and the next post.


We’re building RFF - hosted fleet management for Windows: deploy software, automate patching, and jump on the actual machine when you need to, elevated windows and all. Free for your first 100 endpoints, no sales call. The Win32 plumbing above is the stuff you don’t have to think about when it’s hosted.