Skip to content

Image.copy() aliases mutable .info values (e.g. list-valued "Comment" in IM/IPTC) between original and copy #9963

Description

@tritsystem

What did you do?

Loaded an IM (.im) file containing multiple Comment: header lines — which ImImagePlugin legitimately stores as a list in .info["Comment"] (its own code comment says "COMMENT tags are combined into a list of strings") — called .copy() on the resulting Image, and appended to the copy's info["Comment"] list.

Minimal, self-contained repro (builds the .im file at runtime):

from PIL import Image
import io

im = Image.new("L", (4, 4), 128)
buf = io.BytesIO()
im.save(buf, format="IM")
raw = buf.getvalue()

header = (b'Image type: Greyscale image\r\n'
          b'Image size (x*y): 4*4\r\n'
          b'File size (no of images): 1\r\n'
          b'Comment: first comment\r\n'
          b'Comment: second comment\r\n')
new_header = header + b"\x00" * (511 - len(header)) + b"\x1a"
with open("real_test.im", "wb") as f:
    f.write(new_header + raw[512:])

loaded = Image.open("real_test.im")
copy1 = loaded.copy()
print(loaded.info["Comment"] is copy1.info["Comment"])  # True
copy1.info["Comment"].append("added only to the copy")
print(loaded.info["Comment"])

What did you expect to happen?

Image.copy()'s docstring says: "Use this method if you wish to paste things into an image, but still retain the original." I expected mutating data obtained only through the copy to leave the original untouched, i.e. loaded.info["Comment"] should stay ['first comment', 'second comment'].

What actually happened?

loaded.info["Comment"] also gained the appended entry:

['first comment', 'second comment', 'added only to the copy']

The same aliasing applies to IptcImagePlugin.py, which also stores self.info[tag] = [self.info[tag], tagdata] for a repeated IPTC tag.

Root cause

Image._new() in src/PIL/Image.py:

new.info = self.info.copy()

This is a shallow copy of the dict container only — any mutable value inside (list, dict, etc.) remains a shared reference between the original and the copy. Most .info values (ints, str, bytes, tuples) are immutable, so this is invisible in the common case, but ImImagePlugin and IptcImagePlugin both legitimately place list objects into .info, making the aliasing externally observable and silently breaking copy/original independence for those formats.

Notably, PR #6294 ("Separate multiple GIF comment blocks with newlines") already fixed this exact hazard for GIF's own multi-comment handling by switching to += on immutable bytes — but IM and IPTC were never updated to match, leaving them exposed.

Suggested fix

new.info = copy.deepcopy(self.info) in Image._new(), or at minimum have ImImagePlugin/IptcImagePlugin store an already-copied list before mutating (e.g. self.info[k] = list(self.info[k]) + [v]) so .info never contains an object shared between the original and a .copy()'d Image.

Versions

Pillow 12.3.0 (current PyPI release), Python 3.12.10, Windows 11. Confirmed the same code (Image._new, ImImagePlugin._open, IptcImagePlugin) is unchanged on main as of 2026-09-04.


Found via a property-based audit (copy/clone independence) run across several widely-used Python libraries; repro was written and independently re-run twice (matching output) before filing.

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