Skip to content

fix(server): validate Host header on /argv to prevent DNS rebinding RCE - #1077

Open
sebastionoss wants to merge 1 commit into
hiroi-sora:mainfrom
sebastionoss:fix/cwe94-cmd-server-function-1acc
Open

fix(server): validate Host header on /argv to prevent DNS rebinding RCE#1077
sebastionoss wants to merge 1 commit into
hiroi-sora:mainfrom
sebastionoss:fix/cwe94-cmd-server-function-1acc

Conversation

@sebastionoss

Copy link
Copy Markdown

Summary

The local HTTP server exposes POST /argv, which forwards its JSON body to CmdServer.execute() (UmiOCR-data/py_src/server/cmd_server.py:455). execute() parses the payload with argparse and honors --call_py <module> --func <name> / --call_qml <module> --func <name>, invoking arbitrary functions on registered Python/QML modules. Successful exploitation results in local code execution in the context of the Umi-OCR process.

The existing guard only checks request.environ["REMOTE_ADDR"] == "127.0.0.1". That check is bypassable by DNS rebinding: an attacker-controlled domain with a short TTL first resolves to a public IP (to pass the browser's same-origin fetch), then to 127.0.0.1. The browser then issues the malicious POST /argv from the attacker's page; the TCP peer address is genuinely 127.0.0.1, so REMOTE_ADDR passes, but the request originated from a remote web page. CORS_ALLOW_ALL is set on the server, which further removes any cross-origin friction on the response side.

  • CWE-94 (Code Injection) via CWE-350 (Reliance on Reverse DNS / trusted host without validation)
  • Affected: UmiOCR-data/py_src/server/web_server.py_argv() route handler
  • Impact: Any website a user visits while Umi-OCR's HTTP server is running can execute arbitrary registered Python/QML functions locally.

Fix

Add a Host-header allowlist — the standard defense against DNS rebinding. After the REMOTE_ADDR check, /argv now also requires Host to be one of 127.0.0.1, localhost, or ::1 (port stripped, IPv6 brackets stripped, case-insensitive). A missing Host header is rejected conservatively.

The bundled CLI client (cmd_client.py) uses urllib against http://127.0.0.1:<port>/argv, so urllib sets Host: 127.0.0.1:<port> automatically — legitimate local usage is unaffected. Browser attacks via a rebound domain send Host: attacker.com, which is now rejected with HTTP 403.

Diff is minimal: one new helper (_isLoopbackHostHeader) plus a single additional check in _argv. No behavior change for any other route.

Testing

  • Verified the CLI path: urllib.request.Request("http://127.0.0.1:PORT/argv", ...) sets Host: 127.0.0.1:PORT, which passes the new check.
  • Verified the helper handles the shapes the WSGI layer produces: 127.0.0.1:1234, localhost:1234, [::1]:1234, bare localhost, mixed case, and empty/missing.
  • Manually simulated a rebinding request by sending POST /argv with Host: evil.example — server now returns HTTP 403 instead of executing the payload.
  • Existing legitimate flow (Umi-OCR --call_py ... from the CLI) is unchanged.

Proof of Concept

With Umi-OCR running locally (HTTP server enabled), a request from a browser after a DNS rebind — or, equivalently, any request with a non-loopback Host — reaches the handler:

# Before fix: executes; After fix: 403 Forbidden
curl -X POST http://127.0.0.1:1224/argv \
  -H 'Host: attacker.example' \
  -H 'Content-Type: application/json' \
  --data '["--call_py","utils","--func","runCmd","--argv","[\"calc.exe\"]"]'

The actual attack sequence in the wild:

  1. Victim runs Umi-OCR with the HTTP server enabled (default port 1224).
  2. Victim visits http://rebind.attacker.tld (short-TTL A record initially pointing to the attacker's server so the page loads).
  3. Attacker's page waits, then flips the DNS record to 127.0.0.1 and issues fetch("http://rebind.attacker.tld:1224/argv", {method:"POST", body: JSON.stringify(["--call_py", ...])}).
  4. The browser reuses the hostname; TCP connects to 127.0.0.1:1224; REMOTE_ADDR check passes; CmdServer.execute runs the attacker-chosen function.

Route/function existence verified in this branch: UmiOCR-data/py_src/server/web_server.py:70 defines POST /argv; UmiOCR-data/py_src/server/cmd_server.py:455 defines execute; --call_py / --call_qml are registered at cmd_server.py:406–409.

Adversarial review

Before submitting we tried to disprove this. We considered whether the REMOTE_ADDR == "127.0.0.1" check alone was sufficient — it isn't, because DNS rebinding intentionally causes the browser to connect to 127.0.0.1 while the origin is attacker-controlled. We checked whether the browser's Same-Origin Policy or CORS would block the POST — it doesn't: the server sets permissive CORS headers (CORS_ALLOW_ALL), and even without them, a simple POST with Content-Type: text/plain is a CORS-simple request whose side effects fire regardless of whether the response is readable. We checked whether cmd_client.py sets a custom Host that our check might break — it uses urllib, which derives Host from the URL host (127.0.0.1), so it's fine. Finally we considered whether the precondition ("victim visits attacker page while Umi-OCR is running") is realistic — Umi-OCR is a long-running desktop app, so the window is typically the whole session.


Discovered by the Sebastion AI GitHub App.

The /argv endpoint invokes CmdServer.execute which allows calling arbitrary functions on any registered Python/QML module via --call_py/--call_qml/--func. It was previously guarded only by a REMOTE_ADDR == 127.0.0.1 check, which is bypassable by a malicious website through DNS rebinding: the victim's browser resolves an attacker domain to 127.0.0.1 and issues cross-origin POSTs (permitted by the wildcard CORS headers), granting arbitrary local code execution.

Add a Host header allowlist (127.0.0.1 / localhost / ::1). The bundled cmd_client uses urllib against http://127.0.0.1:PORT so its Host header remains valid.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant