This is a trapdoor in the fancy quote converter, not a master key for every Go markdown app
CVE-2026-40890 is an out-of-bounds read / panic bug in github.com/gomarkdown/markdown's SmartypantsRenderer. If the renderer processes text containing a < with no matching > later in the buffer, smartLeftAngle() can slice past the intended bounds and either read one extra byte or panic. The GitHub advisory marks affected builds as <= 37c66b8, and the fix is commit 759bbc3e32073c3bc4e25969c132fc520eda2778 published in April 2026.
The vendor's HIGH 7.5 score reflects the pure CVSS view: no auth, low complexity, network reachable, availability impact only. In real deployments that overstates the risk. An attacker first needs a reachable application that accepts untrusted Markdown, uses this specific Go library, and *explicitly* routes content through SmartypantsRenderer rather than the library's ordinary HTML renderer; that stack of prerequisites materially shrinks exposure.
4 steps from start to impact.
Find a Markdown ingestion path that uses Smartypants
curl, Burp, or passive app mapping to locate a comment box, wiki, docs preview, or render API that accepts attacker-controlled Markdown. The key technical requirement is not just the dependency but application code that instantiates html.NewSmartypantsRenderer(...) or an equivalent wrapper around the vulnerable Smartypants path.- A reachable application accepts attacker-supplied Markdown or text later passed to the renderer
- The application depends on
github.com/gomarkdown/markdown - The vulnerable Smartypants code path is actually enabled
- Many consumers use
markdown.ToHTML(..., nil, nil)orhtml.NewRenderer, which does not require Smartypants - A large fraction of enterprises will have the library only in build tooling, CLIs, or internal services with no attacker reachability
- SCA may flag the module, but reachability to this exact renderer is often absent
Send malformed input that lacks a closing angle bracket
<a to the vulnerable render path. That drives execution into smartLeftAngle()`, where the loop walks until end-of-buffer and then the buggy slice/write path misbehaves.- Input is forwarded to Smartypants processing before any rejecting validation
- The payload preserves the unmatched
<through transport and preprocessing
- Some applications normalize or reject malformed markup before it reaches the renderer
- Some apps only use Markdown rendering on trusted or authenticated content flows
< characters immediately before renderer crashes, plus request/response correlations to 5xx spikes.Trigger panic or out-of-bounds read in the renderer
out.Write(text[:i+1]) even when no > exists. In practice the advisory says the dominant outcome is denial of service through a Go panic on the processing service, with the out-of-bounds read side effect limited to one extra byte in some slice-capacity conditions.- The vulnerable source version is present
- The application does not fully contain or recover the panic
- Go services often deploy panic-recovery middleware, worker isolation, or per-request supervisors
- If the panic only kills one request goroutine and is recovered, the blast radius is much smaller than a full process crash
panic: slice bounds out of range, crash-loop events, Kubernetes restart counts, and APM error-rate spikes are the best signals.Repeat requests to sustain an application-level DoS
hey, ab, or any low-rate bot traffic. This is not a stealthy post-exploitation primitive; it is a blunt service disruption path whose real impact depends on how exposed and how fragile the renderer service is.- The service is externally reachable or reachable from the attacker position
- Repeated crash/restart behavior materially reduces availability
- Rate limits, autoscaling, queue-based rendering, retries, and crash recovery all reduce real-world impact
- This does not provide code execution, tenant escape, or data modification
The supporting signals.
| In-the-wild status | No current exploitation signal. Not present in CISA KEV, and I found no authoritative evidence of active campaigns. |
|---|---|
| Public PoC availability | Yes, but minimal. The GitHub advisory includes a tiny Go PoC using src := []byte("<a") and html.NewSmartypantsRenderer(...). |
| EPSS | Very low. User-provided EPSS is 0.00054, which is consistent with low near-term exploitation interest for an optional library-only DoS bug. |
| KEV status | Not listed. No KEV entry as of the reviewed catalog. |
| CVSS vector meaning | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H scores high because unauthenticated remote input can crash a reachable service, but it assumes direct network reachability to the vulnerable code path. |
| Affected versions | Pre-fix trees only. GitHub lists affected versions as <= 37c66b8; the structured CVE record shows versions before commit 759bbc3e32073c3bc4e25969c132fc520eda2778. |
| Fixed version | Commit-level fix, not a clean semver target in the advisory. Patch is commit 759bbc3. If you pin Go modules by pseudo-version, verify the resolved source actually contains the if i == len(text) guard. |
| Exposure / scanning reality | No meaningful Shodan/Censys/FOFA measurement. This is a library embedded inside applications, not a bannered network product, so internet-wide scan data does not map cleanly to exposure. |
| Default usage nuance | Important downward pressure. Package docs show common examples using html.NewRenderer or markdown.ToHTML(..., nil, nil), while Smartypants is documented as an optional feature rather than the default path. |
| Disclosure / credit | GitHub advisory published 2026-04-13; CVE/NVD published 2026-04-21. Reporter credited in the advisory is JulesDT, with impact text attributed to The Datadog Security Team. |
noisgate verdict.
The decisive factor is reachability to the vulnerable code path, not the panic itself. A remote attacker only gets value if your application both accepts untrusted Markdown and explicitly wires in SmartypantsRenderer, which sharply narrows the exposed population compared with the vendor's generic network-based CVSS view.
Why this verdict
- Downgrade for optional code path: the bug lives in
SmartypantsRenderer, not in the package's normal markdown parsing/rendering path. - Downgrade for exposure narrowing: attacker success requires a reachable app that accepts hostile Markdown, uses this exact library, and invokes the vulnerable renderer; each prerequisite compounds downward pressure on severity.
- Keep it at MEDIUM, not LOW: if those prerequisites are met, the payload is trivial and unauthenticated, and repeated requests can create real availability pain for public-facing render services.
Why not higher?
This is not an RCE, auth bypass, tenant breakout, or data theft primitive. The vendor CVSS treats the vulnerable function like a broadly reachable network service, but in practice it is an opt-in library feature hidden behind application-specific integration choices.
Why not lower?
A one-token payload can still crash a vulnerable request path with no authentication, and many teams will not know whether Smartypants is enabled until they inspect code. For Internet-facing apps that render user Markdown, the operational blast radius can be real enough that this should not be dismissed as mere hygiene.
What to do — in priority order.
- Inventory actual Smartypants usage — Search source and dependency trees for
NewSmartypantsRendererand wrapper helpers to separate truly reachable apps from dead code or build-only dependencies. For a MEDIUM verdict there is no mitigation SLA — go straight to the 365-day remediation window, but do this discovery early so exposed services are not buried in backlog. - Wrap renderer calls with panic recovery — If you must keep vulnerable builds temporarily, ensure markdown rendering runs behind Go panic recovery middleware or isolated workers so a single malformed document does not take down the service process. This is the best temporary blast-radius reducer while you work through the 365-day remediation window.
- Disable or bypass Smartypants on untrusted input — Where business requirements allow it, use the normal HTML renderer for public/untrusted content paths and reserve Smartypants for trusted content or offline generation. That removes the vulnerable code path entirely while remaining within the MEDIUM backlog-driven remediation cycle.
- Rate-limit markdown render endpoints — Apply per-user and per-IP throttles to comment preview, wiki preview, and conversion APIs so repeat-crash attempts do not become sustained availability incidents. This is a resilience control, not a fix, and it fits the same no mitigation SLA posture for this severity.
- HTML sanitization such as
bluemondayafter rendering does not help; the panic occurs during Smartypants processing before sanitized output exists. - A generic WAF rule is weak here because the trigger can be tiny and syntactically plausible, such as an unmatched
<in otherwise normal text. - SBOM presence alone is not enough to prioritize aggressively; you need code-path confirmation, because many installations will never invoke Smartypants at runtime.
Crowdsourced verification payload.
Run this on a developer workstation, CI runner, or source checkout server where the application source or vendored Go modules are present. Invoke it as python3 verify_cve_2026_40890.py /path/to/repo with read access only; no admin privileges are required. It inspects local smartypants.go copies for the fixed if i == len(text) guard and prints VULNERABLE, PATCHED, or UNKNOWN.
#!/usr/bin/env python3
"""
verify_cve_2026_40890.py
Checks local source trees for the gomarkdown Smartypants fix for CVE-2026-40890.
It does NOT resolve all Go module graphs; it inspects source present in a repo,
vendor tree, or unpacked module cache beneath the supplied path.
Exit codes:
0 = PATCHED
1 = VULNERABLE
2 = UNKNOWN / not found / insufficient evidence
"""
import os
import re
import sys
from pathlib import Path
TARGET_REL = os.path.join("github.com", "gomarkdown", "markdown", "html", "smartypants.go")
FUNC_HINT = "func (r *SPRenderer) smartLeftAngle"
FIX_GUARD = "if i == len(text)"
OLD_WRITE = "out.Write(text[:i+1])"
def read_text(path: Path):
try:
return path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return None
def classify_file(path: Path):
text = read_text(path)
if text is None:
return None, f"unable to read {path}"
if FUNC_HINT not in text:
return None, f"target function not found in {path}"
has_fix = FIX_GUARD in text
# Vulnerable pattern: old write exists and fixed guard does not.
has_old_pattern = OLD_WRITE in text and not has_fix
if has_fix:
return "PATCHED", f"fixed guard present in {path}"
if has_old_pattern:
return "VULNERABLE", f"old slice/write pattern present without guard in {path}"
# Fallback: extract the function body and look for guard heuristically.
m = re.search(r"func \(r \*SPRenderer\) smartLeftAngle\([^\)]*\) int \{(.*?)\n\}", text, re.S)
if m:
body = m.group(1)
if "len(text)" in body and "return i" in body:
return "PATCHED", f"heuristic indicates patched smartLeftAngle in {path}"
if OLD_WRITE in body:
return "VULNERABLE", f"heuristic indicates vulnerable smartLeftAngle in {path}"
return None, f"could not confidently classify {path}"
def discover_candidates(root: Path):
candidates = []
# Exact vendor/module paths first.
for p in root.rglob("smartypants.go"):
parts = str(p).replace("\\", "/")
if TARGET_REL.replace("\\", "/") in parts:
candidates.append(p)
continue
# Common Go module cache naming: .../gomarkdown/markdown@version/html/smartypants.go
if re.search(r"gomarkdown[/\\]markdown(@[^/\\]+)?[/\\]html[/\\]smartypants\.go$", str(p)):
candidates.append(p)
# De-duplicate while preserving order.
seen = set()
uniq = []
for c in candidates:
s = str(c.resolve())
if s not in seen:
seen.add(s)
uniq.append(c)
return uniq
def main():
if len(sys.argv) != 2:
print("UNKNOWN - usage: python3 verify_cve_2026_40890.py /path/to/repo_or_modules")
sys.exit(2)
root = Path(sys.argv[1]).resolve()
if not root.exists():
print(f"UNKNOWN - path does not exist: {root}")
sys.exit(2)
candidates = discover_candidates(root)
if not candidates:
print("UNKNOWN - no local gomarkdown smartypants.go file found under supplied path")
sys.exit(2)
patched = []
vulnerable = []
unknown = []
for candidate in candidates:
status, reason = classify_file(candidate)
if status == "PATCHED":
patched.append(reason)
elif status == "VULNERABLE":
vulnerable.append(reason)
else:
unknown.append(reason)
# If any vulnerable copy exists in the supplied tree, fail closed.
if vulnerable:
print("VULNERABLE")
for line in vulnerable:
print(f"- {line}")
if patched:
for line in patched:
print(f"- also found patched copy: {line}")
sys.exit(1)
if patched:
print("PATCHED")
for line in patched:
print(f"- {line}")
sys.exit(0)
print("UNKNOWN")
for line in unknown:
print(f"- {line}")
sys.exit(2)
if __name__ == "__main__":
main()
If you remember one thing.
github.com/gomarkdown/markdown and instantiate SmartypantsRenderer; for this MEDIUM reassessment there is no noisgate mitigation SLA — go straight to the 365-day remediation window for the actual fix, but pull any Internet-facing Markdown render services to the front of that queue once reachability is confirmed. Your noisgate remediation SLA is ≤365 days to land the vendor fix commit or an equivalent upgraded module version everywhere it is truly reachable.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.