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
NewSmartypantsRenderer and 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.- 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.
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. |
Sources.
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.
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()