{"id":"CVE-2026-40035","aliases":["EUVD-2026-20777","GHSA-92wx-pxch-p9px","GHSA-vg9h-jx4v-cwx2"],"status":"published","title":"Unfurl - Werkzeug Debugger Exposure via String Config Parsing","description":"Unfurl through 2025.08 contains an improper input validation vulnerability in config parsing that enables Flask debug mode by default. The debug configuration value is read as a string and passed directly to app.run(), causing any non-empty string to evaluate truthy, allowing attackers to access the Werkzeug debugger and disclose sensitive information or achieve remote code execution.","severity":{"version":"4.0","score":9.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N","level":"critical","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-09","score":0.00566,"percentile":0.47289,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":false},"cwes":["CWE-489"],"published":"2026-01-29T15:32:33Z","modified":"2026-11-08T14:02:46Z","affected":[{"source":"ghsa","ecosystem":"PyPI","package":"dfir-unfurl","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"20250810"}]}]},{"source":"osv","ecosystem":"PyPI","package":"dfir-unfurl","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"20250810"}]}],"versions":["20200629","20200630","20200630.2","20200630.3","20200630.4","20200702","20200703","20200729","20200812","20201102","20210308","20210309","20210310","20210311","20210615","20220200","20221100","20230900","20230901","20240625","20240626","20240627","20241120","20241121","20250218","20250312","20250810"]}],"products":[{"vendor":"obsidianforensics","product":"unfurl"},{"vendor":"ryandfir","product":"unfurl"}],"references":[{"url":"https://github.com/obsidianforensics/unfurl/security/advisories/GHSA-vg9h-jx4v-cwx2","tags":["vendor-advisory","exploit","Exploit","Vendor Advisory","advisory","web"]},{"url":"https://www.vulncheck.com/advisories/dfir-unfurl-werkzeug-debugger-exposure-via-string-config-parsing","tags":["third-party-advisory","Third Party Advisory","advisory"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-40035","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-92wx-pxch-p9px"},{"url":"https://github.com/obsidianforensics/unfurl/commit/4c0a07ab1e9af3a1ddf0e7f47153ec9ba77946dd","tags":["web"]},{"url":"https://github.com/advisories/GHSA-vg9h-jx4v-cwx2"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/40xxx/CVE-2026-40035.json","tags":["advisory"]},{"url":"https://github.com/obsidianforensics/unfurl","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-40035","title":"Unfurl - Werkzeug Debugger Exposure via String Config Parsing","description":"Unfurl through 2025.08 contains an improper input validation vulnerability in config parsing that enables Flask debug mode by default. The debug configuration value is read as a string and passed directly to app.run(), causing any non-empty string to evaluate truthy, allowing attackers to access the Werkzeug debugger and disclose sensitive information or achieve remote code execution.","status":"published","published":"2026-04-08T21:35:27.703Z","modified":"2026-11-08T14:02:46.548Z","cvss":[{"version":"4.0","score":9.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N","level":"critical","assessor":"cna"},{"version":"3.1","score":9.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N","level":"critical","assessor":"cna"}],"cwes":["CWE-489"],"updated_at":"2026-11-08T15:43:33.322657Z"}],"euvd":[{"id":"EUVD-2026-20777","description":"Unfurl through 2025.08 contains an improper input validation vulnerability in config parsing that enables Flask debug mode by default. The debug configuration value is read as a string and passed directly to app.run(), causing any non-empty string to evaluate truthy, allowing attackers to access the Werkzeug debugger and disclose sensitive information or achieve remote code execution.","status":"published","published":"2026-04-08T21:35:27Z","modified":"2026-11-08T14:02:46Z","cvss":[{"version":"4.0","score":9.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N","level":"critical"}],"updated_at":"2026-11-08T15:13:09.268962Z"}],"ghsa":[{"id":"GHSA-92wx-pxch-p9px","title":"Unfurl through 2025.08 contains an improper input validation vulnerability in config parsing that...","description":"Unfurl through 2025.08 contains an improper input validation vulnerability in config parsing that enables Flask debug mode by default. The debug configuration value is read as a string and passed directly to app.run(), causing any non-empty string to evaluate truthy, allowing attackers to access the Werkzeug debugger and disclose sensitive information or achieve remote code execution.","status":"published","published":"2026-04-09T00:31:59Z","modified":"2026-04-09T00:32:07Z","cvss":[{"version":"4.0","score":9.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"critical"},{"version":"3.1","score":9.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N","level":"critical"}],"cwes":["CWE-489"],"updated_at":"2026-11-02T15:07:23.155154Z"},{"id":"GHSA-vg9h-jx4v-cwx2","title":"Unfurl's debug mode cannot be disabled due to string config parsing (Werkzeug debugger exposure)","description":"### Summary\nThe Unfurl web app enables Flask debug mode even when configuration sets `debug = False`. The config value is read as a string and passed directly to `app.run(debug=...)`, so any non-empty string evaluates truthy. This leaves the Werkzeug debugger active by default.\n\n### Details\n- `unfurl/app.py:web_app()` reads `debug` via `config['UNFURL_APP'].get('debug')`, which returns a string.\n- `UnfurlApp.__init__` passes that string directly to `app.run(debug=unfurl_debug, ...)`.\n- If `unfurl.ini` omits `debug`, the default argument is the string `\"True\"`.\n- As a result, debug mode is effectively always on and cannot be reliably disabled via config.\n\n### PoC\n1. Create a local `unfurl.ini` with `debug = False` under `[UNFURL_APP]`.\n2. Run the server using `unfurl_app` (or `python -c 'from unfurl.app import web_app; web_app()'`).\n3. Observe server logs showing `Debug mode: on` / `Debugger is active!`.\n4. The included PoC script `security_poc/poc_debug_mode.py --spawn` automates this check.\n\n### PoC Script (inline)\n```python\n#!/usr/bin/env python3\n\"\"\"\nUnfurl Debug Mode PoC (Corrected)\n================================\n\nThis PoC demonstrates that Unfurl's Flask debug mode is effectively\n**always enabled by default** due to string parsing of the `debug`\nconfig value. Even `debug = False` in `unfurl.ini` evaluates truthy\nwhen passed to `app.run(debug=...)`.\n\nTwo modes:\n1) --spawn (default): launch a local Unfurl server with debug=False\n   in a temp config and inspect logs for \"Debug mode: on\".\n2) --target: attempt a remote indicator check (best-effort; may be silent\n   if no exception is triggered).\n\"\"\"\n\nimport argparse\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport textwrap\nimport time\n\n\ndef run_spawn_check() -\u003e None:\n    repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))\n\n    ini_contents = textwrap.dedent(\"\"\"\n    [UNFURL_APP]\n    host = 127.0.0.1\n    port = 5055\n    debug = False\n    remote_lookups = false\n\n    [API_KEYS]\n    bitly =\n    macaddress_io =\n    \"\"\").strip() + \"\\n\"\n\n    with tempfile.TemporaryDirectory() as tmp:\n        ini_path = os.path.join(tmp, 'unfurl.ini')\n        with open(ini_path, 'w') as f:\n            f.write(ini_contents)\n\n        env = os.environ.copy()\n        env['PYTHONPATH'] = repo_root\n\n        cmd = [sys.executable, '-c', 'from unfurl.app import web_app; web_app()']\n        proc = subprocess.Popen(\n            cmd,\n            cwd=tmp,\n            env=env,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            text=True\n        )\n\n        # Allow server to start and emit logs\n        time.sleep(2)\n        proc.terminate()\n        try:\n            out, err = proc.communicate(timeout=2)\n        except subprocess.TimeoutExpired:\n            proc.kill()\n            out, err = proc.communicate()\n\n        output = (out or \"\") + (err or \"\")\n\n    print(\"\\n[+] Debug mode spawn check\")\n    print(\"    Config: debug = False\")\n\n    if \"Debug mode: on\" in output or \"Debugger is active\" in output:\n        print(\"    ✅ Debug mode is ON despite debug=False (vulnerable)\")\n    else:\n        print(\"    ⚠️  Debug mode not detected in logs (check output below)\")\n\n    if output.strip():\n        print(\"\\n--- server output (truncated) ---\")\n        print(\"\\n\".join(output.splitlines()[:15]))\n        print(\"--- end ---\")\n\n\ndef run_remote_probe(target: str) -\u003e None:\n    import requests\n\n    print(\"\\n[+] Remote debug indicator probe (best-effort)\")\n    print(f\"    Target: {target}\")\n\n    # This app does not easily throw exceptions from user input, so\n    # absence of indicators does NOT prove debug is off.\n    probe_urls = [\n        f\"{target.rstrip('/')}/__nonexistent__\",\n    ]\n\n    detected = False\n    for url in probe_urls:\n        try:\n            resp = requests.get(url, timeout=10)\n            if \"Werkzeug Debugger\" in resp.text or \"Traceback\" in resp.text:\n                detected = True\n                print(\"    ✅ Debug indicators found\")\n                break\n        except Exception as e:\n            print(f\"    ⚠️  Probe failed: {e}\")\n\n    if not detected:\n        print(\"    ⚠️  No debug indicators found (this is not definitive)\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(description='Unfurl debug mode PoC (corrected)')\n    parser.add_argument('--spawn', action='store_true', help='Run local spawn check (default)')\n    parser.add_argument('--target', help='Target Unfurl URL for remote probe')\n    args = parser.parse_args()\n\n    if args.target:\n        run_remote_probe(args.target)\n    else:\n        run_spawn_check()\n\n\nif __name__ == '__main__':\n    main()\n```\n\n### Impact\nIf the service is exposed beyond localhost (bound to 0.0.0.0 or reverse-proxied), an attacker can access the Werkzeug debugger. This can disclose sensitive information and may allow remote code execution if a debugger PIN is obtained. At minimum, stack traces and environment details are exposed on errors.","status":"published","published":"2026-01-29T15:32:33Z","modified":"2026-01-29T15:32:36Z","cvss":[{"version":"4.0","score":9.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N","level":"critical"}],"cwes":["CWE-489"],"updated_at":"2026-11-02T14:39:20.563096Z"}],"nvd":[{"id":"CVE-2026-40035","description":"Unfurl through 2025.08 contains an improper input validation vulnerability in config parsing that enables Flask debug mode by default. The debug configuration value is read as a string and passed directly to app.run(), causing any non-empty string to evaluate truthy, allowing attackers to access the Werkzeug debugger and disclose sensitive information or achieve remote code execution.","status":"published","published":"2026-04-08T22:16:24.01Z","modified":"2026-10-08T16:17:14.723Z","cvss":[{"version":"4.0","score":9.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"critical","assessor":"secondary"},{"version":"3.1","score":9.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N","level":"critical","assessor":"secondary"}],"cwes":["CWE-489"],"updated_at":"2026-11-02T14:12:13.902911Z"}],"osv":[{"id":"CVE-2026-40035","title":"Unfurl - Werkzeug Debugger Exposure via String Config Parsing","description":"Unfurl through 2025.08 contains an improper input validation vulnerability in config parsing that enables Flask debug mode by default. The debug configuration value is read as a string and passed directly to app.run(), causing any non-empty string to evaluate truthy, allowing attackers to access the Werkzeug debugger and disclose sensitive information or achieve remote code execution.","status":"published","published":"2026-04-08T21:35:27.703Z","modified":"2026-11-09T02:31:07.733320593Z","cvss":[{"version":"4.0","score":9.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N","level":"critical"}],"cwes":["CWE-489"],"updated_at":"2026-11-09T03:16:44.651171Z"},{"id":"GHSA-vg9h-jx4v-cwx2","title":"Unfurl's debug mode cannot be disabled due to string config parsing (Werkzeug debugger exposure)","description":"### Summary\nThe Unfurl web app enables Flask debug mode even when configuration sets `debug = False`. The config value is read as a string and passed directly to `app.run(debug=...)`, so any non-empty string evaluates truthy. This leaves the Werkzeug debugger active by default.\n\n### Details\n- `unfurl/app.py:web_app()` reads `debug` via `config['UNFURL_APP'].get('debug')`, which returns a string.\n- `UnfurlApp.__init__` passes that string directly to `app.run(debug=unfurl_debug, ...)`.\n- If `unfurl.ini` omits `debug`, the default argument is the string `\"True\"`.\n- As a result, debug mode is effectively always on and cannot be reliably disabled via config.\n\n### PoC\n1. Create a local `unfurl.ini` with `debug = False` under `[UNFURL_APP]`.\n2. Run the server using `unfurl_app` (or `python -c 'from unfurl.app import web_app; web_app()'`).\n3. Observe server logs showing `Debug mode: on` / `Debugger is active!`.\n4. The included PoC script `security_poc/poc_debug_mode.py --spawn` automates this check.\n\n### PoC Script (inline)\n```python\n#!/usr/bin/env python3\n\"\"\"\nUnfurl Debug Mode PoC (Corrected)\n================================\n\nThis PoC demonstrates that Unfurl's Flask debug mode is effectively\n**always enabled by default** due to string parsing of the `debug`\nconfig value. Even `debug = False` in `unfurl.ini` evaluates truthy\nwhen passed to `app.run(debug=...)`.\n\nTwo modes:\n1) --spawn (default): launch a local Unfurl server with debug=False\n   in a temp config and inspect logs for \"Debug mode: on\".\n2) --target: attempt a remote indicator check (best-effort; may be silent\n   if no exception is triggered).\n\"\"\"\n\nimport argparse\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport textwrap\nimport time\n\n\ndef run_spawn_check() -\u003e None:\n    repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))\n\n    ini_contents = textwrap.dedent(\"\"\"\n    [UNFURL_APP]\n    host = 127.0.0.1\n    port = 5055\n    debug = False\n    remote_lookups = false\n\n    [API_KEYS]\n    bitly =\n    macaddress_io =\n    \"\"\").strip() + \"\\n\"\n\n    with tempfile.TemporaryDirectory() as tmp:\n        ini_path = os.path.join(tmp, 'unfurl.ini')\n        with open(ini_path, 'w') as f:\n            f.write(ini_contents)\n\n        env = os.environ.copy()\n        env['PYTHONPATH'] = repo_root\n\n        cmd = [sys.executable, '-c', 'from unfurl.app import web_app; web_app()']\n        proc = subprocess.Popen(\n            cmd,\n            cwd=tmp,\n            env=env,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            text=True\n        )\n\n        # Allow server to start and emit logs\n        time.sleep(2)\n        proc.terminate()\n        try:\n            out, err = proc.communicate(timeout=2)\n        except subprocess.TimeoutExpired:\n            proc.kill()\n            out, err = proc.communicate()\n\n        output = (out or \"\") + (err or \"\")\n\n    print(\"\\n[+] Debug mode spawn check\")\n    print(\"    Config: debug = False\")\n\n    if \"Debug mode: on\" in output or \"Debugger is active\" in output:\n        print(\"    ✅ Debug mode is ON despite debug=False (vulnerable)\")\n    else:\n        print(\"    ⚠️  Debug mode not detected in logs (check output below)\")\n\n    if output.strip():\n        print(\"\\n--- server output (truncated) ---\")\n        print(\"\\n\".join(output.splitlines()[:15]))\n        print(\"--- end ---\")\n\n\ndef run_remote_probe(target: str) -\u003e None:\n    import requests\n\n    print(\"\\n[+] Remote debug indicator probe (best-effort)\")\n    print(f\"    Target: {target}\")\n\n    # This app does not easily throw exceptions from user input, so\n    # absence of indicators does NOT prove debug is off.\n    probe_urls = [\n        f\"{target.rstrip('/')}/__nonexistent__\",\n    ]\n\n    detected = False\n    for url in probe_urls:\n        try:\n            resp = requests.get(url, timeout=10)\n            if \"Werkzeug Debugger\" in resp.text or \"Traceback\" in resp.text:\n                detected = True\n                print(\"    ✅ Debug indicators found\")\n                break\n        except Exception as e:\n            print(f\"    ⚠️  Probe failed: {e}\")\n\n    if not detected:\n        print(\"    ⚠️  No debug indicators found (this is not definitive)\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(description='Unfurl debug mode PoC (corrected)')\n    parser.add_argument('--spawn', action='store_true', help='Run local spawn check (default)')\n    parser.add_argument('--target', help='Target Unfurl URL for remote probe')\n    args = parser.parse_args()\n\n    if args.target:\n        run_remote_probe(args.target)\n    else:\n        run_spawn_check()\n\n\nif __name__ == '__main__':\n    main()\n```\n\n### Impact\nIf the service is exposed beyond localhost (bound to 0.0.0.0 or reverse-proxied), an attacker can access the Werkzeug debugger. This can disclose sensitive information and may allow remote code execution if a debugger PIN is obtained. At minimum, stack traces and environment details are exposed on errors.","status":"published","published":"2026-01-29T15:32:33Z","modified":"2026-07-08T08:26:45.753125542Z","cvss":[{"version":"4.0","score":9.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N","level":"critical"}],"cwes":["CWE-489"],"updated_at":"2026-11-02T15:46:57.348035Z"}]}}
