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
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. |
noisgate verdict.
This lands in MEDIUM because the single biggest real-world limiter is reachability: the attacker needs an application-specific path that passes untrusted input into webbrowser.open() and then actually launches an affected browser handler. When that path exists the impact is genuine command injection, but it is still a local/UI-driven foothold, not a remotely reachable fleet-burner.
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.
What to do — in priority order.
- Audit
webbrowser.open()call sites — Search internal code, packaged apps, and automation wrappers forwebbrowser.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. - Disable auto-open of untrusted links — Where low-friction, stop automatically launching browser links from tickets, chat messages, logs, imported files, or external feeds. This is the best non-patch breaker for the attack chain because it removes the launch event; for a MEDIUM finding there is no mitigation SLA, so deploy it opportunistically where change risk is low.
- Constrain browser-launching Python workloads — Apply least privilege to desktop helpers, support tools, and automation users that launch browsers so a successful injection lands in a smaller user context. Use EDR policy, application control, or container/user isolation where practical; again, this is routine hardening rather than an emergency deadline item for MEDIUM.
- Prefer vendor backports over hand edits — Because distro backports already exist and version strings may be misleading, use distro/vendor packages instead of local source patching when possible. That reduces verification ambiguity and keeps you inside your normal 365-day remediation window with less operational risk.
- 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().
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())
If you remember one thing.
webbrowser.open() paths fed by untrusted content. Because this is MEDIUM, the noisgate mitigation SLA is no mitigation SLA — go straight to the 365-day remediation window; use low-cost guardrails like disabling auto-open where convenient, then roll vendor-fixed CPython builds or distro backports during normal maintenance under the noisgate remediation SLA of ≤ 365 days.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.