After reading Austin Martin’s research on URI handlers, I checked what was registered on my own Windows machine. That’s how I came across crosshair-x://.
I found code execution in Crosshair X 15.5.3 through two routes. With the URI, I clicked a link and approved Edge’s prompt to open the app; Calculator launched without another in-app click. The same payload also ran when I added or viewed a crosshair from the Steam Workshop, with no URI or browser prompt. Both routes fed the crosshair’s text layer into the same vulnerable SVG renderer.
From model to URI
In Crosshair X 15.5.3, the legacy URI could carry a complete crosshair model: a JSON array of layers. I started with this text layer:
[
{
"type": "text",
"text": "URI_PROBE",
"fontSize": 14,
"fontFamily": "Arial",
"fontWeight": 700,
"color": "#FFFFFF",
"outline": { "opacity": 0, "thickness": 1 },
"position": { "x": 0, "y": 0 }
}
]
I used URI_PROBE to check that the link loaded the model. Then I replaced the label with URI_PROBE</text><svg onload='...'></svg><text>. I encoded the JSON as UTF-8, converted it to base64url, and put it in the link:
crosshair-x://<base64url(JSON model)>&canary
I put both links on a page and opened them in Edge. After I approved the external app prompt, Crosshair X decoded the model and rendered it. Base64url was only transport encoding; the original text came back before rendering.
Where text became markup
ShareLinks.getModelFromLegacyUrl decoded the URI and parsed the JSON. configureAppFromCustomProtocol called setCurrentActiveCrosshair("model", ...). Model migration copied the text layer’s text unchanged.
The SVG serializer inserted text between SVG <text> tags without escaping it. The renderer assigned the resulting string to innerHTML:
return "<text ...>" + modelText + "</text>";
container.innerHTML = svgString;
With the probe in text, Crosshair X’s Electron renderer parsed markup equivalent to:
<text>URI_PROBE</text>
<svg onload='require("child_process").execFile("calc.exe",[])'></svg>
<text></text>
</text> closed the element that the serializer had opened. The injected <svg> became a real node, and its onload fired. The last <text> kept the output parseable. If the app had escaped the label before building the SVG, those tags would have stayed text.
The 15.5.3 renderer had nodeIntegration: true and contextIsolation: false, so the event handler could call require("child_process") and launch Calculator. A field from the URI had become code in a renderer with Node access.
PoC
The original legacy PoC is still on my website for archival purposes. The issue it demonstrates has been patched. The video shows the URI route and the Steam Workshop route:
The Workshop route landed on the same renderer. getUgcModel called SteamWorkshop.getCrosshairModel, and applyUgc passed that model to setCurrentActiveCrosshair("model", ...). Adding or viewing my test crosshair ran the injected text without a URI link or browser prompt.
The Crosshair X community has thousands of shared designs. Steam downloads subscribed Workshop items on other devices signed into the same account. I tested execution from Workshop content on one machine; I did not repeat it on a second.
15.5.4
In the 15.5.4 build I checked, the active renderer escaped SVG text and attributes. My payload displayed as text. The relevant windows also had nodeIntegration: false and contextIsolation: true. The URI chain no longer worked in my test.
Disclosure timeline
- 26 September 2026: I reported the issue to Graham, one of the Crosshair X developers. He was moving house that day and still pushed a patch before the day was over.