Driving the Windows login screen from a browser tab (the secure-desktop rabbit hole)
RFF just shipped remote desktop: click a machine, get its screen in a browser tab, take the mouse and keyboard. The demo looks like every other remote-control tool. The part that ate the schedule doesn’t show up in the demo at all - it’s what happens when nobody is logged in.
Streaming a signed-in user’s desktop is the easy 80%. The remaining 20% is the Windows logon screen: the box is sitting there before anyone’s at their desk, and you need to type a password into it. That screen is a different desktop, owned by a different account, and your code isn’t allowed to touch it. Here’s the rabbit hole.
The setup: one agent, desktops it doesn’t own
The RFF agent is a Windows service running as LocalSystem in session 0. Session 0 is headless by design - ever since Vista, services were kicked out of the interactive session so a compromised service couldn’t draw on a logged-in user’s screen. Good for security. Inconvenient when your whole job is to draw on a logged-in user’s screen.
So the agent can’t capture or inject anything itself. It launches a small per-session helper - RFF.Agent.exe --remote-helper - into the interactive desktop, and talks to it over a duplex named pipe (rff-rd-{sessionId}, locked down with an explicit SDDL). Frames go up the pipe as JPEG; mouse and keyboard events come back down. The agent stays the only process touching the network, so the security model doesn’t change: one outbound connection, no inbound ports.
The whole trick, then, is getting a helper running on the right desktop - and “the right desktop” is where it gets interesting.
Attended is the easy part (and still has a trap)
When a user is signed in, you want your helper inside their session so it sees their screen. The move is well-trodden: find a process already running in that session (explorer.exe is a reliable one), duplicate its token, and CreateProcessAsUser with that token.
// Borrow the interactive user's token from explorer, launch the helper into their session.
OpenProcessToken(explorer, TOKEN_DUPLICATE | TOKEN_QUERY, out var hTok);
DuplicateTokenEx(hTok, MAXIMUM_ALLOWED, IntPtr.Zero,
SecurityImpersonation, TokenPrimary, out var hDup);
var si = new STARTUPINFO { cb = Marshal.SizeOf<STARTUPINFO>() };
// si.lpDesktop is LEFT NULL on purpose - see below.
CreateProcessAsUser(hDup, exePath, $"\"{exePath}\" --remote-helper",
IntPtr.Zero, IntPtr.Zero, false, CREATE_NO_WINDOW,
IntPtr.Zero, null, ref si, out var pi);
That lpDesktop = null comment matters more than it looks. The “obvious” thing is to set STARTUPINFO.lpDesktop to "winsta0\\default" so the child lands on the visible desktop. Do that from session 0 with a session-N user token and the child dies instantly with 0xC0000142 - STATUS_DLL_INIT_FAILED: the process is created, but it can’t initialize its DLLs against the desktop you named and the loader gives up before main. The fix is counterintuitive - don’t name a desktop. Let the token’s own session pick the default, and the child boots cleanly.
(Hold that thought, because the no-user launcher does the exact opposite, and it’s correct for it to.)
Capture is deliberately boring: GDI CopyFromScreen (a BitBlt under the hood), downscale, JPEG-encode, push up the pipe. We composite the cursor in by hand with GetCursorInfo + DrawIconEx, because BitBlt doesn’t draw it. GDI is the universal path - it works on RDP sessions, headless VMs, and no-GPU boxes where the fancier capture APIs fall over. (DXGI desktop duplication with dirty-rectangles is the faster path and it’s on the list - but “works everywhere” beat “works fast on a gaming PC” for v1.)
Nobody’s home: meet the secure desktop
Now the hard case. No user is signed in, so there’s no session to borrow a token from and no explorer.exe to borrow it from. The console is showing Winlogon’s secure desktop - a separate desktop object (Winlogon, not Default) that only SYSTEM is allowed to open. This is the same isolation that stops malware from screen-scraping your password as you type it. Which is exactly what we’re now trying to do on purpose, with permission.
The key: there is a SYSTEM process sitting right there in the console session - winlogon.exe itself. So we do the no-user version of the same dance: open winlogon, duplicate its token, and launch the helper into the console session with it. Old-timers will recognize this as the PsExec -s -i maneuver. And here - unlike the attended path - we do pin the launch to the console session explicitly, because the SYSTEM token doesn’t carry a user session of its own to fall back to.
The bug that produces black frames: desktop switches
Get the helper running on the logon screen and you’ll capture exactly one good frame, then black. Because the logon flow isn’t one desktop - it’s several, and Windows switches between them out from under you:
Default- the normal interactive desktopWinlogon- the secure desktop (the actual password box, UAC prompts)Screen-saver- its own desktop too
A Win32 thread is bound to a single desktop for its lifetime unless you move it. Capture the Winlogon desktop, then Windows flips to Default when sign-in completes, and your thread is still staring at a desktop that’s no longer on screen. Black frames, no error.
The fix is a dedicated capture thread whose only job is to follow the active input desktop:
// One pinned thread. Re-bind to whatever desktop is currently receiving input.
while (running) {
IntPtr input = OpenInputDesktop(0, true, GENERIC_ALL); // the desktop on screen NOW
if (input != IntPtr.Zero && input != _current) {
SetThreadDesktop(input); // re-point THIS thread at it
_current = input;
}
CaptureAndSend(); // BitBlt now hits the right desktop
}
Two things that cost an afternoon each: SetThreadDesktop fails silently if the thread owns any windows or hooks, so the follow-loop has to be a clean, UI-less thread that does nothing but capture. And only the SYSTEM helper can OpenInputDesktop the Winlogon one - which is the whole reason the no-user path had to borrow winlogon’s token rather than start as a plain service thread.
Input: normalized coordinates and the Ctrl+C that does nothing
Injecting input is SendInput, but two details matter.
Coordinates are normalized in the browser, scaled on the endpoint. The viewer has no idea the remote box is a 3-monitor 5120×1440 setup, and we don’t want it to. It sends mouse positions as 0..1 fractions; the helper scales them to the virtual desktop with MOUSEEVENTF_VIRTUALDESK | MOUSEEVENTF_ABSOLUTE (the 0..65535 coordinate space that spans all monitors). Multi-monitor “just works” and the wire format never mentions a resolution.
Typing and shortcuts are not the same thing. The clean way to type arbitrary characters is KEYEVENTF_UNICODE - hand SendInput a UTF-16 code unit and it types it, no virtual-key mapping, no layout guessing. Great for text. Useless for Ctrl+C: a Unicode-injected c while Ctrl is held does not make a copy, because the shortcut layer keys off virtual-key codes, and a Unicode event carries none. So the rule is: any keystroke with Ctrl/Alt/Win held routes through VK injection; everything else goes Unicode. We also flat-out refuse to inject Ctrl+Alt+Del and Win+L - the secure attention sequence is special-cased by Windows anyway, and locking the box you’re remoted into is a great way to lock yourself out.
What shipped, and what’s deliberately still on the list
In the spirit of how we write about security - here’s the honest matrix of what reaches what today:
| Situation | Desktop in play | How we get on it | Status |
|---|---|---|---|
| User signed in | their Default | borrow explorer’s token → CreateProcessAsUser | shipped |
| Nobody signed in | Winlogon secure desktop | borrow winlogon’s SYSTEM token, follow the input desktop | shipped |
| Locked, user still signed in | the lock screen | same family of tricks, different session plumbing | next up - not yet |
And on the wire: today every frame is a full JPEG relayed through our API over the same TLS/WebSocket the rest of RFF uses - roughly 8 fps on the default “balanced” preset, capped at a 4 MB message. That is not the most efficient design and we know it: relaying bytes through us costs us bandwidth, and full frames are wasteful when only the taskbar clock changed. The planned v2 is WebRTC peer-to-peer with a TURN fallback plus dirty-rectangle encoding - but that’s a cost-of-goods optimization, not a capability, so it lost the race to “ship the thing that lets an admin fix a stuck login screen.” The one rough edge we’re still chasing: the session transport isn’t yet happy behind a TLS-inspecting corporate proxy, which is why remote desktop ships with a beta tag while we make it as proxy-tolerant as the rest of the agent already is.
The one principle
Almost every bug here came from the same root: on Windows, “the screen” is not a thing - a desktop is, and your thread can only touch one at a time, only if it’s allowed to. Get the token right, follow the desktop switches, and a browser tab can drive a login screen on a machine three time zones away that nobody is sitting in front of.
We’re building RFF - hosted fleet management for Windows: deploy software, automate patching, and now jump on the actual machine when you need to. 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.