{"id":"GHSA-78g4-xqwv-q3g8","kind":"vulnerability","aliases":["GHSA-78g4-xqwv-q3g8"],"related":[],"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()","description":"### Summary\n\nEvery 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\n\nBecause 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.\n\nI am reporting it here rather than only upstream because Pillow is where the vulnerable binary actually reaches users.\n\nNote 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.\n\n### Details\n\n## Affected wheels\n\nFrom `.github/dependencies.json` (`openjpeg`) at each tag:\n\n| Pillow | bundled OpenJPEG | status |\n|---|---|---|\n| 10.4.0 | 2.5.2 | safe |\n| 11.0.0 | 2.5.2 | safe |\n| **11.1.0, 11.2.1, 11.3.0** | **2.5.3** | **affected** |\n| **12.0.0 … 12.3.0** | **2.5.4** | **affected** |\n\nConfirmed 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.\n\n## The defect\n\n`src/lib/openjp2/j2k.c`, in `opj_j2k_read_sod`:\n\n```c\nOPJ_UINT32 l_current_tile_part =\n    l_cstr_index->tile_index[p_j2k->m_current_tile_number].current_tpsno;\nl_cstr_index->tile_index[...].tp_index[l_current_tile_part].end_header = l_current_pos;\nl_cstr_index->tile_index[...].tp_index[l_current_tile_part].end_pos   =\n    l_current_pos + p_j2k->m_specific_param.m_decoder.m_sot_length + 2;\n```\n\n`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.\n\nThe 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.\n\nReached 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.\n\n## Patch status\n\nFixed upstream in `91d08b11` (\"Fix heap buffer overlfow in opj_j2k_read_sod\", 2026-02-10).\n\nThe 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**.\n\n## Suggested remediation\n\n1. 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.\n2. 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.\n3. 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.\n\n## Not a Pillow bug, but relevant: the extension allowlist most apps rely on does not apply\n\n`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.\n\nI 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`.\n\nThat is the reason a decoder-level issue like this one has much wider reach than downstream maintainers tend to assume.\n\n### Proof of concept\n\nASan build of OpenJPEG 2.5.4 interposed under a **stock Pillow 12.3.0**, running `Image.open(p); im.load()`:\n\n```\n==766332==ERROR: AddressSanitizer: heap-buffer-overflow\nWRITE of size 8\n    #0 opj_j2k_read_sod          j2k.c:5086\n    #1 opj_j2k_read_tile_header  j2k.c:9912\n    #2 opj_read_tile_header      openjpeg.c:575\n    #3 j2k_decode_entry          src/libImaging/Jpeg2KDecode.c:793\n    #4 ImagingJpeg2KDecode       src/libImaging/Jpeg2KDecode.c:949\n    #5 _decode                   src/decode.c:132\nlocated 8 bytes to the right of a 24-byte region allocated by\n    opj_j2k_build_tp_index_from_tlm j2k.c:8939\n```\n\nFrames 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.\n\nI 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.\n\nNo disclosure deadline from me; I will not publish anything while this is being worked on.\n\n### Impact\n\nAny 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.\n\n**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.\n\n**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):\n\n```\nrip  0x4000    <- value chosen by the attacker's file layout\nrsi  0x3ff2    <- m_error_data, written by the same walk\n```\n\nShifting 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.\n\nTo 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.","summary":"Pillow wheels from 11.1.0 through 12.x bundle libopenjp2 2.5.3/2.5.4, which contains a heap-buffer-overflow write in opj_j2k_read_sod reached by Image.open() and im.load() on a crafted JPEG 2000 file. An attacker who can supply an image to an upload or thumbnail path can crash the process and, per the reporter, gain a controlled instruction-pointer primitive that is not a finished exploit.","severity":"high","cvss":8.100000381469727,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"pillow","fixed":["13.0.0"],"affected":">= 11.1.0, < 13.0.0","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["cwe-1395","cwe-787","image-upload","package","pillow","python","self-hosted","thumbnailers"],"not_affected_if":["jpeg2000-not-decoded","managed-hosting-without-untrusted-images","pillow-13-or-later","pillow-below-11.1.0"],"how_to_check":"Run: python -c \"from PIL import features; print(features.version_codec('jpg_2000'))\" and check whether it prints 2.5.3 or 2.5.4 with Pillow between 11.1.0 and 12.x.\nCheck whether the app calls Image.open() on untrusted uploads without formats= restriction.","fix":"Upgrade Pillow to 13.0.0 or later, per the advisory's fixed version.\nUntil then, pass formats= to Image.open() to exclude JPEG 2000 on untrusted input.","status":"open","published_at":"2026-10-22T17:01:08+00:00","first_seen_at":"2026-10-23T19:11:31+00:00","triaged_at":"2026-10-23T19:52:43+00:00","updated_at":"2026-10-23T19:52:43+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T19:52:43Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade Pillow to 13.0.0 or later, per the advisory's fixed version.\nUntil then, pass formats= to Image.open() to exclude JPEG 2000 on untrusted input.","summary":"Pillow wheels from 11.1.0 through 12.x bundle libopenjp2 2.5.3/2.5.4, which contains a heap-buffer-overflow write in opj_j2k_read_sod reached by Image.open() and im.load() on a crafted JPEG 2000 file. An attacker who can supply an image to an upload or thumbnail path can crash the process and, per the reporter, gain a controlled instruction-pointer primitive that is not a finished exploit.","category":"package","who_cares":["pillow","python","image-upload","thumbnailers","self-hosted"],"confidence":0.8,"analyst_note":"Verify whether the fixed Pillow 13.0.0 bundles an OpenJPEG release containing 91d08b11, and whether a Pillow patch release exists for 12.x; see https://github.com/advisories/GHSA-78g4-xqwv-q3g8 and https://osv.dev/vulnerability/OSV-2025-219.","how_to_check":"Run: python -c \"from PIL import features; print(features.version_codec('jpg_2000'))\" and check whether it prints 2.5.3 or 2.5.4 with Pillow between 11.1.0 and 12.x.\nCheck whether the app calls Image.open() on untrusted uploads without formats= restriction.\nunknown for any further exposure detail.","needs_analyst":true,"not_affected_if":["pillow-below-11.1.0","pillow-13-or-later","jpeg2000-not-decoded","managed-hosting-without-untrusted-images"],"severity_if_unknown":"unknown"},"cost_usd":0.000788,"input_tokens":4770,"output_tokens":621,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-02':489C '-06':217C,280C '-09':498C '-10':279C,490C '-1395':35B '-20':499C '-2025':12A '-219':13A '-25':218C '-787':37B '/2.5.4':58B '/libopenjp2-*.so.2.5.4':271C '/vulnerability/osv-2025-219':151C '0':457C,507C '1':519C '10.4.0':242C '11.0.0':245C '11.1.0':9A,51B,122C,248C '11.2.1':249C '11.3.0':250C '12':53B '12.0.0':253C '12.3.0':254C '16':679C '2':346C,547C '2.5.2':205C,243C,246C '2.5.3':57B,251C '2.5.4':4A,255C,266C,275C,742C '2000':81B,265C,640C '2024':216C '2025':497C '2026':278C,488C,568C '3':577C '43':687C '6':505C '78g4':25A,30A '8':514C '8459':391C '91d08b11':478C,502C,521C '954c6e3c':215C 'accept':668C 'actual':192C 'add':388C 'affect':230C,252C,256C 'ahead':504C 'alloc':429C,472C 'allowlist':625C 'alreadi':379C,541C,581C 'anyway':656C 'app':614C,627C 'appear':143C 'appli':632C 'applic':583C,589C 'array':395C 'asan':738C 'ask':549C 'assum':734C 'attack':84B 'avatar.png':646C 'behind':400C,506C 'binari':191C 'block':585C 'bound':356C 'bounds-check':355C 'buffer':16A,64B,131C,481C 'bug':211C,620C 'build':434C,528C,739C 'bundl':2A,55B,124C,155C,239C,267C 'byte':369C,636C 'c':289C 'carri':10A,520C 'check':353C,357C,412C 'client':708C 'code':576C 'codec':263C,606C 'codestream':420C,641C 'come':363C 'compare/v2.5.4':501C 'compil':604C 'compiled-in':603C 'concept':737C 'confirm':257C 'consid':548C 'contain':60B,127C 'content':649C,711C 'content-typ':648C,710C 'control':106B 'copi':158C 'correct':377C 'corrupt':562C 'count':444C 'craft':79B 'crash':97B 'creat':226C 'cstr':297C,310C,326C 'current':223C,293C,304C,307C,317C,323C,333C,339C,360C,405C 'cut':552C 'cwe':34B,36B 'declar':647C 'decod':655C,671C,719C 'decoder-level':718C 'default':174C 'defect':282C 'depend':222C 'detail':229C 'detect':703C 'direct':364C 'distro':571C 'document':664C 'downstream':613C,730C 'earlier':207C 'easi':201C 'end':320C,336C 'endpoint':685C 'entri':443C 'ep':586C 'everi':118C,459C,570C 'exact':675C 'exist':380C 'explicit':593C 'exploit':116B 'exposur':228C 'extens':624C 'februari':567C 'field':371C 'file':82B,268C,546C,644C 'filename/content-type':598C 'finish':115B 'fix':136C,475C,479C,509C,563C,694C 'format':608C,680C,698C,704C 'fp':171C 'fulli':670C 'gain':104B 'ghsa':24A,29A 'ghsa-78g4-xqwv-q3g8':23A,28A 'github/dependencies.json':233C 'give':418C 'guard':378C,540C 'handbook':580C 'header':321C 'heap':15A,63B,130C,480C 'heap-buffer-overflow':14A,62B,129C 'help':165C,612C 'host':46B,161C 'ident':537C 'ignor':597C 'im.load':76B,172C 'imag':39B,89B 'image-upload':38B 'image.open':21A,74B,170C,596C,633C 'image/png':651C 'includ':533C 'index':298C,300C,311C,313C,315C,327C,329C,331C,348C,399C,403C,427C,436C 'instal':260C 'instruct':108B 'instruction-point':107B 'interpos':743C 'introduc':213C 'invers':197C 'issu':721C 'j2k':69B,286C,302C,342C,387C,433C,447C,485C 'j2k.c':390C 'jp2':654C,673C 'jpeg':80B,639C 'jpg':264C 'keep':220C,462C 'l':292C,296C,309C,316C,322C,325C,332C,338C,359C,404C 'later':384C 'layer':590C 'let':467C 'level':720C 'libopenjp2':3A,56B,126C 'like':722C 'line':383C,536C 'load':22A 'lot':616C 'm':303C,343C 'magic':635C 'main':272C 'maintain':731C 'make':199C 'marker':375C,424C 'master':139C,565C 'memori':561C 'memory-corrupt':560C 'miss':203C 'month':515C 'much':726C 'name':645C 'nb':408C 'neither':350C 'never':142C 'newest':492C 'note':195C,594C 'null':352C 'null-check':351C 'number':306C 'one':368C,410C,683C,724C 'one-byt':367C 'onward':123C 'open':689C 'openjpeg':146C,204C,234C,240C,493C,531C,550C,741C 'opj':68B,285C,290C,386C,432C,446C,484C 'os':162C 'osv':11A 'osv.dev':150C 'osv.dev/vulnerability/osv-2025-219':149C 'overflow':17A,65B,132C 'overlfow':482C 'p':301C,341C 'packag':41B 'param.m_decoder.m_sot_length':345C 'part':295C,319C,335C,362C,407C 'pass':610C,697C 'past':470C 'patch':160C,473C,524C 'path':95B,677C 'per':101B 'pil.features.version':262C 'pillow':1A,33A,42B,48B,119C,186C,238C,619C 'pillow.libs':270C 'pillow.libs/libopenjp2-*.so.2.5.4':269C 'pin':274C 'png/jpeg-only':665C 'pointer':109B 'pos':324C,337C,340C 'present':542C 'primit':110B 'process':99B 'product':662C 'proof':735C 'python':43B 'q3g8':27A,32A 'rather':181C,705C 'reach':72B,193C,416C,601C,652C,728C 'reachabl':19A,168C,681C 'read':70B,287C,448C,486C 'reason':716C 'regist':688C 'releas':147C,513C,532C,554C 'relev':622C 'reli':628C 'remedi':518C 'report':103B,178C,503C 'resiz':453C 'safe':209C,244C,247C 'secur':579C 'see':148C 'self':45B 'self-host':44B 'servic':663C,691C 'set':175C,455C 'ship':573C 'sibl':414C 'similar':592C 'sinc':8A,566C,693C 'size':439C 'skip':451C 'sniff':634C 'sod':71B,288C,487C 'sot':374C,449C,460C 'specif':344C 'src/lib/openjp2/j2k.c':283C 'status':241C,474C 'still':273C 'suggest':517C 'summari':117C 'suppli':87B 'tag':237C,494C 'tell':582C 'tend':732C 'three':535C 'thumbnail':47B,94B 'tile':294C,299C,305C,312C,318C,328C,334C,361C,406C 'tlm':423C,438C,442C,464C 'tlmarker':389C 'tnsot':456C 'tp':314C,330C,347C,402C,426C,435C 'tps':409C 'tpsno':308C 'tpsot':370C,468C 'type':650C,712C 'uint32':291C 'unless':607C 'unreleas':559C 'upload':40B,92B,666C,684C 'upstream':137C,184C,476C 'user':194C 'v2.5.4':496C 'valid':422C,465C,700C 'verifi':658C 'vulner':190C,575C 'walk':469C 'wheel':7A,49B,120C,154C,231C,261C,527C 'wider':727C 'would':611C 'write':18A,66B,133C,392C 'writer':411C 'x':54B 'xqwv':26A,31A","nicknames":[],"mitigations":[],"who_affected":null,"tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-22T17:01:08+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-78g4-xqwv-q3g8","url":"https://github.com/advisories/GHSA-78g4-xqwv-q3g8","severity":"high","published_at":"2026-10-22T17:01:08+00:00","modified_at":"2026-10-22T17:01:19+00:00","first_seen_at":"2026-10-23T19:11:31+00:00","fetched_at":"2026-10-23T19:11:31+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}