Skip to content
Fencer Intel
medium 5.5

GHSA-9mqm-qcwf-5qhg json

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

published 4mo ago modified 4mo ago

cvss
5.5
version 3.1, GitHub
cisa kev
Not listed
not in CISA's catalog
epss
—
no score, EPSS covers CVEs only
public exploits
0
none known

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(): …

Patch / fix

  • pypi credsweeper 1.16.0

Is GHSA-9mqm-qcwf-5qhg in your dependencies? Fencer shows which of your repositories are affected as soon as an advisory lands, and opens the fix as a pull request.

Check with Fencer

CVSS scores

assigned by version score vector
GitHub 3.1 5.5 CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

Affected packages

ecosystem package affected fixed in
pypi credsweeper
>=1.4.9, <1.16.0
1.16.0

Classification

Weakness
  • CWE-400 Uncontrolled Resource Consumption
  • CWE-409 Improper Handling of Highly Compressed Data (Data Amplification)

References

Sources

Fencer Intel keeps one entry per vulnerability: the CVE id wins, then GHSA, and records that alias each other are merged, except that two CVEs always keep separate entries. The same data is available as JSON.