Skip to content

getlist()/_putdata(): OOB read when __len__ overstates materialized items #9892

Description

@espressolee

What did you do?

I passed a custom sequence to Image.point() and Image.putdata() whose __len__ reports more items than iteration actually materializes. This is independent of the free-threading race in #9852 / #9853: it is deterministic, single-threaded, and reproduces with the GIL enabled.

Image.point() reproducer:

from PIL import Image

class OverstatedLengthSequence:
    def __len__(self):
        return 256

    def __getitem__(self, index):
        if index >= 8:
            raise IndexError
        return float(index)

Image.new("L", (4, 4)).point(OverstatedLengthSequence(), "F")

Image.putdata() reproducer:

from PIL import Image

class OverstatedLengthSequence:
    def __len__(self):
        return 16

    def __getitem__(self, index):
        if index >= 2:
            raise IndexError
        return float(index + 1)

Image.new("L", (4, 4)).putdata(OverstatedLengthSequence())

What did you expect to happen?

Pillow should use the length of the sequence returned by PySequence_Fast():

  • fixed-size consumers such as Image.point() should raise their existing wrong-length error;
  • Image.putdata() should consume only the items that were actually materialized.

It should not index past the end of the materialized list.

What actually happened?

On current main at
4e5f09f533dbd0d87d39951f48b62f4d1a421cba, each script terminated with SIGSEGV (exit 139) in 5/5 fresh processes on CPython 3.14.6 with the GIL enabled.

The two C paths first obtain n from the caller-controlled __len__, then materialize by iteration, but continue indexing to the earlier value of n:

/* getlist() */
n = PySequence_Size(arg);
seq = PySequence_Fast(arg, must_be_sequence);
for (i = 0; i < n; i++) {
    op = PySequence_Fast_GET_ITEM(seq, i);

/* _putdata() */
n = PyObject_Length(data);
seq = PySequence_Fast(data, must_be_sequence);
for (i = 0; i < n; i++) {
    op = PySequence_Fast_GET_ITEM(seq, i);

PySequence_Fast_GET_ITEM() is unchecked, so when n is 256 but seq contains 8 items (or 16 versus 2), the loop reads beyond the materialized list.

The same getlist() path is reachable through Image.transform() and ImageFilter.Kernel. JPEG qtables are not included: that path normalizes each table through array.array and list before the C code sees it.

I prepared an independent candidate at
41b675109723dcd0fab6f935518501992e0b26c6.
It materializes once, then derives n from PySequence_Fast_GET_SIZE(seq).
Against that exact commit:

  • the Image.point() reproducer raises ValueError: wrong number of lut entries;
  • the Image.putdata() reproducer writes (1, 2) followed by 14 zeroes;
  • the affected-file suite passes 332/332 on CPython 3.14.6 (GIL enabled);
  • the same suite passes 332/332 on CPython 3.14.0rc1t (GIL disabled).

This is reported as a regular robustness bug. The trigger is a Python object supplied by the caller; I am not assigning a security severity.

What are your OS, Python and Pillow versions?

  • OS: macOS 26.6.1 (25G76), arm64
  • Python: CPython 3.14.6, GIL enabled
  • Pillow: 13.0.0.dev0, main at
    4e5f09f533dbd0d87d39951f48b62f4d1a421cba

(Issue text and candidate developed with AI assistance; reproductions and measurements run by me.)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions