This is the spare key hidden under the mat after the front door lock was already replaced
CVE-2026-4786 is a follow-on flaw in CPython webbrowser.open(): the first fix for CVE-2026-4519 blocked obvious dash-prefixed URLs, but %action expansion could still turn attacker-controlled input into shell arguments for certain browser handlers. In practice, the vulnerable population is not every Python process; it is Python code paths that pass attacker-influenced URLs into webbrowser.open() on affected branches or distro builds before the follow-up fix/backport. Public references show follow-up fixes/backports for main, 3.14, 3.13, 3.11, and 3.10, while distro status is uneven and backport-heavy.
The vendor framing is technically understandable—Python’s CNA published a CVSS-B 7.0 / HIGH on 2026-04-13—but it overshoots enterprise patch priority if you manage real fleets. This is local, user-assisted, app-dependent command injection in a client/library feature, not an unauthenticated remote service bug. The decisive downgrade pressure is the compound friction: attacker-controlled URL input plus a code path that calls webbrowser.open() plus an affected browser handler plus an actual launch event.
4 steps from start to impact.
Find a Python workflow that auto-opens links
webbrowser.open(). The weaponized input is just a crafted URL string; no memory corruption or exploit kit is required. The relevant upstream references are CPython issue #148169 and PR #148170.- Attacker can influence a URL consumed by the target Python application
- Target workflow actually uses
webbrowser.open()instead of rendering or logging the URL - The vulnerable CPython or distro build is present
- Many enterprise Python apps never call
webbrowser.open()on untrusted input - Server-side Python estates often do not use this API at all
- Fleet inventory usually undercounts embedded Python runtimes, so exposure is real but narrow
webbrowser.open(, but package scanners alone only tell you version state, not whether attacker-controlled input reaches the API.Use %action to bypass the earlier dash check
%action expansion. The fix commit shows the corrected order: the expanded URL is now checked with _check_url(url.replace("%action", action)), and argument replacement order was changed to expand %action before %s. A crafted value like %action--incognito or even %action with new=1 can become a dash-prefixed browser flag after expansion on affected builds.- Affected browser handler uses
%actionsubstitution semantics - The Python build contains the incomplete CVE-2026-4519 mitigation rather than the follow-up fix
- Application behavior allows attacker input to survive into the browser launch string
- Only certain browser types/handlers are in scope
- Patched distro backports may keep the same marketing version while fixing the code
- Branch/version mapping is messy enough that code-level validation is safer than naive version matching
Lib/webbrowser.py is stronger than naked semantic version checks here.Trigger browser launch and land command injection
- A launch event occurs: user click, automated open, or workflow action
- No higher-level sanitization strips hostile input before
webbrowser.open() - The target user context is valuable enough to matter
- User interaction or application action is typically required
- Execution is usually limited to the current user/session
- Modern EDR may catch suspicious child-process chains from desktop tooling
Exploit the user context, not the whole fleet
- Compromised user session has access to secrets, repos, tokens, or admin tools
- Lateral movement paths exist after initial execution
- No direct pre-auth network path to mass exploitation
- Privilege escalation is not built into the vulnerability
- Blast radius is host/user scoped unless chained
webbrowser.open() call sites — Search internal code, packaged apps, and automation wrappers for webbrowser.open( and classify whether untrusted URLs can reach it. Because this is a MEDIUM finding, no noisgate mitigation SLA applies; do this in the next normal engineering cycle and use the result to decide whether patching can stay in the standard 365-day remediation window.- A WAF or perimeter firewall does not help much here because the vulnerable primitive is a local/client library path, not an inbound HTTP service bug.
- External attack-surface scanning will not tell you who is exposed; this is not something Shodan can enumerate in a useful way.
- MFA is largely irrelevant to the exploit step itself; it may help laterally after compromise, but it does not stop hostile input from reaching
webbrowser.open().
The supporting signals.
| In-the-wild status | No evidence found of active exploitation in the sources reviewed; not KEV-listed. |
|---|---|
| PoC availability | No standalone public exploit repo stood out in primary-source review. Public technical material exists in the CPython issue #148169, PR #148170, and the fix commit d22922c, which is enough for a competent attacker to reproduce. |
| EPSS | User-supplied EPSS 0.00021. Third-party display reviewed via Docker Scout shows 0.0002 (0.054), which likely implies an approximately 5.4th percentile display format; treat that percentile as an inference, not an authoritative FIRST quote. |
| KEV status | Absent from CISA KEV as of the catalog page reviewed. Disclosure date: 2026-04-13. |
| Vendor/CNA severity baseline | The prompt's 'no baseline' is outdated. As of 2026-04-13, the Python Software Foundation had published CVSS-B 7.0 / HIGH with vector CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:A/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N; NVD was still awaiting enrichment when reviewed. |
| CVSS interpretation | That vector already tells the real story: local attack vector, user interaction required, and no built-in privilege escalation. Good technical impact, but heavily constrained reachability. |
| Affected versions / branches | Public references show follow-up fixes/backports for main, 3.14, 3.13, 3.11, and 3.10. Exact upstream version mapping is noisier than usual because distro backports and branch timing differ; treat branch commit state and distro advisories as more trustworthy than simplistic semantic-version assumptions. |
| Fixed versions / backports | Examples of authoritative downstream fixes: Debian marks python3.14 fixed at 3.14.5-1 and python3.11 fixed in bookworm packages; Amazon Linux 2023 ships a backport for python3.13 in 3.13.13-1.amzn2023.0.2 under ALAS2023-2026-1638. |
| Scanning / exposure reality | There is no meaningful Shodan/Censys/FOFA-style internet fingerprint for this bug because it is a client/library execution path, not a remotely exposed network service. Exposure measurement is an inventory + code-path problem, not an external attack-surface counting problem. |
| Disclosure / reporter | Disclosed 2026-04-13 by the Python Software Foundation; advisory email was sent by Seth Larson, and the follow-up fix work is tied to Stan Ulbrych (StanFromIreland) in upstream PR/commits. |
Sources.
Why this verdict
- Local + user-assisted preconditions cut hard: the CNA vector is
AV:LwithUI:A, which means this is not an unauthenticated remote edge exploit and should not compete with externally reachable RCEs. - Reachable population is narrow: only environments where attacker-controlled URLs flow into
webbrowser.open()on affected handlers are truly exposed; most server-side Python estates never hit that path. - Impact is still meaningful: once the chain lands, it is shell argument/command injection in the current user context, which matters on developer, support, and admin-adjacent workstations.
Why not higher?
There is no direct network path, no default privilege escalation, and no evidence of active exploitation or KEV pressure. The chain depends on multiple environmental assumptions that compound downward: attacker-controlled input, a specific Python API usage pattern, an affected browser handler, and a launch event.
Why not lower?
This is not harmless parser weirdness; it is a real command injection primitive when the path exists. Python is everywhere in enterprise tooling, so even a narrow client-side issue can matter on high-value endpoints and automation nodes.
Crowdsourced verification payload.
Run this on the target host or inside the affected Python image/venv using the exact interpreter you want to assess, for example: python3 check_cve_2026_4786.py or /opt/app/venv/bin/python check_cve_2026_4786.py. No elevated privileges are required; the script inspects the local standard-library webbrowser.py implementation and prints VULNERABLE, PATCHED, or UNKNOWN.
#!/usr/bin/env python3
"""
check_cve_2026_4786.py
Detects whether the local Python interpreter's stdlib webbrowser.py
contains the follow-up fix for CVE-2026-4786.
Exit codes:
0 = PATCHED
1 = VULNERABLE
2 = UNKNOWN / could not determine
"""
import inspect
import os
import sys
try:
import webbrowser
except Exception as exc:
print(f"UNKNOWN: failed to import webbrowser: {exc}")
sys.exit(2)
def main() -> int:
path = getattr(webbrowser, "__file__", None)
if not path or not os.path.exists(path):
print(f"UNKNOWN: could not locate webbrowser.py (path={path!r})")
return 2
try:
with open(path, "r", encoding="utf-8") as f:
src = f.read()
except Exception as exc:
print(f"UNKNOWN: failed to read {path}: {exc}")
return 2
# Fixed follow-up logic from the upstream patch:
# self._check_url(url.replace("%action", action))
# arg.replace("%action", action).replace("%s", url)
patched_markers = [
'self._check_url(url.replace("%action", action))',
"self._check_url(url.replace('%action', action))",
'replace("%action", action).replace("%s", url)',
"replace('%action', action).replace('%s', url)",
]
# Vulnerable/incomplete-fix logic seen before the follow-up patch:
# self._check_url(url)
# arg.replace("%s", url).replace("%action", action)
vulnerable_markers = [
'self._check_url(url)',
'replace("%s", url).replace("%action", action)',
"replace('%s', url).replace('%action', action)",
]
has_patched = any(marker in src for marker in patched_markers)
has_vuln = all(marker in src for marker in vulnerable_markers)
print(f"Interpreter: {sys.executable}")
print(f"Version: {sys.version.split()[0]}")
print(f"Module: {path}")
if has_patched:
print("PATCHED")
return 0
if has_vuln:
print("VULNERABLE")
return 1
print("UNKNOWN")
print("Reason: webbrowser.py does not clearly match either the known vulnerable or known patched code pattern; this may be a downstream backport or modified runtime.")
return 2
if __name__ == "__main__":
sys.exit(main())