GHSA-VG9H-JX4V-CWX2

Vulnerability from github – Published: 2026-01-29 15:32 – Updated: 2026-01-29 15:32
VLAI?
Summary
Unfurl's debug mode cannot be disabled due to string config parsing (Werkzeug debugger exposure)
Details

Summary

The 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.

Details

  • unfurl/app.py:web_app() reads debug via config['UNFURL_APP'].get('debug'), which returns a string.
  • UnfurlApp.__init__ passes that string directly to app.run(debug=unfurl_debug, ...).
  • If unfurl.ini omits debug, the default argument is the string "True".
  • As a result, debug mode is effectively always on and cannot be reliably disabled via config.

PoC

  1. Create a local unfurl.ini with debug = False under [UNFURL_APP].
  2. Run the server using unfurl_app (or python -c 'from unfurl.app import web_app; web_app()').
  3. Observe server logs showing Debug mode: on / Debugger is active!.
  4. The included PoC script security_poc/poc_debug_mode.py --spawn automates this check.

PoC Script (inline)

#!/usr/bin/env python3
"""
Unfurl Debug Mode PoC (Corrected)
================================

This PoC demonstrates that Unfurl's Flask debug mode is effectively
**always enabled by default** due to string parsing of the `debug`
config value. Even `debug = False` in `unfurl.ini` evaluates truthy
when passed to `app.run(debug=...)`.

Two modes:
1) --spawn (default): launch a local Unfurl server with debug=False
   in a temp config and inspect logs for "Debug mode: on".
2) --target: attempt a remote indicator check (best-effort; may be silent
   if no exception is triggered).
"""

import argparse
import os
import subprocess
import sys
import tempfile
import textwrap
import time


def run_spawn_check() -> None:
    repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))

    ini_contents = textwrap.dedent("""
    [UNFURL_APP]
    host = 127.0.0.1
    port = 5055
    debug = False
    remote_lookups = false

    [API_KEYS]
    bitly =
    macaddress_io =
    """).strip() + "\n"

    with tempfile.TemporaryDirectory() as tmp:
        ini_path = os.path.join(tmp, 'unfurl.ini')
        with open(ini_path, 'w') as f:
            f.write(ini_contents)

        env = os.environ.copy()
        env['PYTHONPATH'] = repo_root

        cmd = [sys.executable, '-c', 'from unfurl.app import web_app; web_app()']
        proc = subprocess.Popen(
            cmd,
            cwd=tmp,
            env=env,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True
        )

        # Allow server to start and emit logs
        time.sleep(2)
        proc.terminate()
        try:
            out, err = proc.communicate(timeout=2)
        except subprocess.TimeoutExpired:
            proc.kill()
            out, err = proc.communicate()

        output = (out or "") + (err or "")

    print("\n[+] Debug mode spawn check")
    print("    Config: debug = False")

    if "Debug mode: on" in output or "Debugger is active" in output:
        print("    ✅ Debug mode is ON despite debug=False (vulnerable)")
    else:
        print("    ⚠️  Debug mode not detected in logs (check output below)")

    if output.strip():
        print("\n--- server output (truncated) ---")
        print("\n".join(output.splitlines()[:15]))
        print("--- end ---")


def run_remote_probe(target: str) -> None:
    import requests

    print("\n[+] Remote debug indicator probe (best-effort)")
    print(f"    Target: {target}")

    # This app does not easily throw exceptions from user input, so
    # absence of indicators does NOT prove debug is off.
    probe_urls = [
        f"{target.rstrip('/')}/__nonexistent__",
    ]

    detected = False
    for url in probe_urls:
        try:
            resp = requests.get(url, timeout=10)
            if "Werkzeug Debugger" in resp.text or "Traceback" in resp.text:
                detected = True
                print("    ✅ Debug indicators found")
                break
        except Exception as e:
            print(f"    ⚠️  Probe failed: {e}")

    if not detected:
        print("    ⚠️  No debug indicators found (this is not definitive)")


def main():
    parser = argparse.ArgumentParser(description='Unfurl debug mode PoC (corrected)')
    parser.add_argument('--spawn', action='store_true', help='Run local spawn check (default)')
    parser.add_argument('--target', help='Target Unfurl URL for remote probe')
    args = parser.parse_args()

    if args.target:
        run_remote_probe(args.target)
    else:
        run_spawn_check()


if __name__ == '__main__':
    main()

Impact

If 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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "dfir-unfurl"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "20250810"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-489"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-01-29T15:32:33Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "### 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[\u0027UNFURL_APP\u0027].get(\u0027debug\u0027)`, 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 \u0027from unfurl.app import web_app; web_app()\u0027`).\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\u0027s 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__), \u0027..\u0027))\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, \u0027unfurl.ini\u0027)\n        with open(ini_path, \u0027w\u0027) as f:\n            f.write(ini_contents)\n\n        env = os.environ.copy()\n        env[\u0027PYTHONPATH\u0027] = repo_root\n\n        cmd = [sys.executable, \u0027-c\u0027, \u0027from unfurl.app import web_app; web_app()\u0027]\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(\"    \u2705 Debug mode is ON despite debug=False (vulnerable)\")\n    else:\n        print(\"    \u26a0\ufe0f  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(\u0027/\u0027)}/__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(\"    \u2705 Debug indicators found\")\n                break\n        except Exception as e:\n            print(f\"    \u26a0\ufe0f  Probe failed: {e}\")\n\n    if not detected:\n        print(\"    \u26a0\ufe0f  No debug indicators found (this is not definitive)\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\u0027Unfurl debug mode PoC (corrected)\u0027)\n    parser.add_argument(\u0027--spawn\u0027, action=\u0027store_true\u0027, help=\u0027Run local spawn check (default)\u0027)\n    parser.add_argument(\u0027--target\u0027, help=\u0027Target Unfurl URL for remote probe\u0027)\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__ == \u0027__main__\u0027:\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.",
  "id": "GHSA-vg9h-jx4v-cwx2",
  "modified": "2026-01-29T15:32:33Z",
  "published": "2026-01-29T15:32:33Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/obsidianforensics/unfurl/security/advisories/GHSA-vg9h-jx4v-cwx2"
    },
    {
      "type": "WEB",
      "url": "https://github.com/obsidianforensics/unfurl/commit/4c0a07ab1e9af3a1ddf0e7f47153ec9ba77946dd"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/obsidianforensics/unfurl"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "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",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Unfurl\u0027s debug mode cannot be disabled due to string config parsing (Werkzeug debugger exposure)"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…