Back to Blog
critical SEVERITY8 min read

BrowserLauncher.openURL() Injects Args Into Runtime.exec()

The desktop `BrowserLauncher.openURL(String url)` helper built a Windows command line by concatenating its `fixedURL` value onto `"rundll32.exe url.dll,FileProtocolHandler "` and handing the whole string to `Runtime.getRuntime().exec()`. Because the single-string form of `exec()` tokenizes on whitespace, a URL containing spaces or metacharacters was split into extra process arguments, giving an attacker control over what `rundll32.exe` was asked to open. The fix replaces the concatenation with `

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published October 15, 2026•Reviewed October 15, 2026

Answer Summary

The flaw was in the first-party desktop helper `BrowserLauncher.openURL(String url)`, in its Windows branch; there is no published package version range, and no CVE or GHSA was assigned. An attacker who controlled the URL string — for example through a crafted document, link, or protocol handler that reaches `openURL()` — could inject additional whitespace-separated arguments into the `rundll32.exe url.dll,FileProtocolHandler` command line and cause `rundll32` to open an attacker-chosen target such as an executable on a UNC share, resulting in code execution in the user's session. The fix invokes the process through `new ProcessBuilder("rundll32.exe", "url.dll,FileProtocolHandler", fixedURL).start()` so that `fixedURL` is passed as a single, discrete argument rather than being parsed out of a command string; the fixed release version is unknown. The weakness class is CWE-78, OS Command Injection.

Vulnerability at a Glance

cweCWE-78
fixLaunch via `new ProcessBuilder("rundll32.exe", "url.dll,FileProtocolHandler", fixedURL).start()` so the URL is one argv element
riskAttacker-controlled URL text becomes extra `rundll32.exe` arguments, enabling execution of an attacker-chosen target in the user's session
languageJava
root cause`fixedURL` concatenated into the single-String form of `Runtime.getRuntime().exec()`, which tokenizes the command line on whitespace
vulnerabilityOS command / argument injection via string-concatenated process invocation

Summary

A critical command-injection flaw lived in a very ordinary-looking piece of desktop glue code: the BrowserLauncher.openURL(String url) helper that opens a link in the user's default browser. On Windows, that method built a command line by string concatenation —

"rundll32.exe url.dll,FileProtocolHandler " + fixedURL

— and passed the result to Runtime.getRuntime().exec(). Any whitespace or metacharacter inside fixedURL escaped the author's intent and became part of the process invocation. The fix passes the URL as a discrete process argument through ProcessBuilder.

Introduction

BrowserLauncher.openURL() is the kind of utility nobody audits. It takes a URL, figures out the host operating system, and hands the URL to whatever the platform uses to open links: Desktop.browse(), open on macOS, xdg-open and friends on Linux, and rundll32.exe url.dll,FileProtocolHandler on Windows.

Three of those four paths pass the URL as a separate value. The Windows path did not. It did this:

Runtime.getRuntime().exec("rundll32.exe url.dll,FileProtocolHandler " + fixedURL);

fixedURL is a locally derived variable — in the branch just above it, the code even re-prefixes it with "file:///" when the original URL pointed at a local file ("put file:/// back in"). But the string it is derived from is the url parameter, and that parameter is whatever the caller handed in: a link embedded in a document, a help topic resolved from metadata, a value that travelled in from a protocol handler or a command-line argument. None of that is trustworthy, and none of it was validated before being spliced into a command line.

This matters beyond this one file because the mistake is subtle in Java specifically. Developers often assume Runtime.exec(String) is safe because "there's no shell involved." That assumption is half-right and entirely dangerous, as the next section explains.

Affected Versions

Affected not applicable (first-party code) — the BrowserLauncher.openURL(String) Windows launch path
Fixed in not applicable (first-party code); fixed by the referenced pull request. Release version unknown
Ecosystem maven (vendored Java desktop module)
CVE / GHSA not assigned
CWE CWE-78 (Improper Neutralization of Special Elements used in an OS Command)

Because this is first-party, vendored code rather than a published dependency, there is no version range to pin. Exposure is determined by whether your build includes the BrowserLauncher utility and whether any caller can reach openURL() with a string it did not fully construct itself.

The Vulnerability Explained

Here is the vulnerable line, in context with the branch that feeds it:

if (url.startsWith("file:")) {
    // ... normalization ...
    fixedURL = "file:///" + fixedURL; // put "file:///" back in
}

Runtime.getRuntime().exec("rundll32.exe url.dll,FileProtocolHandler " + fixedURL);

Why "no shell" does not mean "no injection"

Runtime.getRuntime().exec(String command) does not spawn cmd.exe. What it does instead is tokenize the string with a whitespace tokenizer and then call the array form, exec(String[] cmdarray). So the single string above is split into tokens:

  1. rundll32.exe
  2. url.dll,FileProtocolHandler
  3. ...everything the attacker put in fixedURL, split on every space, tab, newline, carriage return, and form feed.

That is the whole vulnerability. The author intended fixedURL to be one argument. The runtime treats it as n arguments, where n is chosen by whoever supplied the URL. This is argument injection — the CWE-78 family — and on Windows it is handed straight to a program whose entire job is "open the thing I was given."

Exploiting this specific call

rundll32.exe url.dll,FileProtocolHandler <target> is Windows' generic "open this URL or path" entry point. It is not restricted to http and https; it resolves file paths and UNC paths too. So an attacker-supplied url needs only to survive the normalization above and arrive with a space in it:

  • A URL whose tail is something \\attacker.example\share\payload.exe tokenizes into an extra argument pointing at a remote executable on an SMB/WebDAV share. FileProtocolHandler opens it, and the user's machine runs attacker code with the user's privileges.
  • A URL containing file:///C:/Windows/System32/<binary> plus trailing tokens lets the attacker both pick a local target and append parameters to it, because the quoting that would have kept fixedURL intact never existed.
  • Even without reaching execution, truncation at the first space is a silent correctness bug: a legitimate URL containing an encoded space or a path with spaces opens the wrong thing.

The delivery vector is the interesting part, and it is not exotic. Desktop applications call a BrowserLauncher-style helper from menu actions, "visit author's page" links, error dialogs that link to documentation, and URLs stored inside the documents they open. A crafted document carrying a malicious link field, opened by a user who clicks it, is enough. No privilege escalation, no network position — just a double-click and a click.

Real-world impact

For a service or application shipping this code on Windows, the impact is arbitrary program execution in the context of the logged-in user: credential theft, persistence, ransomware staging, lateral movement. Because the launch looks like a normal browser-open action, there is very little for the user to notice and very little for endpoint tooling to flag beyond "the app spawned rundll32," which it does legitimately every time someone clicks a link.

The Fix

The change is three lines, and one of them is the actual fix.

Before:

Runtime.getRuntime().exec("rundll32.exe url.dll,FileProtocolHandler " + fixedURL);

After:

// Pass fixedURL as a single, separate process argument (not
// concatenated into a shell command string) to prevent
// shell metacharacter / argument injection.
new ProcessBuilder("rundll32.exe", "url.dll,FileProtocolHandler", fixedURL).start();

Why this solves the problem

ProcessBuilder takes an explicit argument vector. The executable is rundll32.exe, the first argument is the fixed literal url.dll,FileProtocolHandler, and fixedURL is argument two — one argument, whatever it contains. There is no tokenization step for an attacker to exploit, because there is no command string left to tokenize. Spaces inside fixedURL stay inside fixedURL; the JDK handles the platform-specific quoting required to deliver that single value to the child process intact.

Note what the fix also does not change: the DLL and entry point are now immutable literals in the argument vector, so there is no path by which attacker input could swap url.dll for another library or FileProtocolHandler for another exported function. Under the old concatenation, those tokens happened to precede the injection point, but structuring the call this way makes that guarantee explicit rather than accidental.

The fix also brings the Windows branch in line with the rest of the same method. The Unix branch already declares its launchers as an array:

String[] browsers = {
    "xdg-open",
    // ...
};

and invokes them with the URL as a separate element. The Windows path was the sole outlier — a useful reminder that inconsistency within a single method is a strong smell worth grepping for.

What the fix deliberately leaves to callers

url.dll,FileProtocolHandler is a broad primitive. Even with perfect argument separation, asking it to open \\host\share\payload.exe will open \\host\share\payload.exe. Argument injection is closed; scheme abuse is not. Callers that can receive untrusted URLs should validate url against an allowlist (http, https, and perhaps mailto) before openURL() is ever invoked, and reject UNC-style and file: inputs that did not originate from the application itself.

Key Takeaways

  • Runtime.getRuntime().exec(String) is not "safe because there's no shell." It splits the command string on whitespace before executing, so any concatenated untrusted value becomes multiple arguments. The single-String overload should be treated as deprecated in any code that touches outside input.
  • "rundll32.exe url.dll,FileProtocolHandler " + fixedURL is the exact anti-pattern — a fixed prefix plus an interpolated value is precisely the shape that argument injection exploits, because the attacker only needs to supply a space.
  • FileProtocolHandler is a code-execution sink, not a browser. It opens local paths and UNC paths as readily as https URLs, so URL-handling code must validate the scheme even after the process invocation is made safe.
  • When one branch of a method uses an argument array and another builds a string, the string branch is the bug. The Unix path's String[] browsers pattern was already correct; the Windows path's concatenation was the inconsistency that gave this away.
  • url and fixedURL are parameters, not constants. The "file:///" normalization in openURL() made the value look locally derived and trustworthy, which is how this line survived review — derived values inherit the taint of their inputs.

How Orbis AppSec Detected This

  • Source: the url parameter of the public BrowserLauncher.openURL(String) method, and the locally derived fixedURL string built from it during file: normalization.
  • Sink: Runtime.getRuntime().exec(String) — the single-String overload — invoked on a command line assembled by concatenating fixedURL onto the literal "rundll32.exe url.dll,FileProtocolHandler ".
  • Missing control: no scheme allowlisting, no rejection of whitespace or metacharacters, and no use of an argument vector to keep the URL as one token; the whitespace tokenization performed by exec(String) therefore let the URL expand into additional rundll32.exe arguments.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ("OS Command Injection").
  • Fix: the invocation was rewritten as new ProcessBuilder("rundll32.exe", "url.dll,FileProtocolHandler", fixedURL).start(), passing the URL as a single, discrete process argument.

Orbis AppSec detected this vulnerability automatically. Try Orbis AppSec on your repositories to find and fix issues like this.

Conclusion

This was not an exotic bug. It was one + operator in a utility method that opens links, written years ago on the reasonable-sounding assumption that Runtime.exec() without a shell cannot be injected. The whitespace tokenizer inside exec(String) quietly turned that assumption into an argument-injection primitive aimed at rundll32.exe url.dll,FileProtocolHandler — a Windows entry point that will open local executables and remote UNC paths as happily as a web page.

The remedy is the one that applies to every process launch in Java: build an argument vector, never a command line. new ProcessBuilder("rundll32.exe", "url.dll,FileProtocolHandler", fixedURL).start() is both shorter to read and structurally incapable of the splitting that made the old line dangerous. If you ship a BrowserLauncher-style helper, check its Windows branch specifically, and then check whether any caller can hand openURL() a URL it did not build itself — and if so, allowlist the scheme before the call.

Prevention and further reading

Frequently Asked Questions

Does `Runtime.exec("rundll32.exe url.dll,FileProtocolHandler " + fixedURL)` actually go through cmd.exe?

No — and that is why this bug is easy to under-rate. The single-String form of `exec()` runs the command directly, but it first tokenizes the string on whitespace, so a `fixedURL` containing spaces still turns into additional `rundll32.exe` arguments even without a shell in the picture.

Why was only the Windows branch of `openURL()` vulnerable and not the Unix `xdg-open` path?

The Unix branch already keeps its candidate launchers in a `String[] browsers` array and invokes them as discrete arguments, so the URL never gets re-parsed. Only the Windows branch built a single command string by concatenation.

Is the `ProcessBuilder` rewrite enough, or does `fixedURL` still need validation?

The rewrite closes argument injection, but `url.dll,FileProtocolHandler` will happily open whatever target it is given, including local executables and UNC paths. Callers should still restrict `fixedURL` to an allowlist of schemes such as `http` and `https` before calling `openURL()`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1

Related Articles

critical

ffmpeg.Probe() Protocol Abuse in the GetSnapshot Thumbnailer

The local storage driver's thumbnail helpers — `GetSnapshot()` and `resizeImageToBufferWithFFmpegGo()` — handed user-controlled paths to `ffmpeg.Input()` and `ffmpeg.Probe()` with no restriction on which FFmpeg protocols those processes could open. A file that passed the existing `sanitizeFilePath` check (absolute, cleaned, regular file) could still be an HLS playlist or concat script pointing at `http://`, `subfile:` or `file:` targets, turning thumbnail generation into SSRF and arbitrary file

critical

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

high

THiNX Device Platform: Command Injection via source.owner in Git Fetch

The THiNX device management platform fixed a critical vulnerability where the `source.owner` parameter flowed directly into shell commands during git repository operations. An attacker with source configuration access could inject arbitrary shell commands by providing malicious owner values containing metacharacters like `; curl`.

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

critical

ARTIFICIAL_ANALYSIS_API_KEY: Unvalidated os.getenv in Model Sync

A model-catalogue sync utility read the `ARTIFICIAL_ANALYSIS_API_KEY` environment variable with a bare `os.getenv()` call and passed whatever it found straight into `fetch_intelligence_from_api()`. Any malformed value — a trailing newline from an `--env-file`, a whitespace-only placeholder, or a pasted `Bearer …` prefix — was treated as a valid credential and forwarded into an outbound HTTP request, where it could surface in CI error output. The fix normalizes the value, validates it against a s