How to Prevent Argument Injection in a Windows Custom URI Protocol Handler
Register a real Windows custom URI protocol handler, exploit it with a genuine argument-injection attack, then fix it with two independent layers of defense you can verify yourself.
Custom URI schemes are how mailto: opens your mail client, how slack:// jumps straight into a channel, and how vscode:// opens a file in your editor from a browser link. Any application can register one of these schemes with Windows, and from that point on, clicking a link anywhere on the system, in a browser, a chat client, a document, or a webview, will launch that application and hand it the full URI. That handoff is convenient, and it is also a real attack surface: whatever text follows the scheme name came from outside your program, and if your handler trusts it without checking, an attacker who can get you to click a link can make your own code run commands you never intended.
Table Of Content
- What You Will Build
- Prerequisites
- Understanding Custom URI Protocol Handlers
- Step 1: Build the “Legitimate” Helper Tool
- Step 2: Write the Naive (Vulnerable) Handler
- Why shell=True Is the Actual Bug Here
- Step 3: Register the Protocol Handler in Windows
- Step 4: Trigger It Like a Browser or Another App Would
- Step 5: Prove the Vulnerability End to End
- Step 6: Fix It With Two Independent Layers of Defense
- Layer 1: Never Build a Shell String From Untrusted Input
- Layer 2: Validate Against an Allowlist, Not a Blocklist
- Step 7: Re-Test the Same Attack Against the Fixed Handler
- Common Mistakes and Gotchas
- Step 8: Automate the Verification With pytest
- How to Verify Everything Works End-to-End
- Next Steps
That is not a hypothetical. On September 13, 2026, BleepingComputer reported that a China-aligned threat group was exploiting a flaw (CVE-2026-51990) in Tencent’s Sogou Input Method for Windows, a Chinese-character typing tool with hundreds of millions of installs. Researchers at Gen Digital found that clicking a crafted sgbiz: link caused Windows to launch Sogou’s own protocol handler, which then passed attacker-controlled command-line arguments to another Sogou executable without validating them, eventually reaching an outdated, unsandboxed browser engine and full code execution. This tutorial does not touch Sogou or reproduce that specific exploit chain. Instead it builds an original, much smaller Windows application from scratch that has the exact same first link in that chain, an unvalidated argument arriving through a custom URI, so you can see the vulnerability class up close, exploit it for real against your own code, and fix it with defenses you can verify yourself.
What You Will Build
- A tiny fictional internal tool,
TicketOpener, that registers thesxzdemo://protocol with Windows so a web page or another app can open a specific ticket by number. - A first, naive version of the handler that is genuinely vulnerable to argument injection, registered for real in the Windows registry and triggered exactly the way a browser or another application would trigger it.
- A real, working exploit against that handler (with a captured proof file to show code ran that should not have) and a real, working fix, both verified end to end through the actual registered protocol handler, not just by calling a function directly.
- An automated
pytestsuite that checks the fix deterministically, so you do not have to trigger the Windows registry every time you want to confirm it still works.
Prerequisites
- Windows 10 or 11. Every command in this tutorial was run on Windows 11 with PowerShell 5.1 and Command Prompt.
- Python 3.10 or later (this tutorial was written and tested against Python 3.13). Everything here uses only the standard library, so nothing extra to install for the main walkthrough.
- pytest for the verification section (
pip install pytest). - No administrator rights needed. Every registry change in this tutorial lives under
HKEY_CURRENT_USER, which any standard user account can write to. - Basic comfort with a terminal and with reading a short Python traceback.
Understanding Custom URI Protocol Handlers
A URI (Uniform Resource Identifier) like sxzdemo://open?ticket=4521 has a scheme (sxzdemo), and everything after the :// is scheme-specific content that Windows itself does not interpret. To make Windows recognize a new scheme, an application adds a key to the registry under HKEY_CLASSES_ROOT (or, for a change that only affects your own user account, the equivalent path under HKEY_CURRENT_USER\Software\Classes) whose name is the scheme itself. According to Microsoft’s own documentation on registering an application to a URI scheme, that key needs a URL Protocol string value (an empty string whose mere presence marks the key as a protocol handler) and a shell\open\command subkey whose default value is the command line to run, with %1 marking where Windows should substitute the URI. Their own example looks like this:
HKEY_CLASSES_ROOT
alert
(Default) = "URL:Alert Protocol"
URL Protocol = ""
DefaultIcon
(Default) = "alert.exe,1"
shell
open
command
(Default) = "C:\Program Files\Alert\alert.exe" "%1"
With that in place, navigating to alert:Hello%20World anywhere on the system launches alert.exe with the complete URI as a command-line argument. That single sentence is the entire attack surface this tutorial is about: the argument your handler receives is exactly whatever the URI said, and the URI can come from anyone who can get you to click a link. Microsoft’s own page says as much directly: “Applications that handle URI schemes must consider how to respond to malicious data. Because handler applications can receive data from untrusted sources, the URI and other parameter values passed to the application may contain malicious data that attempts to exploit the handling application.”
The vulnerability class this tutorial demonstrates has a name: CWE-88, Improper Neutralization of Argument Delimiters in a Command, better known as argument injection. MITRE defines it as a product that “constructs a string for a command to be executed by a separate component in another control sphere, but it does not properly delimit the intended arguments, options, or switches within that command string.” A custom URI protocol handler is a textbook example of that “separate control sphere” boundary: the browser or another app is one component, your handler is another, and the URI is the string that crosses between them.
Step 1: Build the “Legitimate” Helper Tool
Create a project folder and, inside it, a file named ticket_lookup.py. This stands in for whatever your real application would do, opening a viewer window, querying a database, and so on. For this tutorial it just proves it ran with the ticket ID it was given:
"""Stand-in for a legitimate internal tool. Real code would open a ticket
viewer window; this just proves it ran with the ticket ID it received."""
import sys
ticket_id = sys.argv[1] if len(sys.argv) > 1 else "(none)"
print(f"[ticket_lookup] Opening viewer for ticket #{ticket_id}")
Next, add marker_write.py. This plays the role of an attacker’s payload: something that should never run as part of a normal ticket lookup. It writes a small proof file with a timestamp, which gives you a clean, unambiguous way to tell, after the fact, whether code outside the intended flow actually executed:
"""Stand-in for an attacker payload. Writes a marker file so we can prove,
after the fact, that code outside the intended ticket-lookup flow executed."""
import datetime
from pathlib import Path
marker = Path(__file__).parent / "pwned_marker.txt"
marker.write_text(
f"unauthorized code execution reached marker_write.py at "
f"{datetime.datetime.now().isoformat()}\n"
)
print(f"[marker_write] wrote {marker}")
Step 2: Write the Naive (Vulnerable) Handler
Now write the piece Windows will actually launch, handler_naive.py. Its job is to receive the full URI as argv[1], pull the ticket value out of the query string, and open that ticket. The bug: it builds a shell command by dropping the ticket value straight into an f-string, then runs it with shell=True.
"""NAIVE custom protocol handler for the sxzdemo:// scheme.
Windows invokes this script with the full URI as argv[1], exactly like it
would invoke a real app's registered handler (see the registry command in
the tutorial). This version builds a shell command by string-formatting the
ticket ID straight from the URI, then runs it with shell=True.
"""
import sys
import subprocess
from urllib.parse import urlparse, parse_qs
LOG = "handler_naive.log"
def log(line: str) -> None:
with open(LOG, "a", encoding="utf-8") as f:
f.write(line + "\n")
def main() -> None:
if len(sys.argv) < 2:
log("no URI argument received")
return
uri = sys.argv[1]
log(f"received URI: {uri}")
parsed = urlparse(uri)
params = parse_qs(parsed.query)
ticket_id = params.get("ticket", [""])[0]
log(f"parsed ticket param: {ticket_id!r}")
# VULNERABLE: the ticket value came straight from an externally
# triggerable URI and is spliced into a shell string unvalidated.
cmd = f"python ticket_lookup.py {ticket_id}"
log(f"running (shell=True): {cmd}")
subprocess.run(cmd, shell=True)
if __name__ == "__main__":
main()
The handler writes everything it does to handler_naive.log instead of just printing, since once Windows launches this script through the registry there is no visible console window to read output from.
Why shell=True Is the Actual Bug Here
Python's own documentation is direct about this. The subprocess module docs explain that without shell=True, "this library will not implicitly choose to call a system shell. This means that all characters, including shell metacharacters, can safely be passed to child processes." The moment you add shell=True, that safety goes away: "it is the application's responsibility to ensure that all whitespace and metacharacters are quoted appropriately to avoid shell injection vulnerabilities." On Windows, shell=True hands your command string to cmd.exe, which treats characters like &, |, and > as command separators and redirections rather than plain text. If any part of that string came from outside your program, whoever controls that part effectively controls what runs.
Step 3: Register the Protocol Handler in Windows
With both scripts saved, find your Python interpreter's full path (python -c "import sys; print(sys.executable)" will print it), then register the scheme. Open PowerShell and run:
reg add "HKCU\Software\Classes\sxzdemo" /ve /d "URL:SXZ Demo Protocol" /f
reg add "HKCU\Software\Classes\sxzdemo" /v "URL Protocol" /d "" /f
reg add "HKCU\Software\Classes\sxzdemo\shell\open\command" /ve /d "\"C:\path\to\python.exe\" \"C:\path\to\handler_naive.py\" \"%1\"" /f
Replace both C:\path\to\... segments with your own real paths. Quoting %1 matters: Microsoft's documentation specifically recommends it as a mitigation, and leaving it unquoted makes the argument-splitting problems described later in this tutorial worse, not better.
Verify the key with reg query "HKCU\Software\Classes\sxzdemo". When this tutorial ran that command for real, the output was not what the commands above look like they should produce:
HKEY_CURRENT_USER\Software\Classes\sxzdemo
(Default) REG_SZ URL:SXZ Demo Protocol
URL Protocol REG_SZ /f
The URL Protocol value came out as the literal string /f instead of empty. This is a genuine, reproducible gotcha, not a typo in this article: when PowerShell calls a native executable like reg.exe and one of the arguments is an empty string (/d ""), PowerShell can drop that empty argument from what it actually hands to the process, so reg.exe receives /d /f back to back and reads /f as the value. The fix is to set that one value through PowerShell's own registry provider instead of shelling out to reg.exe:
Remove-ItemProperty -Path "HKCU:\Software\Classes\sxzdemo" -Name "URL Protocol" -ErrorAction SilentlyContinue
New-ItemProperty -Path "HKCU:\Software\Classes\sxzdemo" -Name "URL Protocol" -Value "" -PropertyType String -Force | Out-Null
Query the key again and confirm URL Protocol now shows as an empty REG_SZ value with nothing after it. This is worth checking explicitly: an application whose URL Protocol value silently ends up as garbage like /f will simply fail to launch at all when a link is clicked, and the failure mode gives you no useful error to search for.
Step 4: Trigger It Like a Browser or Another App Would
Rather than calling handler_naive.py directly, trigger it the way Windows itself would, through ShellExecute, which is what happens when a browser, a chat client, or the Windows Run dialog opens a link. PowerShell's Start-Process does this:
Start-Process "sxzdemo://open?ticket=4521" -Wait
Checking handler_naive.log afterward shows this real, captured output:
received URI: sxzdemo://open/?ticket=4521
parsed ticket param: '4521'
running (shell=True): python ticket_lookup.py 4521
Notice the URI logged is sxzdemo://open/?ticket=4521, with a slash inserted right before the query string, even though the string handed to Start-Process had no slash there. Windows' own URI resolution normalized it. This matters for how you write your parsing code: never assume the URI your handler receives will be byte-for-byte identical to whatever string was originally clicked or typed. urllib.parse.urlparse handles this correctly regardless, since it identifies the host portion (netloc) as everything up to the next /, ?, or #, so open parses out the same either way. Writing your own parsing by hand instead of using a real URI parser is exactly how that kind of normalization turns into a silent bug.
Step 5: Prove the Vulnerability End to End
Now try to actually exploit it. The most obvious first attempt is to put a shell metacharacter straight into the ticket value:
Start-Process "sxzdemo://open?ticket=4521 & python marker_write.py" -Wait
Check for pwned_marker.txt afterward. It does not exist, and the log shows something that looks like the attack never even arrived:
received URI: sxzdemo://open/?ticket=4521
parsed ticket param: '4521'
running (shell=True): python ticket_lookup.py 4521
There is no trace of & python marker_write.py anywhere, not even a trailing space. Everything after the raw space character was gone before handler_naive.py ever saw argv[1]. This is worth pausing on because it is easy to misread as the fix already working. It is not: a plain space is not a valid unencoded character inside a URI, and whatever converts a clicked or typed sxzdemo://... string into the process Windows actually launches appears to cut the string off at that first invalid character rather than pass it through. Encode only the space and try again, leaving the ampersand raw:
Start-Process "sxzdemo://open?ticket=4521%20&%20python%20marker_write.py" -Wait
This time the full string does survive into the handler:
received URI: sxzdemo://open/?ticket=4521%20&%20python%20marker_write.py
parsed ticket param: '4521 '
running (shell=True): python ticket_lookup.py 4521
argv[1] now genuinely contains the whole payload, but the parsed ticket value is still just '4521 ', cut off right before the injected command. This second truncation is a completely different mechanism from the first: it is urllib.parse.parse_qs doing exactly what it is documented to do. A raw & character is the delimiter between query parameters, so parse_qs split the string there before the vulnerable code ever saw the rest. Two independent layers, one at the OS/launch level and one in this script's own parsing, each neutralized a different part of the same first attempt, for two unrelated reasons. To get a payload past both, encode both offending characters, the space as %20 and the ampersand as %26. parse_qs decodes percent-encoded characters inside a value's content (as opposed to characters that structurally separate keys and values), so this version survives intact:
Start-Process "sxzdemo://open?ticket=4521%20%26%20python%20marker_write.py" -Wait
This time, pwned_marker.txt exists, with real, captured contents:
unauthorized code execution reached marker_write.py at 2026-09-14T06:54:19.842395
And the log shows exactly how it happened:
received URI: sxzdemo://open/?ticket=4521%20%26%20python%20marker_write.py
parsed ticket param: '4521 & python marker_write.py'
running (shell=True): python ticket_lookup.py 4521 & python marker_write.py
The decoded value carried a real & straight into the cmd.exe command line, which treated it as a command separator and ran marker_write.py as a second, completely unintended command. This is a genuine, end-to-end exploit of a real Windows-registered protocol handler triggered exactly the way clicking a link would trigger it, not a simulation.
Step 6: Fix It With Two Independent Layers of Defense
Save this as handler_fixed.py. It fixes the bug in two ways that do not depend on each other, so that a mistake in one layer does not remove all protection:
"""FIXED custom protocol handler for the sxzdemo:// scheme.
Two independent layers of defense, either of which alone would have
stopped the attack in handler_naive.py:
1. No shell involved at all: subprocess.run() gets a list of argv
tokens, so there is no string for metacharacters to hide in.
2. The ticket value is validated against an allowlist pattern before
it is used for anything, so a rejected value never reaches step 1.
"""
import re
import sys
import subprocess
from urllib.parse import urlparse, parse_qs
LOG = "handler_fixed.log"
# Ticket IDs in this toy system are always 1-8 digits. Anything else is
# rejected outright instead of being "cleaned up" or partially trusted.
TICKET_ID_RE = re.compile(r"^[0-9]{1,8}$")
def log(line: str) -> None:
with open(LOG, "a", encoding="utf-8") as f:
f.write(line + "\n")
def validate_ticket_id(raw: str) -> str | None:
"""Return raw if it matches the allowlist, else None."""
if TICKET_ID_RE.match(raw):
return raw
return None
def main() -> None:
if len(sys.argv) < 2:
log("no URI argument received")
return
uri = sys.argv[1]
log(f"received URI: {uri}")
parsed = urlparse(uri)
if parsed.scheme != "sxzdemo" or parsed.netloc != "open":
log(f"rejected: unexpected scheme/host ({parsed.scheme}://{parsed.netloc})")
return
params = parse_qs(parsed.query)
raw_ticket = params.get("ticket", [""])[0]
log(f"parsed ticket param: {raw_ticket!r}")
ticket_id = validate_ticket_id(raw_ticket)
if ticket_id is None:
log(f"rejected: {raw_ticket!r} failed the ticket-ID allowlist")
return
# SAFE: argv list, no shell=True, so there is no string for
# metacharacters to be reinterpreted in.
cmd = ["python", "ticket_lookup.py", ticket_id]
log(f"running (shell=False, argv list): {cmd}")
subprocess.run(cmd, shell=False)
if __name__ == "__main__":
main()
Layer 1: Never Build a Shell String From Untrusted Input
subprocess.run now gets a Python list, ["python", "ticket_lookup.py", ticket_id], with shell=False (the default, written explicitly here for clarity). Each list element is passed to the operating system as one complete argument, with no shell in between to reinterpret &, |, or anything else as special. To confirm this layer works on its own, run the identical malicious ticket value through subprocess.run with an argv list and no validation at all:
python -c "
import subprocess
subprocess.run(['python', 'ticket_lookup.py', '4521 & python marker_write.py'], shell=False)
"
Real output:
[ticket_lookup] Opening viewer for ticket #4521 & python marker_write.py
No marker file appears. The entire malicious string, ampersand and all, was passed to ticket_lookup.py as one literal argument and printed as inert text. Switching away from shell=True is, by itself, enough to stop this exact attack.
Layer 2: Validate Against an Allowlist, Not a Blocklist
The second layer, validate_ticket_id, checks the value against ^[0-9]{1,8}$ before it is used for anything at all. This is an allowlist: it defines the narrow shape a valid ticket ID can take (one to eight digits) and rejects everything else, rather than trying to enumerate and strip out dangerous characters one by one. A blocklist that only strips & would still miss |, >, backticks, and whatever new metacharacter someone thinks of next. An allowlist does not need to know what an attack looks like; it only needs to know what legitimate input looks like, which is a much smaller and more stable thing to define. Layer 2 is what makes this defense in depth: even if a future change to this codebase reintroduced shell=True somewhere downstream, a value that never passes the allowlist never reaches that code at all.
Step 7: Re-Test the Same Attack Against the Fixed Handler
Point the registered command at the fixed script:
reg add "HKCU\Software\Classes\sxzdemo\shell\open\command" /ve /d "\"C:\path\to\python.exe\" \"C:\path\to\handler_fixed.py\" \"%1\"" /f
Delete any leftover pwned_marker.txt and handler_fixed.log, then fire the exact same attack URI through the real registered handler:
Start-Process "sxzdemo://open?ticket=4521%20%26%20python%20marker_write.py" -Wait
Real captured log output:
received URI: sxzdemo://open/?ticket=4521%20%26%20python%20marker_write.py
parsed ticket param: '4521 & python marker_write.py'
rejected: '4521 & python marker_write.py' failed the ticket-ID allowlist
pwned_marker.txt does not exist. Then confirm the fixed handler still does its actual job by triggering a normal ticket URI:
Start-Process "sxzdemo://open?ticket=4521" -Wait
received URI: sxzdemo://open/?ticket=4521
parsed ticket param: '4521'
running (shell=False, argv list): ['python', 'ticket_lookup.py', '4521']
The exact same malicious link that compromised the first version is now inert, and the exact same legitimate link still works. That is the shape a real fix should have: it removes the attack, not the feature.
Common Mistakes and Gotchas
- Empty registry values silently corrupted through PowerShell. As shown in Step 3, calling
reg.exe /d ""from PowerShell can drop the empty argument and shift everything after it. If a custom protocol handler you registered refuses to launch with no visible error, check every value you intended to be empty withreg querybefore looking anywhere else. - Don't assume the URI you receive matches the URI you sent. Step 4 showed Windows inserting a slash before the query string, and Step 5 showed a raw, unencoded space silently truncating everything after it before the handler script even started. Microsoft's documentation confirms this is expected: "any non-encoded spaces, quotes, and backslashes in the URI will be interpreted as part of the command line," which can split or shorten your argument depending on what actually launched the link. Always parse defensively with a real URI library instead of assuming a fixed string shape, and never assume a value that never reached your code is proof of nothing malicious being attempted.
- Quoting
%1is necessary but not sufficient. This tutorial tested whether a literal double-quote character embedded in the URI could break out of the quoted%1substitution and inject extra command-line arguments directly, the mechanism behind several historical real-world protocol-handler vulnerabilities. On this system, triggered through PowerShell'sStart-Process, the embedded quote characters were silently stripped rather than breaking the argument boundary. That is a fine outcome, but it is not a security guarantee: Microsoft's own documentation explicitly warns that "avoidance doesn't completely solve the problem of quotes in the URI," and the exact behavior can differ across Windows versions, shells, and which application performs the launch. Quote%1in your registration and validate every value on the receiving end regardless. - A blocklist for "dangerous characters" is not a fix. If Step 6 had stripped only
&from the ticket value instead of validating the whole value's shape, the attack in this tutorial would have failed, but|,>>, or a backtick would not have. Validate what correct input looks like, not what an attack currently looks like. - Test the launch layer and the execution layer separately. The first attack attempt in Step 5 looked like a failed exploit but was actually two unrelated accidents (URI truncation, then query-string splitting) with nothing to do with the eventual fix. Isolating each layer, as the standalone
subprocess.runtest in Step 6 does, is what makes it possible to say with confidence which specific change stopped which specific attack, instead of crediting a fix for something that never touched it.
Step 8: Automate the Verification With pytest
Triggering the real Windows registry every time you want to confirm the fix still works is slow and only runs on Windows. Save this as test_handler.py to verify the parsing and validation logic deterministically:
"""pytest suite for the fixed handler's parsing/validation logic.
Runs entirely without touching the Windows registry or launching a
protocol handler, so it verifies the fix deterministically and works in
CI. The OS-level proof is the separate PowerShell walkthrough in the
tutorial itself.
"""
from urllib.parse import urlparse, parse_qs
from handler_fixed import validate_ticket_id
def parse_ticket(uri: str) -> str:
"""Mirror handler_fixed.main()'s parsing, minus the scheme check."""
parsed = urlparse(uri)
params = parse_qs(parsed.query)
return params.get("ticket", [""])[0]
def test_valid_ticket_id_accepted():
assert validate_ticket_id("4521") == "4521"
def test_valid_single_digit_ticket_id_accepted():
assert validate_ticket_id("7") == "7"
def test_empty_ticket_id_rejected():
assert validate_ticket_id("") is None
def test_nine_digit_ticket_id_rejected():
# Real system caps ticket IDs at 8 digits; 9 digits is out of range.
assert validate_ticket_id("123456789") is None
def test_injection_payload_rejected():
payload = "4521 & python marker_write.py"
assert validate_ticket_id(payload) is None
def test_semicolon_payload_rejected():
assert validate_ticket_id("4521; rm -rf something") is None
def test_pipe_payload_rejected():
assert validate_ticket_id("4521 | whoami") is None
def test_real_attack_uri_end_to_end_parse_and_reject():
# The exact percent-encoded URI that exploited handler_naive.py.
uri = "sxzdemo://open?ticket=4521%20%26%20python%20marker_write.py"
raw = parse_ticket(uri)
assert raw == "4521 & python marker_write.py"
assert validate_ticket_id(raw) is None
def test_real_benign_uri_end_to_end_parse_and_accept():
uri = "sxzdemo://open?ticket=4521"
raw = parse_ticket(uri)
assert validate_ticket_id(raw) == "4521"
def test_windows_normalized_uri_still_parses_correctly():
# Windows/ShellExecute delivered "open/?ticket=..." (trailing slash
# before the query string) instead of "open?ticket=..." verbatim.
uri = "sxzdemo://open/?ticket=4521%20%26%20python%20marker_write.py"
parsed = urlparse(uri)
assert parsed.scheme == "sxzdemo"
assert parsed.netloc == "open"
assert parsed.path == "/"
raw = parse_ticket(uri)
assert validate_ticket_id(raw) is None
Run it with python -m pytest test_handler.py -v. Real output from this environment:
collecting ... collected 10 items
test_handler.py::test_valid_ticket_id_accepted PASSED [ 10%]
test_handler.py::test_valid_single_digit_ticket_id_accepted PASSED [ 20%]
test_handler.py::test_empty_ticket_id_rejected PASSED [ 30%]
test_handler.py::test_nine_digit_ticket_id_rejected PASSED [ 40%]
test_handler.py::test_injection_payload_rejected PASSED [ 50%]
test_handler.py::test_semicolon_payload_rejected PASSED [ 60%]
test_handler.py::test_pipe_payload_rejected PASSED [ 70%]
test_handler.py::test_real_attack_uri_end_to_end_parse_and_reject PASSED [ 80%]
test_handler.py::test_real_benign_uri_end_to_end_parse_and_accept PASSED [ 90%]
test_handler.py::test_windows_normalized_uri_still_parses_correctly PASSED [100%]
============================= 10 passed in 0.05s ==============================
How to Verify Everything Works End-to-End
- Run
reg query "HKCU\Software\Classes\sxzdemo"and confirm the key exists with a real, emptyURL Protocolvalue (not/for anything else). - Trigger
Start-Process "sxzdemo://open?ticket=4521" -Waitagainst whichever handler is currently registered and confirmticket_lookup.pyran with the correct ID in the log. - Point the registration at
handler_naive.py, trigger the percent-encoded attack URI from Step 5, and confirmpwned_marker.txtis created. This proves you can reproduce the vulnerability on demand, which matters for confirming the fix actually fixes something real. - Point the registration at
handler_fixed.py, delete any existingpwned_marker.txt, trigger the identical attack URI again, and confirm no marker file appears and the log shows arejectedline. - Trigger the benign URI once more against the fixed handler and confirm it still opens the ticket correctly.
- Run
python -m pytest test_handler.py -vand confirm all ten tests pass. - Clean up when you are done experimenting:
reg delete "HKCU\Software\Classes\sxzdemo" /fremoves the registry key entirely so nothing is left registered on your machine.
Next Steps
- Read the full Security Issues section of Microsoft's URI scheme registration guide, which covers additional risks like a handler being launched with no arguments at all and applications that assume a fixed argument count.
- Look at how a real application you use registers its own scheme. VS Code's
vscode://, Slack'sslack://, and Zoom'szoommtg://are all inspectable in your own registry underHKEY_CLASSES_ROOT, and comparing theirshell\open\commandvalues to what this tutorial built is a fast way to see the same pattern at production scale. - This tutorial's
shell=Truebug is a Windows-flavored instance of CWE-78, OS Command Injection, the same underlying class covered from a web-application angle in sxz.io's SQL injection tutorial and XSS and Content Security Policy tutorial. All three share the same root cause: untrusted input crossing into a context (a SQL query, an HTML page, a shell command) that has its own syntax the input can hijack. - If you maintain a real application with a registered URI scheme, audit it for the two things this tutorial fixed: does any code path build a shell string from a URI-derived value, and does anything validate that value against an allowlist before it is used for anything at all.








No Comment! Be the first one.