Repository navigation
chore(dependencies): update dependency pillow to v13 [security] - #265
Open
renovate[bot] wants to merge 1 commit into
Open
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
Contributor
Author
|
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
==12.3.0→==13.0.0Pillow tiled-TIFF decoder reads past tile buffer in copy4 (Unpack.c:1290)
CVE-2026-112395 / GHSA-462x-pgg6-55fp
More information
Details
Summary
Pillow's libImaging
_decodeTiledecoder, used to decode tiled TIFF images, allocates a per-tile buffer usingrealloc(state->buffer, TIFFTileSize(tiff)). After eachTIFFReadTileit then iteratestile_yfrom 0 up tocurrent_tile_lengthand calls the configured row unpacker (herecopy4) with the source pointerstate->buffer + tile_y * row_byte_size. When libtiff returns atile_bytes_sizesmaller than the worst-caserow_byte_size * tile_length, the inner stride-and-copy loop walks past the realloc'd region andcopy4performs a 1024-bytememcpythat reads up to(tile_length-1) * row_byte_size + row_byte_sizebytes from a buffer sized only totile_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
_decodeTilepath. The function:TIFFTileSize(tiff)(the bytes-per-tile) andTIFFTileRowSize(tiff)(the bytes per row as stored by libtiff).state->bufferto exactlytile_bytes_size.(x, y)and each plane, callsTIFFReadTileintostate->buffer, then walks the tile row by row, calling the configuredImagingShuffler(herecopy4) on each row's worth of source bytes.The validation done before the loop is:
This rejects oversized libtiff tiles, but it does not reject undersized ones — i.e. the case where libtiff returns a
tile_bytes_sizestrictly less thanrow_byte_size * tile_length. The loop body, however, indexes into the buffer assuming the buffer is at leastrow_byte_size * tile_lengthbytes long.Vulnerable Code
The PoC's tile metadata yields
tile_width = tile_length = 256,row_byte_size = 1024(4 bytes/pixel × 256 pixels), andtile_bytes_size = 196608(which is 256·256·3 = three-channel-byte storage). The realloc'd buffer is 196608 bytes. The unpacker iteratestile_yfrom 0 to 255 and for the highest rows the source pointerstate->buffer + tile_y * 1024already lies at or past offset 196608 — the very last row'smemcpy(in=buffer+255·1024, 1024)reads 1024 bytes starting260096 - 196608 = 63488bytes 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
TIFFTileSizereturns the packed per-tile storage (which depends on the file's photometric/separation layout — it can legitimately be smaller thanrow_byte_size × tile_length), while the inner loop strides as if the buffer always equalsrow_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:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Out-of-bounds access in _imagingmorph on a zero-width image
CVE-2026-112397 / GHSA-58r4-6rv3-gfh8
More information
Details
Summary
A PPM or PGM file can declare a width of 0, which Pillow accepts, producing an image of size
(0, N). Passing that image 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 OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: GIF seek keeps rejected size after DecompressionBombError, allowing DoS
CVE-2026-112400 / GHSA-65qm-jvmh-6pqg
More information
Details
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, built in memory
by
bomb_gif(). The GIF contains a 1×1 screen and a 1×1 frame 0, soImage.open()passes its own bomb check. Next comes a bare frame-1 imagedescriptor that declares 40000×40000 (1.6 Gpx, about 9× the error
threshold). A real 1×1 frame follows it.
poc()callsim.seek(1)and catchesDecompressionBombError, the wayan application that skips bad frames would.
im.sizenextto the real pixel buffer
im.im.size. If the rejected frame had left theobject unchanged, both would be
(1, 1). On the affected codeim.sizeis(40000, 40000).im.seek(1), which nowparses the trailing 1×1 frame, and calls
im.load(). It prints theprocess's peak RSS before and after.
load_prepare()sizes itsImage.core.fill()fromim.size, so loading a single pixel fills a1.6 GB buffer before the 3 GiB cap stops the next allocation.
Expected output on the affected tree:
The script prints
BUGonly if both checks hold: the over-limit size stayedon 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 patchapplied, it prints
im.size=(1, 1), peak RSS stays at about 23 MiB, and thelast 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 alocal, passed to
_decompression_bomb_check(), and only then assigned toself._size, matching the order of the twodispose_sizechecks further downthe 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:
n_frames/is_animatedgo throughupdate_image=Falseand are unaffected.The patch also extends
test_exception_gif_extentsinTests/test_decompression_bomb.pyto assert thatim.sizeis unchanged afterthe
DecompressionBombError. It does not changeseek()itself; optionally,seek()could also treatDecompressionBombErrorlikeEOFErrorand re-seekto
last_frame, so that a retriedseek()does not resume parsing from themiddle 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.pyat the commit above.The reproducer
Reproducer —
repro, 2,474 bytes (click to open)Its last line is
BUGwhen the issue triggers andOKwhen it does not. Re-run on a clean build of that commit it printedBUG.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 OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: Heap buffer overflow in ImagingJpegDecode
CVE-2026-112403 / GHSA-8g5g-8vmr-9782
More information
Details
Summary
JpegImageFile.draft()— 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 OSV and the GitHub Advisory Database (CC-BY 4.0).
Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels
CVE-2026-112404 / GHSA-c6hw-5grf-ff96
More information
Details
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()insrc/libImaging/TiffDecode.c. Both size their decode buffer from libtiff's view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), orimg.width * 4). They then unpack each row with the unpacker thatTiffImagePlugin._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-Fuzzcrash-0da0in 8.2.0, but the tile and RGBA paths never got it.The two parsers can be made to disagree with a duplicated
PhotometricInterpretationorBitsPerSampletag, 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()insrc/libImaging/TiffDecode.csizestate->bufferfrom what libtiff says it will produce (TIFFTileSize(), resp.img.width * 4), but unpack each row with the shuffler thatTiffImagePlugin._setup()selected from Pillow's own parse of the IFD. Nothing in either function checks that the unpacker's input row (width * state->bits / 8bytes) 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 duplicatedPhotometricInterpretation/BitsPerSample/SamplesPerPixelentry (libtiff keeps the first occurrence,ImageFileDirectory_v2.load()the last) — the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (RGBX,RGB;16N,RGBA;16N,I;32,F;32F, …);RGBA;16N(64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.The ~400-byte TIFF
tile_count2.tifgenerated by the reproducer — one 16x16 tile,BitsPerSample 8,SamplesPerPixel 1,Compression 8(Adobe Deflate), andPhotometricInterpretationwritten as a SHORT with count 2 (6, 6) — is opened byImage.open()as modeRGBwith libtiff rawmodeRGBX, andload()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 underMALLOC_PERTURB_=85and=170yields identical rows 0–12 and different bytes in rows 13–15 (aavs55, i.e. glibc's fill pattern). Under an ASan build of the extensions the sameload()reportsheap-buffer-overflow READ of size 64incopy4(src/libImaging/Unpack.c:1290) called fromsrc/libImaging/TiffDecode.c:483, against the 256-byte region allocated atTiffDecode.c:448. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV incopy4 ← _decodeTilereading 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
PhotometricInterpretationentry whose count is not 1 (TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored), so for this fileTIFFGetField(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:
so
_setup()seesphoto == 6.OPEN_INFO[(II, 6, (1,), 1, (8,), ())]is("L", "L"), and because the file is compressed the libtiff branch then overrides the rawmode:RGBXis a 32-bit rawmode, soget_unpacker()(src/decode.c:301-309) setsstate->bits = 32andstate->shuffle = copy4. The intent of thephoto == 6override is that libtiff will deliver RGBA throughTIFFRGBAImage, but that decision is made in C from libtiff's reading of the tag, which here is "not YCbCr":The missing check
_decodeTile()only guards against libtiff's tile being larger than what the unpacker expects, then allocates exactlyTIFFTileSize()bytes and feedsrow_byte_size-strided rows to the shuffler:Row
tile_yis read from[16·tile_y, 16·tile_y + 64); fortile_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_sizebytes, androw_byte_sizescales with the attacker-chosenTileWidth.Note also that the existing one-sided check at line 434 multiplies the file-controlled
tile_width/tile_length(UINT32, accepted up toINT_MAXat line 428) bystate->bitsin 32-bit unsigned arithmetic, sotile_width * state->bitswraps fortile_width ≥ 2^32 / bits(2^26 for the 64-bit rawmodes, 2^27 forRGBX/I;32/F;32F). An 8 KB file declaring a 2,097,152×1 image withTILEWIDTH = 2^26+16,TILELENGTH = 1, 1-bit data for libtiff andRGBA;16Nfor Pillow makes libtiff allocate an ~8 MB tile from whichunpackRGBA16Lreads 16 MB, and the stock build SIGSEGVs inunpackRGBA16L ← _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-0da0in 8.2.0):The identical file written with one strip instead of one tile fails
load()withOSError: decoder error -2because of this check.Same gap in
_decodeAsRGBA()_decodeAsRGBA()always producesimg.width * 4bytes per row (TiffDecode.c:325, buffer allocated at line 339) and unpacks withstate->shuffle(line 373) without looking atstate->bits. It is reached when libtiff sees YCbCr while Pillow does not forceRGBX. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow'sImageFileDirectory_v2.load()overwrites (TiffImagePlugin.py:945, last wins). The reproducer'sycbcr_dup.tifis a 16x16 Deflate strip withPhotometricInterpretationpresent twice (6, then2) andBitsPerSampletwice (8,8,8, then16,16,16): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selectsRGB;16N(state->bits = 48), andunpackRGB16Lreads 96 bytes per 64-byte row — ASan reportsheap-buffer-overflowinunpackRGB16L(Unpack.c:609) fromTiffDecode.c:373, past the 1024-byte region fromTiffDecode.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 == 6file only gets rawmodeRGBXsince the unreleased commit 155330d63 ("Do not open YCbCr colorspace images as RGB without libtiff (#9779)", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with twoPhotometricInterpretationentries (1or2, then6) produces the samecopy4←_decodeTileover-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps6and unpacks 4 (the reproducer'stile_dup.tif).ycbcr_dup.tifabove reaches_decodeAsRGBA()the same way.Impact
Any application that calls
Image.open()+load()(orthumbnail(),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 andTiffDecode.cwithoutREAD_LIBTIFFbeing 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 theRGBX-over-8-bit case, with the tile row length (TileWidth) chosen by the attacker.state->bufferis 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 sizedImagingrows, 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 latercrash-0da0fix 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 toTIFFTileSize()/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 withPhotometricInterpretationwritten as SHORT count 2,(6, 6). Pillow keeps6and unpacks withcopy4(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 withPhotometricInterpretationpresent twice,2then6. libtiff keeps2(RGB, 3 bytes per pixel) and Pillow keeps6(RGBX, 4 bytes per pixel). This variant also applies to released versions.ycbcr_dup.tif: the 16x16 strip withBitsPerSample8,8,8then16,16,16andPhotometricInterpretation6then2. libtiff decodes 8-bit YCbCr through_decodeAsRGBA()(4 bytes per pixel), and Pillow unpacksRGB;16NwithunpackRGB16L(6 bytes per pixel).tile_big.tif: thetile_count2.tiflayout with a single 65536x16 tile.For each of the first three files, the script runs
Image.open()+load()+tobytes()twice, once withMALLOC_PERTURB_=85and 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: theaa/55fill bytes show up, along with heap pointer bytes. The script then decodestile_big.tifand checks for a SIGSEGV (exit status -11). Ifgdbis 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
_imagingbuild it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line isBUGif any file leaked he