GHSA-24P2-J2JR-386W

Vulnerability from github – Published: 2026-02-26 15:20 – Updated: 2026-02-26 15:20
VLAI?
Summary
psd-tools: Compression module has unguarded zlib decompression, missing dimension validation, and hardening gaps
Details

Summary

A security review of the psd_tools.compression module (conducted against the fix/invalid-rle-compression branch, commits 7490ffa2a006f5) identified the following pre-existing issues. The two findings introduced and fixed by those commits (Cython buffer overflow, IndexError on lone repeat header) are excluded from this report.


Findings

1. Unguarded zlib.decompress — ZIP bomb / memory exhaustion (Medium)

Location: src/psd_tools/compression/__init__.py, lines 159 and 162

result = zlib.decompress(data)          # Compression.ZIP
decompressed = zlib.decompress(data)    # Compression.ZIP_WITH_PREDICTION

zlib.decompress is called without a max_length cap. A crafted PSD file containing a ZIP-compressed channel whose compressed payload expands to gigabytes would exhaust process memory before any limit is enforced. The RLE path is not vulnerable to this because the decoder pre-allocates exactly row_size × height bytes; the ZIP path has no equivalent ceiling.

Impact: Denial-of-service / OOM crash when processing untrusted PSD files.

Suggested mitigation: Pass a reasonable max_length to zlib.decompress, derived from the expected width * height * depth // 8 byte count already computed in decompress().


2. No upper-bound validation on image dimensions before allocation (Low)

Location: src/psd_tools/compression/__init__.py, lines 138 and 193

length = width * height * max(1, depth // 8)   # decompress()
row_size = max(width * depth // 8, 1)           # decode_rle()

Neither width, height, nor depth are range-checked before these values drive memory allocation. The PSD format (version 2 / PSB) permits dimensions up to 300,000 × 300,000 pixels; a 4-channel 32-bit image at that size would require ~144 TB to hold. While the OS/Python allocator will reject such a request, there is no early, explicit guard that produces a clean, user-facing error.

Impact: Uncontrolled allocation attempt from a malformed or adversarially crafted PSB file; hard crash rather than a recoverable error.

Suggested mitigation: Validate width, height, and depth against known PSD/PSB limits before entering decompression, and raise a descriptive ValueError early.


3. assert used as a runtime integrity check (Low)

Location: src/psd_tools/compression/__init__.py, line 170

assert len(result) == length, "len=%d, expected=%d" % (len(result), length)

This assertion can be silently disabled by running the interpreter with -O (or -OO), which strips all assert statements. If the assertion ever becomes relevant (e.g., after future refactoring), disabling it would allow a length mismatch to propagate silently into downstream image compositing.

Impact: Loss of an integrity guard in optimised deployments.

Suggested mitigation: Replace with an explicit if + raise ValueError(...).


4. cdef int indices vs. Py_ssize_t size type mismatch in Cython decoder (Low)

Location: src/psd_tools/compression/_rle.pyx, lines 18–20

cdef int i = 0
cdef int j = 0
cdef int length = data.shape[0]

All loop indices are C signed int (32-bit). The size parameter is Py_ssize_t (64-bit on modern platforms). The comparison j < size promotes j to Py_ssize_t, but if j wraps due to a row size exceeding INT_MAX (~2.1 GB), the resulting comparison is undefined behaviour in C. In practice, row sizes are bounded by PSD/PSB dimension limits and are unreachable at this scale; however, the mismatch is a latent defect if the function is ever called directly with large synthetic inputs.

Impact: Theoretical infinite loop or UB at >2 GB row sizes; not reachable from standard PSD/PSB parsing.

Suggested mitigation: Change cdef int i, j, length to cdef Py_ssize_t.


5. Silent data degradation not surfaced to callers (Informational)

Location: src/psd_tools/compression/__init__.py, lines 144–157

The tolerant RLE decoder (introduced in 2a006f5) replaces malformed channel data with zero-padded (black) pixels and emits a logger.warning. This is the correct trade-off over crashing, but the warning is only observable if the caller has configured a log handler. The public PSDImage API does not surface channel-level decode failures to the user in any other way.

Impact: A user parsing a silently corrupt file gets a visually wrong image with no programmatic signal to check.

Suggested mitigation: Consider exposing a per-channel decode-error flag or raising a distinct warning category that users can filter or escalate via the warnings module.


6. encode() zero-length return type inconsistency in Cython (Informational)

Location: src/psd_tools/compression/_rle.pyx, lines 66–67

if length == 0:
    return data   # returns a memoryview, not an explicit std::string

All other return paths return an explicit cdef string result. This path returns data (a const unsigned char[:] memoryview) and relies on Cython's implicit coercion to bytes. It is functionally equivalent today but is semantically inconsistent and fragile if Cython's coercion rules change in a future version.

Impact: Potential silent breakage in future Cython versions; not a current security issue.

Suggested mitigation: Replace return data with return result (the already-declared empty string).


Environment

  • Branch: fix/invalid-rle-compression
  • Reviewed commits: 7490ffa, 2a006f5
  • Python: 3.x (Cython extension compiled for CPython)
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "psd-tools"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.12.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-27809"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-190",
      "CWE-409",
      "CWE-617",
      "CWE-704",
      "CWE-755",
      "CWE-789"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-02-26T15:20:51Z",
    "nvd_published_at": "2026-02-26T00:16:26Z",
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nA security review of the `psd_tools.compression` module (conducted against the `fix/invalid-rle-compression` branch, commits `7490ffa`\u2013`2a006f5`) identified the following pre-existing issues. The two findings introduced and **fixed** by those commits (Cython buffer overflow, `IndexError` on lone repeat header) are excluded from this report.\n\n---\n\n## Findings\n\n### 1. Unguarded `zlib.decompress` \u2014 ZIP bomb / memory exhaustion (Medium)\n\n**Location**: `src/psd_tools/compression/__init__.py`, lines 159 and 162\n\n```python\nresult = zlib.decompress(data)          # Compression.ZIP\ndecompressed = zlib.decompress(data)    # Compression.ZIP_WITH_PREDICTION\n```\n\n`zlib.decompress` is called without a `max_length` cap. A crafted PSD file containing a ZIP-compressed channel whose compressed payload expands to gigabytes would exhaust process memory before any limit is enforced. The RLE path is not vulnerable to this because the decoder pre-allocates exactly `row_size \u00d7 height` bytes; the ZIP path has no equivalent ceiling.\n\n**Impact**: Denial-of-service / OOM crash when processing untrusted PSD files.\n\n**Suggested mitigation**: Pass a reasonable `max_length` to `zlib.decompress`, derived from the expected `width * height * depth // 8` byte count already computed in `decompress()`.\n\n---\n\n### 2. No upper-bound validation on image dimensions before allocation (Low)\n\n**Location**: `src/psd_tools/compression/__init__.py`, lines 138 and 193\n\n```python\nlength = width * height * max(1, depth // 8)   # decompress()\nrow_size = max(width * depth // 8, 1)           # decode_rle()\n```\n\nNeither `width`, `height`, nor `depth` are range-checked before these values drive memory allocation. The PSD format (version 2 / PSB) permits dimensions up to 300,000 \u00d7 300,000 pixels; a 4-channel 32-bit image at that size would require ~144 TB to hold. While the OS/Python allocator will reject such a request, there is no early, explicit guard that produces a clean, user-facing error.\n\n**Impact**: Uncontrolled allocation attempt from a malformed or adversarially crafted PSB file; hard crash rather than a recoverable error.\n\n**Suggested mitigation**: Validate `width`, `height`, and `depth` against known PSD/PSB limits before entering decompression, and raise a descriptive `ValueError` early.\n\n---\n\n### 3. `assert` used as a runtime integrity check (Low)\n\n**Location**: `src/psd_tools/compression/__init__.py`, line 170\n\n```python\nassert len(result) == length, \"len=%d, expected=%d\" % (len(result), length)\n```\n\nThis assertion can be silently disabled by running the interpreter with `-O` (or `-OO`), which strips all `assert` statements. If the assertion ever becomes relevant (e.g., after future refactoring), disabling it would allow a length mismatch to propagate silently into downstream image compositing.\n\n**Impact**: Loss of an integrity guard in optimised deployments.\n\n**Suggested mitigation**: Replace with an explicit `if` + `raise ValueError(...)`.\n\n---\n\n### 4. `cdef int` indices vs. `Py_ssize_t size` type mismatch in Cython decoder (Low)\n\n**Location**: `src/psd_tools/compression/_rle.pyx`, lines 18\u201320\n\n```cython\ncdef int i = 0\ncdef int j = 0\ncdef int length = data.shape[0]\n```\n\nAll loop indices are C `signed int` (32-bit). The `size` parameter is `Py_ssize_t` (64-bit on modern platforms). The comparison `j \u003c size` promotes `j` to `Py_ssize_t`, but if `j` wraps due to a row size exceeding `INT_MAX` (~2.1 GB), the resulting comparison is undefined behaviour in C. In practice, row sizes are bounded by PSD/PSB dimension limits and are unreachable at this scale; however, the mismatch is a latent defect if the function is ever called directly with large synthetic inputs.\n\n**Impact**: Theoretical infinite loop or UB at \u003e2 GB row sizes; not reachable from standard PSD/PSB parsing.\n\n**Suggested mitigation**: Change `cdef int i`, `j`, `length` to `cdef Py_ssize_t`.\n\n---\n\n### 5. Silent data degradation not surfaced to callers (Informational)\n\n**Location**: `src/psd_tools/compression/__init__.py`, lines 144\u2013157\n\nThe tolerant RLE decoder (introduced in `2a006f5`) replaces malformed channel data with zero-padded (black) pixels and emits a `logger.warning`. This is the correct trade-off over crashing, but the warning is only observable if the caller has configured a log handler. The public `PSDImage` API does not surface channel-level decode failures to the user in any other way.\n\n**Impact**: A user parsing a silently corrupt file gets a visually wrong image with no programmatic signal to check.\n\n**Suggested mitigation**: Consider exposing a per-channel decode-error flag or raising a distinct warning category that users can filter or escalate via the `warnings` module.\n\n---\n\n### 6. `encode()` zero-length return type inconsistency in Cython (Informational)\n\n**Location**: `src/psd_tools/compression/_rle.pyx`, lines 66\u201367\n\n```cython\nif length == 0:\n    return data   # returns a memoryview, not an explicit std::string\n```\n\nAll other return paths return an explicit `cdef string result`. This path returns `data` (a `const unsigned char[:]` memoryview) and relies on Cython\u0027s implicit coercion to `bytes`. It is functionally equivalent today but is semantically inconsistent and fragile if Cython\u0027s coercion rules change in a future version.\n\n**Impact**: Potential silent breakage in future Cython versions; not a current security issue.\n\n**Suggested mitigation**: Replace `return data` with `return result` (the already-declared empty `string`).\n\n---\n\n## Environment\n\n- Branch: `fix/invalid-rle-compression`\n- Reviewed commits: `7490ffa`, `2a006f5`\n- Python: 3.x (Cython extension compiled for CPython)",
  "id": "GHSA-24p2-j2jr-386w",
  "modified": "2026-02-26T15:20:51Z",
  "published": "2026-02-26T15:20:51Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/psd-tools/psd-tools/security/advisories/GHSA-24p2-j2jr-386w"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27809"
    },
    {
      "type": "WEB",
      "url": "https://github.com/psd-tools/psd-tools/commit/6c0a78f195b5942757886a1863793fd5946c1fb1"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/psd-tools/psd-tools"
    },
    {
      "type": "WEB",
      "url": "https://github.com/psd-tools/psd-tools/releases/tag/v1.12.2"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:U",
      "type": "CVSS_V4"
    }
  ],
  "summary": "psd-tools: Compression module has unguarded zlib decompression, missing dimension validation, and hardening gaps"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

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…