Repository navigation
Update dependency Pillow to v13 [SECURITY] - #405
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #405 +/- ##
=======================================
Coverage 50.34% 50.34%
=======================================
Files 166 166
Lines 11461 11461
=======================================
Hits 5770 5770
Misses 5691 5691
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
12.3.0→13.0.0Pillow: Heap buffer overflow in ImagingJpegDecode
CVE-2026-112403 / GHSA-8g5g-8vmr-9782
More information
Details
Summary
JpegImageFile.draft()— whichImage.thumbnail()calls automatically — requests 1/2, 1/4 or 1/8 scaled JPEG output and shrinksself._sizeand the tile extents to match, so_setimage()allocates a single-row decode buffer ofceil(W/s)samples.ImagingJpegDecode()setscinfo.scale_num/scale_denom, callsjpeg_start_decompress(), and then reads scanlines into that buffer without ever comparingcinfo.output_width,cinfo.output_heightorcinfo.out_color_componentsagainststate->xsize,state->ysizeandstate->bits.libjpeg-turbo 3.x decodes lossless (SOF3) JPEG, but hardwires lossless streams to no scaling — the requested
scale_denomis silently discarded. It therefore returns full-widthW-sample rows into a buffer sized forceil(W/s). Eachjpeg_read_scanlines()call writes(W - ceil(W/s)) x componentsbytes 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 marker0xFFC3("SOF3", "Spatial lossless") at 8-bit precision, so the file opens normally as aJPEG/ modeLimage 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)withstate->bytes = (state->bits * state->xsize + 7) / 8.src/PIL/JpegImagePlugin.py:428-464—JpegImageFile.draft(); computessand shrinks_size, the tile extents anddecoderconfigwith no notion of lossless coding.src/PIL/JpegImagePlugin.py:271—0xFFC3: ("SOF3", "Spatial lossless", SOF).Because
state->ysize(ceil(H/s)) can never equal libjpeg'soutput_heightwhens >= 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. TheOSErroris a consequence of the overflow, not the issue being reported.Primitive
Per scanline: a contiguous write of
(W - ceil(W/s)) x cbytes immediately past acalloc(ceil(W/s) x c)block, wherec = 1for modeLand 4 for RGB (RGBX) and CMYK; repeated forceil(H/s)rows over the same region. The buffer size class is chosen by the attacker throughW, 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 onememcpy.Threshold:
draft()must selects >= 2, i.e.W // requested_w >= 2andH // requested_h >= 2. Forthumbnail((256,256))with the defaultreducing_gap=2.0that meansW, 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}, defaulte41083f383c9cd3db95de52564cc0b6452313d4a). Save the three files below asDockerfile,make_sof3.pyandtrigger.pyin an empty directory, then:make_sof3.pyis a self-contained encoder that emits a standards-compliant 8-bit grayscale lossless-Huffman JPEG (SOI / DHT / SOF3P=8, Y=1100, X=1200, Nf=1/ SOSSs=1predictor 1,Se=0,Ah=Al=0/ entropy-coded row differences / EOI), whose rows carry a repeating marker pattern.trigger.pyopens it and callsthumbnail(), whose implicitdraft()pickss=2and allocates a 600-byte row buffer.Dockerfilemake_sof3.pytrigger.pyValidated output:
Negative control, same image: a baseline DCT JPEG of identical dimensions thumbnails cleanly, because libjpeg honours 1/2 scaling exactly as
draft()computed it.Scope and caveats
mainate41083f383c9cd3db95de52564cc0b6452313d4a, which is currentorigin/mainas of 2026-08-30. Also reported against 12.3.0.Image.thumbnail()callsdraft()by default, and the triggering bytes are entirely file data.draft()before loading are affected.open().load()andresize()decode at scale 1 and are unaffected. Callingimg.load()beforethumbnail(), orthumbnail(..., reducing_gap=None), avoids the path in an application today.JERR_ARITH_NOTIMPLbefore any row; 12- and 16-bit lossless are rejected by Pillow'sSOF()because it requiresbits == 8; SOF5-7/13-15 fail withJERR_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:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by 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 toImageMorph.MorphOp.apply()makes the native morphology code index a row buffer atoutrow[width - 1], which isoutrow[-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:119The native
apply()writes the row edges with no guard that the width is positive:src/_imagingmorph.c:81setsoutrow[0] = outrow[width - 1] = 0;match()andget_on_pixels()use the same edge-indexing pattern.PoC
Run it:
Observed on a source build of commit
3e2ab96.Under AddressSanitizer:
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:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Pillow: TIFF encoder ColorMap plane-size mismatch causes a heap out-of-bounds read
CVE-2026-112896 / GHSA-pchr-8j38-3x4p
More information
Details
Summary
A small attacker-supplied TIFF can be opened and loaded normally, then reach an out-of-bounds read when an application saves it back to TIFF with retained metadata. The fixed reproducer is a 1×1, 16-bit grayscale, PackBits-compressed TIFF containing tag 320 (ColorMap) with 768 SHORT values: three zero-filled planes of 256 entries each. No Python object is forged and no metadata is injected after loading.
Pillow's TIFF encoder allocates 768 × 2 = 1536 bytes, then passes three plane pointers separated by 256 SHORT entries to libtiff. With
BitsPerSample = 16, libtiff instead expects 65536 SHORT values per plane and attempts a 131072-byte read from a plane pointer. Existing ASAN observations identify a heap-buffer-overflow READ crossing the 1536-byte allocation made inPyImaging_LibTiffEncoderNew.The malformed ColorMap need not be a valid palette for this grayscale image. The issue is that accepted metadata is forwarded on save without reconciling the fixed plane extent with the effective sample depth. Opening/loading alone is not the demonstrated fault; the encoder path is required.
Impact and prerequisites
An application must accept the input, retain the relevant TIFF metadata, and invoke the libtiff-backed TIFF save path. The observed consequence is an out-of-bounds read and ASAN termination. Process-memory exposure during encoding is a potential consequence, not demonstrated by extracting arbitrary heap contents in this report. No arbitrary read primitive, full memory-disclosure chain, code execution, address-space bypass, or cross-process effect is claimed.
The 131072-byte attempted copy is not the number of bytes proven disclosed. This report does not run a synthetic-canary serializer or collect an unsanitized encoded output.
Tested environment and limits of the evidence
The existing observations were recorded on August 30, 2026, with CPython 3.11.8 on macOS ARM64, using the unmodified official Pillow 12.3.0 wheel and its bundled libtiff 4.7.1:
pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl4785266bytes37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6bb1d8e8ab8d29048624d96e3ee53cecf7c13d13de8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1Apple's ASAN runtime was injected at process startup. This is not an ASAN-rebuilt Pillow wheel; allocation/memcpy interception detects the reported boundary violation, but the wheel's native instructions are not comprehensively instrumented.
Only this concrete full-package environment is established here. No affected-version range, current development-branch result, or patched release is asserted. Severity is Unknown; no CVSS score is proposed.
Separate earlier evidence reached the original Pillow 8.0.1 encoder constructor compiled as a component against a modern Python/libtiff environment. That was not a complete Pillow 8.0.1 installation and does not establish the historical package's full runtime behavior. It is not part of this reproducer or an affected-version range.
Observed evidence and controls
The earlier integration harness performed the natural sequence
Image.open(BytesIO(input)),image.load(), thenimage.save(BytesIO(), format="TIFF"), without replacing the image's metadata. Each case was recorded three times:colormap16I;16; save terminated with ASAN exit 86, READ of size 131072 crossing a 1536-byte heap region (3/3).colormap8colormap_absentAddress-free summary of the recorded native diagnostic:
Input SHA-256 values (also enforced inside the script):
colormap16:5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4dcolormap8:fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405ccolormap_absent:bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556Root cause and source references
src/encode.c, lines 870–899, pinned revision: the ColorMap tuple must contain exactly 768 entries; a 1536-byte allocation is passed with a fixed stride of 256. The check does not reconcile that extent withBitsPerSample.src/PIL/TiffImagePlugin.py, lines 1944–1991, pinned revision: save carries supplied tags into the libtiff encoder and sorts the tags, so sample depth is established before ColorMap.tif_dir.c, lines 441–448, pinned revision:TIFFTAG_COLORMAPcomputes the per-plane count as1L << td->td_bitspersampleand copies that many SHORTs from each pointer.The reported boundary is Pillow's construction of a fixed-sized array for a native API whose required size depends on metadata; this does not establish a standalone libtiff defect for a correctly sized caller buffer.
Prior art and disclosure scope
Pillow PR #5148, merged December 28, 2020, is related TIFF metadata prior art, but its archived diff replaces a short-lived stack field-name pointer with the persistent literal
"CustomField". That is a different field-name-lifetime fix, not this plane-size mismatch. The reviewed material does not establish an exact prior public report or fix for this root, and novelty is not asserted. This specific report remains NOT_SENT; association with other observations is not a prior disclosure of it.Reproduction instructions
Use an already reviewed, existing installation of the exact wheel above, an existing compatible macOS ARM64 CPython 3.11 interpreter, and an existing compatible Apple ASAN runtime.
--package-pathis the absolute directory containingPIL, not thePILdirectory itself. The script does not install, download, build, patch, or submit anything. It checks selected key package files against the original installation's hashes and checks the loaded package/version; these checks are not a full installation attestation.Save the complete Python block below as
reproduce.pyand run:Both arguments are required. The runtime must be loaded at child startup, not by late
dlopen. Each child resolves__asan_init,__asan_address_is_poisoned, and__asan_region_is_poisonedin its actual process symbol table before importing Pillow or opening a fixture. Merely settingDYLD_INSERT_LIBRARIESis not accepted as proof of ASAN availability. A missing symbol or package mismatch prevents the operation.Each of the three cases runs once in a separate child with a 30-second wall limit, 10-second hard CPU limit, core dumps disabled, and 1 MiB regular-file-size limit. The parent supplies a clean allowlisted environment, isolated Python startup (
-I -S -B), an empty temporary working directory, no stdin, and a 128 KiB combined pipe-output cap. Timeout/output-cap handling kills the child's process group. These limits are containment measures, not a security sandbox. No TIFF is written to disk. Temporary directories are removed; raw diagnostics are never forwarded or persisted.The compressed/base64 constants below are the unchanged original benign TIFF fixtures, with decompressed size and SHA-256 checks. There are no external fixture dependencies. The standalone script supplied with this description is byte-for-byte the same as this block.
Separate August 31, 2026 inline validation: the exact embedded standalone script passed all three fixed cases: one ASan READ of 131,072 bytes crossing a 1,536-byte allocation and two passing controls. Child guards and limits passed, with no timeout. This is additional bounded evidence, separate from earlier runs; no new build, install, full historical chain or older-version integration replay was performed.
Expected results from the standalone script
Cases run in order:
colormap8,colormap_absent,colormap16. Every printed line should haveguard_passed: true,within_limits: true, andexpected_observation: true. The first two require successful saves and the expected retained sample-depth/ColorMap fields. The last requires ASAN exit 86 and the specific READ/extent/stack signatures, not merely any crash. Only the two controls inspect encoded metadata; the unsafe case never exports an encoded TIFF.The parent exits 0 only if all three expectations match; 1 means a case did not match (including guard failure or a changed ASAN diagnostic), and 2 means parent setup failed. A failed guard, timeout, unrelated crash, or missing diagnostic is inconclusive, not evidence that the defect is absent. Do not bypass the ASAN guard to obtain an output TIFF.
Complete standalone reproducer
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by 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
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 toself._sizeand only then callsImage._decompression_bomb_check(). Nothing restores the old size whenDecompressionBombErroris raised, becauseseek()only rolls back onEOFError. After the caught error,im.sizereports the rejected size while the pixel bufferim.imis 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 byload()then reachesload_prepare(), which callsImage.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, ...)androtate(..., expand=True)also allocate from the stale size.The default paths are unaffected: plain
Image.open()plusload(),ImageSequence.Iterator, andn_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 thedecompression-bomb check for the new size only after storing it on the object:
_decompression_bomb_check()(src/PIL/Image.py:3586) raisesDecompressionBombErrorfor anything above2 * MAX_IMAGE_PIXELS(178 956 970 px by default), but by then
self._sizealready holds the rejectedvalue and nothing restores it:
seek()(GifImagePlugin.py:166-179) only rollsback on
EOFError, and_seek(0)only ever grows_size. The object that theapplication still holds therefore reports
im.size == (40000, 40000)whileim.imis the 1×1 buffer of frame 0. Compare the two dispose-extent checks afew lines further down in the same function (
GifImagePlugin.py:383and:407),which call
_decompression_bomb_check(dispose_size)before the correspondingImage.core.fill(), andTiffImagePlugin.load_prepare()(
src/PIL/TiffImagePlugin.py:1319-1321), which checks_tile_sizebeforeallocating from it.
With this 60-byte GIF (hex)
Image.open()succeeds (im.size == (1, 1)),im.seek(1)raisesDecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwardsim.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.sizerather thanfrom the C buffer:
im.seek(1). The failed_seekleftself.__offset == 0and thefile 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():memsets a 40000×40000 "P" image (1.6 GB) to decode a1×1 tile into, and
load_end()(GifImagePlugin.py:485-491) fills a secondone 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 thecommon flatten-onto-background idiom
Image.new("RGB", im.size, bg), allocatefrom
im.sizeas 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 passim.sizeas a box against the real buffer (thumbnail,resize,save)fail with
ValueError/SystemErrorinstead.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_animatedare not affected: they call_seek(frame, update_image=False), which skips the_sizeassignment (the fileabove reports
n_frames == 2,is_animated == Trueand leavesim.sizeat1×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 broadexcept Exceptionarounda
while True: im.seek(im.tell()+1)frame-collection loop, or an explicitexcept DecompressionBombError: log and continue— and then keeps using thesame
Imageobject, ends up driving allocations of 1.6 GB to tens of GB from a60-byte file:
MemoryErrorafter the process has committed gigabytes, or anOOM kill of the worker when no address-space limit is set. The
MAX_IMAGE_PIXELSguard fires, but does not actually keep the rejecteddimensions out of the object it is guarding.
The precondition is real application behaviour but not the default path:
Image.open(); im.load()is unaffected, andImageSequence.Iteratorpropagatesthe error out of the
forstatement (atryinside the loop body does notswallow 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), firstreleased 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
reprois a single Python script run against the installed Pillow. It needsabout 1.7 GB of free RAM.
setup_env()caps the process address space at 3 GiB withRLIMIT_AS, so the oversized allocations end inMemoryErrorinstead ofthe OOM killer. It then opens the 60-byte GIF shown above, buil