---
id: GHSA-78g4-xqwv-q3g8
title: >-
  Pillow: Bundled libopenjp2 2.5.4 in all wheels since 11.1.0 carries
  OSV-2025-219 heap-buffer-overflow write reachable from Image.open().load()
summary: >-
  Pillow: Bundled libopenjp2 2.5.4 in all wheels since 11.1.0 carries
  OSV-2025-219 heap-buffer-overflow write reachable from Image.open().load()
severity: high
cvss: 8.1
cwe:
  - CWE-787
  - CWE-1395
vendor: pillow
product: pillow
ecosystem: pip
affected:
  - 'pillow >= 11.1.0, < 13.0.0'
patched:
  - pillow 13.0.0
published: '2026-10-22'
updated: '2026-10-22'
sourceUpdated: '2026-10-22T17:01:19Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-78g4-xqwv-q3g8'
references:
  - url: >-
      https://github.com/python-pillow/Pillow/security/advisories/GHSA-78g4-xqwv-q3g8
  - url: >-
      https://github.com/python-pillow/Pillow/commit/5f8114c8096b181fae493543cd3837d9f9a971cc
  - url: >-
      https://github.com/python-pillow/Pillow/commit/d3470c327840f4c216a1610f30e7091dfd0b97c0
  - url: >-
      https://github.com/uclouvain/openjpeg/commit/91d08b11a72764f6d199f32fa0c1b1abd4edc2ad
  - url: 'https://github.com/python-pillow/Pillow/releases/tag/13.0.0'
  - url: 'https://osv.dev/vulnerability/OSV-2025-219'
  - url: 'https://github.com/advisories/GHSA-78g4-xqwv-q3g8'
tags:
  - ghsa
  - pip
ingestedAt: '2026-10-22T22:00:50.250Z'
---

## Overview

### Summary

Every Pillow wheel from 11.1.0 onward bundles a libopenjp2 containing a heap-buffer-overflow **write** that is fixed upstream on master but has never appeared in an OpenJPEG release. See https://osv.dev/vulnerability/OSV-2025-219

Because the wheel bundles its own copy, a patched host OS does not help. It is reachable from `Image.open(fp); im.load()` with default settings.

I am reporting it here rather than only upstream because Pillow is where the vulnerable binary actually reaches users.

Note the inversion that makes this easy to miss: **OpenJPEG 2.5.2 and earlier are safe.** The bug was introduced in `954c6e3c` (2024-06-25), so keeping the dependency current is what creates the exposure.

### Details

## Affected wheels

From `.github/dependencies.json` (`openjpeg`) at each tag:

| Pillow | bundled OpenJPEG | status |
|---|---|---|
| 10.4.0 | 2.5.2 | safe |
| 11.0.0 | 2.5.2 | safe |
| **11.1.0, 11.2.1, 11.3.0** | **2.5.3** | **affected** |
| **12.0.0 … 12.3.0** | **2.5.4** | **affected** |

Confirmed on an installed wheel: `PIL.features.version_codec('jpg_2000')` → `2.5.4`, bundled file `pillow.libs/libopenjp2-*.so.2.5.4`. `main` still pins `2.5.4` as of 2026-10-06.

## The defect

`src/lib/openjp2/j2k.c`, in `opj_j2k_read_sod`:

```c
OPJ_UINT32 l_current_tile_part =
    l_cstr_index->tile_index[p_j2k->m_current_tile_number].current_tpsno;
l_cstr_index->tile_index[...].tp_index[l_current_tile_part].end_header = l_current_pos;
l_cstr_index->tile_index[...].tp_index[l_current_tile_part].end_pos   =
    l_current_pos + p_j2k->m_specific_param.m_decoder.m_sot_length + 2;
```

`tp_index` is neither null-checked nor bounds-checked, and `l_current_tile_part` comes directly from the one-byte `TPsot` field of the SOT marker.

The correct guard already exists a few lines later in `opj_j2k_add_tlmarker` (j2k.c:8459), writing the **same array at the same index** behind `if (tp_index && l_current_tile_part < nb_tps)`. One writer checked, its sibling not.

Reached by giving the codestream a *valid* TLM marker — so `tp_index` is allocated once by `opj_j2k_build_tp_index_from_tlm`, sized to the TLM entry count, and `opj_j2k_read_sot` then skips all resizing — while setting `TNsot = 0` in every SOT, which keeps the TLM "valid" and lets `TPsot` walk past the allocation.

## Patch status

Fixed upstream in `91d08b11` ("Fix heap buffer overlfow in opj_j2k_read_sod", 2026-02-10).

The newest OpenJPEG tag is **v2.5.4 (2025-09-20)**, and `compare/v2.5.4...91d08b11` reports *ahead 6, behind 0* — the fix is **in no release, 8 months on**.

## Suggested remediation

1. Carry `91d08b11` as a patch in the wheel build until an OpenJPEG release includes it — three lines, identical to a guard already present in the same file.
2. Consider asking OpenJPEG to cut a release; they have had an unreleased memory-corruption fix on master since February 2026, and every distro is shipping the vulnerable code.
3. Your security handbook already tells applications to block EPS at the application layer. A similarly explicit note that `Image.open()` ignores filename/Content-Type and will reach any compiled-in codec unless `formats=` is passed would help downstream apps a lot.

## Not a Pillow bug, but relevant: the extension allowlist most apps rely on does not apply

`Image.open()` sniffs magic bytes, so a JPEG 2000 codestream in a file named `avatar.png` declared `Content-Type: image/png` reaches the JP2 decoder anyway.

I verified this against a production service documenting PNG/JPEG-only uploads: it accepted and fully decoded a JP2 through exactly that path, with 16 formats reachable from one upload endpoint against 43 registered openers. That service has since fixed it by passing `formats=` and validating on the detected format rather than the client's `Content-Type`.

That is the reason a decoder-level issue like this one has much wider reach than downstream maintainers tend to assume.

### Proof of concept

ASan build of OpenJPEG 2.5.4 interposed under a **stock Pillow 12.3.0**, running `Image.open(p); im.load()`:

```
==766332==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 8
    #0 opj_j2k_read_sod          j2k.c:5086
    #1 opj_j2k_read_tile_header  j2k.c:9912
    #2 opj_read_tile_header      openjpeg.c:575
    #3 j2k_decode_entry          src/libImaging/Jpeg2KDecode.c:793
    #4 ImagingJpeg2KDecode       src/libImaging/Jpeg2KDecode.c:949
    #5 _decode                   src/decode.c:132
located 8 bytes to the right of a 24-byte region allocated by
    opj_j2k_build_tp_index_from_tlm j2k.c:8939
```

Frames 3–5 are Pillow's own decoder, not a synthetic harness. No non-default decoder options are involved; strict mode is on, which is Pillow's behaviour since Pillow never calls `opj_decoder_set_strict_mode`. A benign twin image runs the same path cleanly and returns a valid image, so this is the payload and not a parse failure.

I have deliberately **not** attached the PoC file to this initial report. I'm happy to attach the PoC, the builder script and the RIP-control harness here on request, or hold everything until a fix is ready.

No disclosure deadline from me; I will not publish anything while this is being worked on.

### Impact

Any application that calls `Image.open()` / `im.load()` on an image it did not produce — the overwhelmingly common case for upload handlers, thumbnailers and preview pipelines — can be made to perform an out-of-bounds heap write by supplying one crafted file.

**Floor, already certain.** On a stock non-sanitised wheel the overflow performs 245 writes walking to +5856 bytes past a 24-byte allocation, and glibc's own heap-metadata checker aborts the process (`corrupted size vs. prev_size`, `corrupted double-linked list`, SIGABRT). One file reliably kills a long-lived worker.

**Ceiling, demonstrated as a primitive but not as a finished exploit.** With same-size-class objects alive on both sides — an ordinary busy heap — the walk crosses `opj_event_mgr_t`, which holds three `opj_msg_callback` function pointers, and `opj_event_msg` then calls one (event.c:136):

```
rip  0x4000    <- value chosen by the attacker's file layout
rsi  0x3ff2    <- m_error_data, written by the same walk
```

Shifting the codestream with a COM marker moves `rip` with it (`0x16ce` → `0x4000`), so the instruction pointer is attacker-**chosen** rather than incidentally invalid. A benign control image leaves the same groomed heap untouched.

To be explicit about what I did not do: I have **not** built a working exploit. That additionally needs remote heap grooming and an ASLR defeat, and I attempted neither. I am reporting a controlled control-flow primitive, not a finished chain. The CVSS AC:H reflects that.

## Affected packages

- `pillow >= 11.1.0, < 13.0.0`

## Remediation

Upgrade to a patched release:

- `pillow 13.0.0`
