Skip to content

frombytes() and in-place ImageCms transforms write into read-only image memory #10235

Description

@bigcat88

What did you do?

Called frombytes() on an image opened from an uncompressed TIFF, which Pillow maps into memory read-only:

from PIL import Image

Image.new("RGBA", (64, 64)).save("test.tif")

with Image.open("test.tif") as im:
    im.load()
    im.frombytes(b"\x01" * 64 * 64 * 4)

An in-place colour transform does the same, even without load():

from PIL import Image, ImageCms

srgb = ImageCms.createProfile("sRGB")
with Image.open("test.tif") as im:
    ImageCms.profileToProfile(im, srgb, srgb, inPlace=True)

And frombytes() on an image made by frombuffer() from bytes:

from PIL import Image

data = bytes(64)
im = Image.frombuffer("L", (8, 8), data, "raw", "L", 0, 1)
im.frombytes(b"\x01" * 64)
print(data[:4])

What did you expect to happen?

The same as with paste(), putpixel(), putdata() or putalpha(): the image is copied first and the copy is changed, while the file and data stay as they were.

What actually happened?

The first two crash, with a bus error on macOS and a segmentation fault on Linux. The third prints b'\x01\x01\x01\x01': the bytes object was changed. An image from Image.fromarrow() behaves the same way, the new values end up in the Arrow array.

frombytes() gives self.im to the decoder without looking at readonly, and ImageCmsTransform.apply(im, imOut), which inPlace=True uses, writes into imOut the same way. The methods above call _ensure_mutable() first.

This fixes it for me, and the test suite still passes:

# Image.frombytes(), after the zero-size check
if self.im.readonly:
    self._copy()
# ImageCmsTransform.apply()
if imOut is None:
    imOut = Image.new(self.output_mode, im.size)
else:
    imOut._ensure_mutable()

I did not use _ensure_mutable() in frombytes() because plugins call frombytes() from their own load(), while ImageFile.readonly is still 1. Every GbrImagePlugin and WalImageFile image would be copied once more, and a plugin that marks itself loaded only after frombytes() (pillow-heif 1.8.1, for example) ends in RecursionError.

We ran into this while moving pillow-heif to Image.fromarrow() (bigcat88/pillow_heif#463, after #9896).

What are your OS, Python and Pillow versions?

  • OS: macOS 26.5 arm64, Linux aarch64 (Docker python:3.11-slim)
  • Python: 3.11
  • Pillow: 12.3.0, 13.0.0 and main (6085656)

Activity

  1. radarhere commented on Oct 18, 2026

    @radarhere
    Member

    Did you want to create a PR? Seems like you're most of the way there.

  2. radarhere commented on Oct 20, 2026

    @radarhere
    Member

    Let us know what you think of #10256.

    If you are in favour, I'll review it.
    If you aren't, I'll close as an AI that doesn't understand that we were waiting for your PR.

  3. bigcat88 commented on Oct 20, 2026

    @bigcat88
    ContributorAuthor

    Thanks for asking. I had already prepared a PR as well, #10264. frombytes() is the same in both. In apply(), #10256 calls _ensure_mutable(), which copies every image file that isn't loaded yet: an in-place transform of a freshly opened 12 MP JPEG peaks at 114 MiB instead of 68 MiB. #10264 copies only when the memory itself is read-only, and its tests cover memory-mapped files, which is where the crash happens.

    That said, I don't mind which one is merged. If you prefer #10256, for example for consistency with paste(), feel free to merge it and close mine.

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